hephaestus/crates/hephd/tests/rpc_socket.rs
Erich Blume 4e8f6743cf
Some checks failed
Build / validate (pull_request) Failing after 6m34s
feat: wiki-links by id — id-first resolution + heph.nvim [[ picker (§8.4)
Backend: `links::resolve_id` now checks for an exact live node id before
alias/title, so a canonical `[[NODEID]]` link resolves to its node and
can't be shadowed by a like-named node. Legacy `[[Name]]` links still
resolve by name (until the migration), so this is additive.

heph.nvim: `link.insert` (bound to insert-mode `[[` and `:Heph link`)
searches via the `search` RPC and inserts `[[NODEID]]`, with a "+ Create
new doc" entry; `<CR>` follow resolves the id directly. e2e covers
search→insert→materialize and the create path.

Remaining (§8.4): read-expansion/conceal display + the one-time
[[Title]]→[[NODEID]] migration (then retire name-resolution + the hack).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:07:46 -07:00

546 lines
17 KiB
Rust

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