summaryrefslogtreecommitdiff
path: root/internal/store/chains_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-17 22:22:37 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-17 22:22:37 +0000
commitb007fee8fb5b39a5f9b369c59af71ac9e795ceaf (patch)
treea5b999b8f4c23a52ae9249675753a92a77993008 /internal/store/chains_test.go
parent70e6dd75130e70f2db83096c23eaa75326b183a2 (diff)
Implement linear task chains and recurring maintenance buckets
Backend, web timeline, and Android widget wiring for the last two unimplemented items from doot-future-task-scheduling-ideas. Chains: task_chains table + chain_id/chain_position/chain_unlocked on native_tasks (migration 026), WIP-limit-1 advancement hooked into CompleteNativeTask, locked tasks excluded from all date-based queries, 5 new /api/widget/chains* endpoints, an N/M position badge on web and Android widget rows. Buckets: maintenance_buckets table + bucket_id/bucket_state/ bucket_last_active_at on native_tasks (migration 027), staleness-then-priority selection scoring, a new RunBucketCycleCheck scheduler loop, 5 new endpoints including the distinct Defer action, a Defer button on web and Android widget rows. Also corrected stale "not yet approved" status headers on the two already-shipped specs this work depended on (labels/projects, budgets/ availability) -- their headers were never updated after implementation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'internal/store/chains_test.go')
-rw-r--r--internal/store/chains_test.go165
1 files changed, 165 insertions, 0 deletions
diff --git a/internal/store/chains_test.go b/internal/store/chains_test.go
new file mode 100644
index 0000000..6d3e450
--- /dev/null
+++ b/internal/store/chains_test.go
@@ -0,0 +1,165 @@
+package store
+
+import (
+ "testing"
+)
+
+func TestCreateChain_SeedsPositionsCorrectly(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Ham Radio Track", []string{"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))
+ }
+ if !tasks[0].ChainUnlocked || tasks[0].DueDate == nil {
+ t.Errorf("position 0: ChainUnlocked=%v DueDate=%v, want unlocked with a 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", []string{"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 a 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", []string{"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", []string{"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 re-enables advancement on the *next* completion.
+ if err := s.SetChainStatus(chain.ID, "active"); err != nil {
+ t.Fatal(err)
+ }
+ 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 TestGetUndatedNativeTasks_ExcludesLockedChainTasks(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", []string{"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 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)
+ }
+}