7c8b21c2a5
suggestion.list|accept|reject (accept/reject write through the buffer path, viewers refused with forbidden, stale ranges answer stale_range, role from the bound server's member list cached 5 minutes, an unbound vault is the owner). Renaming a note moves its <note>.comments.json; a sync conflict on a sidecar merges the two by thread id instead of leaving two half-files (comments spec tasks 5 and 6). Built by MiniMax M3.1 Flash (OpenCode Zen free route); verified from a clean checkout of the staged files: notescraft-engine 816 tests pass, clippy -D warnings, xtask layers and tree clean. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
312 lines
14 KiB
Rust
312 lines
14 KiB
Rust
//! Integration tests of the sidecar under a rename and under a conflict: the
|
|
//! threads of a note follow the note, and two devices that commented while
|
|
//! they were offline end with one sidecar that holds both threads.
|
|
|
|
mod common;
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex, MutexGuard};
|
|
|
|
use common::{on_disk, seed, temp_vault};
|
|
use notescraft_engine::review::{Comment, Sidecar};
|
|
use notescraft_engine::{
|
|
Changes, Keep, PutReply, Registry, Remote, Session, SyncError, SyncReport, SyncResult, SyncState, register_core, resolve_conflict, sync_once,
|
|
};
|
|
use notescraft_sync::{Ledger, Outcome, Write, conflict_path, hash_hex};
|
|
use notescraft_vault::Vault;
|
|
use serde_json::{Value, json};
|
|
|
|
/// The note the tests comment on.
|
|
const NOTE: &str = "# Note\n\nThis always works, except on slow disks.\n";
|
|
|
|
/// The quote the tests anchor their threads to.
|
|
const QUOTE: &str = "This always works";
|
|
|
|
/// The date text the conflict copies carry.
|
|
const STAMP: &str = "2026-10-20";
|
|
|
|
/// A registry, a session and a vault with one note.
|
|
fn fixture(name: &str) -> (Registry<Session>, Session, common::TempVault) {
|
|
let vault = temp_vault(name);
|
|
seed(vault.path(), &[("note.md", NOTE)]);
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register the core commands");
|
|
let session = Session::open(vault.path()).expect("open the vault");
|
|
(reg, session, vault)
|
|
}
|
|
|
|
/// The threads of `path`, as `comment.list` answers them.
|
|
fn threads(reg: &Registry<Session>, session: &mut Session, path: &str) -> Vec<Value> {
|
|
let listed = reg.run("comment.list", session, &json!({ "path": path })).expect("comment.list");
|
|
listed.get("comments").and_then(Value::as_array).expect("the threads of the note").clone()
|
|
}
|
|
|
|
/// The bytes of a sidecar holding the given threads.
|
|
fn sidecar_bytes(note: &str, threads: &[(&str, &str)]) -> Vec<u8> {
|
|
let comments: Vec<Value> = threads
|
|
.iter()
|
|
.map(|(id, body)| json!({ "id": id, "author": "local", "created_ms": 1_760_000_000_000u64, "updated_ms": 1_760_000_000_000u64, "body": body }))
|
|
.collect();
|
|
serde_json::to_vec(&json!({ "version": 1, "note": note, "comments": comments })).expect("the sidecar")
|
|
}
|
|
|
|
/// The bodies of the threads of a sidecar, read from the disk.
|
|
fn bodies_on_disk(vault: &std::path::Path, path: &str) -> Vec<String> {
|
|
let parsed: Value = serde_json::from_str(&on_disk(vault, path)).expect("the sidecar is JSON");
|
|
parsed
|
|
.get("comments")
|
|
.and_then(Value::as_array)
|
|
.expect("the threads of the sidecar")
|
|
.iter()
|
|
.map(|comment| comment.get("body").and_then(Value::as_str).expect("the body of a thread").to_owned())
|
|
.collect()
|
|
}
|
|
|
|
/// A rename moves the sidecar of the note with it.
|
|
#[test]
|
|
fn a_rename_moves_the_sidecar_with_the_note() {
|
|
let (reg, mut session, vault) = fixture("rename");
|
|
let start = NOTE.find(QUOTE).expect("the quote is in the note");
|
|
reg.run(
|
|
"comment.add",
|
|
&mut session,
|
|
&json!({ "path": "note.md", "quote": QUOTE, "body": "Can we soften this claim?", "start": start, "end": start + QUOTE.len() }),
|
|
)
|
|
.expect("comment.add");
|
|
|
|
reg.run("note.rename", &mut session, &json!({ "from": "note.md", "to": "moved.md" })).expect("note.rename");
|
|
|
|
assert!(!vault.path().join("note.md.comments.json").exists(), "the old sidecar is gone");
|
|
assert!(vault.path().join("moved.md.comments.json").exists(), "the sidecar sits next to the note");
|
|
let sidecar: Value = serde_json::from_str(&on_disk(vault.path(), "moved.md.comments.json")).expect("the sidecar is JSON");
|
|
assert_eq!(sidecar.get("note"), Some(&json!("moved.md")), "the sidecar points at the new path");
|
|
assert_eq!(threads(®, &mut session, "moved.md").len(), 1, "the thread of the note survived");
|
|
}
|
|
|
|
/// A folder rename keeps the threads readable: the sidecar moved with the
|
|
/// folder, so only the path inside it has to be rewritten.
|
|
#[test]
|
|
fn a_folder_rename_keeps_the_threads_readable() {
|
|
let vault = temp_vault("folder-rename");
|
|
seed(vault.path(), &[("notes/note.md", NOTE)]);
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register the core commands");
|
|
let mut session = Session::open(vault.path()).expect("open the vault");
|
|
let start = NOTE.find(QUOTE).expect("the quote is in the note");
|
|
reg.run(
|
|
"comment.add",
|
|
&mut session,
|
|
&json!({ "path": "notes/note.md", "quote": QUOTE, "body": "Can we soften this claim?", "start": start, "end": start + QUOTE.len() }),
|
|
)
|
|
.expect("comment.add");
|
|
|
|
reg.run("folder.rename", &mut session, &json!({ "from": "notes", "to": "archive" })).expect("folder.rename");
|
|
|
|
assert!(vault.path().join("archive/note.md.comments.json").exists(), "the sidecar moved with the folder");
|
|
let sidecar: Value = serde_json::from_str(&on_disk(vault.path(), "archive/note.md.comments.json")).expect("the sidecar is JSON");
|
|
assert_eq!(sidecar.get("note"), Some(&json!("archive/note.md")), "the sidecar points at the new path");
|
|
assert_eq!(threads(®, &mut session, "archive/note.md").len(), 1, "the thread of the note survived");
|
|
}
|
|
|
|
/// A rename that would land on another sidecar is refused: no thread is lost.
|
|
#[test]
|
|
fn a_rename_onto_an_existing_sidecar_is_refused() {
|
|
let (reg, mut session, vault) = fixture("rename-onto");
|
|
let start = NOTE.find(QUOTE).expect("the quote is in the note");
|
|
reg.run(
|
|
"comment.add",
|
|
&mut session,
|
|
&json!({ "path": "note.md", "quote": QUOTE, "body": "Can we soften this claim?", "start": start, "end": start + QUOTE.len() }),
|
|
)
|
|
.expect("comment.add");
|
|
// The sidecar of another note sits at the new path.
|
|
std::fs::write(vault.path().join("moved.md.comments.json"), sidecar_bytes("moved.md", &[("c-other", "other")])).expect("the leftover sidecar");
|
|
|
|
let result = reg.run("note.rename", &mut session, &json!({ "from": "note.md", "to": "moved.md" }));
|
|
|
|
assert!(result.is_err(), "the rename is refused");
|
|
assert!(vault.path().join("note.md").exists(), "the note stayed where it was");
|
|
assert_eq!(bodies_on_disk(vault.path(), "moved.md.comments.json"), vec!["other".to_owned()], "the other sidecar is untouched");
|
|
assert_eq!(bodies_on_disk(vault.path(), "note.md.comments.json"), vec!["Can we soften this claim?".to_owned()], "the threads of the note are untouched");
|
|
}
|
|
|
|
/// A sidecar that cannot be read does not stop a rename.
|
|
#[test]
|
|
fn a_broken_sidecar_does_not_stop_a_rename() {
|
|
let (reg, mut session, vault) = fixture("broken-sidecar");
|
|
std::fs::write(vault.path().join("note.md.comments.json"), b"{not json").expect("the broken sidecar");
|
|
|
|
reg.run("note.rename", &mut session, &json!({ "from": "note.md", "to": "moved.md" })).expect("note.rename");
|
|
|
|
assert!(vault.path().join("moved.md").exists(), "the note moved");
|
|
assert!(vault.path().join("moved.md.comments.json").exists(), "the broken sidecar moved with it");
|
|
}
|
|
|
|
/// The merge of two sidecars keeps every thread of both.
|
|
#[test]
|
|
fn the_merge_of_two_sidecars_keeps_every_thread() {
|
|
let ours =
|
|
Sidecar { version: 1, note: "note.md".to_owned(), comments: vec![Comment { id: "c-1".to_owned(), body: "ours".to_owned(), ..Comment::default() }] };
|
|
let theirs =
|
|
Sidecar { version: 1, note: "note.md".to_owned(), comments: vec![Comment { id: "c-2".to_owned(), body: "theirs".to_owned(), ..Comment::default() }] };
|
|
|
|
let merged = notescraft_engine::review::merge_sidecars(&ours, &theirs).expect("the two sidecars merge");
|
|
|
|
let bodies: Vec<&str> = merged.comments.iter().map(|comment| comment.body.as_str()).collect();
|
|
assert_eq!(bodies, vec!["ours", "theirs"]);
|
|
}
|
|
|
|
/// The server the two devices talk to.
|
|
struct Server {
|
|
inner: Mutex<Inner>,
|
|
}
|
|
|
|
/// What the server keeps.
|
|
struct Inner {
|
|
ledger: Ledger,
|
|
blobs: HashMap<String, Vec<u8>>,
|
|
}
|
|
|
|
impl Server {
|
|
fn new() -> Arc<Server> {
|
|
Arc::new(Server { inner: Mutex::new(Inner { ledger: Ledger::new(), blobs: HashMap::new() }) })
|
|
}
|
|
|
|
fn lock(&self) -> MutexGuard<'_, Inner> {
|
|
self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
}
|
|
|
|
/// The client the driver talks to.
|
|
struct FakeRemote {
|
|
server: Arc<Server>,
|
|
}
|
|
|
|
impl Remote for FakeRemote {
|
|
fn changes(&self, since: u64, limit: usize, _wait: u64) -> SyncResult<Changes> {
|
|
let inner = self.server.lock();
|
|
Ok(inner.ledger.changes_since(since, limit))
|
|
}
|
|
|
|
fn get(&self, path: &str) -> SyncResult<Vec<u8>> {
|
|
let inner = self.server.lock();
|
|
inner.blobs.get(path).cloned().ok_or_else(|| SyncError::Status { status: 404, body: path.to_owned() })
|
|
}
|
|
|
|
fn put(&self, path: &str, bytes: &[u8], base_seq: u64, mtime_ms: u64) -> SyncResult<PutReply> {
|
|
let mut inner = self.server.lock();
|
|
let outcome = inner
|
|
.ledger
|
|
.apply(Write { path: path.to_owned(), hash: Some(hash_hex(bytes)), size: bytes.len() as u64, mtime_ms, base_seq })
|
|
.map_err(|error| SyncError::Status { status: 400, body: error.to_string() })?;
|
|
match outcome {
|
|
Outcome::Applied(record) => {
|
|
inner.blobs.insert(path.to_owned(), bytes.to_vec());
|
|
Ok(PutReply::Applied(record))
|
|
}
|
|
Outcome::Unchanged(record) => Ok(PutReply::Unchanged(record)),
|
|
Outcome::Conflict(record) => Ok(PutReply::Conflict(record)),
|
|
}
|
|
}
|
|
|
|
fn delete(&self, path: &str, base_seq: u64) -> SyncResult<PutReply> {
|
|
let mut inner = self.server.lock();
|
|
let outcome = inner
|
|
.ledger
|
|
.apply(Write { path: path.to_owned(), hash: None, size: 0, mtime_ms: 0, base_seq })
|
|
.map_err(|error| SyncError::Status { status: 400, body: error.to_string() })?;
|
|
match outcome {
|
|
Outcome::Applied(record) => Ok(PutReply::Applied(record)),
|
|
Outcome::Unchanged(record) => Ok(PutReply::Unchanged(record)),
|
|
Outcome::Conflict(record) => Ok(PutReply::Conflict(record)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A device with its own vault and its own state.
|
|
struct Device {
|
|
vault: Vault,
|
|
/// Keeps the folder of the device alive as long as the device is.
|
|
_root: common::TempVault,
|
|
state: SyncState,
|
|
remote: FakeRemote,
|
|
}
|
|
|
|
impl Device {
|
|
fn new(name: &str, server: &Arc<Server>) -> Self {
|
|
let root = temp_vault(name);
|
|
let vault = Vault::open(root.path()).expect("the vault of the device");
|
|
Self { vault, _root: root, state: SyncState::default(), remote: FakeRemote { server: server.clone() } }
|
|
}
|
|
|
|
/// Syncs once and returns the report.
|
|
fn sync(&mut self, device: &str) -> SyncReport {
|
|
sync_once(&self.vault, &self.remote, &mut self.state, device, STAMP).expect("sync")
|
|
}
|
|
|
|
/// Writes a file of the vault.
|
|
fn write(&self, path: &str, bytes: &[u8]) {
|
|
self.vault.write(path, bytes).expect("write");
|
|
}
|
|
|
|
/// The bodies of the threads of the sidecar of `path`.
|
|
fn bodies(&self, path: &str) -> Vec<String> {
|
|
let bytes = self.vault.read(path).expect("the sidecar");
|
|
let parsed: Value = serde_json::from_slice(&bytes).expect("the sidecar is JSON");
|
|
parsed
|
|
.get("comments")
|
|
.and_then(Value::as_array)
|
|
.expect("the threads of the sidecar")
|
|
.iter()
|
|
.map(|comment| comment.get("body").and_then(Value::as_str).expect("the body of a thread").to_owned())
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// Two devices that commented while they were offline end with one sidecar
|
|
/// that holds both threads.
|
|
#[test]
|
|
fn two_devices_that_commented_while_offline_end_with_one_sidecar() {
|
|
let server = Server::new();
|
|
let mut phone = Device::new("merge-phone", &server);
|
|
let mut laptop = Device::new("merge-laptop", &server);
|
|
phone.write("note.md", NOTE.as_bytes());
|
|
phone.write("note.md.comments.json", &sidecar_bytes("note.md", &[("c-phone", "from the phone")]));
|
|
phone.sync("phone");
|
|
|
|
laptop.write("note.md", NOTE.as_bytes());
|
|
laptop.write("note.md.comments.json", &sidecar_bytes("note.md", &[("c-laptop", "from the laptop")]));
|
|
|
|
let report = laptop.sync("laptop");
|
|
|
|
assert_eq!(report.conflicts, Vec::<String>::new(), "no half-file was left behind");
|
|
assert_eq!(report.merged, vec!["note.md.comments.json".to_string()], "the two sidecars merged");
|
|
let mut bodies = laptop.bodies("note.md.comments.json");
|
|
bodies.sort();
|
|
assert_eq!(bodies, vec!["from the laptop".to_owned(), "from the phone".to_owned()]);
|
|
|
|
// The phone picks the merged sidecar up on its next pass.
|
|
phone.sync("phone");
|
|
let mut bodies = phone.bodies("note.md.comments.json");
|
|
bodies.sort();
|
|
assert_eq!(bodies, vec!["from the laptop".to_owned(), "from the phone".to_owned()]);
|
|
}
|
|
|
|
/// A conflict on a sidecar settled by hand is merged, not kept as two files.
|
|
#[test]
|
|
fn a_conflict_of_a_sidecar_settled_by_hand_is_merged() {
|
|
let root = temp_vault("merge-conflict");
|
|
let vault = Vault::open(root.path()).expect("the vault");
|
|
vault.write("note.md", NOTE.as_bytes()).expect("the note");
|
|
vault.write("note.md.comments.json", &sidecar_bytes("note.md", &[("c-mine", "mine")])).expect("the sidecar");
|
|
let copy = conflict_path("note.md.comments.json", "laptop", STAMP);
|
|
vault.write(©, &sidecar_bytes("note.md", &[("c-theirs", "theirs")])).expect("the copy");
|
|
|
|
resolve_conflict(&vault, ©, Keep::Both).expect("the conflict is settled");
|
|
|
|
let mut bodies = bodies_on_disk(root.path(), "note.md.comments.json");
|
|
bodies.sort();
|
|
assert_eq!(bodies, vec!["mine".to_owned(), "theirs".to_owned()], "both threads survive");
|
|
assert!(!root.path().join(©).exists(), "the copy went to the trash");
|
|
}
|