#![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::().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![ "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::() .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::() .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::().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::().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::().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::().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::().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::().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::().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::() .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::() .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::().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::().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 ); }