diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/handlers/handlers.go | 55 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 269 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 18 | ||||
| -rw-r--r-- | internal/store/sqlite_test.go | 258 |
4 files changed, 600 insertions, 0 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 9ba6351..b3bc8e4 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -672,3 +672,58 @@ func (h *Handler) HandleCompleteTask(w http.ResponseWriter, r *http.Request) { // Return empty response (task will be removed from DOM) w.WriteHeader(http.StatusOK) } + +// HandleCompleteAtom handles completion of a unified task (Atom) +func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if err := r.ParseForm(); err != nil { + http.Error(w, "Failed to parse form", http.StatusBadRequest) + log.Printf("Error parsing form: %v", err) + return + } + + id := r.FormValue("id") + source := r.FormValue("source") + + if id == "" || source == "" { + http.Error(w, "Missing id or source", http.StatusBadRequest) + return + } + + var err error + switch source { + case "todoist": + err = h.todoistClient.CompleteTask(ctx, id) + case "trello": + // Archive the card (closed = true) + updates := map[string]interface{}{ + "closed": true, + } + err = h.trelloClient.UpdateCard(ctx, id, updates) + default: + http.Error(w, "Unknown source: "+source, http.StatusBadRequest) + return + } + + if err != nil { + http.Error(w, "Failed to complete task", http.StatusInternalServerError) + log.Printf("Error completing atom (source=%s, id=%s): %v", source, id, err) + return + } + + // Remove from local cache + switch source { + case "todoist": + if err := h.store.DeleteTask(id); err != nil { + log.Printf("Warning: failed to delete task from cache: %v", err) + } + case "trello": + if err := h.store.DeleteCard(id); err != nil { + log.Printf("Warning: failed to delete card from cache: %v", err) + } + } + + // Return 200 OK with empty body to remove the element from DOM + w.WriteHeader(http.StatusOK) +} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index ac940bb..3ea2a3e 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -395,3 +395,272 @@ func TestHandleGetMeals(t *testing.T) { t.Errorf("Expected 0 meals when client is nil, got %d", len(meals)) } } + +// mockTodoistClientWithComplete tracks CompleteTask calls +type mockTodoistClientWithComplete struct { + mockTodoistClient + completedTaskIDs []string + completeErr error +} + +func (m *mockTodoistClientWithComplete) CompleteTask(ctx context.Context, taskID string) error { + if m.completeErr != nil { + return m.completeErr + } + m.completedTaskIDs = append(m.completedTaskIDs, taskID) + return nil +} + +// mockTrelloClientWithUpdate tracks UpdateCard calls +type mockTrelloClientWithUpdate struct { + mockTrelloClient + updatedCards []string + updateErr error +} + +func (m *mockTrelloClientWithUpdate) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error { + if m.updateErr != nil { + return m.updateErr + } + m.updatedCards = append(m.updatedCards, cardID) + return nil +} + +// TestHandleCompleteAtom_Todoist tests completing a Todoist task +func TestHandleCompleteAtom_Todoist(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + // Save a task to the cache + tasks := []models.Task{ + { + ID: "task123", + Content: "Test task", + Completed: false, + Labels: []string{}, + CreatedAt: time.Now(), + }, + } + if err := db.SaveTasks(tasks); err != nil { + t.Fatalf("Failed to save test task: %v", err) + } + + // Verify task exists in cache + cachedTasks, _ := db.GetTasks() + if len(cachedTasks) != 1 { + t.Fatalf("Expected 1 task in cache, got %d", len(cachedTasks)) + } + + // Create handler with mock client + mockTodoist := &mockTodoistClientWithComplete{} + h := &Handler{ + store: db, + todoistClient: mockTodoist, + config: &config.Config{}, + } + + // Create request + req := httptest.NewRequest("POST", "/complete-atom", nil) + req.Form = map[string][]string{ + "id": {"task123"}, + "source": {"todoist"}, + } + w := httptest.NewRecorder() + + // Execute handler + h.HandleCompleteAtom(w, req) + + // Check response + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // 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) + } + + // Verify task was deleted from cache + cachedTasks, _ = db.GetTasks() + if len(cachedTasks) != 0 { + t.Errorf("Expected task to be deleted from cache, but found %d tasks", len(cachedTasks)) + } +} + +// TestHandleCompleteAtom_Trello tests completing a Trello card +func TestHandleCompleteAtom_Trello(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + // Save a board with a card to the cache + boards := []models.Board{ + { + ID: "board1", + Name: "Test Board", + Cards: []models.Card{ + { + ID: "card456", + Name: "Test Card", + ListID: "list1", + ListName: "To Do", + }, + }, + }, + } + if err := db.SaveBoards(boards); err != nil { + t.Fatalf("Failed to save test board: %v", err) + } + + // Verify card exists in cache + cachedBoards, _ := db.GetBoards() + if len(cachedBoards) != 1 || len(cachedBoards[0].Cards) != 1 { + t.Fatalf("Expected 1 board with 1 card in cache") + } + + // Create handler with mock client + mockTrello := &mockTrelloClientWithUpdate{} + h := &Handler{ + store: db, + trelloClient: mockTrello, + config: &config.Config{}, + } + + // Create request + req := httptest.NewRequest("POST", "/complete-atom", nil) + req.Form = map[string][]string{ + "id": {"card456"}, + "source": {"trello"}, + } + w := httptest.NewRecorder() + + // Execute handler + h.HandleCompleteAtom(w, req) + + // Check response + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Verify UpdateCard was called on the API + if len(mockTrello.updatedCards) != 1 || mockTrello.updatedCards[0] != "card456" { + t.Errorf("Expected UpdateCard to be called with 'card456', got %v", mockTrello.updatedCards) + } + + // Verify card was deleted from cache + cachedBoards, _ = db.GetBoards() + if len(cachedBoards[0].Cards) != 0 { + t.Errorf("Expected card to be deleted from cache, but found %d cards", len(cachedBoards[0].Cards)) + } +} + +// TestHandleCompleteAtom_MissingParams tests error handling for missing parameters +func TestHandleCompleteAtom_MissingParams(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + h := &Handler{ + store: db, + config: &config.Config{}, + } + + tests := []struct { + name string + id string + source string + }{ + {"missing id", "", "todoist"}, + {"missing source", "task123", ""}, + {"both missing", "", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/complete-atom", nil) + req.Form = map[string][]string{ + "id": {tc.id}, + "source": {tc.source}, + } + w := httptest.NewRecorder() + + h.HandleCompleteAtom(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for %s, got %d", tc.name, w.Code) + } + }) + } +} + +// TestHandleCompleteAtom_UnknownSource tests error handling for unknown source +func TestHandleCompleteAtom_UnknownSource(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + h := &Handler{ + store: db, + config: &config.Config{}, + } + + req := httptest.NewRequest("POST", "/complete-atom", nil) + req.Form = map[string][]string{ + "id": {"task123"}, + "source": {"unknown"}, + } + w := httptest.NewRecorder() + + h.HandleCompleteAtom(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for unknown source, got %d", w.Code) + } +} + +// TestHandleCompleteAtom_APIError tests that API errors are handled correctly +func TestHandleCompleteAtom_APIError(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + // Save a task to the cache + tasks := []models.Task{ + { + ID: "task123", + Content: "Test task", + Completed: false, + Labels: []string{}, + CreatedAt: time.Now(), + }, + } + if err := db.SaveTasks(tasks); err != nil { + t.Fatalf("Failed to save test task: %v", err) + } + + // Create handler with mock client that returns an error + mockTodoist := &mockTodoistClientWithComplete{ + completeErr: context.DeadlineExceeded, + } + h := &Handler{ + store: db, + todoistClient: mockTodoist, + config: &config.Config{}, + } + + req := httptest.NewRequest("POST", "/complete-atom", nil) + req.Form = map[string][]string{ + "id": {"task123"}, + "source": {"todoist"}, + } + w := httptest.NewRecorder() + + h.HandleCompleteAtom(w, req) + + // Should return 500 on API error + if w.Code != http.StatusInternalServerError { + t.Errorf("Expected status 500 on API error, got %d", w.Code) + } + + // Verify task was NOT deleted from cache (rollback behavior) + cachedTasks, _ := db.GetTasks() + if len(cachedTasks) != 1 { + t.Errorf("Task should NOT be deleted from cache on API error, found %d tasks", len(cachedTasks)) + } +} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index c97d0af..79f5cc8 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -167,6 +167,12 @@ func (s *Store) GetTasks() ([]models.Task, error) { return tasks, rows.Err() } +// DeleteTask removes a task from the cache by ID +func (s *Store) DeleteTask(id string) error { + _, err := s.db.Exec(`DELETE FROM tasks WHERE id = ?`, id) + return err +} + // Notes operations // SaveNotes saves multiple notes to the database @@ -403,6 +409,12 @@ func (s *Store) IsCacheValid(key string) (bool, error) { return cm.IsCacheValid(), nil } +// InvalidateCache removes the cache metadata for a given key, forcing a refresh on next fetch +func (s *Store) InvalidateCache(key string) error { + _, err := s.db.Exec(`DELETE FROM cache_metadata WHERE key = ?`, key) + return err +} + // Boards operations // SaveBoards saves multiple boards to the database @@ -538,3 +550,9 @@ func (s *Store) GetBoards() ([]models.Board, error) { return boards, cardRows.Err() } + +// DeleteCard removes a card from the cache by ID +func (s *Store) DeleteCard(id string) error { + _, err := s.db.Exec(`DELETE FROM cards WHERE id = ?`, id) + return err +} diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index 3379a11..962a1bf 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -336,3 +336,261 @@ func TestGetNotes_SQLInjectionAttempt(t *testing.T) { // and is not vulnerable to SQL injection via the LIMIT clause fmt.Println("✓ LIMIT clause is properly parameterized") } + +// setupTestStoreWithTasks creates a test store with tasks table +func setupTestStoreWithTasks(t *testing.T) *Store { + t.Helper() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test.db") + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + db.SetMaxOpenConns(1) + + store := &Store{db: db} + + schema := ` + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + description TEXT, + project_id TEXT, + project_name TEXT, + due_date DATETIME, + priority INTEGER DEFAULT 1, + completed BOOLEAN DEFAULT FALSE, + labels TEXT, + url TEXT, + created_at DATETIME, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + ` + if _, err := db.Exec(schema); err != nil { + t.Fatalf("Failed to create schema: %v", err) + } + + return store +} + +// setupTestStoreWithCards creates a test store with boards and cards tables +func setupTestStoreWithCards(t *testing.T) *Store { + t.Helper() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test.db") + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + db.SetMaxOpenConns(1) + + store := &Store{db: db} + + schema := ` + CREATE TABLE IF NOT EXISTS boards ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS cards ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + board_id TEXT NOT NULL, + list_id TEXT, + list_name TEXT, + due_date DATETIME, + url TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + ` + if _, err := db.Exec(schema); err != nil { + t.Fatalf("Failed to create schema: %v", err) + } + + return store +} + +// TestDeleteTask verifies that DeleteTask removes a task from the cache +func TestDeleteTask(t *testing.T) { + store := setupTestStoreWithTasks(t) + defer store.Close() + + // Save some tasks + tasks := []models.Task{ + { + ID: "task1", + Content: "Task 1", + Description: "Description 1", + Priority: 1, + Completed: false, + Labels: []string{}, + CreatedAt: time.Now(), + }, + { + ID: "task2", + Content: "Task 2", + Description: "Description 2", + Priority: 2, + Completed: false, + Labels: []string{}, + CreatedAt: time.Now(), + }, + { + ID: "task3", + Content: "Task 3", + Description: "Description 3", + Priority: 3, + Completed: false, + Labels: []string{}, + CreatedAt: time.Now(), + }, + } + + if err := store.SaveTasks(tasks); err != nil { + t.Fatalf("Failed to save tasks: %v", err) + } + + // Verify all 3 tasks exist + result, err := store.GetTasks() + if err != nil { + t.Fatalf("Failed to get tasks: %v", err) + } + if len(result) != 3 { + t.Fatalf("Expected 3 tasks, got %d", len(result)) + } + + // Delete task2 + if err := store.DeleteTask("task2"); err != nil { + t.Fatalf("DeleteTask failed: %v", err) + } + + // Verify only 2 tasks remain + result, err = store.GetTasks() + if err != nil { + t.Fatalf("Failed to get tasks after delete: %v", err) + } + if len(result) != 2 { + t.Errorf("Expected 2 tasks after delete, got %d", len(result)) + } + + // Verify task2 is gone + for _, task := range result { + if task.ID == "task2" { + t.Errorf("task2 should have been deleted but was found") + } + } + + // Verify task1 and task3 still exist + foundTask1, foundTask3 := false, false + for _, task := range result { + if task.ID == "task1" { + foundTask1 = true + } + if task.ID == "task3" { + foundTask3 = true + } + } + if !foundTask1 { + t.Error("task1 should still exist") + } + if !foundTask3 { + t.Error("task3 should still exist") + } +} + +// TestDeleteTask_NonExistent verifies that deleting a non-existent task doesn't error +func TestDeleteTask_NonExistent(t *testing.T) { + store := setupTestStoreWithTasks(t) + defer store.Close() + + // Delete a task that doesn't exist - should not error + err := store.DeleteTask("nonexistent") + if err != nil { + t.Errorf("DeleteTask on non-existent task should not error, got: %v", err) + } +} + +// TestDeleteCard verifies that DeleteCard removes a card from the cache +func TestDeleteCard(t *testing.T) { + store := setupTestStoreWithCards(t) + defer store.Close() + + // First create a board + _, err := store.db.Exec(`INSERT INTO boards (id, name) VALUES (?, ?)`, "board1", "Test Board") + if err != nil { + t.Fatalf("Failed to create board: %v", err) + } + + // Insert cards directly + cards := []struct { + id string + name string + boardID string + }{ + {"card1", "Card 1", "board1"}, + {"card2", "Card 2", "board1"}, + {"card3", "Card 3", "board1"}, + } + + for _, card := range cards { + _, err := store.db.Exec( + `INSERT INTO cards (id, name, board_id, list_id, list_name) VALUES (?, ?, ?, ?, ?)`, + card.id, card.name, card.boardID, "list1", "To Do", + ) + if err != nil { + t.Fatalf("Failed to insert card: %v", err) + } + } + + // Verify all 3 cards exist + var count int + err = store.db.QueryRow(`SELECT COUNT(*) FROM cards`).Scan(&count) + if err != nil { + t.Fatalf("Failed to count cards: %v", err) + } + if count != 3 { + t.Fatalf("Expected 3 cards, got %d", count) + } + + // Delete card2 + if err := store.DeleteCard("card2"); err != nil { + t.Fatalf("DeleteCard failed: %v", err) + } + + // Verify only 2 cards remain + err = store.db.QueryRow(`SELECT COUNT(*) FROM cards`).Scan(&count) + if err != nil { + t.Fatalf("Failed to count cards after delete: %v", err) + } + if count != 2 { + t.Errorf("Expected 2 cards after delete, got %d", count) + } + + // Verify card2 is gone + var exists int + err = store.db.QueryRow(`SELECT COUNT(*) FROM cards WHERE id = ?`, "card2").Scan(&exists) + if err != nil { + t.Fatalf("Failed to check card2: %v", err) + } + if exists != 0 { + t.Errorf("card2 should have been deleted") + } +} + +// TestDeleteCard_NonExistent verifies that deleting a non-existent card doesn't error +func TestDeleteCard_NonExistent(t *testing.T) { + store := setupTestStoreWithCards(t) + defer store.Close() + + // Delete a card that doesn't exist - should not error + err := store.DeleteCard("nonexistent") + if err != nil { + t.Errorf("DeleteCard on non-existent card should not error, got: %v", err) + } +} |
