summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/handlers/handlers.go55
-rw-r--r--internal/handlers/handlers_test.go269
-rw-r--r--internal/store/sqlite.go18
-rw-r--r--internal/store/sqlite_test.go258
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)
+ }
+}