diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-12 07:18:01 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-12 07:18:01 +0000 |
| commit | af0d59d78524127f6cc76e43a7e397da01788d97 (patch) | |
| tree | b534567450d8b43bf6f687cc4fca645f26b1491e /internal/handlers | |
| parent | 84252756c687044d73a0ea5cebf8088c7c9ed3e8 (diff) | |
fix(calendar): split comma-joined GOOGLE_CALENDAR_ID before use
fetchCalendarEvents falls back to config.GoogleCalendarID whenever no
source_configs rows exist yet for the gcal source -- which is the
current live state (0 rows). GoogleCalendarID is a single env var that
itself holds a comma-separated list of calendar IDs, but the whole
joined string was being wrapped in a single-element []string{...} and
passed straight to SetCalendarIDs. Google's API takes one calendarId per
call, so every fetch failed with '404 Not Found' on the literal
comma-joined string -- confirmed live in production logs. This broke all
calendar events, in both the web dashboard and the widget, silently.
Now splits on commas and trims whitespace before use.
Diffstat (limited to 'internal/handlers')
| -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() |
