//! The end-to-end proof of the comments: two devices of one vault on a fake //! server, a comment with a mention, the feed of each member and the thread //! that is closed. //! //! The fake server lives in [`comments_e2e_stub`]: it speaks the JSON of the //! sync server, so the test drives the real clients of the engine - the //! commands, the sync driver and the feed - against a server that keeps the //! files, the members and the events. mod comments_e2e_stub; mod common; use comments_e2e_stub::{ANA, ANA_TOKEN, BOB, BOB_TOKEN, Stub, VAULT}; use common::{on_disk, seed, temp_vault}; use notescraft_engine::{HttpRemote, Registry, Session, SyncReport, SyncState, bind_folder, register_core, sync_once}; use serde_json::{Value, json}; /// The note both devices hold. const NOTE: &str = "# Idea\n\nThis always works, except on slow disks.\n"; /// The path of the note. const NOTE_PATH: &str = "note.md"; /// The path of the sidecar of the note. const SIDECAR: &str = "note.md.comments.json"; /// The quote Ana anchors her comment to. const QUOTE: &str = "This always works"; /// The body of Ana's comment. const BODY: &str = "Can we soften this claim? @Bob"; /// The name the device leaves on a conflict copy. const DEVICE: &str = "e2e"; /// The date the conflict copies carry. const STAMP: &str = "2026-10-21"; /// One device of the vault: its commands, its session and its sync state. struct Device { /// The registry the commands live in. reg: Registry, /// The session of the open vault. session: Session, /// What the device last agreed with the server on. state: SyncState, /// The transport to the fake server. remote: HttpRemote, } impl Device { /// A device with the note of the vault, bound to the fake as `user`. fn new(name: &str, stub: &Stub, token: &str, user: &str, device: &str) -> (Self, common::TempVault) { let folder = temp_vault(name); seed(folder.path(), &[(NOTE_PATH, NOTE)]); bind_folder(folder.path(), &stub.base, VAULT, token, device, Some(user)).expect("bind the folder to the fake"); let mut reg = Registry::new(); register_core(&mut reg).expect("register the core commands"); let session = Session::open(folder.path()).expect("open the vault"); let state = SyncState::load(session.vault()).expect("read the sync state"); let remote = HttpRemote::new(&stub.base, VAULT, token); (Self { reg, session, state, remote }, folder) } /// Runs a command of the device and answers its value. fn run(&mut self, id: &str, params: &Value) -> Value { match self.reg.run(id, &mut self.session, params) { Ok(value) => value, Err(error) => panic!("{id} failed: {error:?}"), } } /// Syncs the device once and answers what the pass did. fn sync(&mut self) -> SyncReport { sync_once(self.session.vault(), &self.remote, &mut self.state, DEVICE, STAMP).expect("sync the vault") } } /// The events of a feed, as the command answered them. fn events(feed: &Value) -> Vec { feed.get("events").and_then(Value::as_array).cloned().unwrap_or_default() } /// The kinds of the events of a feed. fn kinds(feed: &Value) -> Vec { events(feed).iter().filter_map(|event| event.get("kind").and_then(Value::as_str).map(str::to_owned)).collect() } /// Ana comments on a sentence of the note and mentions Bob. /// /// The client posts the mention of Bob right away; the event names the note /// and the thread, never the text of the comment. fn ana_comments_and_mentions_bob(ana: &mut Device, stub: &Stub) -> String { let start = NOTE.find(QUOTE).expect("the quote is in the note"); let added = ana.run("comment.add", &json!({ "path": NOTE_PATH, "quote": QUOTE, "body": BODY, "start": start, "end": start + QUOTE.len(), "mentions": [BOB] })); let thread = added.get("id").and_then(Value::as_str).expect("the id of the thread").to_owned(); let posted = stub.posted(); assert_eq!(posted.len(), 1, "one event per comment"); let event = posted.first().expect("the event of the comment"); assert_eq!(event.kind, "mention"); assert_eq!(event.actor, ANA); assert_eq!(event.mentions, vec![BOB.to_owned()]); assert_eq!(event.thread_id.as_deref(), Some(thread.as_str())); let body = stub.requests().iter().find(|request| request.method == "POST" && request.path.ends_with("/activity")).expect("the mention event").text(); assert!(!body.contains("soften"), "the event carries no comment body: {body}"); thread } /// Bob sees the thread of Ana in his copy of the note. fn bob_sees_the_thread(bob: &mut Device, thread: &str) { let listed = bob.run("comment.list", &json!({ "path": NOTE_PATH })); let comments = listed.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!(thread))); assert_eq!(comment.get("author"), Some(&json!(ANA))); assert_eq!(comment.get("body"), Some(&json!(BODY))); assert_eq!(comment.get("mentions"), Some(&json!([BOB]))); assert_eq!(comment.get("resolved"), Some(&json!(false))); assert_eq!(comment.get("state"), Some(&json!("exact"))); } /// The feed of Bob shows the mention and the comment of Ana. fn bob_sees_the_feed(bob: &mut Device) { let feed = bob.run("activity.list", &Value::Null); assert_eq!(kinds(&feed), vec!["mention".to_owned(), "comment".to_owned()]); assert_eq!(feed.get("unread"), Some(&json!(2))); let rows = events(&feed); assert_eq!(rows.first().and_then(|row| row.get("note")), Some(&json!(NOTE_PATH))); assert_eq!(rows.first().and_then(|row| row.get("actor")), Some(&json!(ANA))); } /// The feed of Ana does not show her own comment. fn ana_does_not_see_her_own_comment(ana: &mut Device) { let feed = ana.run("activity.list", &Value::Null); assert!(events(&feed).is_empty(), "the actor never sees her own events: {feed:?}"); assert_eq!(feed.get("unread"), Some(&json!(0))); } /// The whole story: the comment travels, the feed tells both members and a /// second sync changes nothing. #[test] fn a_comment_travels_from_ana_to_bob_and_the_thread_comes_back() { let stub = Stub::spawn(); let (mut ana, ana_folder) = Device::new("ana", &stub, ANA_TOKEN, ANA, "d-ana"); let (mut bob, bob_folder) = Device::new("bob", &stub, BOB_TOKEN, BOB, "d-bob"); // Ana comments on the sentence and mentions Bob. let thread = ana_comments_and_mentions_bob(&mut ana, &stub); assert_eq!(on_disk(ana_folder.path(), NOTE_PATH), NOTE, "the note never changes"); assert!(ana_folder.path().join(SIDECAR).exists(), "the sidecar sits next to the note"); // Ana syncs: the note and its sidecar travel to the server. let report = ana.sync(); assert_eq!(report.uploaded, vec![NOTE_PATH.to_owned(), SIDECAR.to_owned()]); // Bob syncs: the sidecar comes to him and his note stays the same. let report = bob.sync(); assert_eq!(report.downloaded, vec![SIDECAR.to_owned()]); assert_eq!(on_disk(bob_folder.path(), NOTE_PATH), NOTE, "the note never changes"); bob_sees_the_thread(&mut bob, &thread); // The feed of each member tells its own story. bob_sees_the_feed(&mut bob); ana_does_not_see_her_own_comment(&mut ana); // Bob closes the thread and the closed thread travels back to Ana. bob.run("comment.resolve", &json!({ "path": NOTE_PATH, "id": thread, "resolved": true })); let report = bob.sync(); assert_eq!(report.uploaded, vec![SIDECAR.to_owned()]); let report = ana.sync(); assert_eq!(report.downloaded, vec![SIDECAR.to_owned()]); let listed = ana.run("comment.list", &json!({ "path": NOTE_PATH })); let comments = listed.get("comments").and_then(Value::as_array).expect("the threads of the note"); let comment = comments.first().expect("the thread"); assert_eq!(comment.get("resolved"), Some(&json!(true))); assert_eq!(comment.get("resolved_by"), Some(&json!(BOB))); // Ana sees that the thread was closed. let feed = ana.run("activity.list", &Value::Null); assert_eq!(kinds(&feed), vec!["thread_resolved".to_owned()]); assert_eq!(events(&feed).first().and_then(|row| row.get("actor")), Some(&json!(BOB))); // Bob does not see the closing of his own thread. let feed = bob.run("activity.list", &Value::Null); assert!(events(&feed).is_empty(), "the actor never sees his own events: {feed:?}"); // A second sync changes nothing: no file travels and no event is posted. let posted = stub.events().len(); assert_eq!(ana.sync(), SyncReport::default()); assert_eq!(bob.sync(), SyncReport::default()); assert_eq!(stub.events().len(), posted, "no new event was posted"); assert_eq!(stub.file(SIDECAR).expect("the sidecar of the server"), on_disk(ana_folder.path(), SIDECAR).into_bytes()); }