package handlers import ( "context" "fmt" "os" "path/filepath" "testing" "time" "task-dashboard/internal/config" "task-dashboard/internal/models" "task-dashboard/internal/store" _ "github.com/mattn/go-sqlite3" ) // MockCalendarClient implements GoogleCalendarAPI interface for testing 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) { return m.Events, m.Err } func (m *MockCalendarClient) GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error) { return m.Events, m.Err } func (m *MockCalendarClient) GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error) { return nil, m.Err } func (m *MockCalendarClient) SetCalendarIDs(ids []string) { m.SetCalendarIDsCalls = append(m.SetCalendarIDsCalls, ids) } func setupTestStore(t *testing.T) *store.Store { t.Helper() tempDir := t.TempDir() dbPath := filepath.Join(tempDir, "test.db") migrationDir := filepath.Join(tempDir, "migrations") if err := os.MkdirAll(migrationDir, 0755); err != nil { t.Fatalf("Failed to create migration dir: %v", err) } schema := ` CREATE TABLE IF NOT EXISTS meals ( id TEXT PRIMARY KEY, recipe_name TEXT NOT NULL, date DATETIME, meal_type TEXT, recipe_url TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS boards ( id TEXT PRIMARY KEY, name TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS cards ( id TEXT PRIMARY KEY, name TEXT NOT NULL, board_id TEXT NOT NULL, list_id TEXT, list_name TEXT, due_date DATETIME, url TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS calendar_events ( id TEXT PRIMARY KEY, summary TEXT NOT NULL, description TEXT, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, html_link TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS google_tasks ( id TEXT PRIMARY KEY, title TEXT NOT NULL, notes TEXT, status TEXT NOT NULL, completed BOOLEAN NOT NULL DEFAULT 0, due_date DATETIME, updated_at DATETIME, list_id TEXT NOT NULL, url TEXT ); ` if err := os.WriteFile(filepath.Join(migrationDir, "001_init.sql"), []byte(schema), 0644); err != nil { t.Fatalf("Failed to write migration file: %v", err) } // Initialize store (this creates tables) s, err := store.New(dbPath, migrationDir) if err != nil { t.Fatalf("Failed to create store: %v", err) } return s } func TestBuildTimeline(t *testing.T) { s := setupTestStore(t) // Fix a base time: 2023-01-01 08:00:00 baseTime := time.Date(2023, 1, 1, 8, 0, 0, 0, time.UTC) // Meal: Lunch (defaults to 12:00) mealDate := baseTime // Date part matters _ = s.SaveMeals([]models.Meal{ {ID: "m1", RecipeName: "Lunch", Date: mealDate, MealType: "lunch"}, }) // Card: 14:00 cardDate := baseTime.Add(6 * time.Hour) _ = s.SaveBoards([]models.Board{ { ID: "b1", Name: "Board 1", Cards: []models.Card{ {ID: "c1", Name: "Card 1", DueDate: &cardDate, ListID: "l1"}, }, }, }) // Calendar Event: 09:00 (saved to store cache) eventDate := baseTime.Add(1 * time.Hour) _ = s.SaveCalendarEvents([]models.CalendarEvent{ {ID: "e1", Summary: "Event 1", Start: eventDate, End: eventDate.Add(1 * time.Hour)}, }) // Test Range: Full Day start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) end := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC) items, err := BuildTimeline(context.Background(), s, start, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } if len(items) != 3 { t.Errorf("Expected 3 items, got %d", len(items)) } // Expected Order: // 1. Event (09:00) // 2. Meal (12:00) // 3. Card (14:00) if items[0].Type != models.TimelineItemTypeEvent { t.Errorf("Expected item 0 to be Event, got %s", items[0].Type) } if items[1].Type != models.TimelineItemTypeMeal { t.Errorf("Expected item 1 to be Meal, got %s", items[1].Type) } if items[2].Type != models.TimelineItemTypeCard { t.Errorf("Expected item 2 to be Card, got %s", items[2].Type) } } func TestCalcCalendarBounds(t *testing.T) { tests := []struct { name string items []models.TimelineItem currentHour int wantStart int wantEnd int }{ { name: "no timed events returns default", items: []models.TimelineItem{}, currentHour: -1, wantStart: 8, wantEnd: 18, }, { name: "single event at 10am", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)}, }, currentHour: -1, wantStart: 9, // 1 hour buffer before wantEnd: 11, // 1 hour buffer after }, { name: "includes current hour", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)}, }, currentHour: 8, wantStart: 7, // 1 hour before 8am wantEnd: 11, // 1 hour after 10am }, { name: "event with end time extends range", items: []models.TimelineItem{ { Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), EndTime: timePtr(time.Date(2023, 1, 1, 14, 0, 0, 0, time.UTC)), }, }, currentHour: -1, wantStart: 9, // 1 hour before 10am wantEnd: 15, // 1 hour after 2pm end }, { name: "all-day events are skipped", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), IsAllDay: true}, }, currentHour: -1, wantStart: 8, wantEnd: 18, }, { name: "overdue events are skipped", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), IsOverdue: true}, }, currentHour: -1, wantStart: 8, wantEnd: 18, }, { name: "clamps to 0-23 range", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 0, 30, 0, 0, time.UTC)}, {Time: time.Date(2023, 1, 1, 23, 0, 0, 0, time.UTC)}, }, currentHour: -1, wantStart: 0, // Can't go below 0 wantEnd: 23, // Can't go above 23 }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { start, end := calcCalendarBounds(tc.items, tc.currentHour) if start != tc.wantStart { t.Errorf("Expected start %d, got %d", tc.wantStart, start) } if end != tc.wantEnd { t.Errorf("Expected end %d, got %d", tc.wantEnd, end) } }) } } func TestBuildTimeline_ReadsCalendarEventsFromStore(t *testing.T) { s := setupTestStore(t) start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) end := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC) // Save events to the store (simulating a prior cache) eventTime := time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC) err := s.SaveCalendarEvents([]models.CalendarEvent{ {ID: "cached-e1", Summary: "Cached Meeting", Start: eventTime, End: eventTime.Add(time.Hour)}, }) if err != nil { t.Fatalf("Failed to save calendar events: %v", err) } // Call BuildTimeline with NO calendar client (nil) — events should come from store items, err := BuildTimeline(context.Background(), s, start, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } foundEvent := false for _, item := range items { if item.ID == "cached-e1" { foundEvent = true if item.Title != "Cached Meeting" { t.Errorf("Expected title 'Cached Meeting', got %q", item.Title) } if item.Source != "calendar" { t.Errorf("Expected source 'calendar', got %q", item.Source) } } } if !foundEvent { t.Error("BuildTimeline should read calendar events from store, but cached event was not found") } } func TestFetchCalendarEvents_CacheFallbackOnAPIError(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() // Pre-cache some calendar events eventTime := time.Date(2023, 6, 15, 10, 0, 0, 0, time.UTC) err := db.SaveCalendarEvents([]models.CalendarEvent{ {ID: "e-cached", Summary: "Cached Event", Start: eventTime, End: eventTime.Add(time.Hour)}, }) if err != nil { t.Fatalf("Failed to seed calendar events: %v", err) } // Mark cache as valid if err := db.UpdateCacheMetadata(store.CacheKeyGoogleCalendar, 60); err != nil { t.Fatalf("Failed to update cache metadata: %v", err) } // Enable the calendar in config err = db.SyncSourceConfigs("gcal", "calendar", []models.SourceConfig{ {Source: "gcal", ItemType: "calendar", ItemID: "e-cached", ItemName: "Cached", Enabled: true}, }) if err != nil { t.Fatalf("Failed to seed calendar config: %v", err) } // Create handler with a failing calendar client failingCal := &MockCalendarClient{Err: fmt.Errorf("API unavailable")} h := &Handler{ store: db, googleCalendarClient: failingCal, config: &config.Config{CacheTTLMinutes: 5}, renderer: newTestRenderer(), } // Force refresh to hit the API (which fails), should fall back to cache events, err := h.fetchCalendarEvents(context.Background(), true) if err != nil { t.Fatalf("fetchCalendarEvents should not return error on API failure with cached data, got: %v", err) } if len(events) != 1 { t.Fatalf("Expected 1 cached event on fallback, got %d", len(events)) } if events[0].ID != "e-cached" { t.Errorf("Expected cached event ID 'e-cached', got %q", events[0].ID) } } // 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() events := []models.CalendarEvent{ { ID: "evt-1", Summary: "Morning Standup", Description: "Daily team sync", Start: time.Date(2023, 6, 1, 9, 0, 0, 0, time.UTC), End: time.Date(2023, 6, 1, 9, 30, 0, 0, time.UTC), HTMLLink: "https://calendar.google.com/event/1", }, { ID: "evt-2", Summary: "Lunch", Start: time.Date(2023, 6, 1, 12, 0, 0, 0, time.UTC), End: time.Date(2023, 6, 1, 13, 0, 0, 0, time.UTC), }, } if err := db.SaveCalendarEvents(events); err != nil { t.Fatalf("SaveCalendarEvents failed: %v", err) } // Get all events got, err := db.GetCalendarEvents() if err != nil { t.Fatalf("GetCalendarEvents failed: %v", err) } if len(got) != 2 { t.Fatalf("Expected 2 events, got %d", len(got)) } if got[0].Summary != "Morning Standup" { t.Errorf("Expected first event 'Morning Standup', got %q", got[0].Summary) } // Get by date range (only morning) rangeStart := time.Date(2023, 6, 1, 8, 0, 0, 0, time.UTC) rangeEnd := time.Date(2023, 6, 1, 10, 0, 0, 0, time.UTC) ranged, err := db.GetCalendarEventsByDateRange(rangeStart, rangeEnd) if err != nil { t.Fatalf("GetCalendarEventsByDateRange failed: %v", err) } if len(ranged) != 1 { t.Fatalf("Expected 1 event in range, got %d", len(ranged)) } if ranged[0].ID != "evt-1" { t.Errorf("Expected event ID 'evt-1', got %q", ranged[0].ID) } } func timePtr(t time.Time) *time.Time { return &t }