Files
NotesAgentic/frontend/src-tauri/tests/sync_push.rs
T

307 lines
9.7 KiB
Rust

#![cfg(feature = "desktop")]
use notesagent_host::{sync_client::SyncClient, workspace::Workspace};
use serde_json::{json, Value};
use std::{
io::{BufRead, BufReader},
path::Path,
process::{Child, Command, Stdio},
sync::{Arc, Mutex},
};
use zeroize::Zeroizing;
struct Server(Child);
impl Drop for Server {
fn drop(&mut self) {
self.0.stdin.take();
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[tokio::test]
async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicates() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join(".opennexus-test"), b"fixture").unwrap();
let service = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../server sync")
.canonicalize()
.unwrap();
let python = service.join(if cfg!(windows) {
".venv/Scripts/python.exe"
} else {
".venv/bin/python"
});
let mut server = Server(
Command::new(python)
.args(["-m", "tests.host_fixture"])
.arg(root.path())
.current_dir(service)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let mut line = String::new();
BufReader::new(server.0.stdout.take().unwrap())
.read_line(&mut line)
.unwrap();
let ready: Value = serde_json::from_str(&line).unwrap();
let endpoint = format!("http://127.0.0.1:{}", ready["port"]);
let public = SyncClient::new(&endpoint, Zeroizing::new(String::new()), true).unwrap();
public.handshake().await.unwrap();
let session = public
.login(
"rust-fixture",
Zeroizing::new("controlled-fixture-password".into()),
"Rust integration",
)
.await
.unwrap();
let client = SyncClient::new(
&endpoint,
Zeroizing::new(session.access_token.clone()),
true,
)
.unwrap();
let vault = client
.json(
reqwest::Method::POST,
"sync/v1/vaults",
Some(json!({"name":"Rust test"})),
)
.await
.unwrap();
let remote = vault["vault_id"].as_str().unwrap();
client.verify_empty(remote).await.unwrap();
let local = tempfile::tempdir().unwrap();
let workspace = Arc::new(Mutex::new(Workspace::open(local.path()).unwrap()));
let binding = {
let mut ws = workspace.lock().unwrap();
let mut digest = String::new();
for index in 0..20 {
digest = ws
.write(
"note.md",
&digest,
format!("fixture-{index}").as_bytes(),
"local",
)
.unwrap()
.hash;
}
ws.sync_bind_empty(&endpoint, remote, "rust-fixture")
.unwrap()
};
let first = workspace
.lock()
.unwrap()
.sync_next(&binding.id)
.unwrap()
.unwrap();
assert!(client.push_one(&workspace, &binding).await.unwrap());
let payload = workspace
.lock()
.unwrap()
.sync_commit_payload(&first)
.unwrap();
for _ in 0..100 {
let replay = client
.json(
reqwest::Method::POST,
&format!("sync/v1/vaults/{remote}/revisions"),
Some(payload.clone()),
)
.await
.unwrap();
assert_eq!(replay["sequence"], 1);
}
for _ in 1..20 {
assert!(client.push_one(&workspace, &binding).await.unwrap());
}
assert!(!client.push_one(&workspace, &binding).await.unwrap());
let changes = client
.json(
reqwest::Method::GET,
&format!("sync/v1/vaults/{remote}/changes"),
None,
)
.await
.unwrap();
assert_eq!(changes["items"].as_array().unwrap().len(), 20);
for (index, revision) in changes["items"].as_array().unwrap().iter().enumerate() {
assert_eq!(revision["base_revision"], index as i64);
assert_eq!(revision["sequence"], index as i64 + 1);
}
assert_eq!(
client.verify_empty(remote).await.unwrap_err().code,
"SYNC_RECONCILIATION_REQUIRED"
);
let session_b = public
.login(
"rust-fixture",
Zeroizing::new("controlled-fixture-password".into()),
"Device B",
)
.await
.unwrap();
let client_b = SyncClient::new(
&endpoint,
Zeroizing::new(session_b.access_token.clone()),
true,
)
.unwrap();
let root_b = tempfile::tempdir().unwrap();
let workspace_b = Arc::new(Mutex::new(Workspace::open(root_b.path()).unwrap()));
let binding_b = workspace_b
.lock()
.unwrap()
.sync_bind_download(&endpoint, remote, "rust-fixture")
.unwrap();
assert_eq!(
client_b.pull_page(&workspace_b, &binding_b).await.unwrap(),
20
);
assert_eq!(
workspace_b.lock().unwrap().read("note.md").unwrap().content,
"fixture-19"
);
assert_eq!(workspace_b.lock().unwrap().pending_count().unwrap(), 0);
assert_eq!(
workspace_b
.lock()
.unwrap()
.read("note.md")
.unwrap()
.entry
.file_id,
first.file_id
);
// Receiving one's historical commits never rolls back newer local edits.
{
let mut ws = workspace.lock().unwrap();
let current = ws.read("note.md").unwrap();
ws.write("note.md", &current.entry.hash, b"new-a", "local")
.unwrap();
}
assert_eq!(client.pull_page(&workspace, &binding).await.unwrap(), 20);
assert_eq!(
workspace.lock().unwrap().read("note.md").unwrap().content,
"new-a"
);
{
let mut ws = workspace_b.lock().unwrap();
let current = ws.read("note.md").unwrap();
ws.write("note.md", &current.entry.hash, b"offline-b", "local")
.unwrap();
}
client.push_one(&workspace, &binding).await.unwrap();
assert_eq!(
client_b.pull_page(&workspace_b, &binding_b).await.unwrap(),
1
);
assert_eq!(
workspace_b.lock().unwrap().read("note.md").unwrap().content,
"offline-b"
);
let conflicts = workspace_b
.lock()
.unwrap()
.sync_conflicts(&binding_b.id)
.unwrap();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0]["remote"]["sequence"], 21);
assert_eq!(
workspace_b
.lock()
.unwrap()
.sync_binding()
.unwrap()
.unwrap()
.cursor,
21
);
// All three explicit choices converge; the local copy gets an independent file ID.
for (iteration, choice) in ["local", "remote", "copy"].into_iter().enumerate() {
if iteration > 0 {
for (ws, content) in [(&workspace, "next-a"), (&workspace_b, "next-b")] {
let mut ws = ws.lock().unwrap();
let current = ws.read("note.md").unwrap();
ws.write("note.md", &current.entry.hash, content.as_bytes(), "local")
.unwrap();
}
assert!(client.push_one(&workspace, &binding).await.unwrap());
assert_eq!(
client_b.pull_page(&workspace_b, &binding_b).await.unwrap(),
1
);
}
{
let mut ws = workspace_b.lock().unwrap();
let conflict = ws.sync_conflicts(&binding_b.id).unwrap().remove(0);
let sequence = conflict["sequence"].as_i64().unwrap();
let expected = ws.read("note.md").unwrap().entry.hash;
assert_eq!(
ws.sync_resolve(
&binding_b.id,
sequence,
choice,
if choice == "copy" { "copy.md" } else { "" },
"wrong"
)
.unwrap_err()
.code,
"REVISION_CONFLICT"
);
ws.sync_resolve(
&binding_b.id,
sequence,
choice,
if choice == "copy" { "copy.md" } else { "" },
&expected,
)
.unwrap();
assert!(ws.sync_conflicts(&binding_b.id).unwrap().is_empty());
// Repeating a persisted decision is harmless.
ws.sync_resolve(
&binding_b.id,
sequence,
choice,
if choice == "copy" { "copy.md" } else { "" },
&expected,
)
.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 expected = if choice == "local" {
"offline-b"
} else {
"next-a"
};
assert_eq!(
workspace.lock().unwrap().read("note.md").unwrap().content,
expected
);
assert_eq!(
workspace_b.lock().unwrap().read("note.md").unwrap().content,
expected
);
if choice == "copy" {
let copy = workspace.lock().unwrap().read("copy.md").unwrap();
assert_eq!(copy.content, "next-b");
assert_ne!(copy.entry.file_id, first.file_id);
assert_eq!(
copy.entry.file_id,
workspace_b
.lock()
.unwrap()
.read("copy.md")
.unwrap()
.entry
.file_id
);
}
}
}