diff --git a/frontend/src-tauri/tests/core_workspace.rs b/frontend/src-tauri/tests/core_workspace.rs index edbd84e..f0a0ce2 100644 --- a/frontend/src-tauri/tests/core_workspace.rs +++ b/frontend/src-tauri/tests/core_workspace.rs @@ -289,4 +289,84 @@ async fn real_core_notes_roundtrip_only_through_bound_host_and_confirm_commits() .path() .join("core/unbound-vault/Core fixture.md") .exists()); + // The actual Python HTTP handler must round-trip revision through Host pipes, + // including repeat requests after a successful commit. + let path = "/api/settings/persona"; + let (status, empty) = request( + &mut core, + "GET", + path, + &vault, + &uuid::Uuid::new_v4().to_string(), + None, + ) + .await; + assert_eq!(status, 200, "{empty}"); + assert_eq!(empty["revision"], ""); + let operation = uuid::Uuid::new_v4().to_string(); + let mut body = empty.clone(); + body["system_prompt"] = json!("Host persona fixture"); + let (status, first) = request( + &mut core, + "PUT", + path, + &vault, + &operation, + Some(body.clone()), + ) + .await; + assert_eq!(status, 200, "{first}"); + assert_eq!(first["revision"].as_str().unwrap().len(), 64); + for _ in 0..20 { + let (status, replay) = request( + &mut core, + "PUT", + path, + &vault, + &operation, + Some(body.clone()), + ) + .await; + assert_eq!(status, 200, "{replay}"); + assert_eq!(replay, first); + } + let (status, stale) = request( + &mut core, + "PUT", + path, + &vault, + &uuid::Uuid::new_v4().to_string(), + Some(body.clone()), + ) + .await; + assert_eq!(status, 409, "{stale}"); + let (status, wrong_vault) = request( + &mut core, + "GET", + path, + &uuid::Uuid::new_v4().to_string(), + &uuid::Uuid::new_v4().to_string(), + None, + ) + .await; + assert_eq!(status, 409, "{wrong_vault}"); + let (status, loaded) = request( + &mut core, + "GET", + path, + &vault, + &uuid::Uuid::new_v4().to_string(), + None, + ) + .await; + assert_eq!(status, 200, "{loaded}"); + assert_eq!(loaded, first); + let stored = workspace + .lock() + .unwrap() + .record_get_kind("persona", "default") + .unwrap() + .unwrap(); + assert_eq!(stored["hash"], first["revision"]); + assert_eq!(workspace.lock().unwrap().pending_count().unwrap(), 9); } diff --git a/frontend/src-tauri/tests/sync_push.rs b/frontend/src-tauri/tests/sync_push.rs index d4575a1..d5b069d 100644 --- a/frontend/src-tauri/tests/sync_push.rs +++ b/frontend/src-tauri/tests/sync_push.rs @@ -535,6 +535,121 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate .unwrap()["record"]["data"]["fontEditorSize"], 24 ); + // Portable records traverse real HTTP, including equal display versions with + // different contents. A numeric persona version cannot replace content CAS. + for (kind, id, data, field) in [ + ( + "persona", + "default", + json!({"version":1,"name":"initial","system_prompt":"Scoped prompt","dialogue_pairs":[]}), + "name", + ), + ( + "layout", + "sidebars", + json!({"primaryExpanded":true,"workspaceWidth":272,"chatWidth":320}), + "workspaceWidth", + ), + ] { + let path = notesagent_host::records::path_for(kind, id).unwrap(); + let record = json!({"schema":1,"kind":kind,"id":id,"data":data}); + workspace + .lock() + .unwrap() + .write(&path, "", &serde_json::to_vec(&record).unwrap(), "local") + .unwrap(); + while client.push_one(&workspace, &binding).await.unwrap() {} + client_b.pull_page(&workspace_b, &binding_b).await.unwrap(); + for round in 0..20 { + let mut hashes = Vec::new(); + for (side, target) in [(0, &workspace), (1, &workspace_b)] { + let mut ws = target.lock().unwrap(); + let current = ws.record_get_kind(kind, id).unwrap().unwrap(); + let mut next = current["record"].clone(); + next["data"][field] = if kind == "persona" { + json!(format!("side-{side}-round-{round}")) + } else { + json!(300 + round * 2 + side) + }; + let entry = ws + .write( + &path, + current["hash"].as_str().unwrap(), + &serde_json::to_vec(&next).unwrap(), + "local", + ) + .unwrap(); + hashes.push(entry.hash); + } + while client.push_one(&workspace, &binding).await.unwrap() {} + client_b.pull_page(&workspace_b, &binding_b).await.unwrap(); + let choice = if round % 2 == 0 { "local" } else { "remote" }; + { + let mut ws = workspace_b.lock().unwrap(); + let conflicts = ws.sync_conflicts(&binding_b.id).unwrap(); + assert_eq!(conflicts.len(), 1); + let sequence = conflicts[0]["sequence"].as_i64().unwrap(); + assert_eq!(ws.read(&path).unwrap().entry.hash, hashes[1]); + assert_eq!( + ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[0]) + .unwrap_err() + .code, + "REVISION_CONFLICT" + ); + ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[1]) + .unwrap(); + ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[1]) + .unwrap(); + } + while client_b.push_one(&workspace_b, &binding_b).await.unwrap() {} + client.pull_page(&workspace, &binding).await.unwrap(); + client_b.pull_page(&workspace_b, &binding_b).await.unwrap(); + let a = workspace + .lock() + .unwrap() + .record_get_kind(kind, id) + .unwrap() + .unwrap(); + let b = workspace_b + .lock() + .unwrap() + .record_get_kind(kind, id) + .unwrap() + .unwrap(); + assert_eq!(a, b); + assert_eq!(a["hash"], hashes[usize::from(choice == "local")]); + assert!(workspace + .lock() + .unwrap() + .sync_next(&binding.id) + .unwrap() + .is_none()); + assert!(workspace_b + .lock() + .unwrap() + .sync_next(&binding_b.id) + .unwrap() + .is_none()); + } + } + drop(workspace_b); + let workspace_b = Arc::new(Mutex::new(Workspace::open(root_b.path()).unwrap())); + assert_eq!( + client_b.pull_page(&workspace_b, &binding_b).await.unwrap(), + 0 + ); + assert!(!client_b.push_one(&workspace_b, &binding_b).await.unwrap()); + for (kind, id) in [("persona", "default"), ("layout", "sidebars")] { + assert_eq!( + workspace.lock().unwrap().record_get_kind(kind, id).unwrap(), + workspace_b + .lock() + .unwrap() + .record_get_kind(kind, id) + .unwrap() + ); + } + // Kill the actual client process after each durable 10 MiB server offset, // before its response reaches the client. The next process must query offset. use sha2::{Digest, Sha256};