test(kanban): prove public offline sync journey

req: sync/023
This commit is contained in:
slhx agent
2026-07-13 20:26:55 +02:00
parent f1dc6edb7a
commit 25fcdae9b9
2 changed files with 106 additions and 5 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+104 -3
View File
@@ -192,13 +192,29 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
}
#[tokio::test]
async fn kanban_command_persists_before_projection_and_restores_after_reload() -> WebDriverResult<()>
{
// test req: local/001 req: local/002 req: local/003 req: local/004
async fn kanban_public_api_offline_sync_journey_converges_without_duplicate_replay(
) -> WebDriverResult<()> {
// test req: local/001 req: local/002 req: local/003 req: local/004 req: sync/023
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root")
.to_owned();
let host_build = Command::new("cargo")
.current_dir(&workspace)
.args([
"build",
"-p",
"hemx-kanban-example",
"--bin",
"hemx-kanban-example",
])
.status()
.expect("build kanban host server");
assert!(host_build.success(), "build kanban host server");
let target_dir = std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| workspace.join("target"));
let host_binary = target_dir.join("debug/hemx-kanban-example");
let (package, bootstrap, rendered) = build_kanban_artifact(&workspace);
let runtime = workspace.join("hemx-js/runtime/hemx.js");
let mut server = StaticServer::start(
@@ -304,6 +320,91 @@ async fn kanban_command_persists_before_projection_and_restores_after_reload() -
assert!(restored["error"].is_null());
assert_eq!(restored["notice"], "Moved 1 with click");
let app_addr = server.address.to_string();
let mut app_command = Command::new(&host_binary);
app_command.env("HEMX_KANBAN_ADDR", &app_addr);
let _app = ProcessGuard::start(app_command, &app_addr);
driver.goto(&format!("http://{app_addr}/sync-demo")).await?;
wait_until(
&driver,
"const root = document.querySelector('[data-kanban-sync]'); return root?.getAttribute('data-sync-phase') === 'acknowledged' && root?.getAttribute('data-sync-pending-count') === '0'",
)
.await?;
let command_id = persisted["persisted"]["detail"]["id"]
.as_str()
.expect("persisted command id");
let duplicate_script = format!(
r#"
const done = arguments[arguments.length - 1];
const commandId = {command_id:?};
(async () => {{
const duplicateResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=1&column=done`, {{ method: 'POST' }});
const duplicate = await duplicateResponse.json();
const conflictResponse = await fetch(`/sync/commands?command_id=${{encodeURIComponent(commandId)}}&card_id=2&column=done`, {{ method: 'POST' }});
const conflict = await conflictResponse.json();
const rejectionResponse = await fetch('/sync/commands?command_id=journey-rejected&card_id=999&column=done', {{ method: 'POST' }});
const rejection = await rejectionResponse.json();
const snapshot = await (await fetch('/sync/snapshot', {{ cache: 'no-store' }})).json();
const history = await (await fetch('/sync/acknowledgements?after=0', {{ headers: {{ Accept: 'text/event-stream' }}, cache: 'no-store' }})).text();
const open = indexedDB.open('hemx-kanban-v1');
open.onsuccess = () => {{
const count = open.result.transaction('commands', 'readonly').objectStore('commands').count();
count.onsuccess = () => done({{
duplicateStatus: duplicateResponse.status,
duplicate,
conflictStatus: conflictResponse.status,
conflict,
rejectionStatus: rejectionResponse.status,
rejection,
snapshot,
history,
queueCount: count.result,
}});
}};
}})().catch((error) => done({{ error: String(error), stack: error.stack }}));
"#
);
let convergence = driver
.execute_async(&duplicate_script, Vec::new())
.await?
.json()
.clone();
assert!(
convergence.get("error").is_none(),
"sync convergence failed: {convergence}"
);
assert_eq!(convergence["duplicateStatus"], 200);
assert_eq!(convergence["duplicate"]["commandId"], command_id);
assert_eq!(convergence["duplicate"]["serverSequence"], 1);
assert_eq!(convergence["conflictStatus"], 409);
assert_eq!(convergence["conflict"]["kind"], "command-conflict");
assert_eq!(
convergence["conflict"]["error"],
"command_id was already used for a different payload"
);
assert_eq!(convergence["rejectionStatus"], 400);
assert_eq!(convergence["rejection"]["kind"], "invalid-command");
assert_eq!(convergence["rejection"]["error"], "unknown card_id");
assert_eq!(convergence["queueCount"], 0);
assert_eq!(convergence["snapshot"]["serverSequence"], 1);
assert_eq!(convergence["snapshot"]["cards"][0]["id"], 1);
assert_eq!(convergence["snapshot"]["cards"][0]["column"], "done");
assert_eq!(
convergence["history"]
.as_str()
.expect("acknowledgement history")
.matches(command_id)
.count(),
1,
"duplicate replay emitted another acknowledgement: {convergence}"
);
driver.goto(&format!("http://{app_addr}/")).await?;
wait_until(
&driver,
"return document.querySelector('[data-hemx-root]')?.hasAttribute('data-kanban-command-ready') === true",
)
.await?;
driver
.execute(
r#"