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 } 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 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 tasks ( id TEXT PRIMARY KEY, content TEXT NOT NULL, description TEXT, project_id TEXT, project_name TEXT, due_date DATETIME, priority INTEGER DEFAULT 1, completed BOOLEAN DEFAULT FALSE, labels TEXT, url TEXT, created_at DATETIME, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); 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 ); ` 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) // Task: 10:00 taskDate := baseTime.Add(2 * time.Hour) _ = s.SaveTasks([]models.Task{ {ID: "t1", Content: "Task 1", DueDate: &taskDate}, }) // 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, nil, start, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } if len(items) != 4 { t.Errorf("Expected 4 items, got %d", len(items)) } // Expected Order: // 1. Event (09:00) // 2. Task (10:00) // 3. Meal (12:00) // 4. 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.TimelineItemTypeTask { t.Errorf("Expected item 1 to be Task, got %s", items[1].Type) } if items[2].Type != models.TimelineItemTypeMeal { t.Errorf("Expected item 2 to be Meal, got %s", items[2].Type) } if items[3].Type != models.TimelineItemTypeCard { t.Errorf("Expected item 3 to be Card, got %s", items[3].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_IncludesOverdueItems(t *testing.T) { s := setupTestStore(t) // Base: "today" is Jan 2, 2023 today := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC) yesterday := time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC) // overdue todayNoon := time.Date(2023, 1, 2, 12, 0, 0, 0, time.UTC) // current // Save a task that is overdue (yesterday) and one that is current (today) _ = s.SaveTasks([]models.Task{ {ID: "overdue1", Content: "Overdue task", DueDate: &yesterday}, {ID: "current1", Content: "Current task", DueDate: &todayNoon}, }) // Query range: today through tomorrow end := today.AddDate(0, 0, 1) items, err := BuildTimeline(context.Background(), s, nil,today, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } // Should include both the overdue task and the current task if len(items) < 2 { t.Errorf("Expected at least 2 items (overdue + current), got %d", len(items)) for _, item := range items { t.Logf(" item: %s (type=%s, time=%s)", item.Title, item.Type, item.Time) } } // Verify overdue task is marked as overdue foundOverdue := false for _, item := range items { if item.ID == "overdue1" { foundOverdue = true if !item.IsOverdue { t.Error("Expected overdue task to be marked IsOverdue=true") } if item.DaySection != models.DaySectionToday { t.Errorf("Expected overdue task in Today section, got %s", item.DaySection) } } } if !foundOverdue { t.Error("Overdue task was not included in timeline results") } } func TestBuildTimeline_ExcludesCompletedOverdue(t *testing.T) { s := setupTestStore(t) yesterday := time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC) today := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC) end := today.AddDate(0, 0, 1) // Save a completed overdue task — should NOT appear _ = s.SaveTasks([]models.Task{ {ID: "done1", Content: "Done overdue", DueDate: &yesterday, Completed: true}, }) items, err := BuildTimeline(context.Background(), s, nil,today, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } for _, item := range items { if item.ID == "done1" { t.Error("Completed overdue task should not appear in timeline") } } } 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, nil,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) } // 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) } } 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 TestBuildTimeline_IncludesUndatedTodoistTasks(t *testing.T) { s := setupTestStore(t) // Save a task with no due date _ = s.SaveTasks([]models.Task{ {ID: "undated1", Content: "Take out recycling"}, // DueDate is nil }) 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, nil, start, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } found := false for _, item := range items { if item.ID == "undated1" { found = true if item.DaySection != models.DaySectionToday { t.Errorf("Undated task should be in Today section, got %s", item.DaySection) } } } if !found { t.Error("Undated Todoist task should appear in timeline Today section") } } func timePtr(t time.Time) *time.Time { return &t }