generated from eblume/project-template
Add `Store::set_task_project` (heph-core + RemoteStore) and the `task.set_project` RPC: tombstone the task's existing `in-project` link(s) and add a new one (or none, to unfile). A given project id must name a live project-kind node, else InvalidArg/NodeNotFound. Route `heph edit --project` through it, fixing a duplicate-link bug (the old path added an in-project link without removing the prior one); `--project none` now unfiles. Factor a `links::tombstone` helper out of `sync_wiki_links`. Tests: core move/unfile/reject + a duplicate-link regression; a socket dispatch test. The TUI `m` gesture follows in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
412 lines
13 KiB
Rust
412 lines
13 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);
|
|
|
|
// 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 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());
|
|
}
|