test(kanban): prove public offline sync journey
req: sync/023
This commit is contained in:
+104
-3
@@ -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#"
|
||||
|
||||
Reference in New Issue
Block a user