//! 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); // 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 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); }