323 lines
10 KiB
Rust
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
|
|
);
|
|
}
|