diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-02-17 14:43:42 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-02-17 14:43:42 -1000 |
| commit | ec7d895c00c571b37ad9255b99b2e1756776c9e1 (patch) | |
| tree | 31f8a925375fd5b00ee5febfe5d83f35487b1dd3 /internal/handlers | |
| parent | 44fa97ce901bbfc5957e6d9ba90a53086bb5950b (diff) | |
Add calendar cache layer, incremental sync tests, completion assertions
- Google Calendar events now cached via CacheFetcher pattern with
stale-cache fallback on API errors (new migration 015, store methods,
fetchCalendarEvents handler, BuildTimeline reads from store)
- Todoist incremental sync path covered by 5 new tests
- Task completion tests assert response body, headers, and template data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/handlers')
| -rw-r--r-- | internal/handlers/agent.go | 2 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 23 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 295 | ||||
| -rw-r--r-- | internal/handlers/timeline.go | 8 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic.go | 44 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic_test.go | 157 |
6 files changed, 490 insertions, 39 deletions
diff --git a/internal/handlers/agent.go b/internal/handlers/agent.go index 15715bc..b285520 100644 --- a/internal/handlers/agent.go +++ b/internal/handlers/agent.go @@ -322,7 +322,7 @@ func (h *Handler) HandleAgentContext(w http.ResponseWriter, r *http.Request) { // buildAgentContext builds the context timeline by reusing BuildTimeline func (h *Handler) buildAgentContext(ctx context.Context, start, end time.Time) []agentContextItem { // Reuse the main BuildTimeline function (excludes live API calls for Google services) - timelineItems, err := BuildTimeline(ctx, h.store, nil, nil, start, end) + timelineItems, err := BuildTimeline(ctx, h.store, nil, start, end) if err != nil { return nil } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 876326e..e06c35e 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -267,7 +267,7 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models if h.googleCalendarClient != nil { fetch("Google Calendar", func() error { - events, err := h.googleCalendarClient.GetUpcomingEvents(ctx, 10) + events, err := h.fetchCalendarEvents(ctx, false) if err == nil { mu.Lock() data.Events = events @@ -450,6 +450,22 @@ func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.M return fetcher.FetchWithCache(ctx, forceRefresh) } +// fetchCalendarEvents fetches Google Calendar events from cache or API +func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([]models.CalendarEvent, error) { + if h.googleCalendarClient == nil { + return nil, nil + } + 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) }, + GetFromCache: h.store.GetCalendarEvents, + SaveToCache: h.store.SaveCalendarEvents, + } + return fetcher.FetchWithCache(ctx, forceRefresh) +} + // fetchBoards fetches Trello boards from cache or API func (h *Handler) fetchBoards(ctx context.Context, forceRefresh bool) ([]models.Board, error) { fetcher := &CacheFetcher[models.Board]{ @@ -999,10 +1015,7 @@ func (h *Handler) HandleTabPlanning(w http.ResponseWriter, r *http.Request) { boards, _ := h.store.GetBoards() tasks, _ := h.store.GetTasks() - var events []models.CalendarEvent - if h.googleCalendarClient != nil { - events, _ = h.googleCalendarClient.GetUpcomingEvents(r.Context(), 20) - } + events, _ := h.fetchCalendarEvents(r.Context(), false) var scheduled []ScheduledItem var unscheduled []models.Atom diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 6f7cc92..1cab29d 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -502,6 +502,18 @@ func TestHandleCompleteAtom_Todoist(t *testing.T) { t.Errorf("Expected status 200, got %d", w.Code) } + // Verify response body contains rendered completed-atom template + body := w.Body.String() + if !strings.Contains(body, "rendered:completed-atom") { + t.Errorf("Expected response body to contain 'rendered:completed-atom', got %q", body) + } + + // Verify Content-Type header + ct := w.Header().Get("Content-Type") + if ct != "text/html; charset=utf-8" { + t.Errorf("Expected Content-Type 'text/html; charset=utf-8', got %q", ct) + } + // Verify CompleteTask was called on the API if len(mockTodoist.completedTaskIDs) != 1 || mockTodoist.completedTaskIDs[0] != "task123" { t.Errorf("Expected CompleteTask to be called with 'task123', got %v", mockTodoist.completedTaskIDs) @@ -514,6 +526,64 @@ func TestHandleCompleteAtom_Todoist(t *testing.T) { } } +func TestHandleCompleteAtom_RendersCorrectTemplateData(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + // Seed a task with known title + tasks := []models.Task{ + {ID: "task-data-1", Content: "Buy groceries", Labels: []string{}, CreatedAt: time.Now()}, + } + if err := db.SaveTasks(tasks); err != nil { + t.Fatalf("Failed to seed: %v", err) + } + + renderer := NewMockRenderer() + h := &Handler{ + store: db, + todoistClient: &mockTodoistClientWithComplete{}, + config: &config.Config{}, + renderer: renderer, + } + + req := httptest.NewRequest("POST", "/complete-atom", nil) + req.Form = map[string][]string{"id": {"task-data-1"}, "source": {"todoist"}} + w := httptest.NewRecorder() + + h.HandleCompleteAtom(w, req) + + // Verify renderer was called with "completed-atom" and correct data + if len(renderer.Calls) != 1 { + t.Fatalf("Expected 1 render call, got %d", len(renderer.Calls)) + } + call := renderer.Calls[0] + if call.Name != "completed-atom" { + t.Errorf("Expected template 'completed-atom', got %q", call.Name) + } + + // The data should contain the task ID, source, and title + type atomData struct { + ID string + Source string + Title string + } + // Use JSON round-trip to extract fields from the anonymous struct + jsonBytes, _ := json.Marshal(call.Data) + var got atomData + if err := json.Unmarshal(jsonBytes, &got); err != nil { + t.Fatalf("Failed to unmarshal render data: %v", err) + } + if got.ID != "task-data-1" { + t.Errorf("Expected data.ID 'task-data-1', got %q", got.ID) + } + if got.Source != "todoist" { + t.Errorf("Expected data.Source 'todoist', got %q", got.Source) + } + if got.Title != "Buy groceries" { + t.Errorf("Expected data.Title 'Buy groceries', got %q", got.Title) + } +} + // TestHandleCompleteAtom_Trello tests completing a Trello card func TestHandleCompleteAtom_Trello(t *testing.T) { db, cleanup := setupTestDB(t) @@ -1621,6 +1691,24 @@ func TestHandleUncompleteAtom_Todoist(t *testing.T) { t.Errorf("Expected status 200, got %d", w.Code) } + // Verify HX-Reswap header set to "none" + reswap := w.Header().Get("HX-Reswap") + if reswap != "none" { + t.Errorf("Expected HX-Reswap header 'none', got %q", reswap) + } + + // Verify HX-Trigger header set to "refresh-tasks" + trigger := w.Header().Get("HX-Trigger") + if trigger != "refresh-tasks" { + t.Errorf("Expected HX-Trigger header 'refresh-tasks', got %q", trigger) + } + + // Verify response body is empty (no template rendered) + body := w.Body.String() + if body != "" { + t.Errorf("Expected empty response body for uncomplete, got %q", body) + } + if len(mockTodoist.reopenedTaskIDs) != 1 || mockTodoist.reopenedTaskIDs[0] != "task123" { t.Errorf("Expected ReopenTask to be called with 'task123', got %v", mockTodoist.reopenedTaskIDs) } @@ -2162,3 +2250,210 @@ func TestHandleTimeline_InvalidParams(t *testing.T) { t.Errorf("Expected status 200 or 500, got %d", w.Code) } } + +// syncAwareMockTodoist records the sync token passed to Sync and returns a configurable response. +type syncAwareMockTodoist struct { + mockTodoistClient + syncResponse *api.TodoistSyncResponse + receivedTokens []string // tracks tokens passed to Sync +} + +func (m *syncAwareMockTodoist) Sync(ctx context.Context, syncToken string) (*api.TodoistSyncResponse, error) { + m.receivedTokens = append(m.receivedTokens, syncToken) + if m.err != nil { + return nil, m.err + } + return m.syncResponse, nil +} + +func TestFetchTasks_IncrementalSync_UpsertsActiveTasks(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + // Seed DB with an existing task (simulating previous full sync) + existingTask := models.Task{ID: "existing-1", Content: "Old task", Priority: 1} + if err := h.store.SaveTasks([]models.Task{existingTask}); err != nil { + t.Fatalf("Failed to seed task: %v", err) + } + // Set a sync token so fetchTasks uses incremental sync + if err := h.store.SetSyncToken("todoist", "previous-token"); err != nil { + t.Fatalf("Failed to set sync token: %v", err) + } + + mock := &syncAwareMockTodoist{ + syncResponse: &api.TodoistSyncResponse{ + SyncToken: "new-token-1", + FullSync: false, + Items: []api.SyncItemResponse{ + {ID: "new-1", Content: "New task", Priority: 2}, + {ID: "existing-1", Content: "Updated task", Priority: 3}, + }, + Projects: []api.SyncProjectResponse{}, + }, + } + h.todoistClient = mock + + tasks, err := h.fetchTasks(context.Background(), false) + if err != nil { + t.Fatalf("fetchTasks returned error: %v", err) + } + + // Should have 2 tasks: the upserted existing-1 (updated) and new-1 + if len(tasks) != 2 { + t.Fatalf("Expected 2 tasks, got %d", len(tasks)) + } + + // Verify the existing task was updated + taskMap := make(map[string]models.Task) + for _, task := range tasks { + taskMap[task.ID] = task + } + if taskMap["existing-1"].Content != "Updated task" { + t.Errorf("Expected existing-1 content 'Updated task', got %q", taskMap["existing-1"].Content) + } + if taskMap["new-1"].Content != "New task" { + t.Errorf("Expected new-1 content 'New task', got %q", taskMap["new-1"].Content) + } +} + +func TestFetchTasks_IncrementalSync_DeletesCompletedAndDeletedTasks(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + // Seed DB with tasks + seeds := []models.Task{ + {ID: "keep-1", Content: "Keep me"}, + {ID: "complete-1", Content: "Will be completed"}, + {ID: "delete-1", Content: "Will be deleted"}, + } + if err := h.store.SaveTasks(seeds); err != nil { + t.Fatalf("Failed to seed tasks: %v", err) + } + if err := h.store.SetSyncToken("todoist", "prev-token"); err != nil { + t.Fatalf("Failed to set sync token: %v", err) + } + + mock := &syncAwareMockTodoist{ + syncResponse: &api.TodoistSyncResponse{ + SyncToken: "new-token-2", + FullSync: false, + Items: []api.SyncItemResponse{ + {ID: "complete-1", Content: "Will be completed", IsCompleted: true}, + {ID: "delete-1", Content: "Will be deleted", IsDeleted: true}, + }, + Projects: []api.SyncProjectResponse{}, + }, + } + h.todoistClient = mock + + tasks, err := h.fetchTasks(context.Background(), false) + if err != nil { + t.Fatalf("fetchTasks returned error: %v", err) + } + + // Only keep-1 should remain + if len(tasks) != 1 { + t.Fatalf("Expected 1 task, got %d: %+v", len(tasks), tasks) + } + if tasks[0].ID != "keep-1" { + t.Errorf("Expected remaining task ID 'keep-1', got %q", tasks[0].ID) + } +} + +func TestFetchTasks_IncrementalSync_StoresNewSyncToken(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + if err := h.store.SetSyncToken("todoist", "old-token"); err != nil { + t.Fatalf("Failed to set sync token: %v", err) + } + + mock := &syncAwareMockTodoist{ + syncResponse: &api.TodoistSyncResponse{ + SyncToken: "brand-new-token", + FullSync: false, + Items: []api.SyncItemResponse{}, + Projects: []api.SyncProjectResponse{}, + }, + } + h.todoistClient = mock + + _, err := h.fetchTasks(context.Background(), false) + if err != nil { + t.Fatalf("fetchTasks returned error: %v", err) + } + + // Verify the new sync token was stored + token, err := h.store.GetSyncToken("todoist") + if err != nil { + t.Fatalf("Failed to get sync token: %v", err) + } + if token != "brand-new-token" { + t.Errorf("Expected sync token 'brand-new-token', got %q", token) + } +} + +func TestFetchTasks_IncrementalSync_UsesSavedSyncToken(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + // Set a known sync token + if err := h.store.SetSyncToken("todoist", "my-saved-token"); err != nil { + t.Fatalf("Failed to set sync token: %v", err) + } + + mock := &syncAwareMockTodoist{ + syncResponse: &api.TodoistSyncResponse{ + SyncToken: "next-token", + FullSync: false, + Items: []api.SyncItemResponse{}, + Projects: []api.SyncProjectResponse{}, + }, + } + h.todoistClient = mock + + _, err := h.fetchTasks(context.Background(), false) + if err != nil { + t.Fatalf("fetchTasks returned error: %v", err) + } + + // Verify the saved token was passed to Sync + if len(mock.receivedTokens) != 1 { + t.Fatalf("Expected 1 Sync call, got %d", len(mock.receivedTokens)) + } + if mock.receivedTokens[0] != "my-saved-token" { + t.Errorf("Expected Sync to receive token 'my-saved-token', got %q", mock.receivedTokens[0]) + } +} + +func TestFetchTasks_ForceRefresh_ClearsSyncToken(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + if err := h.store.SetSyncToken("todoist", "existing-token"); err != nil { + t.Fatalf("Failed to set sync token: %v", err) + } + + mock := &syncAwareMockTodoist{ + syncResponse: &api.TodoistSyncResponse{ + SyncToken: "fresh-token", + FullSync: true, + Items: []api.SyncItemResponse{}, + Projects: []api.SyncProjectResponse{}, + }, + } + h.todoistClient = mock + + _, err := h.fetchTasks(context.Background(), true) + if err != nil { + t.Fatalf("fetchTasks returned error: %v", err) + } + + // forceRefresh should send empty token (full sync) + if len(mock.receivedTokens) != 1 { + t.Fatalf("Expected 1 Sync call, got %d", len(mock.receivedTokens)) + } + if mock.receivedTokens[0] != "" { + t.Errorf("Expected empty sync token for forceRefresh, got %q", mock.receivedTokens[0]) + } +} diff --git a/internal/handlers/timeline.go b/internal/handlers/timeline.go index 29b156a..86b89ea 100644 --- a/internal/handlers/timeline.go +++ b/internal/handlers/timeline.go @@ -1,6 +1,7 @@ package handlers import ( + "log" "net/http" "strconv" "time" @@ -63,8 +64,13 @@ func (h *Handler) HandleTimeline(w http.ResponseWriter, r *http.Request) { end := start.AddDate(0, 0, days) + // Refresh calendar events cache before building timeline + if _, err := h.fetchCalendarEvents(r.Context(), false); err != nil { + log.Printf("Warning: failed to fetch calendar events: %v", err) + } + // Call BuildTimeline - items, err := BuildTimeline(r.Context(), h.store, h.googleCalendarClient, h.googleTasksClient, start, end) + items, err := BuildTimeline(r.Context(), h.store, h.googleTasksClient, start, end) if err != nil { JSONError(w, http.StatusInternalServerError, "Failed to build timeline", err) return diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index 7a85393..4334f6d 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -14,7 +14,7 @@ import ( ) // BuildTimeline aggregates and normalizes data into a timeline structure -func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.GoogleCalendarAPI, tasksClient api.GoogleTasksAPI, start, end time.Time) ([]models.TimelineItem, error) { +func BuildTimeline(ctx context.Context, s *store.Store, tasksClient api.GoogleTasksAPI, start, end time.Time) ([]models.TimelineItem, error) { var items []models.TimelineItem now := config.Now() @@ -128,29 +128,27 @@ func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.Googl items = append(items, item) } - // 4. Fetch Events - if calendarClient != nil { - events, err := calendarClient.GetEventsByDateRange(ctx, start, end) - if err != nil { - log.Printf("Warning: failed to fetch calendar events: %v", err) - } else { - for _, event := range events { - endTime := event.End - item := models.TimelineItem{ - ID: event.ID, - Type: models.TimelineItemTypeEvent, - Title: event.Summary, - Time: event.Start, - EndTime: &endTime, - Description: event.Description, - URL: event.HTMLLink, - OriginalItem: event, - IsCompleted: false, - Source: "calendar", - } - item.ComputeDaySection(now) - items = append(items, item) + // 4. Fetch Events from store cache (populated by fetchCalendarEvents) + events, err := s.GetCalendarEventsByDateRange(start, end) + if err != nil { + log.Printf("Warning: failed to read cached calendar events: %v", err) + } else { + for _, event := range events { + endTime := event.End + item := models.TimelineItem{ + ID: event.ID, + Type: models.TimelineItemTypeEvent, + Title: event.Summary, + Time: event.Start, + EndTime: &endTime, + Description: event.Description, + URL: event.HTMLLink, + OriginalItem: event, + IsCompleted: false, + Source: "calendar", } + item.ComputeDaySection(now) + items = append(items, item) } } diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index 11406b9..b42ad4c 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -2,11 +2,13 @@ package handlers import ( "context" + "fmt" "os" "path/filepath" "testing" "time" + "task-dashboard/internal/config" "task-dashboard/internal/models" "task-dashboard/internal/store" @@ -79,6 +81,15 @@ func setupTestStore(t *testing.T) *store.Store { 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) @@ -122,19 +133,17 @@ func TestBuildTimeline(t *testing.T) { }, }) - // Calendar Event: 09:00 + // Calendar Event: 09:00 (saved to store cache) eventDate := baseTime.Add(1 * time.Hour) - mockCal := &MockCalendarClient{ - Events: []models.CalendarEvent{ - {ID: "e1", Summary: "Event 1", Start: eventDate, End: eventDate.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, mockCal, nil, start, end) + items, err := BuildTimeline(context.Background(), s, nil, start, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } @@ -268,7 +277,7 @@ func TestBuildTimeline_IncludesOverdueItems(t *testing.T) { // Query range: today through tomorrow end := today.AddDate(0, 0, 1) - items, err := BuildTimeline(context.Background(), s, nil, nil, today, end) + items, err := BuildTimeline(context.Background(), s, nil,today, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } @@ -311,7 +320,7 @@ func TestBuildTimeline_ExcludesCompletedOverdue(t *testing.T) { {ID: "done1", Content: "Done overdue", DueDate: &yesterday, Completed: true}, }) - items, err := BuildTimeline(context.Background(), s, nil, nil, today, end) + items, err := BuildTimeline(context.Background(), s, nil,today, end) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) } @@ -323,6 +332,136 @@ func TestBuildTimeline_ExcludesCompletedOverdue(t *testing.T) { } } +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 timePtr(t time.Time) *time.Time { return &t } |
