feat: complete Epic 1 production foundation
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
#![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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user