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 '', 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) } }