diff options
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) + } +} |
