summaryrefslogtreecommitdiff
path: root/internal/handlers/handlers_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-04 11:12:44 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-03-04 11:12:44 -1000
commit0fd54eddc40f517cf491310d4f8a60b0d79dc937 (patch)
treeb026328a49b9583efb7c94b3777830c31c46fa33 /internal/handlers/handlers_test.go
parent4853a4a917bb7942776ffd8b3e003ee03fc49160 (diff)
feat: sync log, cache clear endpoint, Todoist projects from cached tasks
- migration 016: sync_log table - store: AddSyncLogEntry, GetRecentSyncLog, InvalidateAllCaches, GetProjectsFromTasks - settings: HandleClearCache (POST /settings/clear-cache), SyncLog in page data - settings: use GetProjectsFromTasks instead of deprecated Todoist REST /projects - handlers: populate atom projects from store - agent: log warning on registration failure instead of silently swallowing - google_tasks: simplify URL literal - tests: sync log CRUD, clear cache handler, settings page includes sync log, sync sources adds log entry, incremental sync paths, task completion response/headers, calendar cache fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/handlers/handlers_test.go')
-rw-r--r--internal/handlers/handlers_test.go404
1 files changed, 397 insertions, 7 deletions
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index 1cab29d..470ea5c 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"os"
+ "reflect"
"strings"
"testing"
"time"
@@ -1840,13 +1841,14 @@ func TestHandleSyncSources(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
- // Setup mock clients
- mockTodoist := &mockTodoistClientWithProjects{
- projects: []models.Project{
- {ID: "proj1", Name: "Project 1"},
- {ID: "proj2", Name: "Project 2"},
- },
+ // Seed tasks table with project info (projects now come from store, not REST API)
+ tasks := []models.Task{
+ {ID: "t1", Content: "Task 1", ProjectID: "proj1", ProjectName: "Project 1", Labels: []string{}},
+ {ID: "t2", Content: "Task 2", ProjectID: "proj2", ProjectName: "Project 2", Labels: []string{}},
}
+ _ = h.store.SaveTasks(tasks)
+
+ mockTodoist := &mockTodoistClient{}
mockTrello := &mockTrelloClientWithBoards{
mockTrelloClient: mockTrelloClient{
boards: []models.Board{
@@ -1867,7 +1869,7 @@ func TestHandleSyncSources(t *testing.T) {
t.Errorf("Expected status 200, got %d", w.Code)
}
- // Verify configs were synced
+ // Verify configs were synced from store (not REST API)
configs, _ := h.store.GetSourceConfigsBySource("todoist")
if len(configs) != 2 {
t.Errorf("Expected 2 todoist configs, got %d", len(configs))
@@ -2080,6 +2082,16 @@ func TestSettingsTemplate_HasCSRFToken(t *testing.T) {
"settings.html must expose .CSRFToken (e.g. via meta tag) for JavaScript passkey registration")
}
+// TestSettingsTemplate_InjectsCSRFForHTMX verifies that settings.html sends the
+// CSRF token with HTMX requests via hx-headers on the body. Without this, all
+// HTMX POST requests from the settings page (clear cache, sync) are rejected
+// with 403 Forbidden and appear to silently fail.
+func TestSettingsTemplate_InjectsCSRFForHTMX(t *testing.T) {
+ assertTemplateContains(t, "../../web/templates/settings.html",
+ `hx-headers`,
+ "settings.html body must set hx-headers with X-CSRF-Token so HTMX POSTs are not rejected with 403")
+}
+
// TestSettingsTemplate_HidesPasskeysWhenDisabled verifies that the settings
// template conditionally shows the passkeys section based on WebAuthnEnabled.
func TestSettingsTemplate_HidesPasskeysWhenDisabled(t *testing.T) {
@@ -2457,3 +2469,381 @@ func TestFetchTasks_ForceRefresh_ClearsSyncToken(t *testing.T) {
t.Errorf("Expected empty sync token for forceRefresh, got %q", mock.receivedTokens[0])
}
}
+
+// =============================================================================
+// Clear Cache + GetProjects removal tests
+// =============================================================================
+
+// TestHandleClearCache verifies that POST /settings/clear-cache invalidates
+// all cache keys, clears the todoist sync token, and returns 200 with HX-Trigger.
+func TestHandleClearCache(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ // Seed cache metadata and sync token so we can verify they get cleared
+ _ = h.store.UpdateCacheMetadata("todoist_tasks", 5)
+ _ = h.store.UpdateCacheMetadata("trello_boards", 5)
+ _ = h.store.UpdateCacheMetadata("plantoeat_meals", 5)
+ _ = h.store.UpdateCacheMetadata("google_calendar", 5)
+ _ = h.store.SetSyncToken("todoist", "some-sync-token")
+
+ req := httptest.NewRequest("POST", "/settings/clear-cache", nil)
+ w := httptest.NewRecorder()
+
+ h.HandleClearCache(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ // Verify HX-Trigger header
+ if trigger := w.Header().Get("HX-Trigger"); trigger != "refresh-tasks" {
+ t.Errorf("Expected HX-Trigger 'refresh-tasks', got %q", trigger)
+ }
+
+ // Verify all cache keys invalidated
+ for _, key := range []string{"todoist_tasks", "trello_boards", "plantoeat_meals", "google_calendar"} {
+ valid, _ := h.store.IsCacheValid(key)
+ if valid {
+ t.Errorf("Cache key %q should be invalidated but is still valid", key)
+ }
+ }
+
+ // Verify sync token cleared
+ token, _ := h.store.GetSyncToken("todoist")
+ if token != "" {
+ t.Errorf("Expected empty sync token after clear cache, got %q", token)
+ }
+}
+
+// TestGetProjectsFromTasks verifies that distinct project pairs are extracted
+// from the tasks table.
+func TestGetProjectsFromTasks(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ // Seed tasks with different projects (including duplicates and empty)
+ tasks := []models.Task{
+ {ID: "1", Content: "Task 1", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
+ {ID: "2", Content: "Task 2", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
+ {ID: "3", Content: "Task 3", ProjectID: "p2", ProjectName: "Work", Labels: []string{}},
+ {ID: "4", Content: "Task 4", ProjectID: "", ProjectName: "", Labels: []string{}},
+ }
+ if err := h.store.SaveTasks(tasks); err != nil {
+ t.Fatalf("Failed to save tasks: %v", err)
+ }
+
+ projects, err := h.store.GetProjectsFromTasks()
+ if err != nil {
+ t.Fatalf("GetProjectsFromTasks returned error: %v", err)
+ }
+
+ if len(projects) != 2 {
+ t.Fatalf("Expected 2 distinct projects, got %d: %v", len(projects), projects)
+ }
+
+ // Verify both projects are present
+ found := map[string]bool{}
+ for _, p := range projects {
+ found[p.ID] = true
+ }
+ if !found["p1"] || !found["p2"] {
+ t.Errorf("Expected projects p1 and p2, got %v", projects)
+ }
+}
+
+// TestInvalidateAllCaches verifies the convenience method clears all 4 keys.
+func TestInvalidateAllCaches(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ keys := []string{"todoist_tasks", "trello_boards", "plantoeat_meals", "google_calendar"}
+ for _, key := range keys {
+ _ = h.store.UpdateCacheMetadata(key, 5)
+ }
+
+ if err := h.store.InvalidateAllCaches(); err != nil {
+ t.Fatalf("InvalidateAllCaches returned error: %v", err)
+ }
+
+ for _, key := range keys {
+ valid, _ := h.store.IsCacheValid(key)
+ if valid {
+ t.Errorf("Cache key %q should be invalidated", key)
+ }
+ }
+}
+
+// TestSettingsTemplate_HasClearCacheButton verifies settings.html has a
+// Clear Cache button with hx-post.
+func TestSettingsTemplate_HasClearCacheButton(t *testing.T) {
+ assertTemplateContains(t, "../../web/templates/settings.html",
+ "clear-cache",
+ "settings.html must contain a clear-cache button/endpoint")
+}
+
+// TestAggregateData_DoesNotCallGetProjects verifies that aggregateData no longer
+// calls the deprecated GetProjects REST API.
+func TestAggregateData_DoesNotCallGetProjects(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ getProjectsCalled := false
+ mock := &mockTodoistClientTracksGetProjects{
+ mockTodoistClient: mockTodoistClient{
+ tasks: []models.Task{
+ {ID: "1", Content: "Test", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
+ },
+ },
+ onGetProjects: func() { getProjectsCalled = true },
+ }
+ h.todoistClient = mock
+ h.trelloClient = &mockTrelloClient{boards: []models.Board{}}
+
+ _, err := h.aggregateData(context.Background(), false)
+ if err != nil {
+ t.Fatalf("aggregateData returned error: %v", err)
+ }
+
+ if getProjectsCalled {
+ t.Error("aggregateData should NOT call GetProjects (deprecated REST API)")
+ }
+}
+
+// TestHandleCreateTask_DoesNotCallGetProjects verifies that HandleCreateTask
+// populates projects from the store, not the REST API.
+func TestHandleCreateTask_DoesNotCallGetProjects(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ // Seed tasks with project info
+ tasks := []models.Task{
+ {ID: "1", Content: "Existing", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
+ }
+ _ = h.store.SaveTasks(tasks)
+
+ getProjectsCalled := false
+ mock := &mockTodoistClientTracksGetProjects{
+ mockTodoistClient: mockTodoistClient{
+ tasks: tasks,
+ },
+ onGetProjects: func() { getProjectsCalled = true },
+ }
+ h.todoistClient = mock
+
+ body := strings.NewReader("content=New+Task&project_id=p1")
+ req := httptest.NewRequest("POST", "/tasks", body)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ h.HandleCreateTask(w, req)
+
+ if getProjectsCalled {
+ t.Error("HandleCreateTask should NOT call GetProjects (deprecated REST API)")
+ }
+}
+
+// TestHandleSyncSources_UsesStoreForProjects verifies that HandleSyncSources
+// reads projects from the tasks table instead of the REST API.
+func TestHandleSyncSources_UsesStoreForProjects(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ // Seed tasks with project info
+ tasks := []models.Task{
+ {ID: "1", Content: "Task1", ProjectID: "p1", ProjectName: "Inbox", Labels: []string{}},
+ {ID: "2", Content: "Task2", ProjectID: "p2", ProjectName: "Work", Labels: []string{}},
+ }
+ _ = h.store.SaveTasks(tasks)
+
+ getProjectsCalled := false
+ mock := &mockTodoistClientTracksGetProjects{
+ mockTodoistClient: mockTodoistClient{tasks: tasks},
+ onGetProjects: func() { getProjectsCalled = true },
+ }
+ h.todoistClient = mock
+
+ req := httptest.NewRequest("POST", "/settings/sync", nil)
+ w := httptest.NewRecorder()
+
+ h.HandleSyncSources(w, req)
+
+ if getProjectsCalled {
+ t.Error("HandleSyncSources should NOT call GetProjects (deprecated REST API)")
+ }
+
+ // Verify todoist project configs were still synced from store
+ configs, _ := h.store.GetSourceConfigsBySource("todoist")
+ if len(configs) != 2 {
+ t.Errorf("Expected 2 todoist configs from store, got %d", len(configs))
+ }
+}
+
+// mockTodoistClientTracksGetProjects wraps mockTodoistClient and tracks GetProjects calls.
+type mockTodoistClientTracksGetProjects struct {
+ mockTodoistClient
+ onGetProjects func()
+}
+
+func (m *mockTodoistClientTracksGetProjects) GetProjects(ctx context.Context) ([]models.Project, error) {
+ if m.onGetProjects != nil {
+ m.onGetProjects()
+ }
+ return []models.Project{}, nil
+}
+
+// --- Sync Log Tests ---
+
+// TestStore_AddAndGetSyncLog verifies that sync log entries can be added and retrieved.
+func TestStore_AddAndGetSyncLog(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ if err := db.AddSyncLogEntry("cache_clear", "All caches cleared"); err != nil {
+ t.Fatalf("AddSyncLogEntry failed: %v", err)
+ }
+ if err := db.AddSyncLogEntry("sync", "Sources synced"); err != nil {
+ t.Fatalf("AddSyncLogEntry failed: %v", err)
+ }
+
+ entries, err := db.GetRecentSyncLog(10)
+ if err != nil {
+ t.Fatalf("GetRecentSyncLog failed: %v", err)
+ }
+ if len(entries) != 2 {
+ t.Fatalf("Expected 2 entries, got %d", len(entries))
+ }
+ // Most recent first
+ if entries[0].Message != "Sources synced" {
+ t.Errorf("Expected first entry to be 'Sources synced', got %q", entries[0].Message)
+ }
+ if entries[1].Message != "All caches cleared" {
+ t.Errorf("Expected second entry to be 'All caches cleared', got %q", entries[1].Message)
+ }
+}
+
+// TestStore_GetRecentSyncLog_LimitsResults verifies the limit parameter is respected.
+func TestStore_GetRecentSyncLog_LimitsResults(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ for i := 0; i < 5; i++ {
+ _ = db.AddSyncLogEntry("test", "entry")
+ }
+
+ entries, err := db.GetRecentSyncLog(3)
+ if err != nil {
+ t.Fatalf("GetRecentSyncLog failed: %v", err)
+ }
+ if len(entries) != 3 {
+ t.Errorf("Expected 3 entries with limit=3, got %d", len(entries))
+ }
+}
+
+// TestHandleClearCache_AddsLogEntry verifies that clearing cache creates a sync log entry.
+func TestHandleClearCache_AddsLogEntry(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := httptest.NewRequest("POST", "/settings/clear-cache", nil)
+ w := httptest.NewRecorder()
+ h.HandleClearCache(w, req)
+
+ entries, err := h.store.GetRecentSyncLog(10)
+ if err != nil {
+ t.Fatalf("GetRecentSyncLog failed: %v", err)
+ }
+ if len(entries) == 0 {
+ t.Fatal("Expected sync log entry after clear cache, got none")
+ }
+ if entries[0].EventType != "cache_clear" {
+ t.Errorf("Expected event_type 'cache_clear', got %q", entries[0].EventType)
+ }
+}
+
+// TestHandleClearCache_ReturnsHTMLSyncLog verifies that the response renders the sync-log template.
+func TestHandleClearCache_ReturnsHTMLSyncLog(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := httptest.NewRequest("POST", "/settings/clear-cache", nil)
+ w := httptest.NewRecorder()
+ h.HandleClearCache(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("Expected status 200, got %d", w.Code)
+ }
+ // MockRenderer writes "rendered:{name}" — verify sync-log template was rendered
+ body := w.Body.String()
+ if !strings.Contains(body, "sync-log") {
+ t.Errorf("Expected response body to contain 'sync-log', got: %s", body)
+ }
+ // Verify the renderer was called with the sync-log template and log entries
+ mr := h.renderer.(*MockRenderer)
+ var found bool
+ for _, call := range mr.Calls {
+ if call.Name == "sync-log" {
+ found = true
+ entries, ok := call.Data.([]store.SyncLogEntry)
+ if !ok {
+ t.Errorf("Expected sync-log data to be []store.SyncLogEntry, got %T", call.Data)
+ }
+ if len(entries) == 0 {
+ t.Error("Expected sync-log data to contain at least one entry")
+ }
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected renderer to be called with 'sync-log' template")
+ }
+}
+
+// TestHandleSettingsPage_IncludesSyncLog verifies settings page passes SyncLog to template.
+func TestHandleSettingsPage_IncludesSyncLog(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+ _ = h.store.AddSyncLogEntry("cache_clear", "All caches cleared")
+
+ req := httptest.NewRequest("GET", "/settings", nil)
+ w := httptest.NewRecorder()
+ mr := h.renderer.(*MockRenderer)
+ h.HandleSettingsPage(w, req)
+
+ if len(mr.Calls) == 0 {
+ t.Fatal("Expected renderer to be called")
+ }
+ call := mr.Calls[len(mr.Calls)-1]
+
+ type hasSyncLog interface {
+ GetSyncLog() interface{}
+ }
+
+ // Use reflection to check for SyncLog field in the data struct
+ dataVal := reflect.ValueOf(call.Data)
+ syncLogField := dataVal.FieldByName("SyncLog")
+ if !syncLogField.IsValid() {
+ t.Fatal("Expected template data to have 'SyncLog' field")
+ }
+}
+
+// TestHandleSyncSources_AddsLogEntry verifies that syncing sources creates a sync log entry.
+func TestHandleSyncSources_AddsLogEntry(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := httptest.NewRequest("POST", "/settings/sync", nil)
+ w := httptest.NewRecorder()
+ h.HandleSyncSources(w, req)
+
+ entries, err := h.store.GetRecentSyncLog(10)
+ if err != nil {
+ t.Fatalf("GetRecentSyncLog failed: %v", err)
+ }
+ if len(entries) == 0 {
+ t.Fatal("Expected sync log entry after sync sources, got none")
+ }
+ if entries[0].EventType != "sync" {
+ t.Errorf("Expected event_type 'sync', got %q", entries[0].EventType)
+ }
+}