diff options
Diffstat (limited to 'internal/handlers/timeline_logic_test.go')
| -rw-r--r-- | internal/handlers/timeline_logic_test.go | 110 |
1 files changed, 109 insertions, 1 deletions
diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index 02dd5fb..1da2d8a 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -19,6 +19,13 @@ import ( type MockCalendarClient struct { Events []models.CalendarEvent Err error + // SetCalendarIDsCalls records every ids slice SetCalendarIDs was called + // with, for tests that need to assert on how the caller resolved its + // calendar ID list (e.g. fetchCalendarEvents' comma-split fallback). + SetCalendarIDsCalls [][]string + // RecurrenceRule is returned by GetRecurrenceRule for any id when RecurrenceErr is nil. + RecurrenceRule string + RecurrenceErr error } func (m *MockCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) { @@ -33,7 +40,13 @@ func (m *MockCalendarClient) GetCalendarList(ctx context.Context) ([]models.Cale return nil, m.Err } -func (m *MockCalendarClient) SetCalendarIDs(ids []string) {} +func (m *MockCalendarClient) SetCalendarIDs(ids []string) { + m.SetCalendarIDsCalls = append(m.SetCalendarIDsCalls, ids) +} + +func (m *MockCalendarClient) GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) { + return m.RecurrenceRule, m.RecurrenceErr +} func setupTestStore(t *testing.T) *store.Store { t.Helper() @@ -77,6 +90,7 @@ func setupTestStore(t *testing.T) *store.Store { start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, html_link TEXT, + recurring_event_id TEXT DEFAULT '', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS google_tasks ( @@ -288,6 +302,56 @@ func TestBuildTimeline_ReadsCalendarEventsFromStore(t *testing.T) { } } +// TestBuildTimeline_IncludesOverdueNativeTasks proves the 2026-07-12 fix: a +// native task whose due_date is BEFORE the requested range's start must +// still appear in the timeline, marked IsOverdue, instead of being silently +// dropped. Root cause: GetNativeTasksByDateRange's SQL bound (due_date >= +// start) excluded overdue tasks from ever being fetched, so ComputeDaySection +// never got a chance to set IsOverdue -- both the web Timeline view and the +// widget API (which both call BuildTimeline with a start of "today") showed +// zero overdue tasks even though the Tasks tab (which calls the unbounded +// GetNativeTasks) showed them fine. +func TestBuildTimeline_IncludesOverdueNativeTasks(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + overdueDate := today.AddDate(0, 0, -12) // 12 days in the past + + if err := db.CreateNativeTask(models.Task{ + ID: "overdue-1", + Content: "Pay the water bill", + DueDate: &overdueDate, + }); err != nil { + t.Fatalf("Failed to create native task: %v", err) + } + + start := today + end := today.AddDate(0, 0, 2) + + items, err := BuildTimeline(context.Background(), db, start, end) + if err != nil { + t.Fatalf("BuildTimeline failed: %v", err) + } + + var found *models.TimelineItem + for i := range items { + if items[i].ID == "overdue-1" { + found = &items[i] + } + } + if found == nil { + t.Fatal("expected overdue native task to appear in timeline, but it was missing") + } + if !found.IsOverdue { + t.Error("expected overdue native task to have IsOverdue = true") + } + if found.Title != "Pay the water bill" { + t.Errorf("Title: got %q, want %q", found.Title, "Pay the water bill") + } +} + func TestFetchCalendarEvents_CacheFallbackOnAPIError(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() @@ -336,6 +400,50 @@ func TestFetchCalendarEvents_CacheFallbackOnAPIError(t *testing.T) { } } +// TestFetchCalendarEvents_ConfigFallback_SplitsCommaJoinedIDs proves the +// 2026-07-12 fix: when no source_configs rows exist yet for "gcal" (the +// normal state before any calendar-discovery sync has run), fetchCalendarEvents +// falls back to config.GoogleCalendarID -- but that's a single env var that +// itself holds a comma-separated list of calendar IDs (see GOOGLE_CALENDAR_ID +// in .env). Previously the whole joined string was passed to SetCalendarIDs +// as a single one-element slice, which Google's API rejected with a 404 +// ("Not Found") on every single fetch -- a real production incident that +// broke all calendar events, in both the web dashboard and the widget, until +// this fix. It must now be split into separate IDs. +func TestFetchCalendarEvents_ConfigFallback_SplitsCommaJoinedIDs(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + mock := &MockCalendarClient{Events: []models.CalendarEvent{{ID: "e1"}}} + h := &Handler{ + store: db, + googleCalendarClient: mock, + config: &config.Config{ + CacheTTLMinutes: 5, + GoogleCalendarID: "cal-a@group.calendar.google.com, cal-b@gmail.com,cal-c@group.calendar.google.com", + }, + renderer: newTestRenderer(), + } + + if _, err := h.fetchCalendarEvents(context.Background(), true); err != nil { + t.Fatalf("fetchCalendarEvents: %v", err) + } + + if len(mock.SetCalendarIDsCalls) == 0 { + t.Fatal("expected SetCalendarIDs to be called") + } + got := mock.SetCalendarIDsCalls[len(mock.SetCalendarIDsCalls)-1] + want := []string{"cal-a@group.calendar.google.com", "cal-b@gmail.com", "cal-c@group.calendar.google.com"} + if len(got) != len(want) { + t.Fatalf("SetCalendarIDs called with %d ids, want %d: got %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("id[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + func TestSaveAndGetCalendarEvents(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() |
