diff options
Diffstat (limited to 'internal/handlers')
| -rw-r--r-- | internal/handlers/agent.go | 5 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 23 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 404 | ||||
| -rw-r--r-- | internal/handlers/settings.go | 50 |
4 files changed, 442 insertions, 40 deletions
diff --git a/internal/handlers/agent.go b/internal/handlers/agent.go index b285520..aa3f000 100644 --- a/internal/handlers/agent.go +++ b/internal/handlers/agent.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" + "log" "net/http" "time" @@ -253,8 +254,8 @@ func (h *Handler) HandleAgentAuthApprove(w http.ResponseWriter, r *http.Request) // Register/update agent in the trusted agents table if err := h.store.CreateOrUpdateAgent(session.AgentName, session.AgentID); err != nil { - // Log but don't fail - the session was approved - // This just affects future trust level checks + // Don't fail - the session was approved; this just affects future trust level checks + log.Printf("warning: failed to register agent %q: %v", session.AgentName, err) } w.Header().Set("Content-Type", "application/json") diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index e06c35e..4988038 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -243,16 +243,6 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models return err }) - fetch("Projects", func() error { - projects, err := h.todoistClient.GetProjects(ctx) - if err == nil { - mu.Lock() - data.Projects = projects - mu.Unlock() - } - return err - }) - if h.planToEatClient != nil { fetch("PlanToEat", func() error { meals, err := h.fetchMeals(ctx, forceRefresh) @@ -279,6 +269,11 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models wg.Wait() + // Populate projects from cached tasks (avoids deprecated REST API) + if projects, err := h.store.GetProjectsFromTasks(); err == nil { + data.Projects = projects + } + // Extract and sort Trello tasks data.TrelloTasks = filterAndSortTrelloTasks(data.Boards) @@ -586,7 +581,7 @@ func (h *Handler) HandleCreateTask(w http.ResponseWriter, r *http.Request) { return } - projects, _ := h.todoistClient.GetProjects(ctx) + projects, _ := h.store.GetProjectsFromTasks() data := struct { Tasks []models.Task @@ -772,12 +767,6 @@ func (h *Handler) getAtomDetails(id, source string) (string, *time.Time) { return "Task", nil } -// getAtomTitle retrieves the title for a task/card/bug from the store (legacy) -func (h *Handler) getAtomTitle(id, source string) string { - title, _ := h.getAtomDetails(id, source) - return title -} - // HandleUnifiedAdd creates a task in Todoist or a card in Trello from the Quick Add form func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) { ctx := r.Context() 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) + } +} diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index 0ad362b..c0ba654 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -8,12 +8,14 @@ import ( "task-dashboard/internal/auth" "task-dashboard/internal/models" + "task-dashboard/internal/store" ) // HandleSettingsPage renders the settings page func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { configs, _ := h.store.GetSourceConfigs() toggles, _ := h.store.GetFeatureToggles() + syncLog, _ := h.store.GetRecentSyncLog(20) // Group configs by source bySource := make(map[string][]models.SourceConfig) @@ -25,12 +27,14 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { Configs map[string][]models.SourceConfig Sources []string Toggles []models.FeatureToggle + SyncLog []store.SyncLogEntry CSRFToken string WebAuthnEnabled bool }{ Configs: bySource, Sources: []string{"trello", "todoist", "gcal", "gtasks"}, Toggles: toggles, + SyncLog: syncLog, CSRFToken: auth.GetCSRFTokenFromContext(r.Context()), WebAuthnEnabled: h.WebAuthnEnabled, } @@ -61,21 +65,18 @@ func (h *Handler) HandleSyncSources(w http.ResponseWriter, r *http.Request) { } } - // Sync Todoist projects - if h.todoistClient != nil { - projects, err := h.todoistClient.GetProjects(ctx) - if err == nil { - var items []models.SourceConfig - for _, p := range projects { - items = append(items, models.SourceConfig{ - Source: "todoist", - ItemType: "project", - ItemID: p.ID, - ItemName: p.Name, - }) - } - _ = h.store.SyncSourceConfigs("todoist", "project", items) + // Sync Todoist projects from cached tasks (avoids deprecated REST API) + if projects, err := h.store.GetProjectsFromTasks(); err == nil && len(projects) > 0 { + var items []models.SourceConfig + for _, p := range projects { + items = append(items, models.SourceConfig{ + Source: "todoist", + ItemType: "project", + ItemID: p.ID, + ItemName: p.Name, + }) } + _ = h.store.SyncSourceConfigs("todoist", "project", items) } // Sync Google Calendar calendars @@ -112,10 +113,31 @@ func (h *Handler) HandleSyncSources(w http.ResponseWriter, r *http.Request) { } } + _ = h.store.AddSyncLogEntry("sync", "Sources synced") + // Return updated configs h.HandleSettingsPage(w, r) } +// HandleClearCache invalidates all caches and clears the Todoist sync token +func (h *Handler) HandleClearCache(w http.ResponseWriter, r *http.Request) { + if err := h.store.InvalidateAllCaches(); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to clear cache", err) + return + } + + if err := h.store.ClearSyncToken("todoist"); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to clear sync token", err) + return + } + + _ = h.store.AddSyncLogEntry("cache_clear", "Cache cleared — next load fetches fresh data") + + syncLog, _ := h.store.GetRecentSyncLog(20) + w.Header().Set("HX-Trigger", "refresh-tasks") + HTMLResponse(w, h.renderer, "sync-log", syncLog) +} + // HandleToggleSourceConfig toggles a source config item func (h *Handler) HandleToggleSourceConfig(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { |
