use std::collections::HashMap;
use aion_core::{Payload, RunId, WorkflowId};
use aion_server::ServerState;
use aion_server::api::http::http_router;
use aion_server::config::ServerConfig;
use aion_server::update_check::{
UPDATE_CHECK_WORKFLOW_TYPE, UpdateCheckInstall, install_embedded_update_check_for_server,
};
use aion_store::InMemoryStore;
use axum::{
body,
http::{Request, StatusCode},
};
use serde_json::{Value, json};
use tower::ServiceExt;
type TestError = Box<dyn std::error::Error>;
const STOCK_CONFIG: &[u8] = b"[observability]\nmax_batch_events = 64\nmax_batch_hold_ms = 0\n";
fn private_tempdir() -> Result<tempfile::TempDir, TestError> {
let dir = tempfile::tempdir()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))?;
}
Ok(dir)
}
async fn booted_state() -> Result<ServerState, TestError> {
let home = private_tempdir()?;
let config = ServerConfig::from_slice_with_home(STOCK_CONFIG, home.path())?;
let (_, runtime) = config.into_parts();
let state = ServerState::build_with_store(InMemoryStore::default(), runtime).await?;
let install = install_embedded_update_check_for_server(&state).await;
assert!(
matches!(install, UpdateCheckInstall::Installed { .. }),
"a fresh temp home must install the embedded document, got {install:?}"
);
Ok(state)
}
async fn http_start(
state: &ServerState,
body_value: &Value,
) -> Result<(StatusCode, Value), TestError> {
let response = http_router(state.clone())?
.oneshot(
Request::builder()
.uri("/workflows/start")
.method("POST")
.header("content-type", "application/json")
.header("x-aion-subject", "update-check-binding-test")
.header("x-aion-namespaces", "default")
.body(body::Body::from(serde_json::to_vec(body_value)?))?,
)
.await?;
let status = response.status();
let decoded: Value =
serde_json::from_slice(&body::to_bytes(response.into_body(), usize::MAX).await?)?;
Ok((status, decoded))
}
async fn cancel_started_run(
state: &ServerState,
workflow_id: &WorkflowId,
run_id: &RunId,
) -> Result<(), TestError> {
match state
.engine()?
.cancel(workflow_id, run_id, "offline binding test cleanup (r3-m5)")
.await
{
Ok(()) => Ok(()),
Err(error) if error.to_string().contains("not found") => Ok(()),
Err(error) => Err(format!("the cleanup cancel failed unexpectedly: {error}").into()),
}
}
fn started_ids(decoded: &Value) -> Result<(WorkflowId, RunId), TestError> {
let workflow_id = decoded["workflow_id"]
.as_str()
.ok_or("an accepted start names its workflow id")?;
let run_id = decoded["run_id"]
.as_str()
.ok_or("an accepted start names its run id")?;
Ok((
WorkflowId::new(workflow_id.parse()?),
RunId::new(run_id.parse()?),
))
}
#[tokio::test(flavor = "multi_thread")]
async fn an_http_start_with_the_empty_object_input_is_accepted() -> Result<(), TestError> {
let state = booted_state().await?;
let (status, decoded) = http_start(
&state,
&json!({
"namespace": "default",
"workflow_type": UPDATE_CHECK_WORKFLOW_TYPE,
"input": {},
}),
)
.await?;
assert_eq!(status, StatusCode::OK, "refused: {decoded}");
let (workflow_id, run_id) = started_ids(&decoded)?;
cancel_started_run(&state, &workflow_id, &run_id).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn an_http_start_with_null_input_is_refused_as_missing_payload() -> Result<(), TestError> {
let state = booted_state().await?;
let (status, decoded) = http_start(
&state,
&json!({
"namespace": "default",
"workflow_type": UPDATE_CHECK_WORKFLOW_TYPE,
"input": null,
}),
)
.await?;
assert_eq!(
status,
StatusCode::INTERNAL_SERVER_ERROR,
"the null-input refusal changed shape; re-pin it and re-check the ops doc: {decoded}"
);
assert!(
decoded["message"]
.as_str()
.is_some_and(|message| message.contains("payload is missing")),
"the refusal must name the missing payload: {decoded}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn a_cli_shaped_null_payload_start_is_refused_by_input_validation() -> Result<(), TestError> {
let state = booted_state().await?;
let engine = state.engine()?;
let refusal = engine
.start_workflow(
UPDATE_CHECK_WORKFLOW_TYPE,
Payload::from_json(&Value::Null)?,
HashMap::new(),
"default".to_owned(),
)
.await;
match refusal {
Err(error) => {
let rendered = error.to_string();
assert!(
rendered.contains("null is not of type"),
"the refusal must name the schema mismatch: {rendered}"
);
Ok(())
}
Ok(handle) => Err(format!(
"a null start input must be refused by schema validation, but run {} started — \
re-check the ops doc's CLI command if this ever changes",
handle.workflow_id()
)
.into()),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_cli_shaped_empty_object_payload_start_binds_and_starts() -> Result<(), TestError> {
let state = booted_state().await?;
let engine = state.engine()?;
let handle = engine
.start_workflow(
UPDATE_CHECK_WORKFLOW_TYPE,
Payload::from_json(&json!({}))?,
HashMap::new(),
"default".to_owned(),
)
.await?;
assert!(!handle.workflow_id().to_string().is_empty());
let workflow_id = handle.workflow_id().clone();
let run_id = handle.run_id().clone();
cancel_started_run(&state, &workflow_id, &run_id).await?;
Ok(())
}