package store import ( "os" "strings" "testing" "time" "task-dashboard/internal/config" "task-dashboard/internal/models" ) // chainTasks builds bare-content ChainTaskInputs (no description/priority) // for tests that only care about title ordering and lock state. func chainTasks(titles ...string) []models.ChainTaskInput { tasks := make([]models.ChainTaskInput, len(titles)) for i, t := range titles { tasks[i] = models.ChainTaskInput{Content: t} } return tasks } func TestCreateChain_SeedsPositionsCorrectly(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Ham Radio Track", chainTasks("Study Technician", "Pass Technician exam", "Study General")) if err != nil { t.Fatalf("CreateChain: %v", err) } if chain.Status != "active" { t.Errorf("chain.Status = %q, want active", chain.Status) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatalf("GetChainTasks: %v", err) } if len(tasks) != 3 { t.Fatalf("len(tasks) = %d, want 3", len(tasks)) } // Unlocked but with no due date: a chain step is a milestone, not a // deadline (migration 031). It reaches the day's list by being committed. if !tasks[0].ChainUnlocked || tasks[0].DueDate != nil { t.Errorf("position 0: ChainUnlocked=%v DueDate=%v, want unlocked with no due date", tasks[0].ChainUnlocked, tasks[0].DueDate) } for i := 1; i < 3; i++ { if tasks[i].ChainUnlocked || tasks[i].DueDate != nil { t.Errorf("position %d: ChainUnlocked=%v DueDate=%v, want locked with no due date", i, tasks[i].ChainUnlocked, tasks[i].DueDate) } } if tasks[0].Content != "Study Technician" || tasks[1].Content != "Pass Technician exam" || tasks[2].Content != "Study General" { t.Errorf("unexpected content order: %q, %q, %q", tasks[0].Content, tasks[1].Content, tasks[2].Content) } } func TestCompleteNativeTask_AdvancesChain(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2")) if err != nil { t.Fatal(err) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if err := s.CompleteNativeTask(tasks[0].ID); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } after, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if !after[1].ChainUnlocked || after[1].DueDate != nil { t.Errorf("position 1 after completing position 0: ChainUnlocked=%v DueDate=%v, want unlocked with no due date", after[1].ChainUnlocked, after[1].DueDate) } updatedChain, err := s.GetChain(chain.ID) if err != nil { t.Fatal(err) } if updatedChain.Status != "active" { t.Errorf("chain.Status = %q, want active (not yet done)", updatedChain.Status) } } func TestCompleteNativeTask_LastPosition_MarksChainCompleted(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Track", chainTasks("Only step")) if err != nil { t.Fatal(err) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if err := s.CompleteNativeTask(tasks[0].ID); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } updatedChain, err := s.GetChain(chain.ID) if err != nil { t.Fatal(err) } if updatedChain.Status != "completed" { t.Errorf("chain.Status = %q, want completed", updatedChain.Status) } } func TestCompleteNativeTask_PausedChain_DoesNotAdvance(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("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.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if err := s.CompleteNativeTask(tasks[0].ID); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } after, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if after[1].ChainUnlocked { t.Error("position 1 should still be locked while chain is paused") } // Resuming performs the deferred unlock itself -- position 1 becomes // completable immediately, not only after some future completion. if err := s.SetChainStatus(chain.ID, "active"); err != nil { t.Fatal(err) } resumed, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if !resumed[1].ChainUnlocked { t.Fatal("expected resume to unlock position 1 immediately (deferred advancement catch-up)") } if err := s.CompleteNativeTask(tasks[1].ID); err != nil { t.Fatalf("CompleteNativeTask after resume: %v", err) } updatedChain, err := s.GetChain(chain.ID) if err != nil { t.Fatal(err) } if updatedChain.Status != "completed" { t.Errorf("chain.Status = %q, want completed after resuming and finishing the last step", updatedChain.Status) } } func TestCompleteNativeTask_LockedChainTask_ReturnsErrChainTaskLocked(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2")) if err != nil { t.Fatal(err) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if err := s.CompleteNativeTask(tasks[1].ID); err != ErrChainTaskLocked { t.Errorf("err = %v, want ErrChainTaskLocked", err) } // Confirm nothing was mutated -- still locked, still not completed. after, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if after[1].Completed || after[1].ChainUnlocked { t.Errorf("locked task should be untouched by the rejected completion attempt: %+v", after[1]) } } // Chain steps no longer carry due dates, so the original form of this test (that // resume must not reset the head's due date) no longer has a subject. The // underlying property it guarded still matters though: a resume catch-up against // a chain that was never stuck must be a no-op, not a re-unlock that disturbs // existing state. func TestSetChainStatus_ResumeWithNothingStuck_IsANoOp(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2")) if err != nil { t.Fatal(err) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } originalHeadID := tasks[0].ID // Pause and resume with nothing completed yet -- position 0 is already // unlocked and should be left untouched by the resume catch-up. if err := s.SetChainStatus(chain.ID, "paused"); err != nil { t.Fatal(err) } if err := s.SetChainStatus(chain.ID, "active"); err != nil { t.Fatal(err) } after, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if after[0].ID != originalHeadID || !after[0].ChainUnlocked { t.Errorf("head after resume = %+v, want the same unlocked head (resume catch-up should be a no-op when nothing was stuck)", after[0]) } if after[1].ChainUnlocked { t.Error("resume unlocked position 1 despite nothing being completed -- catch-up should not advance an unstuck chain") } } func TestGetUndatedNativeTasks_ExcludesLockedChainTasks(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2", "Step 3")) if err != nil { t.Fatal(err) } _ = chain undated, err := s.GetUndatedNativeTasks() if err != nil { t.Fatalf("GetUndatedNativeTasks: %v", err) } for _, task := range undated { if task.ChainID != "" && !task.ChainUnlocked { t.Errorf("locked chain task %q leaked into GetUndatedNativeTasks", task.ID) } } } func TestCreateChain_PersistsDescriptionAndPriority(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Track", []models.ChainTaskInput{ {Content: "Step 1", Description: "do the thing", Priority: 4}, {Content: "Step 2"}, // no priority given -- should default to 1 }) if err != nil { t.Fatal(err) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if tasks[0].Description != "do the thing" || tasks[0].Priority != 4 { t.Errorf("tasks[0] = %+v, want description=%q priority=4", tasks[0], "do the thing") } if tasks[1].Priority != 1 { t.Errorf("tasks[1].Priority = %d, want default of 1", tasks[1].Priority) } } func TestGetChain_UnknownID_ReturnsErrNotFound(t *testing.T) { s := newNativeTasksTestStore(t) if _, err := s.GetChain("does-not-exist"); err != ErrNativeTaskNotFound { t.Errorf("err = %v, want ErrNativeTaskNotFound", err) } } // TestDeleteNativeTask_LockedChainTask_ClosesPositionGap proves deleting a // not-yet-reached chain step doesn't strand the chain: positions after the // deleted one shift down by one and stay a contiguous 0..N-1 sequence, since // advanceChain's chain_position+1 lookup depends on that contiguity. func TestDeleteNativeTask_LockedChainTask_ClosesPositionGap(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2", "Step 3")) if err != nil { t.Fatal(err) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if err := s.DeleteNativeTask(tasks[1].ID); err != nil { t.Fatalf("DeleteNativeTask: %v", err) } after, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if len(after) != 2 { t.Fatalf("len(after) = %d, want 2", len(after)) } if after[0].Content != "Step 1" || after[1].Content != "Step 3" { t.Errorf("unexpected content order: %q, %q", after[0].Content, after[1].Content) } if after[1].ChainPosition != 1 { t.Errorf("Step 3 chain_position = %d, want 1 (gap closed)", after[1].ChainPosition) } if !after[0].ChainUnlocked { t.Errorf("position 0 should still be unlocked") } if after[1].ChainUnlocked { t.Errorf("position 1 (formerly locked position 2) should still be locked") } // Completing position 0 should now correctly advance to the // renumbered position 1 (Step 3), proving advanceChain's // chain_position+1 lookup still works post-deletion. if err := s.CompleteNativeTask(after[0].ID); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } final, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if !final[1].ChainUnlocked { t.Errorf("Step 3 should be unlocked after completing Step 1") } } // TestDeleteNativeTask_UnlockedChainTask_PromotesSuccessor proves deleting // the currently-actionable (unlocked) step unlocks whatever now sits at its // position, rather than leaving the chain with nothing unlocked. func TestDeleteNativeTask_UnlockedChainTask_PromotesSuccessor(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2")) if err != nil { t.Fatal(err) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if err := s.DeleteNativeTask(tasks[0].ID); err != nil { t.Fatalf("DeleteNativeTask: %v", err) } after, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if len(after) != 1 { t.Fatalf("len(after) = %d, want 1", len(after)) } if !after[0].ChainUnlocked || after[0].DueDate != nil { t.Errorf("Step 2 should be promoted to unlocked with no due date, got ChainUnlocked=%v DueDate=%v", after[0].ChainUnlocked, after[0].DueDate) } } // TestDeleteNativeTask_LastRemainingChainTask_MarksChainCompleted proves // deleting the sole unlocked task with nothing left to promote finishes the // chain instead of leaving it active with zero unlocked tasks forever. func TestDeleteNativeTask_LastRemainingChainTask_MarksChainCompleted(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Track", chainTasks("Only step")) if err != nil { t.Fatal(err) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if err := s.DeleteNativeTask(tasks[0].ID); err != nil { t.Fatalf("DeleteNativeTask: %v", err) } updatedChain, err := s.GetChain(chain.ID) if err != nil { t.Fatal(err) } if updatedChain.Status != "completed" { 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) } } // A chain step is a milestone, not a deadline. Stamping due_date on unlock made // every step -- a five-minute call and a six-month certification alike -- arrive // looking due today, which is what drove the hand-rescheduling that made the // list untrustworthy. Steps now carry no due date; they reach the day's list // only by being committed. func TestCreateChain_HeadTaskHasNoDueDate(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2")) if err != nil { t.Fatal(err) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if !tasks[0].ChainUnlocked { t.Fatal("expected position 0 to be unlocked") } if tasks[0].DueDate != nil { t.Errorf("head task DueDate = %v, want nil -- a chain step is a milestone, not a deadline", tasks[0].DueDate) } } func TestCompleteNativeTask_AdvancedStepHasNoDueDate(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2")) if err != nil { t.Fatal(err) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if err := s.CompleteNativeTask(tasks[0].ID); err != nil { t.Fatal(err) } after, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if !after[1].ChainUnlocked { t.Fatal("expected position 1 to be unlocked after completing position 0") } if after[1].DueDate != nil { t.Errorf("newly unlocked step DueDate = %v, want nil", after[1].DueDate) } } // With no due date, an unlocked chain step would otherwise fall into the undated // bucket and show up on the timeline anyway -- defeating the point. Chain work // reaches the day only via commitment. func TestGetUndatedNativeTasks_ExcludesUnlockedChainSteps(t *testing.T) { s := newNativeTasksTestStore(t) chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2")) if err != nil { t.Fatal(err) } undated, err := s.GetUndatedNativeTasks() if err != nil { t.Fatal(err) } for _, task := range undated { if task.ChainID == chain.ID { t.Errorf("chain step %q leaked into the undated list; chain work should surface via the chain summary and commitment only", task.Content) } } }