package store import ( "database/sql" "errors" "path/filepath" "testing" "time" "task-dashboard/internal/models" _ "github.com/mattn/go-sqlite3" ) // newNativeTasksTestStore creates a Store backed by a fresh temp sqlite DB // with just the native_tasks table -- enough to exercise // CompleteNativeTask/UncompleteNativeTask/RescheduleNativeTask without // running the full migration set. func newNativeTasksTestStore(t *testing.T) *Store { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") db, err := sql.Open("sqlite3", dbPath) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) if _, err := db.Exec(` CREATE TABLE native_tasks ( id TEXT PRIMARY KEY, content TEXT NOT NULL, description TEXT DEFAULT '', project_name TEXT DEFAULT '', project_id TEXT DEFAULT '', due_date DATETIME, priority INTEGER DEFAULT 1, completed BOOLEAN DEFAULT 0, labels TEXT DEFAULT '[]', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, recurrence_freq TEXT DEFAULT '', recurrence_interval INTEGER DEFAULT 1, recurrence_weekdays TEXT DEFAULT '', recurrence_series_id TEXT DEFAULT '', next_occurrence_override TEXT DEFAULT '' ) `); err != nil { t.Fatal(err) } if _, err := db.Exec(`INSERT INTO native_tasks (id, content) VALUES ('real-1', 'Real task')`); err != nil { t.Fatal(err) } return &Store{db: db} } // TestCompleteNativeTask_UnknownID_ReturnsErrNotFound proves the 2026-07-12 // fix: a plain UPDATE ... WHERE id = ? silently "succeeds" with a nil error // when 0 rows match (this is how database/sql's Exec behaves for an UPDATE // that matches nothing -- no error, just RowsAffected() == 0). Before this // fix, CompleteNativeTask returned that nil error straight through, so a // stale/wrong id from a caller (the Android widget, in the real incident // this was found from) looked identical to a real completion: HTTP 200, // nothing changed in the database. func TestCompleteNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) { s := newNativeTasksTestStore(t) err := s.CompleteNativeTask("does-not-exist") if !errors.Is(err, ErrNativeTaskNotFound) { t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) } } func TestCompleteNativeTask_RealID_Succeeds(t *testing.T) { s := newNativeTasksTestStore(t) if err := s.CompleteNativeTask("real-1"); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } var completed bool if err := s.db.QueryRow(`SELECT completed FROM native_tasks WHERE id = 'real-1'`).Scan(&completed); err != nil { t.Fatal(err) } if !completed { t.Error("expected task to be marked completed") } } func TestUncompleteNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) { s := newNativeTasksTestStore(t) err := s.UncompleteNativeTask("does-not-exist") if !errors.Is(err, ErrNativeTaskNotFound) { t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) } } func TestRescheduleNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) { s := newNativeTasksTestStore(t) err := s.RescheduleNativeTask("does-not-exist", time.Now()) if !errors.Is(err, ErrNativeTaskNotFound) { t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) } } func TestGetNativeTasks_ParsesRecurrenceFields(t *testing.T) { s := newNativeTasksTestStore(t) if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override) VALUES ('rec-1', 'Recurring task', '2026-07-13', 'weekly', 2, '1,3,5', 'series-abc', '2026-07-27') `); err != nil { t.Fatal(err) } tasks, err := s.GetNativeTasks() if err != nil { t.Fatalf("GetNativeTasks: %v", err) } var found *models.Task for i := range tasks { if tasks[i].ID == "rec-1" { found = &tasks[i] } } if found == nil { t.Fatal("expected to find rec-1") } if found.RecurrenceFreq != "weekly" { t.Errorf("RecurrenceFreq = %q, want %q", found.RecurrenceFreq, "weekly") } if found.RecurrenceInterval != 2 { t.Errorf("RecurrenceInterval = %d, want 2", found.RecurrenceInterval) } if len(found.RecurrenceWeekdays) != 3 || found.RecurrenceWeekdays[0] != 1 || found.RecurrenceWeekdays[1] != 3 || found.RecurrenceWeekdays[2] != 5 { t.Errorf("RecurrenceWeekdays = %v, want [1 3 5]", found.RecurrenceWeekdays) } if found.RecurrenceSeriesID != "series-abc" { t.Errorf("RecurrenceSeriesID = %q, want %q", found.RecurrenceSeriesID, "series-abc") } if found.NextOccurrenceOverride == nil || found.NextOccurrenceOverride.Format("2006-01-02") != "2026-07-27" { t.Errorf("NextOccurrenceOverride = %v, want 2026-07-27", found.NextOccurrenceOverride) } } func TestGetNativeTasks_NonRecurringTask_HasEmptyRecurrenceFields(t *testing.T) { s := newNativeTasksTestStore(t) // "real-1" (inserted by newNativeTasksTestStore) has no recurrence columns set. tasks, err := s.GetNativeTasks() if err != nil { t.Fatalf("GetNativeTasks: %v", err) } var found *models.Task for i := range tasks { if tasks[i].ID == "real-1" { found = &tasks[i] } } if found == nil { t.Fatal("expected to find real-1") } if found.RecurrenceSeriesID != "" { t.Errorf("expected empty RecurrenceSeriesID for non-recurring task, got %q", found.RecurrenceSeriesID) } if found.RecurrenceWeekdays != nil { t.Errorf("expected nil RecurrenceWeekdays for non-recurring task, got %v", found.RecurrenceWeekdays) } if found.NextOccurrenceOverride != nil { t.Errorf("expected nil NextOccurrenceOverride for non-recurring task, got %v", found.NextOccurrenceOverride) } } func TestGetNativeTaskByID_UnknownID_ReturnsErrNotFound(t *testing.T) { s := newNativeTasksTestStore(t) _, err := s.GetNativeTaskByID("does-not-exist") if !errors.Is(err, ErrNativeTaskNotFound) { t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) } } func TestGetNativeTaskByID_RealID_ReturnsTask(t *testing.T) { s := newNativeTasksTestStore(t) task, err := s.GetNativeTaskByID("real-1") if err != nil { t.Fatalf("GetNativeTaskByID: %v", err) } if task.Content != "Real task" { t.Errorf("Content = %q, want %q", task.Content, "Real task") } } func TestCompleteNativeTask_NonRecurring_JustCompletes(t *testing.T) { s := newNativeTasksTestStore(t) if err := s.CompleteNativeTask("real-1"); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } tasks, err := s.db.Query(`SELECT id FROM native_tasks`) if err != nil { t.Fatal(err) } defer tasks.Close() count := 0 for tasks.Next() { count++ } if count != 1 { t.Errorf("expected exactly 1 row (no iteration created for a non-recurring task), got %d", count) } } func TestCompleteNativeTask_LatestInSeries_CreatesNextIteration(t *testing.T) { s := newNativeTasksTestStore(t) if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } if err := s.CompleteNativeTask("rec-1"); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } var completed bool if err := s.db.QueryRow(`SELECT completed FROM native_tasks WHERE id = 'rec-1'`).Scan(&completed); err != nil { t.Fatal(err) } if !completed { t.Error("expected rec-1 to be marked completed") } var nextCount int var nextDue string if err := s.db.QueryRow(` SELECT COUNT(*), COALESCE(MAX(due_date), '') FROM native_tasks WHERE recurrence_series_id = 'series-1' AND id != 'rec-1' `).Scan(&nextCount, &nextDue); err != nil { t.Fatal(err) } if nextCount != 1 { t.Fatalf("expected exactly 1 new iteration, got %d", nextCount) } if nextDue[:10] != "2026-07-20" { t.Errorf("next iteration due_date = %q, want 2026-07-20", nextDue) } } func TestCompleteNativeTask_UsesNextOccurrenceOverride(t *testing.T) { s := newNativeTasksTestStore(t) if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id, next_occurrence_override) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1', '2026-08-01') `); err != nil { t.Fatal(err) } if err := s.CompleteNativeTask("rec-1"); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } var nextDue string if err := s.db.QueryRow(` SELECT due_date FROM native_tasks WHERE recurrence_series_id = 'series-1' AND id != 'rec-1' `).Scan(&nextDue); err != nil { t.Fatal(err) } if nextDue[:10] != "2026-08-01" { t.Errorf("next iteration due_date = %q, want 2026-08-01 (the override)", nextDue) } } func TestCompleteNativeTask_AlreadySuperseded_DoesNotDoubleCreate(t *testing.T) { s := newNativeTasksTestStore(t) if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } // Simulate the periodic due-check having already created the successor // before the user got around to completing rec-1. if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) VALUES ('rec-2', 'Water plants', '2026-07-20', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } if err := s.CompleteNativeTask("rec-1"); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } var count int if err := s.db.QueryRow(`SELECT COUNT(*) FROM native_tasks WHERE recurrence_series_id = 'series-1'`).Scan(&count); err != nil { t.Fatal(err) } if count != 2 { t.Errorf("expected still exactly 2 rows in the series (no double-create), got %d", count) } } func TestCreateNextIteration_ConcurrentGuard_SecondCallIsNoOp(t *testing.T) { s := newNativeTasksTestStore(t) if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } old, err := s.GetNativeTaskByID("rec-1") if err != nil { t.Fatal(err) } // Simulate two concurrent triggers both computing from the same // snapshot of rec-1 and both attempting to create its successor. if err := s.CreateNextIteration(*old); err != nil { t.Fatalf("first CreateNextIteration: %v", err) } if err := s.CreateNextIteration(*old); err != nil { t.Fatalf("second CreateNextIteration (should be a safe no-op, not an error): %v", err) } var count int if err := s.db.QueryRow(`SELECT COUNT(*) FROM native_tasks WHERE recurrence_series_id = 'series-1' AND id != 'rec-1'`).Scan(&count); err != nil { t.Fatal(err) } if count != 1 { t.Errorf("expected exactly 1 successor row after two CreateNextIteration calls from the same snapshot, got %d", count) } } func TestSetTaskRecurrence_FirstTimeGeneratesSeriesID(t *testing.T) { s := newNativeTasksTestStore(t) if err := s.SetTaskRecurrence("real-1", "weekly", 1, []int{1, 3}); err != nil { t.Fatalf("SetTaskRecurrence: %v", err) } task, err := s.GetNativeTaskByID("real-1") if err != nil { t.Fatal(err) } if task.RecurrenceFreq != "weekly" { t.Errorf("RecurrenceFreq = %q, want weekly", task.RecurrenceFreq) } if task.RecurrenceSeriesID == "" { t.Error("expected a generated RecurrenceSeriesID, got empty string") } if len(task.RecurrenceWeekdays) != 2 || task.RecurrenceWeekdays[0] != 1 || task.RecurrenceWeekdays[1] != 3 { t.Errorf("RecurrenceWeekdays = %v, want [1 3]", task.RecurrenceWeekdays) } } func TestSetTaskRecurrence_ClearingKeepsSeriesID(t *testing.T) { s := newNativeTasksTestStore(t) if err := s.SetTaskRecurrence("real-1", "weekly", 1, nil); err != nil { t.Fatal(err) } task, err := s.GetNativeTaskByID("real-1") if err != nil { t.Fatal(err) } seriesID := task.RecurrenceSeriesID if err := s.SetTaskRecurrence("real-1", "", 1, nil); err != nil { t.Fatalf("SetTaskRecurrence (clear): %v", err) } task, err = s.GetNativeTaskByID("real-1") if err != nil { t.Fatal(err) } if task.RecurrenceFreq != "" { t.Errorf("RecurrenceFreq = %q, want empty after clearing", task.RecurrenceFreq) } if task.RecurrenceSeriesID != seriesID { t.Errorf("RecurrenceSeriesID = %q, want unchanged %q after clearing", task.RecurrenceSeriesID, seriesID) } } func TestSetNextOccurrenceOverride_UnknownID_ReturnsErrNotFound(t *testing.T) { s := newNativeTasksTestStore(t) err := s.SetNextOccurrenceOverride("does-not-exist", time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)) if !errors.Is(err, ErrNativeTaskNotFound) { t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) } } func TestAdvanceDueRecurringTasks_DueUncompleted_CreatesSuccessor(t *testing.T) { s := newNativeTasksTestStore(t) if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } now := time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC) // one day after due, still uncompleted n, err := s.AdvanceDueRecurringTasks(now) if err != nil { t.Fatalf("AdvanceDueRecurringTasks: %v", err) } if n != 1 { t.Fatalf("expected 1 iteration created, got %d", n) } var completed bool if err := s.db.QueryRow(`SELECT completed FROM native_tasks WHERE id = 'rec-1'`).Scan(&completed); err != nil { t.Fatal(err) } if completed { t.Error("expected rec-1 to remain uncompleted -- due-date passing doesn't complete it, just spawns the successor") } var count int if err := s.db.QueryRow(`SELECT COUNT(*) FROM native_tasks WHERE recurrence_series_id = 'series-1'`).Scan(&count); err != nil { t.Fatal(err) } if count != 2 { t.Fatalf("expected 2 rows in the series (original + successor), got %d", count) } } func TestAdvanceDueRecurringTasks_NotYetDue_LeavesAlone(t *testing.T) { s := newNativeTasksTestStore(t) if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } now := time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC) // before the due date n, err := s.AdvanceDueRecurringTasks(now) if err != nil { t.Fatalf("AdvanceDueRecurringTasks: %v", err) } if n != 0 { t.Errorf("expected 0 iterations created for a not-yet-due task, got %d", n) } } func TestAdvanceDueRecurringTasks_AlreadyHasSuccessor_SkipsIt(t *testing.T) { s := newNativeTasksTestStore(t) if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) VALUES ('rec-2', 'Water plants', '2026-07-20', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } now := time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC) n, err := s.AdvanceDueRecurringTasks(now) if err != nil { t.Fatalf("AdvanceDueRecurringTasks: %v", err) } if n != 1 { t.Fatalf("expected only rec-2 (the latest, now also due) to spawn a successor, got n=%d", n) } var count int if err := s.db.QueryRow(`SELECT COUNT(*) FROM native_tasks WHERE recurrence_series_id = 'series-1'`).Scan(&count); err != nil { t.Fatal(err) } if count != 3 { t.Fatalf("expected 3 rows total (rec-1, rec-2, and rec-2's new successor), got %d", count) } } func TestAdvanceDueRecurringTasks_NonRecurringTask_NeverTouched(t *testing.T) { s := newNativeTasksTestStore(t) // "real-1" (from newNativeTasksTestStore) has no due_date and no recurrence. now := time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC) n, err := s.AdvanceDueRecurringTasks(now) if err != nil { t.Fatalf("AdvanceDueRecurringTasks: %v", err) } if n != 0 { t.Errorf("expected 0 iterations created (no recurring tasks in fixture), got %d", n) } } func TestGetNativeTasks_ParsesProjectID(t *testing.T) { s := newNativeTasksTestStore(t) if _, err := s.db.Exec(`INSERT INTO native_tasks (id, content, project_id) VALUES ('proj-1', 'Task with project', 'project-abc')`); err != nil { t.Fatal(err) } tasks, err := s.GetNativeTasks() if err != nil { t.Fatalf("GetNativeTasks: %v", err) } var found *models.Task for i := range tasks { if tasks[i].ID == "proj-1" { found = &tasks[i] } } if found == nil { t.Fatal("expected to find proj-1") } if found.ProjectID != "project-abc" { t.Errorf("ProjectID = %q, want %q", found.ProjectID, "project-abc") } } func TestCreateNativeTask_PersistsProjectID(t *testing.T) { s := newNativeTasksTestStore(t) task := models.Task{ID: "new-1", Content: "New task", ProjectID: "project-xyz"} if err := s.CreateNativeTask(task); err != nil { t.Fatalf("CreateNativeTask: %v", err) } got, err := s.GetNativeTaskByID("new-1") if err != nil { t.Fatal(err) } if got.ProjectID != "project-xyz" { t.Errorf("ProjectID = %q, want %q", got.ProjectID, "project-xyz") } } func TestCreateNextIteration_InheritsProjectIDAndLabels(t *testing.T) { s := newNativeTasksTestStore(t) if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id, project_id, labels) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1', 'project-abc', '["chore","garden"]') `); err != nil { t.Fatal(err) } if err := s.CompleteNativeTask("rec-1"); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } var nextID string if err := s.db.QueryRow(`SELECT id FROM native_tasks WHERE recurrence_series_id = 'series-1' AND id != 'rec-1'`).Scan(&nextID); err != nil { t.Fatal(err) } next, err := s.GetNativeTaskByID(nextID) if err != nil { t.Fatal(err) } if next.ProjectID != "project-abc" { t.Errorf("next iteration ProjectID = %q, want %q", next.ProjectID, "project-abc") } if len(next.Labels) != 2 || next.Labels[0] != "chore" || next.Labels[1] != "garden" { t.Errorf("next iteration Labels = %v, want [chore garden]", next.Labels) } }