From f651fbf76a3c49875d54073537e91f6773cfefd6 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 16 Sep 2026 03:41:31 -0400 Subject: [PATCH] test(daemon): cover the tool-call, account and worktree families in storage storage/mod.rs is the largest remaining gap in the mutation gate: 103 mutants against 17 tests. Three whole families had no coverage at all - tool calls, accounts and worktrees, including their event logs - so every mutant in them survived. Every mutant in those families was hand-applied and the suite confirmed to fail. The discipline is the one the file's own header already sets out, and it matters most here: cargo-mutants replaces an `async fn -> Result<()>` body with `Ok(())`, and replaces a getter with `Ok(None)` or `Ok(Some(Default::default()))`. A test that calls the function and asserts it returned Ok kills none of those. So every write is read back and the observed value asserted, and every getter is checked against a row it did not itself fabricate. Some specifics worth naming: - complete_tool_call writes output, status and completed_at. Asserting only the status would let a mutant drop the output binding and pass, so all three are asserted, plus the identifying fields to catch a mutant that rewrites the wrong row. - A failed tool call carries no output but must still be marked complete, which pins `output` as a nullable binding rather than a skipped one. - Listing is pinned on both axes: scoped to one session, and ordered oldest-first with deliberately distinct timestamps so the ordering is decidable rather than incidental. - Accounts list by ascending priority, which is what the router relies on to pick the preferred account. The fixture inserts them out of order so the ORDER BY is observable. - set_account_limited is exercised in both directions - set a value and clear it back to NULL - because a test that only sets it would miss a mutant that ignores the None case. - load_worktrees is the startup query and must skip the two terminal states. The fixture puts one worktree in each of the four statuses, so the right answer (2) differs from "all four" and from either single exclusion. One of these tests was wrong on the first pass, and the gate said so. `prune_tool_call_events` was covered only at zero days and at a window with nothing old enough to delete - so both assertions expected 0, and a body mutated to `Ok(0)` passed, as did inverting the `days == 0` guard. The replacement backdates two of three events and asserts exactly 2 pruned, a value that differs from 0, 1 and 3. Both mutants die now. A passing test proves nothing until the mutant has been applied. While investigating that I suspected a second bug: prune_tool_call_events compares an RFC3339 `created_at` against SQLite's `datetime()`, which uses a space separator rather than 'T', and 'T' sorts above ' '. It turns out the date prefix is compared first, so the separator only matters within a single day and `days` is at minimum 1. Tested, not a bug - recorded here because the reasoning looked convincing and was not. Still uncovered in this file, and left for follow-up rather than claimed: push tokens (upsert/list/delete), repo contexts (add/list/remove), and a handful of session and message accessors. This PR does not pretend storage/mod.rs is finished. No production code changes. --- apps/daemon/src/storage/tests.rs | 762 +++++++++++++++++++++++++++++++ 1 file changed, 762 insertions(+) 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); +}