cea7b45e23
comment.list|add|reply|edit|delete|resolve|reanchor over <note>.comments.json written through Vault::write; the note file stays byte-identical; author comes from the session identity (binding user_id, else local), edit/delete author-only; limits error and never truncate; anchors re-resolve against the open buffer or the file. Built by MiniMax M3.1 Flash (OpenCode Zen free route); verified from a clean checkout of the staged files: notescraft-engine tests 776 pass, clippy -D warnings and xtask layers clean. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
353 lines
18 KiB
Rust
353 lines
18 KiB
Rust
//! 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>, 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<Value, Error>) -> 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>, 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>, session: &mut Session) -> Vec<Value> {
|
|
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::<Value>::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::<Value>::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<String> = (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<Value> = (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<Value> = (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");
|
|
}
|