diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-02 23:47:56 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-02 23:47:56 +0000 |
| commit | 3e26cd5ad7a0cbd1445f535ccd63c2e79d3580a4 (patch) | |
| tree | 41b6dd8b79f4d078cda1c91570af2f9240ccdce4 /internal/store/chains_test.go | |
| parent | 74bf58335d3542d88b32d447b47342a76f5e5449 (diff) | |
Hide paused chains' tasks from the task list
Pausing a chain is supposed to take its work off the list -- that's the
entire point of the control. But the three task-fetching queries only
ever checked `chain_unlocked`, never the owning chain's status, so a
paused chain's head task kept appearing exactly like an active one.
Verified against production: 4 of the 8 visible chain tasks came from
chains that had been explicitly paused ("Will", "ABYC Marine Electrical
certification", "Standard services agreement", "Find a broker who writes
marine trades"). Half the chain clutter on the list was work the user
had already told doot to put away.
Fixed the class, not just the instances: the predicate now lives in one
shared visibleChainTaskClause constant used by all three queries, with a
structural test asserting no query spells it out inline and that every
query still references it. The previous shape -- the same predicate
copy-pasted into three separate query strings -- is exactly why a fourth
query would have gotten it wrong too.
Also added task_chains to sqlite_test.go's leaner test schema, since the
queries now join against it even for chain-less tasks.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal/store/chains_test.go')
| -rw-r--r-- | internal/store/chains_test.go | 107 |
1 files changed, 107 insertions, 0 deletions
diff --git a/internal/store/chains_test.go b/internal/store/chains_test.go index bcfc09e..d21490c 100644 --- a/internal/store/chains_test.go +++ b/internal/store/chains_test.go @@ -1,8 +1,12 @@ package store import ( + "os" + "strings" "testing" + "time" + "task-dashboard/internal/config" "task-dashboard/internal/models" ) @@ -377,3 +381,106 @@ func TestDeleteNativeTask_LastRemainingChainTask_MarksChainCompleted(t *testing. t.Errorf("chain.Status = %q, want completed", updatedChain.Status) } } + +// Pausing a chain is meant to take its work off the list entirely -- the whole +// point of the control. But the task-fetching queries only ever checked +// `chain_unlocked`, never the owning chain's status, so a paused chain's head +// task kept showing up exactly like an active one. Found live: 4 of the 8 chain +// tasks on the real dashboard came from chains that had been explicitly paused. +func TestPausedChain_HeadTaskHiddenFromDateRange(t *testing.T) { + s := newNativeTasksTestStore(t) + + chain, err := s.CreateChain("Paused Track", chainTasks("Step 1", "Step 2")) + if err != nil { + t.Fatal(err) + } + if err := s.SetChainStatus(chain.ID, "paused"); err != nil { + t.Fatal(err) + } + + now := config.Now() + tasks, err := s.GetNativeTasksByDateRange(now.Add(-24*time.Hour), now.Add(24*time.Hour)) + if err != nil { + t.Fatalf("GetNativeTasksByDateRange: %v", err) + } + for _, task := range tasks { + if task.ChainID == chain.ID { + t.Errorf("paused chain's task %q leaked into GetNativeTasksByDateRange", task.Content) + } + } +} + +func TestPausedChain_HeadTaskHiddenFromOverdue(t *testing.T) { + s := newNativeTasksTestStore(t) + + chain, err := s.CreateChain("Paused Track", chainTasks("Step 1", "Step 2")) + if err != nil { + t.Fatal(err) + } + if err := s.SetChainStatus(chain.ID, "paused"); err != nil { + t.Fatal(err) + } + + tasks, err := s.GetOverdueNativeTasks(config.Now().Add(24 * time.Hour)) + if err != nil { + t.Fatalf("GetOverdueNativeTasks: %v", err) + } + for _, task := range tasks { + if task.ChainID == chain.ID { + t.Errorf("paused chain's task %q leaked into GetOverdueNativeTasks", task.Content) + } + } +} + +func TestPausedChain_HeadTaskHiddenFromUndated(t *testing.T) { + s := newNativeTasksTestStore(t) + + chain, err := s.CreateChain("Paused Track", chainTasks("Step 1", "Step 2")) + if err != nil { + t.Fatal(err) + } + // CreateChain seeds the head with a due date; clear it so this task is + // actually a candidate for the undated query rather than passing vacuously. + if _, err := s.db.Exec(`UPDATE native_tasks SET due_date = NULL WHERE chain_id = ?`, chain.ID); err != nil { + t.Fatal(err) + } + if err := s.SetChainStatus(chain.ID, "paused"); err != nil { + t.Fatal(err) + } + + tasks, err := s.GetUndatedNativeTasks() + if err != nil { + t.Fatalf("GetUndatedNativeTasks: %v", err) + } + for _, task := range tasks { + if task.ChainID == chain.ID { + t.Errorf("paused chain's task %q leaked into GetUndatedNativeTasks", task.Content) + } + } +} + +// Structural guard for the whole class, not just the three queries fixed above: +// any future task query that filters on chain_unlocked must also constrain the +// owning chain's status, or it reintroduces exactly the leak these tests cover. +// Checked by source inspection because the failure mode is "someone wrote a +// fourth query and copied the old predicate," which no single behavioral test +// would catch. +func TestChainVisibilityPredicate_UsedEverywhereChainUnlockedIsFiltered(t *testing.T) { + src, err := os.ReadFile("native_tasks.go") + if err != nil { + t.Fatal(err) + } + text := string(src) + + // The copy-paste hazard is specifically the inline "chain_id = '' OR ..." + // predicate. It should appear exactly once: inside the shared constant's own + // definition. (A bare `chain_unlocked = 1` is fine elsewhere -- advanceChain + // legitimately SETs it.) + if inline := strings.Count(text, "chain_id = '' OR"); inline != 1 { + t.Errorf("found the inline chain-visibility predicate %d time(s) in native_tasks.go, want exactly 1 (the visibleChainTaskClause definition); a query spelling it out inline will silently skip the chain-status check", inline) + } + // Const declaration plus one reference per task query that hides chain work. + if refs := strings.Count(text, "visibleChainTaskClause"); refs < 4 { + t.Errorf("visibleChainTaskClause referenced %d time(s), want >= 4 (declaration + the task queries); a query that stopped using it has drifted off the shared predicate", refs) + } +} |
