//! Integration tests of the `comment.*` commands through the registry: the //! sidecar on disk, the note that never changes, and the refusals. //! //! The commands are driven the way the UI, the CLI and MCP drive them: by id, //! with a params object. mod common; use common::{on_disk, seed, temp_vault}; use notescraft_engine::{Binding, Error, Registry, Session, register_core, save_binding}; 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 body of the thread the tests add. const BODY: &str = "Can we soften this claim?"; /// A registry, a session and a vault with one note. fn fixture(name: &str) -> (Registry, 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 `{ code, message }` a command reported. fn code(result: &Result) -> String { match result { Err(Error::Command { source, .. }) => source.code.clone(), other => panic!("expected a command error, got {other:?}"), } } /// The byte offset of the quote in the note. fn quote_start(text: &str) -> usize { text.find(QUOTE).expect("the quote is in the note") } /// Makes the session write as that user: the binding of the vault carries the /// id of the account, so the commands take their author from it. fn bind_as(session: &mut Session, user: &str) { let binding = Binding { server: "https://sync.example".to_owned(), vault_id: "v-1".to_owned(), device_id: "d-1".to_owned(), user_id: Some(user.to_owned()) }; save_binding(session.vault(), &binding).expect("bind the vault to an account"); } /// Adds a thread on the quote of the note and returns its id. fn add(reg: &Registry, session: &mut Session) -> String { let start = quote_start(NOTE); let added = reg .run("comment.add", session, &json!({ "path": "note.md", "quote": QUOTE, "body": BODY, "start": start, "end": start + QUOTE.len() })) .expect("comment.add"); added.get("id").and_then(Value::as_str).expect("the id of the thread").to_owned() } /// The threads of the note, as `comment.list` answers them. fn threads(reg: &Registry, session: &mut Session) -> Vec { let listed = reg.run("comment.list", session, &json!({ "path": "note.md" })).expect("comment.list"); listed.get("comments").and_then(Value::as_array).expect("the threads of the note").clone() } /// A comment lands in the sidecar next to the note and the note never changes. #[test] fn a_comment_lands_in_the_sidecar_and_the_note_stays_the_same() { let (reg, mut session, vault) = fixture("add"); let before = on_disk(vault.path(), "note.md"); let id = add(®, &mut session); assert_eq!(on_disk(vault.path(), "note.md"), before, "the note is byte for byte the same"); let sidecar = on_disk(vault.path(), "note.md.comments.json"); let parsed: Value = serde_json::from_str(&sidecar).expect("the sidecar is JSON"); assert_eq!(parsed.get("version"), Some(&json!(1))); assert_eq!(parsed.get("note"), Some(&json!("note.md"))); let comments = parsed.get("comments").and_then(Value::as_array).expect("the threads of the note"); assert_eq!(comments.len(), 1); let comment = comments.first().expect("the thread"); assert_eq!(comment.get("id"), Some(&json!(id))); assert_eq!(comment.get("body"), Some(&json!(BODY))); assert_eq!(comment.get("author"), Some(&json!("local"))); assert_eq!(comment.get("resolved"), Some(&json!(false))); assert_eq!(comment.get("mentions"), Some(&json!([]))); assert_eq!(comment.get("anchor").and_then(|anchor| anchor.get("quote")), Some(&json!(QUOTE))); assert!(!vault.path().join(".notescraft/comments.json").exists(), "the sidecar is not private state"); } /// The list answers the threads of the note with the state of their anchors. #[test] fn the_list_answers_the_threads_with_the_state_of_their_anchors() { let (reg, mut session, _vault) = fixture("list"); let id = add(®, &mut session); let listed = threads(®, &mut session); assert_eq!(listed.len(), 1); let comment = listed.first().expect("the thread"); assert_eq!(comment.get("id"), Some(&json!(id))); assert_eq!(comment.get("state"), Some(&json!("exact"))); assert_eq!(comment.get("range").and_then(|range| range.get("start")), Some(&json!(quote_start(NOTE)))); assert_eq!(comment.get("thread"), Some(&json!([]))); } /// A note without threads has an empty list, not an error. #[test] fn a_note_without_threads_has_an_empty_list() { let (reg, mut session, _vault) = fixture("empty"); assert_eq!(threads(®, &mut session), Vec::::new()); assert_eq!(code(®.run("comment.list", &mut session, &json!({ "path": "nope.md" }))), "not_found"); } /// A reply joins the thread of the note. #[test] fn a_reply_joins_the_thread() { let (reg, mut session, _vault) = fixture("reply"); let id = add(®, &mut session); let replied = reg.run("comment.reply", &mut session, &json!({ "path": "note.md", "id": id, "body": "Fair." })).expect("comment.reply"); let reply_id = replied.get("id").and_then(Value::as_str).expect("the id of the reply").to_owned(); let listed = threads(®, &mut session); let thread = listed.first().and_then(|comment| comment.get("thread")).and_then(Value::as_array).expect("the replies of the thread"); assert_eq!(thread.len(), 1); assert_eq!(thread.first().and_then(|reply| reply.get("id")), Some(&json!(reply_id))); assert_eq!(thread.first().and_then(|reply| reply.get("body")), Some(&json!("Fair."))); assert_eq!(thread.first().and_then(|reply| reply.get("author")), Some(&json!("local"))); } /// Only the author of a thread may change or remove it. #[test] fn only_the_author_may_change_or_remove_a_thread() { let (reg, mut session, _vault) = fixture("author"); bind_as(&mut session, "u_ana"); let id = add(®, &mut session); bind_as(&mut session, "u_bob"); assert_eq!(code(®.run("comment.edit", &mut session, &json!({ "path": "note.md", "id": id, "body": "hijacked" }))), "forbidden"); assert_eq!(code(®.run("comment.delete", &mut session, &json!({ "path": "note.md", "id": id }))), "forbidden"); let listed = threads(®, &mut session); let comment = listed.first().expect("the thread"); assert_eq!(comment.get("body"), Some(&json!(BODY)), "the refused edit changed nothing"); assert_eq!(comment.get("author"), Some(&json!("u_ana")), "the author comes from the account, never from the params"); // The author may change and remove the thread. bind_as(&mut session, "u_ana"); reg.run("comment.edit", &mut session, &json!({ "path": "note.md", "id": id, "body": "Softer now." })).expect("comment.edit"); let listed = threads(®, &mut session); assert_eq!(listed.first().and_then(|comment| comment.get("body")), Some(&json!("Softer now."))); reg.run("comment.delete", &mut session, &json!({ "path": "note.md", "id": id })).expect("comment.delete"); assert_eq!(threads(®, &mut session), Vec::::new()); } /// A thread is closed, and opened again. #[test] fn a_thread_is_closed_and_opened_again() { let (reg, mut session, _vault) = fixture("resolve"); let id = add(®, &mut session); reg.run("comment.resolve", &mut session, &json!({ "path": "note.md", "id": id, "resolved": true })).expect("comment.resolve"); let listed = threads(®, &mut session); let comment = listed.first().expect("the thread"); assert_eq!(comment.get("resolved"), Some(&json!(true))); assert_eq!(comment.get("resolved_by"), Some(&json!("local"))); reg.run("comment.resolve", &mut session, &json!({ "path": "note.md", "id": id, "resolved": false })).expect("comment.resolve"); let listed = threads(®, &mut session); let comment = listed.first().expect("the thread"); assert_eq!(comment.get("resolved"), Some(&json!(false))); assert_eq!(comment.get("resolved_by"), Some(&json!(null))); } /// A quote that moved is reanchored, a quote that is gone is orphaned. #[test] fn an_anchor_that_drifted_is_reported() { let (reg, mut session, _vault) = fixture("drift"); add(®, &mut session); let moved = "# Note\n\nA line before.\n\nThis always works, except on slow disks.\n"; reg.run("note.write", &mut session, &json!({ "path": "note.md", "content": moved })).expect("note.write"); let listed = threads(®, &mut session); assert_eq!(listed.first().and_then(|comment| comment.get("state")), Some(&json!("reanchored"))); let gone = "# Note\n\nThat never worked, but people believed it.\n"; reg.run("note.write", &mut session, &json!({ "path": "note.md", "content": gone })).expect("note.write"); let listed = threads(®, &mut session); assert_eq!(listed.first().and_then(|comment| comment.get("state")), Some(&json!("orphaned"))); } /// An orphaned thread is moved to another quote of the note. #[test] fn an_orphaned_thread_can_be_moved_to_another_quote() { let (reg, mut session, _vault) = fixture("reanchor"); let id = add(®, &mut session); let changed = "# Note\n\nThat never worked, but people believed it.\n"; reg.run("note.write", &mut session, &json!({ "path": "note.md", "content": changed })).expect("note.write"); let quote = "people believed it"; let start = changed.find(quote).expect("the new quote is in the note"); let moved = reg .run("comment.reanchor", &mut session, &json!({ "path": "note.md", "id": id, "quote": quote, "start": start, "end": start + quote.len() })) .expect("comment.reanchor"); assert_eq!(moved.get("state"), Some(&json!("exact"))); let listed = threads(®, &mut session); let comment = listed.first().expect("the thread"); assert_eq!(comment.get("state"), Some(&json!("exact"))); assert_eq!(comment.get("anchor").and_then(|anchor| anchor.get("quote")), Some(&json!(quote))); } /// A re-anchor on a range that holds another text is refused. #[test] fn a_reanchor_on_a_stale_range_is_refused() { let (reg, mut session, _vault) = fixture("stale"); let id = add(®, &mut session); let result = reg.run("comment.reanchor", &mut session, &json!({ "path": "note.md", "id": id, "quote": QUOTE, "start": 0, "end": QUOTE.len() })); assert_eq!(code(&result), "stale_range"); let listed = threads(®, &mut session); assert_eq!(listed.first().and_then(|comment| comment.get("state")), Some(&json!("exact")), "the refused move changed nothing"); } /// The anchors are resolved against the text the editor shows: the open /// buffer, not the file on disk. #[test] fn the_anchors_resolve_against_the_open_buffer() { let (reg, mut session, vault) = fixture("buffer"); add(®, &mut session); reg.run("buffer.open", &mut session, &json!({ "path": "note.md" })).expect("buffer.open"); reg.run("buffer.edit", &mut session, &json!({ "path": "note.md", "start": 0, "end": 0, "text": "A line before.\n\n" })).expect("buffer.edit"); let listed = threads(®, &mut session); assert_eq!(listed.first().and_then(|comment| comment.get("state")), Some(&json!("reanchored")), "the buffer moved the quote"); assert_eq!(on_disk(vault.path(), "note.md"), NOTE, "the file on disk is untouched"); } /// A read-only session refuses every write command. #[test] fn a_read_only_session_refuses_the_write_commands() { let vault = temp_vault("read-only-comments"); seed(vault.path(), &[("note.md", NOTE)]); let mut reg = Registry::new(); register_core(&mut reg).expect("register the core commands"); let mut session = Session::open_read_only(vault.path()).expect("open read-only"); for id in ["comment.add", "comment.reply", "comment.edit", "comment.delete", "comment.resolve", "comment.reanchor"] { assert!(!reg.is_enabled(id, &session), "{id} must be disabled"); assert!(matches!(reg.run(id, &mut session, &json!({ "path": "note.md" })), Err(Error::Disabled(_))), "{id} must not run"); } assert!(reg.is_enabled("comment.list", &session)); assert!(reg.run("comment.list", &mut session, &json!({ "path": "note.md" })).is_ok()); assert!(!vault.path().join("note.md.comments.json").exists(), "a read-only session writes nothing"); } /// Hostile params are refused, and nothing is written. #[test] fn hostile_params_are_refused() { let (reg, mut session, vault) = fixture("hostile"); let before = on_disk(vault.path(), "note.md"); let cases = [ json!({ "path": "note.md\u{0}.md", "quote": QUOTE, "body": "y" }), json!({ "path": "../escape.md", "quote": QUOTE, "body": "y" }), json!({ "path": "/abs/note.md", "quote": QUOTE, "body": "y" }), json!({ "path": "", "quote": QUOTE, "body": "y" }), json!({ "path": "note.md", "quote": "", "body": "y" }), json!({ "path": "note.md", "quote": QUOTE, "body": "y", "start": 0 }), json!({ "path": "note.md", "quote": QUOTE, "body": "y", "start": 9999, "end": 99999 }), json!({ "path": "note.md", "quote": QUOTE, "body": "y", "start": -1, "end": 2 }), json!({ "path": "note.md", "quote": QUOTE, "body": "y", "mentions": ["u_bob", 7] }), ]; for params in cases { let result = reg.run("comment.add", &mut session, ¶ms); assert!(result.is_err(), "comment.add must refuse {params}"); assert!(!matches!(result, Err(Error::Panicked(_))), "comment.add must not panic on {params}"); } assert_eq!(on_disk(vault.path(), "note.md"), before); assert!(!vault.path().join("note.md.comments.json").exists(), "no sidecar was written"); } /// The reserved folders are never written through the sidecar. #[test] fn the_reserved_folders_are_never_written() { let (reg, mut session, vault) = fixture("reserved"); seed(vault.path(), &[(".obsidian/app.md", "text\n")]); let result = reg.run("comment.add", &mut session, &json!({ "path": ".obsidian/app.md", "quote": "text", "body": "y" })); assert_eq!(code(&result), "reserved"); assert!(!vault.path().join(".obsidian/app.md.comments.json").exists(), "Obsidian's folder is untouched"); assert!(!vault.path().join(".notescraft/app.md.comments.json").exists(), "the private folder is untouched"); } /// The limits of the spec are refused, and the file is never truncated. #[test] fn the_limits_of_the_spec_are_refused() { let (reg, mut session, vault) = fixture("limits"); let start = quote_start(NOTE); let big_body = "x".repeat(4097); assert_eq!(code(®.run("comment.add", &mut session, &json!({ "path": "note.md", "quote": QUOTE, "body": big_body }))), "limit"); let long_quote = "q".repeat(201); assert_eq!(code(®.run("comment.add", &mut session, &json!({ "path": "note.md", "quote": long_quote, "body": "y" }))), "limit"); let many_mentions: Vec = (0..21).map(|index| format!("u_{index}")).collect(); assert_eq!(code(®.run("comment.add", &mut session, &json!({ "path": "note.md", "quote": QUOTE, "body": "y", "mentions": many_mentions }))), "limit"); assert!(!vault.path().join("note.md.comments.json").exists(), "a refused comment writes nothing"); // A note that already holds the most threads refuses one more. let threads: Vec = (0..500).map(|index| json!({ "id": format!("c-{index:04}"), "author": "local", "body": "x" })).collect(); let sidecar = json!({ "version": 1, "note": "note.md", "comments": threads }); std::fs::write(vault.path().join("note.md.comments.json"), serde_json::to_vec(&sidecar).expect("the sidecar")).expect("write the sidecar"); assert_eq!( code(®.run("comment.add", &mut session, &json!({ "path": "note.md", "quote": QUOTE, "body": "y", "start": start, "end": start + QUOTE.len() }))), "limit" ); // A thread that already holds the most replies refuses one more. let replies: Vec = (0..200).map(|index| json!({ "id": format!("r-{index:04}"), "author": "local", "body": "x" })).collect(); let sidecar = json!({ "version": 1, "note": "note.md", "comments": [{ "id": "c-0001", "author": "local", "body": "x", "thread": replies }] }); std::fs::write(vault.path().join("note.md.comments.json"), serde_json::to_vec(&sidecar).expect("the sidecar")).expect("write the sidecar"); assert_eq!(code(®.run("comment.reply", &mut session, &json!({ "path": "note.md", "id": "c-0001", "body": "y" }))), "limit"); } /// The note stays byte for byte the same after every command. #[test] fn the_note_stays_byte_identical_after_every_command() { let (reg, mut session, vault) = fixture("untouched"); let before = on_disk(vault.path(), "note.md"); let id = add(®, &mut session); reg.run("comment.reply", &mut session, &json!({ "path": "note.md", "id": id, "body": "Fair." })).expect("comment.reply"); reg.run("comment.edit", &mut session, &json!({ "path": "note.md", "id": id, "body": "Softer." })).expect("comment.edit"); reg.run("comment.resolve", &mut session, &json!({ "path": "note.md", "id": id, "resolved": true })).expect("comment.resolve"); reg.run("comment.list", &mut session, &json!({ "path": "note.md" })).expect("comment.list"); reg.run( "comment.reanchor", &mut session, &json!({ "path": "note.md", "id": id, "quote": QUOTE, "start": quote_start(NOTE), "end": quote_start(NOTE) + QUOTE.len() }), ) .expect("comment.reanchor"); reg.run("comment.delete", &mut session, &json!({ "path": "note.md", "id": id })).expect("comment.delete"); assert_eq!(on_disk(vault.path(), "note.md"), before, "the note never changed"); }