Files
crank/apps/admin-api/tests/integration/onboarding.rs
T

323 lines
10 KiB
Rust

#![allow(dead_code, unused_imports)]
use super::common::*;
use serde_json::{Value, json};
use serial_test::serial;
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn exposes_server_authoritative_onboarding_projection() {
let registry = test_registry().await;
let storage_root = test_storage_root("onboarding_projection");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let response = client
.get(format!("{base_url}/onboarding"))
.send()
.await
.unwrap();
let status = response.status();
let body = response.json::<Value>().await.unwrap();
assert_eq!(status, reqwest::StatusCode::OK, "body={body}");
assert_eq!(body["schema_version"], 1);
assert_eq!(body["status"], "in_progress");
assert!(body["revision"].as_i64().is_some());
assert!(body["eligible_since"].as_str().is_some());
assert_eq!(body["workspace_id"], "ws_default");
assert_eq!(body["completed"], false);
assert_eq!(
body["steps"]
.as_array()
.unwrap()
.iter()
.map(|step| step["id"].as_str().unwrap())
.collect::<Vec<_>>(),
vec![
"operation",
"test",
"publish_operation",
"agent",
"key",
"mcp_connection",
"first_call",
]
);
assert!(
body["steps"]
.as_array()
.unwrap()
.iter()
.all(|step| step["completed"] == false)
);
assert_eq!(body["steps"][0]["status"], "current");
assert!(
body["steps"]
.as_array()
.unwrap()
.iter()
.skip(1)
.all(|step| step["status"] == "pending")
);
// The server-owned eligibility denominator is recorded on the first GET;
// a repeat is idempotent and cannot advance browser-owned completion.
let repeated = client
.get(format!("{base_url}/onboarding"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(repeated["revision"], body["revision"]);
assert_eq!(repeated["steps"], body["steps"]);
assert_eq!(repeated["eligible_since"], body["eligible_since"]);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn ux_milestones_are_idempotent_and_revision_guarded() {
let registry = test_registry().await;
let storage_root = test_storage_root("onboarding_events");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let initial = client
.get(format!("{base_url}/onboarding"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let revision = initial["revision"].as_i64().unwrap();
let payload = json!({
"event": "started",
"idempotency_key": "onboarding-start-tab-a",
"expected_revision": revision,
});
let first = client
.post(format!("{base_url}/onboarding/events"))
.json(&payload)
.send()
.await
.unwrap();
let first_status = first.status();
let first = first.json::<Value>().await.unwrap();
assert_eq!(first_status, reqwest::StatusCode::OK, "body={first}");
assert_eq!(first["accepted"], true);
let accepted_revision = first["revision"].as_i64().unwrap();
assert_ne!(accepted_revision, revision);
let replay = client
.post(format!("{base_url}/onboarding/events"))
.json(&payload)
.send()
.await
.unwrap();
let replay_status = replay.status();
let replay = replay.json::<Value>().await.unwrap();
assert_eq!(replay_status, reqwest::StatusCode::OK, "body={replay}");
assert_eq!(replay["accepted"], false);
assert_eq!(replay["revision"], accepted_revision);
let semantic_conflict = client
.post(format!("{base_url}/onboarding/events"))
.json(&json!({
"event": "dismissed",
"idempotency_key": "onboarding-start-tab-a",
"expected_revision": revision,
}))
.send()
.await
.unwrap();
let semantic_conflict_status = semantic_conflict.status();
let semantic_conflict = semantic_conflict.json::<Value>().await.unwrap();
assert_eq!(
semantic_conflict_status,
reqwest::StatusCode::CONFLICT,
"body={semantic_conflict}"
);
assert_eq!(
semantic_conflict["error"]["code"],
"onboarding_idempotency_conflict"
);
let current_revision_conflict = client
.post(format!("{base_url}/onboarding/events"))
.json(&json!({
"event": "dismissed",
"idempotency_key": "onboarding-start-tab-a",
"expected_revision": accepted_revision,
}))
.send()
.await
.unwrap();
let current_revision_conflict_status = current_revision_conflict.status();
let current_revision_conflict = current_revision_conflict.json::<Value>().await.unwrap();
assert_eq!(
current_revision_conflict_status,
reqwest::StatusCode::CONFLICT,
"body={current_revision_conflict}"
);
assert_eq!(
current_revision_conflict["error"]["code"],
"onboarding_idempotency_conflict"
);
let stale = client
.post(format!("{base_url}/onboarding/events"))
.json(&json!({
"event": "dismissed",
"idempotency_key": "onboarding-dismiss-tab-b",
"expected_revision": revision,
}))
.send()
.await
.unwrap();
let stale_status = stale.status();
let stale = stale.json::<Value>().await.unwrap();
assert_eq!(stale_status, reqwest::StatusCode::CONFLICT, "body={stale}");
assert_eq!(stale["error"]["code"], "onboarding_stale_revision");
for payload in [
json!({
"event": "eligible",
"idempotency_key": "forged-eligibility",
"expected_revision": accepted_revision,
}),
json!({
"event": "completed",
"idempotency_key": "forged-completion",
"expected_revision": accepted_revision,
}),
json!({
"event": "started",
"idempotency_key": "forged-step",
"expected_revision": accepted_revision,
"completed_steps": ["operation", "first_call"],
}),
json!({
"event": "started",
"idempotency_key": "onboarding:completed:v1",
"expected_revision": accepted_revision,
}),
] {
let response = client
.post(format!("{base_url}/onboarding/events"))
.json(&payload)
.send()
.await
.unwrap();
let status = response.status();
let body = response.json::<Value>().await.unwrap();
assert_eq!(
status,
reqwest::StatusCode::UNPROCESSABLE_ENTITY,
"body={body}"
);
assert_eq!(body["error"]["code"], "onboarding_event_not_allowed");
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn direct_presentation_mutation_cannot_precede_server_eligibility() {
let registry = test_registry().await;
let storage_root = test_storage_root("onboarding_direct_event");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let direct = client
.post(format!("{base_url}/onboarding/events"))
.json(&json!({
"event": "started",
"idempotency_key": "direct-before-snapshot",
"expected_revision": 0,
}))
.send()
.await
.unwrap();
let direct_status = direct.status();
let direct = direct.json::<Value>().await.unwrap();
assert_eq!(
direct_status,
reqwest::StatusCode::CONFLICT,
"body={direct}"
);
assert_eq!(direct["error"]["code"], "onboarding_stale_revision");
let snapshot = client
.get(format!("{base_url}/onboarding"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert!(snapshot["eligible_since"].as_str().is_some());
assert_eq!(snapshot["status"], "in_progress");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn reset_selection_is_explicit_and_revision_guarded() {
let registry = test_registry().await;
let storage_root = test_storage_root("onboarding_reset_selection");
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let initial = client
.get(format!("{base_url}/onboarding"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let revision = initial["revision"].as_i64().unwrap();
let reset = client
.post(format!("{base_url}/onboarding/reset-selection"))
.json(&json!({"expected_revision": revision}))
.send()
.await
.unwrap();
let reset_status = reset.status();
let reset = reset.json::<Value>().await.unwrap();
assert_eq!(reset_status, reqwest::StatusCode::OK, "body={reset}");
assert_eq!(reset["selection_reset"], true);
assert_ne!(reset["revision"], initial["revision"]);
assert_eq!(reset["workspace_id"], initial["workspace_id"]);
let stale = client
.post(format!("{base_url}/onboarding/reset-selection"))
.json(&json!({"expected_revision": revision}))
.send()
.await
.unwrap();
let stale_status = stale.status();
let stale = stale.json::<Value>().await.unwrap();
assert_eq!(stale_status, reqwest::StatusCode::CONFLICT, "body={stale}");
assert_eq!(stale["error"]["code"], "onboarding_stale_revision");
let forged_selection = client
.post(format!("{base_url}/onboarding/reset-selection"))
.json(&json!({
"expected_revision": reset["revision"],
"operation_id": "op_client_selected",
}))
.send()
.await
.unwrap();
assert_eq!(
forged_selection.status(),
reqwest::StatusCode::UNPROCESSABLE_ENTITY
);
}