From 0fd54eddc40f517cf491310d4f8a60b0d79dc937 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 4 Mar 2026 11:12:44 -1000 Subject: 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 --- internal/handlers/handlers_test.go | 404 ++++++++++++++++++++++++++++++++++++- 1 file changed, 397 insertions(+), 7 deletions(-) (limited to 'internal/handlers/handlers_test.go') 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) + } +} -- cgit v1.2.3