package store import ( "testing" "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)) } 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", 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 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", 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]) } } func TestSetChainStatus_ResumeWithNothingStuck_DoesNotResetDueDate(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) } originalDueDate := *tasks[0].DueDate // 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].DueDate.Equal(originalDueDate) { t.Errorf("DueDate = %v, want unchanged %v (resume catch-up should be a no-op when nothing was stuck)", after[0].DueDate, originalDueDate) } } 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) } }