summaryrefslogtreecommitdiff
path: root/internal/handlers
diff options
context:
space:
mode:
Diffstat (limited to 'internal/handlers')
-rw-r--r--internal/handlers/agent.go2
-rw-r--r--internal/handlers/handlers.go23
-rw-r--r--internal/handlers/handlers_test.go295
-rw-r--r--internal/handlers/timeline.go8
-rw-r--r--internal/handlers/timeline_logic.go44
-rw-r--r--internal/handlers/timeline_logic_test.go157
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
}