diff options
| -rw-r--r-- | internal/handlers/handlers.go | 25 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic_test.go | 52 |
2 files changed, 70 insertions, 7 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index d1a7512..0d8b2da 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -314,9 +314,20 @@ func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([ } if len(enabledIDs) == 0 { - // No source_configs synced yet — fall back to the configured calendar ID + // No source_configs synced yet — fall back to the configured calendar + // ID(s). GoogleCalendarID is a single env var that itself holds a + // comma-separated list (see GOOGLE_CALENDAR_ID in .env) -- it must be + // split before use. Passing the raw joined string straight through + // as a single calendar ID (the previous behavior) sends Google's API + // a calendarId that matches nothing, failing every fetch with a 404 — + // this is a real production incident, not a hypothetical: it silently + // broke all calendar events (web and widget both) until fixed. if len(configs) == 0 && h.config.GoogleCalendarID != "" { - enabledIDs = []string{h.config.GoogleCalendarID} + for _, id := range strings.Split(h.config.GoogleCalendarID, ",") { + if trimmed := strings.TrimSpace(id); trimmed != "" { + enabledIDs = append(enabledIDs, trimmed) + } + } } else { // Configs exist but all disabled — respect that return nil, nil @@ -326,10 +337,12 @@ func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([ h.googleCalendarClient.SetCalendarIDs(enabledIDs) fetcher := &CacheFetcher[models.CalendarEvent]{ - Store: h.store, - CacheKey: store.CacheKeyGoogleCalendar, - TTLMinutes: h.config.CacheTTLMinutes, - Fetch: func(ctx context.Context) ([]models.CalendarEvent, error) { return h.googleCalendarClient.GetUpcomingEvents(ctx, 50) }, + Store: h.store, + CacheKey: store.CacheKeyGoogleCalendar, + TTLMinutes: h.config.CacheTTLMinutes, + Fetch: func(ctx context.Context) ([]models.CalendarEvent, error) { + return h.googleCalendarClient.GetUpcomingEvents(ctx, 50) + }, GetFromCache: h.store.GetCalendarEvents, SaveToCache: h.store.SaveCalendarEvents, } diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index 3678b62..0b7bc37 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -19,6 +19,10 @@ 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 } func (m *MockCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) { @@ -33,7 +37,9 @@ 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 setupTestStore(t *testing.T) *store.Store { t.Helper() @@ -335,6 +341,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() |
