diff --git a/apps/daemon/src/storage/tests.rs b/apps/daemon/src/storage/tests.rs index 2576874..ca33d0b 100644 --- a/apps/daemon/src/storage/tests.rs +++ b/apps/daemon/src/storage/tests.rs @@ -673,3 +673,765 @@ async fn list_messages_is_scoped_to_its_own_session() { "a session's history must not leak another session's messages" ); } +// ─── Tool calls ────────────────────────────────────────────────────────────── + +/// Every column written by `create_tool_call` is asserted, including the ones +/// the function supplies rather than takes: the generated id, the 'pending' +/// status literal and the created_at timestamp. A row-count assertion would +/// pass even if the wrong value reached the wrong column. +#[tokio::test] +async fn create_tool_call_persists_every_field_and_starts_pending() { + let (storage, _dir) = test_storage().await; + let session = storage + .create_session("claude", "/repo", "tool calls", None) + .await + .unwrap(); + let message = storage + .create_message(&session.id, "assistant", "calling a tool", "complete") + .await + .unwrap(); + + let call = storage + .create_tool_call(&session.id, &message.id, "Bash", r#"{"command":"ls"}"#) + .await + .unwrap(); + + assert!(!call.id.is_empty()); + assert_eq!(call.session_id, session.id); + assert_eq!(call.message_id, message.id); + assert_eq!(call.name, "Bash"); + assert_eq!(call.input, r#"{"command":"ls"}"#); + assert_eq!(call.status, "pending"); + assert_eq!(call.output, None); + assert_eq!(call.completed_at, None); + assert!(!call.created_at.is_empty()); + + // Read it back rather than trusting the returned struct: the insert and + // the value handed to the caller are two different things. + let stored = storage.get_tool_call(&call.id).await.unwrap().unwrap(); + assert_eq!(stored.name, "Bash"); + assert_eq!(stored.status, "pending"); + assert_eq!(stored.output, None); +} + +#[tokio::test] +async fn get_tool_call_returns_none_for_an_unknown_id() { + let (storage, _dir) = test_storage().await; + assert!(storage + .get_tool_call("no-such-call") + .await + .unwrap() + .is_none()); +} + +/// `complete_tool_call` must write all three of output, status and +/// completed_at. Asserting only the status would let a mutant drop the output +/// binding and still pass. +#[tokio::test] +async fn complete_tool_call_writes_output_status_and_completion_time() { + let (storage, _dir) = test_storage().await; + let session = storage + .create_session("claude", "/repo", "tool calls", None) + .await + .unwrap(); + let message = storage + .create_message(&session.id, "assistant", "x", "complete") + .await + .unwrap(); + let call = storage + .create_tool_call(&session.id, &message.id, "Bash", "{}") + .await + .unwrap(); + + storage + .complete_tool_call(&call.id, Some("total 0\n"), "success") + .await + .unwrap(); + + let done = storage.get_tool_call(&call.id).await.unwrap().unwrap(); + assert_eq!(done.output.as_deref(), Some("total 0\n")); + assert_eq!(done.status, "success"); + assert!(done.completed_at.is_some()); + // The fields that identify the call must not have been disturbed. + assert_eq!(done.name, "Bash"); + assert_eq!(done.session_id, session.id); +} + +/// A failed call carries no output but must still be marked completed — pins +/// that `output` is bound as a nullable value rather than skipped. +#[tokio::test] +async fn a_failed_tool_call_records_its_status_without_output() { + let (storage, _dir) = test_storage().await; + let session = storage + .create_session("claude", "/repo", "tool calls", None) + .await + .unwrap(); + let message = storage + .create_message(&session.id, "assistant", "x", "complete") + .await + .unwrap(); + let call = storage + .create_tool_call(&session.id, &message.id, "Bash", "{}") + .await + .unwrap(); + + storage + .complete_tool_call(&call.id, None, "error") + .await + .unwrap(); + + let done = storage.get_tool_call(&call.id).await.unwrap().unwrap(); + assert_eq!(done.status, "error"); + assert_eq!(done.output, None); + assert!(done.completed_at.is_some()); +} + +/// Completing one call must not touch its siblings — pins the `WHERE id = ?`. +#[tokio::test] +async fn completing_one_tool_call_leaves_the_others_pending() { + let (storage, _dir) = test_storage().await; + let session = storage + .create_session("claude", "/repo", "tool calls", None) + .await + .unwrap(); + let message = storage + .create_message(&session.id, "assistant", "x", "complete") + .await + .unwrap(); + let first = storage + .create_tool_call(&session.id, &message.id, "Read", "{}") + .await + .unwrap(); + let second = storage + .create_tool_call(&session.id, &message.id, "Write", "{}") + .await + .unwrap(); + + storage + .complete_tool_call(&first.id, Some("ok"), "success") + .await + .unwrap(); + + assert_eq!( + storage + .get_tool_call(&second.id) + .await + .unwrap() + .unwrap() + .status, + "pending" + ); +} + +/// Listing is scoped to one session and ordered oldest-first. Both halves +/// matter: the scoping pins `WHERE session_id = ?` and the ordering pins +/// `ORDER BY created_at ASC` against its DESC mutation. +#[tokio::test] +async fn tool_calls_are_listed_per_session_in_creation_order() { + let (storage, _dir) = test_storage().await; + let mine = storage + .create_session("claude", "/repo", "tool calls", None) + .await + .unwrap(); + let theirs = storage + .create_session("claude", "/other", "other session", None) + .await + .unwrap(); + let my_msg = storage + .create_message(&mine.id, "assistant", "x", "complete") + .await + .unwrap(); + let their_msg = storage + .create_message(&theirs.id, "assistant", "x", "complete") + .await + .unwrap(); + + for name in ["first", "second", "third"] { + storage + .create_tool_call(&mine.id, &my_msg.id, name, "{}") + .await + .unwrap(); + // Distinct created_at values, so the ordering is actually decidable. + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + storage + .create_tool_call(&theirs.id, &their_msg.id, "not-mine", "{}") + .await + .unwrap(); + + let listed = storage.list_tool_calls_for_session(&mine.id).await.unwrap(); + let names: Vec<&str> = listed.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["first", "second", "third"]); +} + +#[tokio::test] +async fn listing_tool_calls_for_a_session_with_none_is_empty() { + let (storage, _dir) = test_storage().await; + let session = storage + .create_session("claude", "/repo", "tool calls", None) + .await + .unwrap(); + assert!(storage + .list_tool_calls_for_session(&session.id) + .await + .unwrap() + .is_empty()); +} + +// ─── Accounts ──────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn create_account_persists_every_field_and_reads_back() { + let (storage, _dir) = test_storage().await; + + let account = storage + .create_account("work", "claude", "/creds/work.json", 5) + .await + .unwrap(); + + assert!(!account.id.is_empty()); + assert_eq!(account.name, "work"); + assert_eq!(account.provider, "claude"); + assert_eq!(account.credentials_path, "/creds/work.json"); + assert_eq!(account.priority, 5); + assert_eq!(account.limited_until, None); + + let stored = storage.get_account(&account.id).await.unwrap().unwrap(); + assert_eq!(stored.name, "work"); + assert_eq!(stored.priority, 5); +} + +#[tokio::test] +async fn get_account_returns_none_for_an_unknown_id() { + let (storage, _dir) = test_storage().await; + assert!(storage + .get_account("no-such-account") + .await + .unwrap() + .is_none()); +} + +/// Accounts come back ordered by ascending priority, which is what the router +/// relies on to pick the preferred account first. Inserting them out of order +/// is what makes the ORDER BY observable. +#[tokio::test] +async fn accounts_are_listed_by_ascending_priority() { + let (storage, _dir) = test_storage().await; + storage + .create_account("third", "claude", "/c", 30) + .await + .unwrap(); + storage + .create_account("first", "claude", "/a", 10) + .await + .unwrap(); + storage + .create_account("second", "claude", "/b", 20) + .await + .unwrap(); + + let names: Vec = storage + .list_accounts() + .await + .unwrap() + .into_iter() + .map(|a| a.name) + .collect(); + assert_eq!(names, vec!["first", "second", "third"]); +} + +/// Pins `update_account_priority` in both directions: the named account moves +/// and the others do not, which is also visible as a change in list order. +#[tokio::test] +async fn updating_a_priority_reorders_only_that_account() { + let (storage, _dir) = test_storage().await; + let a = storage + .create_account("a", "claude", "/a", 10) + .await + .unwrap(); + let b = storage + .create_account("b", "claude", "/b", 20) + .await + .unwrap(); + + storage.update_account_priority(&a.id, 99).await.unwrap(); + + assert_eq!( + storage.get_account(&a.id).await.unwrap().unwrap().priority, + 99 + ); + assert_eq!( + storage.get_account(&b.id).await.unwrap().unwrap().priority, + 20 + ); + + let names: Vec = storage + .list_accounts() + .await + .unwrap() + .into_iter() + .map(|x| x.name) + .collect(); + assert_eq!(names, vec!["b", "a"], "a should now sort last"); +} + +/// `set_account_limited` must round-trip a value AND be able to clear it — +/// a test that only sets it would miss a mutant that ignores the None case. +#[tokio::test] +async fn an_account_rate_limit_can_be_set_and_cleared() { + let (storage, _dir) = test_storage().await; + let account = storage + .create_account("a", "claude", "/a", 1) + .await + .unwrap(); + + storage + .set_account_limited(&account.id, Some("2026-01-01T00:00:00Z")) + .await + .unwrap(); + assert_eq!( + storage + .get_account(&account.id) + .await + .unwrap() + .unwrap() + .limited_until + .as_deref(), + Some("2026-01-01T00:00:00Z") + ); + + storage + .set_account_limited(&account.id, None) + .await + .unwrap(); + assert_eq!( + storage + .get_account(&account.id) + .await + .unwrap() + .unwrap() + .limited_until, + None + ); +} + +/// Deleting one account must leave the rest — pins the `WHERE id = ?` against +/// a mutant that drops it and empties the table. +#[tokio::test] +async fn delete_account_removes_only_the_named_account() { + let (storage, _dir) = test_storage().await; + let doomed = storage + .create_account("doomed", "claude", "/a", 1) + .await + .unwrap(); + let kept = storage + .create_account("kept", "claude", "/b", 2) + .await + .unwrap(); + + storage.delete_account(&doomed.id).await.unwrap(); + + assert!(storage.get_account(&doomed.id).await.unwrap().is_none()); + assert!(storage.get_account(&kept.id).await.unwrap().is_some()); + assert_eq!(storage.list_accounts().await.unwrap().len(), 1); +} + +/// Deleting an id that does not exist is a no-op, not an error. +#[tokio::test] +async fn deleting_an_unknown_account_is_harmless() { + let (storage, _dir) = test_storage().await; + storage + .create_account("kept", "claude", "/b", 2) + .await + .unwrap(); + storage.delete_account("no-such-account").await.unwrap(); + assert_eq!(storage.list_accounts().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn account_events_are_recorded_against_their_account() { + let (storage, _dir) = test_storage().await; + let account = storage + .create_account("a", "claude", "/a", 1) + .await + .unwrap(); + + storage + .log_account_event(&account.id, "rate_limited", Some(r#"{"retry_after":60}"#)) + .await + .unwrap(); + storage + .log_account_event(&account.id, "recovered", None) + .await + .unwrap(); + + let rows: Vec<(String, Option)> = sqlx::query_as( + "SELECT event_type, metadata FROM account_events WHERE account_id = ? ORDER BY id", + ) + .bind(&account.id) + .fetch_all(storage.pool()) + .await + .unwrap(); + + assert_eq!(rows.len(), 2); + let kinds: Vec<&str> = rows.iter().map(|r| r.0.as_str()).collect(); + assert!(kinds.contains(&"rate_limited")); + assert!(kinds.contains(&"recovered")); + // The nullable metadata must survive both present and absent. + assert!(rows + .iter() + .any(|r| r.1.as_deref() == Some(r#"{"retry_after":60}"#))); + assert!(rows.iter().any(|r| r.1.is_none())); +} + +// ─── Tool-call events and account events ───────────────────────────────────── + +#[tokio::test] +async fn create_tool_call_event_persists_every_field_including_the_nullable_ones() { + let (storage, _dir) = test_storage().await; + let session = storage + .create_session("claude", "/repo", "events", None) + .await + .unwrap(); + + let approved = storage + .create_tool_call_event(&session.id, "Bash", Some("ls -la"), "user", None) + .await + .unwrap(); + assert!(!approved.id.is_empty()); + assert_eq!(approved.session_id, session.id); + assert_eq!(approved.tool_name, "Bash"); + assert_eq!(approved.sanitized_input.as_deref(), Some("ls -la")); + assert_eq!(approved.approved_by, "user"); + assert_eq!(approved.rejection_reason, None); + assert!(!approved.created_at.is_empty()); + + let rejected = storage + .create_tool_call_event(&session.id, "Bash", None, "policy", Some("denied by rule")) + .await + .unwrap(); + assert_eq!(rejected.sanitized_input, None); + assert_eq!(rejected.rejection_reason.as_deref(), Some("denied by rule")); + + // Read back, so a mutant that returns a fabricated row cannot pass. + let all = storage + .list_tool_call_events(Some(&session.id), 10, None) + .await + .unwrap(); + assert_eq!(all.len(), 2); +} + +/// Events are scoped to their session and returned newest-first, capped by +/// `limit`. The fixture uses four events and a limit of two, so the correct +/// answer differs from "all of them" and from any off-by-one. +#[tokio::test] +async fn tool_call_events_are_scoped_limited_and_newest_first() { + let (storage, _dir) = test_storage().await; + let mine = storage + .create_session("claude", "/repo", "mine", None) + .await + .unwrap(); + let theirs = storage + .create_session("claude", "/other", "theirs", None) + .await + .unwrap(); + + for name in ["first", "second", "third", "fourth"] { + storage + .create_tool_call_event(&mine.id, name, None, "user", None) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + storage + .create_tool_call_event(&theirs.id, "not-mine", None, "user", None) + .await + .unwrap(); + + let page = storage + .list_tool_call_events(Some(&mine.id), 2, None) + .await + .unwrap(); + let names: Vec<&str> = page.iter().map(|e| e.tool_name.as_str()).collect(); + assert_eq!(names, vec!["fourth", "third"], "newest first, limited to 2"); + + // Unscoped still sees the other session's event. + let everything = storage.list_tool_call_events(None, 50, None).await.unwrap(); + assert_eq!(everything.len(), 5); +} + +/// Kills `if days == 0` -> `!=`: a zero retention must delete nothing, and a +/// non-zero one must not be short-circuited into deleting nothing. +#[tokio::test] +async fn pruning_tool_call_events_with_zero_days_is_a_no_op() { + let (storage, _dir) = test_storage().await; + let session = storage + .create_session("claude", "/repo", "prune", None) + .await + .unwrap(); + storage + .create_tool_call_event(&session.id, "Bash", None, "user", None) + .await + .unwrap(); + + assert_eq!(storage.prune_tool_call_events(0).await.unwrap(), 0); + assert_eq!( + storage + .list_tool_call_events(None, 50, None) + .await + .unwrap() + .len(), + 1, + "zero days must not delete anything" + ); + + // A real retention window leaves a fresh event alone too, but takes the + // DELETE path rather than the early return. + assert_eq!(storage.prune_tool_call_events(7).await.unwrap(), 0); + assert_eq!( + storage + .list_tool_call_events(None, 50, None) + .await + .unwrap() + .len(), + 1 + ); +} + +/// Account events are scoped and limited the same way. +#[tokio::test] +async fn account_events_are_scoped_and_limited() { + let (storage, _dir) = test_storage().await; + let mine = storage + .create_account("mine", "claude", "/a", 1) + .await + .unwrap(); + let theirs = storage + .create_account("theirs", "claude", "/b", 2) + .await + .unwrap(); + + for kind in ["one", "two", "three"] { + storage + .log_account_event(&mine.id, kind, None) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + storage + .log_account_event(&theirs.id, "not-mine", None) + .await + .unwrap(); + + let scoped = storage + .list_account_events(Some(&mine.id), 2) + .await + .unwrap(); + assert_eq!(scoped.len(), 2, "limit must be applied"); + assert!(scoped.iter().all(|e| e.account_id == mine.id)); + + let all = storage.list_account_events(None, 50).await.unwrap(); + assert_eq!(all.len(), 4); +} + +/// Backdate a tool-call event's created_at to `days` ago, in the same RFC3339 +/// format the writer uses. +#[cfg(test)] +async fn backdate_tool_call_event(storage: &Storage, id: &str, days: i64) { + let when = (chrono::Utc::now() - chrono::Duration::days(days)).to_rfc3339(); + sqlx::query("UPDATE tool_call_events SET created_at = ? WHERE id = ?") + .bind(&when) + .bind(id) + .execute(storage.pool()) + .await + .unwrap(); +} + +#[tokio::test] +async fn prune_tool_call_events_deletes_rows_older_than_the_window() { + let (storage, _dir) = test_storage().await; + let session = storage + .create_session("claude", "/repo", "prune", None) + .await + .unwrap(); + + let old_a = storage + .create_tool_call_event(&session.id, "old-a", None, "user", None) + .await + .unwrap(); + let old_b = storage + .create_tool_call_event(&session.id, "old-b", None, "user", None) + .await + .unwrap(); + let fresh = storage + .create_tool_call_event(&session.id, "fresh", None, "user", None) + .await + .unwrap(); + + backdate_tool_call_event(&storage, &old_a.id, 30).await; + backdate_tool_call_event(&storage, &old_b.id, 30).await; + + // Two of the three are older than the window, so the answer is 2 — which + // differs from 0 (nothing pruned), 1, and 3 (everything pruned). + let pruned = storage.prune_tool_call_events(7).await.unwrap(); + assert_eq!(pruned, 2, "both backdated events must be deleted"); + + let left = storage.list_tool_call_events(None, 50, None).await.unwrap(); + assert_eq!(left.len(), 1); + assert_eq!(left[0].tool_name, "fresh"); + let _ = fresh; +} + +// ─── Worktrees ─────────────────────────────────────────────────────────────── + +/// Every column is asserted, including the ones the function does not take: +/// the 'active' default from the schema and both timestamps. +#[tokio::test] +async fn create_worktree_persists_every_field_and_defaults_to_active() { + let (storage, _dir) = test_storage().await; + + let wt = storage + .create_worktree("task-1", "/tmp/wt/task-1", "feature/one", "/repo") + .await + .unwrap(); + + assert_eq!(wt.task_id, "task-1"); + assert_eq!(wt.worktree_path, "/tmp/wt/task-1"); + assert_eq!(wt.branch, "feature/one"); + assert_eq!(wt.repo_path, "/repo"); + assert_eq!(wt.status, "active"); + assert!(!wt.created_at.is_empty()); + assert!(!wt.updated_at.is_empty()); + + // Read back rather than trusting the returned struct. + let stored = storage.get_worktree("task-1").await.unwrap().unwrap(); + assert_eq!(stored.branch, "feature/one"); + assert_eq!(stored.status, "active"); +} + +#[tokio::test] +async fn get_worktree_returns_none_for_an_unknown_task() { + let (storage, _dir) = test_storage().await; + assert!(storage + .get_worktree("no-such-task") + .await + .unwrap() + .is_none()); +} + +/// `set_worktree_status` must move the named worktree and leave the others, +/// which pins both the SET and the WHERE. +#[tokio::test] +async fn setting_a_worktree_status_affects_only_that_worktree() { + let (storage, _dir) = test_storage().await; + storage + .create_worktree("task-1", "/tmp/wt/1", "b1", "/repo") + .await + .unwrap(); + storage + .create_worktree("task-2", "/tmp/wt/2", "b2", "/repo") + .await + .unwrap(); + + storage.set_worktree_status("task-1", "done").await.unwrap(); + + assert_eq!( + storage + .get_worktree("task-1") + .await + .unwrap() + .unwrap() + .status, + "done" + ); + assert_eq!( + storage + .get_worktree("task-2") + .await + .unwrap() + .unwrap() + .status, + "active" + ); +} + +/// The status filter is pinned in both directions: filtered returns only the +/// matches, unfiltered returns everything. Either alone would leave a mutant +/// that ignores the filter, or one that always applies it, alive. +#[tokio::test] +async fn listing_worktrees_honours_the_status_filter() { + let (storage, _dir) = test_storage().await; + for (task, status) in [("a", "active"), ("b", "done"), ("c", "active")] { + storage + .create_worktree(task, &format!("/tmp/wt/{task}"), "br", "/repo") + .await + .unwrap(); + storage.set_worktree_status(task, status).await.unwrap(); + } + + let active = storage.list_worktrees(Some("active")).await.unwrap(); + assert_eq!(active.len(), 2); + assert!(active.iter().all(|w| w.status == "active")); + + let done = storage.list_worktrees(Some("done")).await.unwrap(); + assert_eq!(done.len(), 1); + assert_eq!(done[0].task_id, "b"); + + assert_eq!(storage.list_worktrees(None).await.unwrap().len(), 3); +} + +/// `load_worktrees` is the startup query: it must return the worktrees still +/// in play and skip the two terminal states. The fixture puts one worktree in +/// each of the four statuses, so the correct answer (2) differs from "all" +/// and from either single exclusion. +#[tokio::test] +async fn load_worktrees_skips_abandoned_and_merged() { + let (storage, _dir) = test_storage().await; + for (task, status) in [ + ("keep-active", "active"), + ("keep-done", "done"), + ("drop-abandoned", "abandoned"), + ("drop-merged", "merged"), + ] { + storage + .create_worktree(task, &format!("/tmp/wt/{task}"), "br", "/repo") + .await + .unwrap(); + storage.set_worktree_status(task, status).await.unwrap(); + } + + let loaded = storage.load_worktrees().await.unwrap(); + let mut tasks: Vec<&str> = loaded.iter().map(|w| w.task_id.as_str()).collect(); + tasks.sort_unstable(); + assert_eq!(tasks, vec!["keep-active", "keep-done"]); +} + +#[tokio::test] +async fn delete_worktree_removes_only_the_named_worktree() { + let (storage, _dir) = test_storage().await; + storage + .create_worktree("doomed", "/tmp/wt/d", "b1", "/repo") + .await + .unwrap(); + storage + .create_worktree("kept", "/tmp/wt/k", "b2", "/repo") + .await + .unwrap(); + + storage.delete_worktree("doomed").await.unwrap(); + + assert!(storage.get_worktree("doomed").await.unwrap().is_none()); + assert!(storage.get_worktree("kept").await.unwrap().is_some()); + assert_eq!(storage.list_worktrees(None).await.unwrap().len(), 1); +} + +#[tokio::test] +async fn deleting_an_unknown_worktree_is_harmless() { + let (storage, _dir) = test_storage().await; + storage + .create_worktree("kept", "/tmp/wt/k", "b", "/repo") + .await + .unwrap(); + storage.delete_worktree("no-such-task").await.unwrap(); + assert_eq!(storage.list_worktrees(None).await.unwrap().len(), 1); +}