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/handlers_test.go | |
| 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/handlers_test.go')
| -rw-r--r-- | internal/handlers/handlers_test.go | 295 |
1 files changed, 295 insertions, 0 deletions
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]) + } +} |
