//! End-to-end daemon tests (tech-spec §9): a real `hephd` over a real unix //! socket against a temp SQLite file, exercised by the sync client. Time is //! clock-injected (FixedClock) so assertions are deterministic. use std::path::{Path, PathBuf}; use std::thread; use std::time::Duration; use serde_json::{json, Value}; use tokio::net::UnixListener; use heph_core::{FixedClock, LocalStore}; use hephd::{Client, Daemon}; const JAN1: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z const ONE_DAY: i64 = 86_400_000; const NOW: i64 = JAN1 + ONE_DAY / 2; /// Start a daemon on its own thread+runtime against a temp DB and socket. /// Returns the socket path; the returned `TempDir` keeps the files alive. fn spawn_daemon() -> (PathBuf, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("heph.db"); let socket = dir.path().join("d.sock"); let store = LocalStore::open(&db, Box::new(FixedClock(NOW))).unwrap(); let socket_for_thread = socket.clone(); thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async move { let listener = UnixListener::bind(&socket_for_thread).unwrap(); let _ = Daemon::new(store).serve(listener).await; }); }); // Wait for the socket to appear. for _ in 0..200 { if socket.exists() { break; } thread::sleep(Duration::from_millis(5)); } (socket, dir) } fn client(socket: &Path) -> Client { Client::connect(socket).unwrap() } #[test] fn node_create_and_get_round_trip_over_socket() { let (socket, _dir) = spawn_daemon(); let mut c = client(&socket); let created = c .call( "node.create", json!({ "kind": "doc", "title": "Roof log", "body": "# Roof" }), ) .unwrap(); assert_eq!(created["kind"], "doc"); assert_eq!(created["title"], "Roof log"); // Clock injection: created_at is the daemon's FixedClock value. assert_eq!(created["created_at"], NOW); let id = created["id"].as_str().unwrap(); let fetched = c.call("node.get", json!({ "id": id })).unwrap(); assert_eq!(fetched, created); // A missing node is JSON null, not an error. let missing = c.call("node.get", json!({ "id": "nope" })).unwrap(); assert_eq!(missing, Value::Null); } #[test] fn node_resolve_is_exact_not_fuzzy_over_socket() { let (socket, _dir) = spawn_daemon(); let mut c = client(&socket); let target = c .call("node.create", json!({ "kind": "doc", "title": "Roof" })) .unwrap(); let target_id = target["id"].as_str().unwrap().to_string(); // A fuzzy neighbour that an FTS `search` for "Roof" would also surface. c.call( "node.create", json!({ "kind": "doc", "title": "Roofing options" }), ) .unwrap(); // Exact title resolves to exactly the target node. let got = c.call("node.resolve", json!({ "title": "Roof" })).unwrap(); assert_eq!(got["id"], target_id); // A node id resolves to itself — the canonical `[[NODEID]]` link form (§8.4), // checked ahead of name resolution. let by_id = c .call("node.resolve", json!({ "title": target_id })) .unwrap(); assert_eq!(by_id["id"], target_id); // An unresolved link is JSON null, not an error (tech-spec §5). let missing = c .call("node.resolve", json!({ "title": "Nonexistent" })) .unwrap(); assert_eq!(missing, Value::Null); } #[test] fn task_create_appears_in_next_with_context_link() { let (socket, _dir) = spawn_daemon(); let mut c = client(&socket); let task = c .call( "task.create", json!({ "title": "Fix the roof leak", "attention": "red" }), ) .unwrap(); let task_id = task["node_id"].as_str().unwrap().to_string(); let ranked = c.call("next", json!({ "limit": 5 })).unwrap(); let arr = ranked.as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["node_id"], task_id); assert_eq!(arr[0]["attention"], "red"); assert!(arr[0]["canonical_context_id"].is_string()); // The canonical-context link is present and points at a doc. let links = c.call("links.outgoing", json!({ "id": task_id })).unwrap(); let ctx = links .as_array() .unwrap() .iter() .find(|l| l["link_type"] == "canonical-context") .expect("canonical-context link"); let doc = c.call("node.get", json!({ "id": ctx["dst_id"] })).unwrap(); assert_eq!(doc["kind"], "doc"); } #[test] fn task_set_schedule_patches_over_socket() { let (socket, _dir) = spawn_daemon(); let mut c = client(&socket); let task = c .call( "task.create", json!({ "title": "Renew passport", "do_date": 1000, "late_on": 2000, "recurrence": "FREQ=YEARLY" }), ) .unwrap(); let id = task["node_id"].as_str().unwrap().to_string(); // Present value sets, explicit null clears, absent field is left alone. let updated = c .call( "task.set_schedule", json!({ "id": id, "do_date": 5000, "recurrence": null }), ) .unwrap(); assert_eq!(updated["do_date"], 5000, "do_date set"); assert_eq!(updated["late_on"], 2000, "late_on untouched (absent)"); assert!(updated["recurrence"].is_null(), "recurrence cleared"); // The change is durable. let got = c.call("task.get", json!({ "id": id })).unwrap(); assert_eq!(got["do_date"], 5000); assert!(got["recurrence"].is_null()); } #[test] fn task_set_project_moves_and_unfiles_over_socket() { let (socket, _dir) = spawn_daemon(); let mut c = client(&socket); let mk_project = |c: &mut Client, title: &str| -> String { c.call("node.create", json!({ "kind": "project", "title": title })) .unwrap()["id"] .as_str() .unwrap() .to_string() }; let chores = mk_project(&mut c, "Chores"); let garden = mk_project(&mut c, "Garden"); let task = c .call( "task.create", json!({ "title": "Water the beds", "project_id": chores }), ) .unwrap(); let id = task["node_id"].as_str().unwrap().to_string(); // Active in-project destinations of the task. let projects_of = |c: &mut Client| -> Vec { c.call("links.outgoing", json!({ "id": id })) .unwrap() .as_array() .unwrap() .iter() .filter(|l| l["link_type"] == "in-project") .map(|l| l["dst_id"].as_str().unwrap().to_string()) .collect() }; // Move Chores → Garden: exactly one active link, to Garden. c.call( "task.set_project", json!({ "id": id, "project_id": garden }), ) .unwrap(); assert_eq!(projects_of(&mut c), vec![garden.clone()]); // Unfile (null project): no active in-project links. c.call("task.set_project", json!({ "id": id, "project_id": null })) .unwrap(); assert!(projects_of(&mut c).is_empty()); // A non-project destination is rejected. let doc = c .call("node.create", json!({ "kind": "doc", "title": "Note" })) .unwrap(); let doc_id = doc["id"].as_str().unwrap(); assert!( c.call( "task.set_project", json!({ "id": id, "project_id": doc_id }) ) .is_err(), "filing under a non-project node must error" ); } #[test] fn tag_add_list_remove_over_socket() { let (socket, _dir) = spawn_daemon(); let mut c = client(&socket); let doc = c .call( "node.create", json!({ "kind": "doc", "title": "Roof notes" }), ) .unwrap(); let id = doc["id"].as_str().unwrap().to_string(); // Add two tags (trimmed); tag.list returns them sorted. c.call("tag.add", json!({ "node_id": id, "tag": "house" })) .unwrap(); c.call("tag.add", json!({ "node_id": id, "tag": " urgent " })) .unwrap(); let tags = c.call("tag.list", json!({ "node_id": id })).unwrap(); assert_eq!(tags, json!(["house", "urgent"])); // The canonical tag set is enumerable via node.list kind=tag. let all = c.call("node.list", json!({ "kind": "tag" })).unwrap(); let names: Vec<&str> = all .as_array() .unwrap() .iter() .map(|n| n["title"].as_str().unwrap()) .collect(); assert_eq!(names, vec!["house", "urgent"]); // Remove one. c.call("tag.remove", json!({ "node_id": id, "tag": "house" })) .unwrap(); assert_eq!( c.call("tag.list", json!({ "node_id": id })).unwrap(), json!(["urgent"]) ); } #[test] fn frontmatter_renders_on_read_and_is_stripped_on_write() { let (socket, _dir) = spawn_daemon(); let mut c = client(&socket); let doc = c .call( "node.create", json!({ "kind": "doc", "title": "Roof", "body": "# Roof\n\nnotes\n" }), ) .unwrap(); let id = doc["id"].as_str().unwrap().to_string(); c.call("tag.add", json!({ "node_id": id, "tag": "house" })) .unwrap(); // Read with frontmatter: a leading `---` block carries id/kind/title/tags, // and the original body follows it. let got = c .call("node.get", json!({ "id": id, "frontmatter": true })) .unwrap(); let body = got["body"].as_str().unwrap(); assert!(body.starts_with("---\n"), "no frontmatter fence:\n{body}"); assert!(body.contains(&format!("id: {id}")), "no id:\n{body}"); assert!(body.contains("title: Roof"), "no title:\n{body}"); assert!(body.contains("tags: [house]"), "no tags:\n{body}"); assert!( body.contains("# Roof\n\nnotes\n"), "original body missing:\n{body}" ); // Echo that whole buffer back: the frontmatter is stripped, body unchanged. c.call("node.update", json!({ "id": id, "body": body })) .unwrap(); let plain = c.call("node.get", json!({ "id": id })).unwrap(); assert_eq!( plain["body"], "# Roof\n\nnotes\n", "round-trip altered the body" ); } #[test] fn task_context_doc_frontmatter_carries_the_owning_tasks_scalars() { let (socket, _dir) = spawn_daemon(); let mut c = client(&socket); let proj = c .call( "node.create", json!({ "kind": "project", "title": "Camano" }), ) .unwrap(); let pid = proj["id"].as_str().unwrap().to_string(); let task = c .call( "task.create", json!({ "title": "Fix roof", "attention": "red", "project_id": pid, "do_date": 1_704_067_200_000_i64 }), ) .unwrap(); let task_id = task["node_id"].as_str().unwrap().to_string(); // The task's canonical-context doc. let links = c.call("links.outgoing", json!({ "id": task_id })).unwrap(); let ctx = links .as_array() .unwrap() .iter() .find(|l| l["link_type"] == "canonical-context") .unwrap()["dst_id"] .as_str() .unwrap() .to_string(); // Its frontmatter surfaces the owning task's scalars + a `task:` ref. let got = c .call("node.get", json!({ "id": ctx, "frontmatter": true })) .unwrap(); let body = got["body"].as_str().unwrap(); assert!( body.contains(&format!("task: {task_id}")), "no task ref:\n{body}" ); assert!(body.contains("state: outstanding"), "no state:\n{body}"); assert!(body.contains("attention: red"), "no attention:\n{body}"); assert!(body.contains("project: Camano"), "no project:\n{body}"); assert!(body.contains("do_date:"), "no do_date:\n{body}"); } #[test] fn promote_context_item_over_socket() { let (socket, _dir) = spawn_daemon(); let mut c = client(&socket); let container = c .call( "node.create", json!({ "kind": "doc", "title": "Errands", "body": "- [ ] call plumber\n- [ ] water plants" }), ) .unwrap(); let container_id = container["id"].as_str().unwrap().to_string(); // Promote the first context item to a committed task. let task = c .call( "task.promote", json!({ "container_id": container_id, "item_ref": 1, "attention": "orange" }), ) .unwrap(); let task_id = task["node_id"].as_str().unwrap().to_string(); // It appears in `next`, and the source line became a link to it. let ranked = c.call("next", json!({ "limit": 10 })).unwrap(); assert!(ranked .as_array() .unwrap() .iter() .any(|t| t["node_id"] == task_id && t["title"] == "call plumber")); let body = c.call("node.get", json!({ "id": container_id })).unwrap(); assert_eq!(body["body"], "- [[call plumber]]\n- [ ] water plants"); let resolved = c .call("node.resolve", json!({ "title": "call plumber" })) .unwrap(); assert_eq!(resolved["id"], task_id); } #[test] fn errors_are_reported_as_rpc_errors() { let (socket, _dir) = spawn_daemon(); let mut c = client(&socket); // Unknown method. let err = c.call("does.not.exist", json!({})).unwrap_err(); assert!(err.to_string().contains("unknown method"), "{err}"); // set_state on a non-existent task → NotFound error. let err = c .call( "task.set_state", json!({ "id": "missing", "state": "done" }), ) .unwrap_err(); assert!(err.to_string().contains("not found"), "{err}"); // Bad params (missing a genuinely-required field) → invalid params. // (`node.create` needs `kind`; `NewTask` defaults all fields, so an empty // `task.create` is valid, not an error.) let err = c .call("node.create", json!({ "title": "no kind" })) .unwrap_err(); assert!(err.to_string().contains("missing field"), "{err}"); } #[test] fn recurring_task_rolls_forward_over_rpc() { let (socket, _dir) = spawn_daemon(); let mut c = client(&socket); let task = c .call( "task.create", json!({ "title": "Morning routine", "do_date": JAN1, "recurrence": "FREQ=DAILY", }), ) .unwrap(); let task_id = task["node_id"].as_str().unwrap().to_string(); // Find the canonical context doc and put a checked checklist in it. let links = c.call("links.outgoing", json!({ "id": task_id })).unwrap(); let doc_id = links .as_array() .unwrap() .iter() .find(|l| l["link_type"] == "canonical-context") .unwrap()["dst_id"] .as_str() .unwrap() .to_string(); c.call( "node.update", json!({ "id": doc_id, "body": "- [x] brush teeth\n- [x] coffee\n" }), ) .unwrap(); // Complete the occurrence → rolls forward. let rolled = c .call("task.set_state", json!({ "id": task_id, "state": "done" })) .unwrap(); assert_eq!(rolled["state"], "outstanding"); assert_eq!(rolled["do_date"], JAN1 + ONE_DAY); // Fresh checklist; completion did not carry forward. let doc = c.call("node.get", json!({ "id": doc_id })).unwrap(); assert_eq!(doc["body"], "- [ ] brush teeth\n- [ ] coffee\n"); // The completion is in the log. let log = c .call("log.tail", json!({ "task_id": task_id, "n": 10 })) .unwrap(); assert_eq!(log.as_array().unwrap().len(), 1); } #[test] fn multiple_clients_concurrently_create_tasks() { let (socket, _dir) = spawn_daemon(); const N: usize = 8; let handles: Vec<_> = (0..N) .map(|i| { let socket = socket.clone(); thread::spawn(move || { let mut c = Client::connect(&socket).unwrap(); c.call( "task.create", json!({ "title": format!("task {i}"), "attention": "orange" }), ) .unwrap(); }) }) .collect(); for h in handles { h.join().unwrap(); } // A fresh client sees all N tasks ranked. let mut c = client(&socket); let ranked = c.call("next", json!({ "limit": 100 })).unwrap(); assert_eq!(ranked.as_array().unwrap().len(), N); } #[test] fn list_takes_a_filter_and_view_runs_a_builtin_over_socket() { let (socket, _dir) = spawn_daemon(); let mut c = client(&socket); c.call( "task.create", json!({ "title": "red task", "attention": "red" }), ) .unwrap(); c.call( "task.create", json!({ "title": "blue task", "attention": "blue" }), ) .unwrap(); // An empty filter is the whole outstanding set (both tasks). let all = c.call("list", json!({})).unwrap(); assert_eq!(all.as_array().unwrap().len(), 2); // A filter excluding blue drops the on-deck task. let no_blue = c .call("list", json!({ "attention_not": ["blue"] })) .unwrap(); let arr = no_blue.as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["title"], "red task"); // The Top of Mind view (red|orange) returns just the red task. let tom = c.call("view", json!({ "name": "tom" })).unwrap(); let arr = tom.as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["title"], "red task"); // An unknown view name is a reported RPC error. assert!(c.call("view", json!({ "name": "bogus" })).is_err()); }