hephaestus/crates/hephd/tests/rpc_socket.rs
Erich Blume ee865e5635
Some checks failed
Build / validate (pull_request) Failing after 4s
heph.nvim: RPC client + buffer editing + wiki-links + journal (slice 11a)
The primary surface begins (tech-spec §8): a Neovim plugin that is a thin
client of the local hephd over its unix-socket JSON-RPC.

- node.resolve {title} → Node|null (heph-core Store + dispatch): exact,
  owner-scoped, non-tombstoned alias-then-title match — the same mapping that
  materializes wiki links, so follow-link jumps to the node the stored link
  points at (never fuzzy search). Unit + rpc_socket integration tests.
- heph.nvim/: vim.uv unix-socket JSON-RPC client (blocking call via vim.wait,
  id-demuxed, partial-line buffered, luanil so JSON null → Lua nil; isolated
  Sessions for tests). Buffer-backed nodes (heph://node/<id>, acwrite;
  BufReadCmd→node.get / BufWriteCmd→node.update, whole-buffer body round-trips
  exactly through the CRDT). [[wiki-link]] follow on <CR>. Daily journal.
  :Heph command surface + completion.
- Headless e2e (§9): a self-contained busted-style runner (tests/e2e/runner.lua)
  — no external plugins, no network, deterministic CI exit codes. Specs: journal
  round-trip, follow-link (+ unresolved no-op), link-two-docs/backlink.
  `make -C heph.nvim test` builds hephd and runs it.

Docs: heph-nvim reference card, §14 tracker (11a done; 11b/11c/11d queued),
changelog fragment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:33:29 -07:00

242 lines
7.6 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 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);
}