diff options
Diffstat (limited to 'internal/store')
| -rw-r--r-- | internal/store/native_tasks.go | 88 | ||||
| -rw-r--r-- | internal/store/native_tasks_test.go | 96 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 10 | ||||
| -rw-r--r-- | internal/store/sqlite_test.go | 45 |
4 files changed, 215 insertions, 24 deletions
diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go index 218675e..7789a0d 100644 --- a/internal/store/native_tasks.go +++ b/internal/store/native_tasks.go @@ -1,12 +1,21 @@ package store import ( + "database/sql" "encoding/json" + "errors" "time" "task-dashboard/internal/models" ) +// ErrNativeTaskNotFound is returned by CompleteNativeTask, UncompleteNativeTask, +// and RescheduleNativeTask when no row matches the given id -- previously these +// three silently reported success on a 0-row UPDATE (Exec's err is nil even when +// no rows match), so a stale or wrong id from a caller looked identical to a real +// completion: the HTTP response was 200, but nothing in the database changed. +var ErrNativeTaskNotFound = errors.New("native task not found") + // GetNativeTasks returns all non-completed native tasks. func (s *Store) GetNativeTasks() ([]models.Task, error) { rows, err := s.db.Query(` @@ -22,15 +31,37 @@ func (s *Store) GetNativeTasks() ([]models.Task, error) { return scanNativeTasks(rows) } -// GetNativeTasksByDateRange returns non-completed native tasks due within the given range, -// including overdue tasks (due before start) so they keep appearing until completed. +// GetNativeTasksByDateRange returns non-completed native tasks due within the given range. +// Overdue tasks (due before start) are deliberately excluded here -- BuildTimeline fetches +// those separately via GetOverdueNativeTasks so callers that only want "in range" can use this +// without double-counting against that separate fetch. func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task, error) { rows, err := s.db.Query(` SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at FROM native_tasks + WHERE completed = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ? + ORDER BY due_date ASC, priority DESC + `, start, end) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return scanNativeTasks(rows) +} + +// GetOverdueNativeTasks returns non-completed native tasks whose due date is +// before the given time. BuildTimeline calls this alongside +// GetNativeTasksByDateRange, whose lower bound excludes anything due before +// the requested range's start -- without this, a task overdue from a +// previous day never gets fetched at all, so it never reaches +// ComputeDaySection to be marked IsOverdue. +func (s *Store) GetOverdueNativeTasks(before time.Time) ([]models.Task, error) { + rows, err := s.db.Query(` + SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at + FROM native_tasks WHERE completed = 0 AND due_date IS NOT NULL AND due_date < ? ORDER BY due_date ASC, priority DESC - `, end) + `, before) if err != nil { return nil, err } @@ -81,31 +112,62 @@ func (s *Store) UpdateNativeTaskDescription(id, description string) error { return err } -// CompleteNativeTask marks a task as completed. +// CompleteNativeTask marks a task as completed. Returns ErrNativeTaskNotFound +// if id doesn't match any row. func (s *Store) CompleteNativeTask(id string) error { - _, err := s.db.Exec(` + result, err := s.db.Exec(` UPDATE native_tasks SET completed = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, id) - return err + if err != nil { + return err + } + return checkRowsAffected(result) } -// RescheduleNativeTask sets a new due date on a task. +// RescheduleNativeTask sets a new due date on a task. Returns +// ErrNativeTaskNotFound if id doesn't match any row. func (s *Store) RescheduleNativeTask(id string, dueDate time.Time) error { - _, err := s.db.Exec(` + result, err := s.db.Exec(` UPDATE native_tasks SET due_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, dueDate, id) - return err + if err != nil { + return err + } + return checkRowsAffected(result) } -// UncompleteNativeTask marks a task as not completed. +// UncompleteNativeTask marks a task as not completed. Returns +// ErrNativeTaskNotFound if id doesn't match any row. func (s *Store) UncompleteNativeTask(id string) error { - _, err := s.db.Exec(` + result, err := s.db.Exec(` UPDATE native_tasks SET completed = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, id) - return err + if err != nil { + return err + } + return checkRowsAffected(result) +} + +// checkRowsAffected returns ErrNativeTaskNotFound if the update matched no +// rows -- mirrors the RowsAffected() check already used in sqlite.go's +// ApproveAgentSession/DenyAgentSession for the same "silent 0-row update" +// class of bug. +func checkRowsAffected(result sql.Result) error { + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNativeTaskNotFound + } + return nil } -func scanNativeTasks(rows interface{ Next() bool; Scan(...interface{}) error; Err() error }) ([]models.Task, error) { +func scanNativeTasks(rows interface { + Next() bool + Scan(...interface{}) error + Err() error +}) ([]models.Task, error) { var tasks []models.Task for rows.Next() { var t models.Task diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go new file mode 100644 index 0000000..5c11d3a --- /dev/null +++ b/internal/store/native_tasks_test.go @@ -0,0 +1,96 @@ +package store + +import ( + "database/sql" + "errors" + "path/filepath" + "testing" + "time" + + _ "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 + ) + `); 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) + } +} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index f955f71..ad88166 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -600,8 +600,8 @@ func (s *Store) SaveCalendarEvents(events []models.CalendarEvent) error { } stmt, err := tx.Prepare(` - INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link, recurring_event_id) + VALUES (?, ?, ?, ?, ?, ?, ?) `) if err != nil { return err @@ -609,7 +609,7 @@ func (s *Store) SaveCalendarEvents(events []models.CalendarEvent) error { defer func() { _ = stmt.Close() }() for _, e := range events { - _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink) + _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink, e.RecurringEventID) if err != nil { return err } @@ -644,7 +644,7 @@ func (s *Store) GetCalendarEvents() ([]models.CalendarEvent, error) { // GetCalendarEventsByDateRange retrieves cached calendar events within a date range func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.CalendarEvent, error) { rows, err := s.db.Query(` - SELECT id, summary, description, start_time, end_time, html_link + SELECT id, summary, description, start_time, end_time, html_link, recurring_event_id FROM calendar_events WHERE start_time >= ? AND start_time <= ? ORDER BY start_time ASC @@ -657,7 +657,7 @@ func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.Cal var events []models.CalendarEvent for rows.Next() { var e models.CalendarEvent - if err := rows.Scan(&e.ID, &e.Summary, &e.Description, &e.Start, &e.End, &e.HTMLLink); err != nil { + if err := rows.Scan(&e.ID, &e.Summary, &e.Description, &e.Start, &e.End, &e.HTMLLink, &e.RecurringEventID); err != nil { return nil, err } events = append(events, e) diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index 4d3c8f8..e8af436 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -188,10 +188,12 @@ func setupTestStoreWithNativeTasks(t *testing.T) *Store { return store } -// TestGetNativeTasksByDateRange_IncludesOverdue guards against a regression where a native task -// due before the window's start (e.g. yesterday, still incomplete) silently dropped out of the -// widget/timeline the moment the day rolled over, because the query required due_date >= start. -func TestGetNativeTasksByDateRange_IncludesOverdue(t *testing.T) { +// TestGetNativeTasksByDateRange_ExcludesOverdue documents the deliberate contract after +// 2026-07-13's reconciliation: GetNativeTasksByDateRange is scoped to [start, end) only. +// Overdue tasks (due before start) are BuildTimeline's job to fetch separately via +// GetOverdueNativeTasks -- see that test below and timeline_logic.go's "6." section -- +// so this function must NOT also return them, or BuildTimeline would double them up. +func TestGetNativeTasksByDateRange_ExcludesOverdue(t *testing.T) { store := setupTestStoreWithNativeTasks(t) now := time.Now() @@ -221,8 +223,8 @@ func TestGetNativeTasksByDateRange_IncludesOverdue(t *testing.T) { for _, r := range results { ids[r.ID] = true } - if !ids["t-overdue"] { - t.Error("expected overdue task to be included, but it was excluded") + if ids["t-overdue"] { + t.Error("expected overdue task to be excluded from the ranged fetch") } if !ids["t-today"] { t.Error("expected today's task to be included") @@ -232,6 +234,37 @@ func TestGetNativeTasksByDateRange_IncludesOverdue(t *testing.T) { } } +// TestGetOverdueNativeTasks_IncludesOnlyPastDue is the store-level counterpart to +// TestGetNativeTasksByDateRange_ExcludesOverdue: this is the function BuildTimeline relies on +// to actually surface overdue tasks (see timeline_logic.go's "6." section and +// TestBuildTimeline_IncludesOverdueNativeTasks for the integration-level proof). +func TestGetOverdueNativeTasks_IncludesOnlyPastDue(t *testing.T) { + store := setupTestStoreWithNativeTasks(t) + + now := time.Now() + overdue := now.Add(-48 * time.Hour) + today := now + + for _, task := range []models.Task{ + {ID: "t-overdue", Content: "Overdue task", DueDate: &overdue}, + {ID: "t-today", Content: "Today task", DueDate: &today}, + } { + if err := store.CreateNativeTask(task); err != nil { + t.Fatalf("CreateNativeTask(%s) failed: %v", task.ID, err) + } + } + + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + results, err := store.GetOverdueNativeTasks(start) + if err != nil { + t.Fatalf("GetOverdueNativeTasks failed: %v", err) + } + if len(results) != 1 || results[0].ID != "t-overdue" { + t.Errorf("expected only the overdue task, got %+v", results) + } +} + // TestSaveAndGetGoogleTasks_RoundTripsTimestamps guards against a regression where // due_date/updated_at (TEXT columns, not DATETIME) failed to scan back into time.Time // via sql.NullTime whenever a row had a non-null timestamp. |
