diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-22 23:45:19 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-22 23:45:19 +0000 |
| commit | 8abc63efdbc0bb96cd6c9aa99d6e9166e0bcabae (patch) | |
| tree | f4d6a082eed9b10bc67436a3ca5188e0182961eb /internal | |
| parent | 11b905fd437d651b2e39745aa82a5dd36f70331e (diff) | |
chore: unify and centralize agent configuration in .agent/
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/interfaces.go | 1 | ||||
| -rw-r--r-- | internal/api/todoist.go | 155 | ||||
| -rw-r--r-- | internal/api/todoist_test.go | 166 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 62 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 216 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 6 |
6 files changed, 111 insertions, 495 deletions
diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go index c9962c9..cec64be 100644 --- a/internal/api/interfaces.go +++ b/internal/api/interfaces.go @@ -15,7 +15,6 @@ type TodoistAPI interface { UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error CompleteTask(ctx context.Context, taskID string) error ReopenTask(ctx context.Context, taskID string) error - Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error) } // TrelloAPI defines the interface for Trello operations diff --git a/internal/api/todoist.go b/internal/api/todoist.go index be699ce..d6058d3 100644 --- a/internal/api/todoist.go +++ b/internal/api/todoist.go @@ -10,22 +10,19 @@ import ( ) const ( - todoistBaseURL = "https://api.todoist.com/api/v1" - todoistSyncBaseURL = "https://api.todoist.com/sync/v9" + todoistBaseURL = "https://api.todoist.com/api/v1" ) // TodoistClient handles interactions with the Todoist API type TodoistClient struct { BaseClient - syncClient BaseClient - apiKey string + apiKey string } // NewTodoistClient creates a new Todoist API client func NewTodoistClient(apiKey string) *TodoistClient { return &TodoistClient{ BaseClient: NewBaseClient(todoistBaseURL), - syncClient: NewBaseClient(todoistSyncBaseURL), apiKey: apiKey, } } @@ -53,43 +50,33 @@ type todoistProjectResponse struct { Name string `json:"name"` } -// Sync API v9 response types -// TodoistSyncResponse represents the Sync API response -type TodoistSyncResponse struct { - SyncToken string `json:"sync_token"` - FullSync bool `json:"full_sync"` - Items []SyncItemResponse `json:"items"` - Projects []SyncProjectResponse `json:"projects"` -} - -// SyncItemResponse represents a task item from Sync API -type SyncItemResponse struct { - ID string `json:"id"` - Content string `json:"content"` - Description string `json:"description"` - ProjectID string `json:"project_id"` - Priority int `json:"priority"` - Labels []string `json:"labels"` - Due *dueInfo `json:"due"` - IsCompleted bool `json:"is_completed"` - IsDeleted bool `json:"is_deleted"` - AddedAt string `json:"added_at"` -} - -// SyncProjectResponse represents a project from Sync API -type SyncProjectResponse struct { - ID string `json:"id"` - Name string `json:"name"` - IsDeleted bool `json:"is_deleted"` +// todoistTasksPage represents the paginated response from the Todoist REST API v1 +type todoistTasksPage struct { + Results []todoistTaskResponse `json:"results"` + NextCursor *string `json:"next_cursor"` } // GetTasks fetches all active tasks from Todoist func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { - var apiTasks []todoistTaskResponse - if err := c.Get(ctx, "/tasks", c.authHeaders(), &apiTasks); err != nil { - return nil, fmt.Errorf("failed to fetch tasks: %w", err) + var allTasks []todoistTaskResponse + cursor := "" + for { + path := "/tasks" + if cursor != "" { + path = "/tasks?cursor=" + cursor + } + var page todoistTasksPage + if err := c.Get(ctx, path, c.authHeaders(), &page); err != nil { + return nil, fmt.Errorf("failed to fetch tasks: %w", err) + } + allTasks = append(allTasks, page.Results...) + if page.NextCursor == nil || *page.NextCursor == "" { + break + } + cursor = *page.NextCursor } + apiTasks := allTasks // Fetch projects to get project names projects, err := c.GetProjects(ctx) @@ -129,12 +116,32 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { return tasks, nil } +// todoistProjectsPage represents the paginated response for projects +type todoistProjectsPage struct { + Results []todoistProjectResponse `json:"results"` + NextCursor *string `json:"next_cursor"` +} + // GetProjects fetches all projects func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) { - var apiProjects []todoistProjectResponse - if err := c.Get(ctx, "/projects", c.authHeaders(), &apiProjects); err != nil { - return nil, fmt.Errorf("failed to fetch projects: %w", err) + var allProjects []todoistProjectResponse + cursor := "" + for { + path := "/projects" + if cursor != "" { + path = "/projects?cursor=" + cursor + } + var page todoistProjectsPage + if err := c.Get(ctx, path, c.authHeaders(), &page); err != nil { + return nil, fmt.Errorf("failed to fetch projects: %w", err) + } + allProjects = append(allProjects, page.Results...) + if page.NextCursor == nil || *page.NextCursor == "" { + break + } + cursor = *page.NextCursor } + apiProjects := allProjects projects := make([]models.Project, 0, len(apiProjects)) for _, apiProj := range apiProjects { @@ -147,76 +154,6 @@ func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, erro return projects, nil } -// Sync performs an incremental sync using the Sync API v9 -func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error) { - if syncToken == "" { - syncToken = "*" // Full sync - } - - payload := map[string]interface{}{ - "sync_token": syncToken, - "resource_types": []string{"items", "projects"}, - } - - var syncResp TodoistSyncResponse - if err := c.syncClient.Post(ctx, "/sync", c.authHeaders(), payload, &syncResp); err != nil { - return nil, fmt.Errorf("failed to perform sync: %w", err) - } - - return &syncResp, nil -} - -// ConvertSyncItemToTask converts a single sync item to a Task model. -// Returns the task and true if the item is active, or a zero Task and false if it should be skipped. -func ConvertSyncItemToTask(item SyncItemResponse, projectMap map[string]string) (models.Task, bool) { - if item.IsCompleted || item.IsDeleted { - return models.Task{}, false - } - - task := models.Task{ - ID: item.ID, - Content: item.Content, - Description: item.Description, - ProjectID: item.ProjectID, - ProjectName: projectMap[item.ProjectID], - Priority: item.Priority, - Completed: false, - Labels: item.Labels, - URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID), - } - - if item.AddedAt != "" { - if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil { - task.CreatedAt = createdAt - } - } - - task.DueDate = parseDueDate(item.Due) - return task, true -} - -// ConvertSyncItemsToTasks converts sync API items to Task models -func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]string) []models.Task { - tasks := make([]models.Task, 0, len(items)) - for _, item := range items { - if task, ok := ConvertSyncItemToTask(item, projectMap); ok { - tasks = append(tasks, task) - } - } - return tasks -} - -// BuildProjectMapFromSync builds a project ID to name map from sync response -func BuildProjectMapFromSync(projects []SyncProjectResponse) map[string]string { - projectMap := make(map[string]string) - for _, proj := range projects { - if !proj.IsDeleted { - projectMap[proj.ID] = proj.Name - } - } - return projectMap -} - // CreateTask creates a new task in Todoist func (c *TodoistClient) CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) { payload := map[string]interface{}{"content": content} diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go index 2204469..99b9e80 100644 --- a/internal/api/todoist_test.go +++ b/internal/api/todoist_test.go @@ -14,7 +14,6 @@ import ( func newTestTodoistClient(baseURL, apiKey string) *TodoistClient { client := NewTodoistClient(apiKey) client.BaseURL = baseURL - client.syncClient.BaseURL = baseURL return client } @@ -220,9 +219,11 @@ func TestTodoistClient_GetProjects(t *testing.T) { } // Return mock response - response := []todoistProjectResponse{ - {ID: "proj-1", Name: "Project 1"}, - {ID: "proj-2", Name: "Project 2"}, + response := todoistProjectsPage{ + Results: []todoistProjectResponse{ + {ID: "proj-1", Name: "Project 1"}, + {ID: "proj-2", Name: "Project 2"}, + }, } w.Header().Set("Content-Type", "application/json") @@ -265,17 +266,21 @@ func TestTodoistClient_GetTasks(t *testing.T) { // GetTasks also calls GetProjects internally if r.URL.Path == "/projects" { - response := []todoistProjectResponse{ - {ID: "proj-1", Name: "Project 1"}, + response := todoistProjectsPage{ + Results: []todoistProjectResponse{ + {ID: "proj-1", Name: "Project 1"}, + }, } json.NewEncoder(w).Encode(response) return } if r.URL.Path == "/tasks" { - response := []todoistTaskResponse{ - {ID: "task-1", Content: "Task 1", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)}, - {ID: "task-2", Content: "Task 2", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)}, + response := todoistTasksPage{ + Results: []todoistTaskResponse{ + {ID: "task-1", Content: "Task 1", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)}, + {ID: "task-2", Content: "Task 2", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)}, + }, } json.NewEncoder(w).Encode(response) return @@ -345,146 +350,3 @@ func TestTodoistClient_UpdateTask(t *testing.T) { } } -func TestTodoistClient_Sync(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("Expected POST, got %s", r.Method) - } - if r.URL.Path != "/sync" { - t.Errorf("Expected path /sync, got %s", r.URL.Path) - } - - response := TodoistSyncResponse{ - SyncToken: "new-sync-token", - FullSync: true, - Items: []SyncItemResponse{ - {ID: "item-1", Content: "Item 1", ProjectID: "proj-1"}, - }, - Projects: []SyncProjectResponse{ - {ID: "proj-1", Name: "Project 1"}, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - client := newTestTodoistClient(server.URL, "test-key") - resp, err := client.Sync(context.Background(), "*") - if err != nil { - t.Fatalf("Sync failed: %v", err) - } - - if resp.SyncToken != "new-sync-token" { - t.Errorf("Expected sync token 'new-sync-token', got '%s'", resp.SyncToken) - } - if len(resp.Items) != 1 { - t.Errorf("Expected 1 item, got %d", len(resp.Items)) - } -} - -func TestConvertSyncItemsToTasks(t *testing.T) { - projects := map[string]string{ - "proj-1": "Project 1", - } - - items := []SyncItemResponse{ - { - ID: "item-1", - Content: "Task 1", - Description: "Description 1", - ProjectID: "proj-1", - Priority: 3, - Labels: []string{"label1"}, - }, - { - ID: "item-2", - Content: "Completed Task", - ProjectID: "proj-1", - IsCompleted: true, - }, - } - - tasks := ConvertSyncItemsToTasks(items, projects) - - // Should skip completed task - if len(tasks) != 1 { - t.Errorf("Expected 1 task (excluding completed), got %d", len(tasks)) - } - - if tasks[0].ID != "item-1" { - t.Errorf("Expected task ID 'item-1', got '%s'", tasks[0].ID) - } - if tasks[0].ProjectName != "Project 1" { - t.Errorf("Expected project name 'Project 1', got '%s'", tasks[0].ProjectName) - } -} - -func TestConvertSyncItemToTask(t *testing.T) { - projects := map[string]string{"proj-1": "Project 1"} - - t.Run("active item returns task and true", func(t *testing.T) { - item := SyncItemResponse{ - ID: "item-1", - Content: "Active Task", - Description: "desc", - ProjectID: "proj-1", - Priority: 2, - Labels: []string{"work"}, - AddedAt: "2026-01-01T00:00:00Z", - } - task, ok := ConvertSyncItemToTask(item, projects) - if !ok { - t.Fatal("expected ok=true for active item") - } - if task.ID != "item-1" { - t.Errorf("expected ID 'item-1', got '%s'", task.ID) - } - if task.Content != "Active Task" { - t.Errorf("expected Content 'Active Task', got '%s'", task.Content) - } - if task.ProjectName != "Project 1" { - t.Errorf("expected ProjectName 'Project 1', got '%s'", task.ProjectName) - } - if task.Completed { - t.Error("expected Completed=false") - } - if task.URL != "https://todoist.com/app/task/item-1" { - t.Errorf("unexpected URL: %s", task.URL) - } - }) - - t.Run("completed item returns false", func(t *testing.T) { - item := SyncItemResponse{ID: "item-2", Content: "Done", ProjectID: "proj-1", IsCompleted: true} - _, ok := ConvertSyncItemToTask(item, projects) - if ok { - t.Error("expected ok=false for completed item") - } - }) - - t.Run("deleted item returns false", func(t *testing.T) { - item := SyncItemResponse{ID: "item-3", Content: "Gone", ProjectID: "proj-1", IsDeleted: true} - _, ok := ConvertSyncItemToTask(item, projects) - if ok { - t.Error("expected ok=false for deleted item") - } - }) -} - -func TestBuildProjectMapFromSync(t *testing.T) { - projects := []SyncProjectResponse{ - {ID: "proj-1", Name: "Project 1"}, - {ID: "proj-2", Name: "Project 2"}, - } - - projectMap := BuildProjectMapFromSync(projects) - - if len(projectMap) != 2 { - t.Errorf("Expected 2 projects in map, got %d", len(projectMap)) - } - - if projectMap["proj-1"] != "Project 1" { - t.Errorf("Expected 'Project 1', got '%s'", projectMap["proj-1"]) - } -} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index c2e903f..b0fd952 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -327,10 +327,9 @@ func filterAndSortTrelloTasks(boards []models.Board) []models.Card { return tasks } -// fetchTasks fetches tasks from cache or API using incremental sync +// fetchTasks fetches tasks from cache or API func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.Task, error) { cacheKey := store.CacheKeyTodoistTasks - syncService := "todoist" // Check cache validity if !forceRefresh { @@ -340,22 +339,9 @@ func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.T } } - // Get stored sync token (empty string means full sync) - syncToken, err := h.store.GetSyncToken(syncService) + tasks, err := h.todoistClient.GetTasks(ctx) if err != nil { - log.Printf("Failed to get sync token, will do full sync: %v", err) - syncToken = "" - } - - // Force full sync if requested - if forceRefresh { - syncToken = "" - } - - // Fetch using Sync API - syncResp, err := h.todoistClient.Sync(ctx, syncToken) - if err != nil { - // Try to return cached data even if stale + // Fall back to cached data if available cachedTasks, cacheErr := h.store.GetTasks() if cacheErr == nil && len(cachedTasks) > 0 { return cachedTasks, nil @@ -363,49 +349,15 @@ func (h *Handler) fetchTasks(ctx context.Context, forceRefresh bool) ([]models.T return nil, err } - // Build project map from sync response - projectMap := api.BuildProjectMapFromSync(syncResp.Projects) - - // Process sync response - if syncResp.FullSync { - // Full sync: replace all tasks - tasks := api.ConvertSyncItemsToTasks(syncResp.Items, projectMap) - if err := h.store.SaveTasks(tasks); err != nil { - log.Printf("Failed to save tasks to cache: %v", err) - } - } else { - // Incremental sync: merge changes - var deletedIDs []string - for _, item := range syncResp.Items { - if item.IsDeleted || item.IsCompleted { - deletedIDs = append(deletedIDs, item.ID) - } else { - // Upsert active task - task, _ := api.ConvertSyncItemToTask(item, projectMap) - if err := h.store.UpsertTask(task); err != nil { - log.Printf("Failed to upsert task %s: %v", item.ID, err) - } - } - } - // Delete removed tasks - if len(deletedIDs) > 0 { - if err := h.store.DeleteTasksByIDs(deletedIDs); err != nil { - log.Printf("Failed to delete tasks: %v", err) - } - } - } - - // Store the new sync token - if err := h.store.SetSyncToken(syncService, syncResp.SyncToken); err != nil { - log.Printf("Failed to save sync token: %v", err) + if err := h.store.SaveTasks(tasks); err != nil { + log.Printf("Failed to save tasks to cache: %v", err) } - // Update cache metadata if err := h.store.UpdateCacheMetadata(cacheKey, h.config.CacheTTLMinutes); err != nil { log.Printf("Failed to update cache metadata: %v", err) } - return h.store.GetTasks() + return tasks, nil } // fetchMeals fetches meals from cache or API @@ -1022,7 +974,7 @@ type CombinedMeal struct { // HandleTabMeals renders the Meals tab (PlanToEat) func (h *Handler) HandleTabMeals(w http.ResponseWriter, r *http.Request) { - startDate := config.Now() + startDate := config.Today() endDate := startDate.AddDate(0, 0, 7) meals, err := h.store.GetMeals(startDate, endDate) diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 793ccdd..0d097c8 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -15,7 +15,6 @@ import ( "github.com/go-chi/chi/v5" - "task-dashboard/internal/api" "task-dashboard/internal/config" "task-dashboard/internal/models" "task-dashboard/internal/store" @@ -118,31 +117,6 @@ func (m *mockTodoistClient) ReopenTask(ctx context.Context, taskID string) error return nil } -func (m *mockTodoistClient) Sync(ctx context.Context, syncToken string) (*api.TodoistSyncResponse, error) { - if m.err != nil { - return nil, m.err - } - // Return a mock sync response with tasks converted to sync items - items := make([]api.SyncItemResponse, 0, len(m.tasks)) - for _, task := range m.tasks { - items = append(items, api.SyncItemResponse{ - ID: task.ID, - Content: task.Content, - Description: task.Description, - ProjectID: task.ProjectID, - Priority: task.Priority, - Labels: task.Labels, - IsCompleted: task.Completed, - IsDeleted: false, - }) - } - return &api.TodoistSyncResponse{ - SyncToken: "test-sync-token", - FullSync: true, - Items: items, - Projects: []api.SyncProjectResponse{}, - }, nil -} // mockTrelloClient creates a mock Trello client for testing type mockTrelloClient struct { @@ -2236,210 +2210,104 @@ func TestHandleTimeline_InvalidParams(t *testing.T) { } } -// syncAwareMockTodoist records the sync token passed to Sync and returns a configurable response. -type syncAwareMockTodoist struct { - mockTodoistClient - syncResponse *api.TodoistSyncResponse - receivedTokens []string // tracks tokens passed to Sync -} - -func (m *syncAwareMockTodoist) Sync(ctx context.Context, syncToken string) (*api.TodoistSyncResponse, error) { - m.receivedTokens = append(m.receivedTokens, syncToken) - if m.err != nil { - return nil, m.err - } - return m.syncResponse, nil -} - -func TestFetchTasks_IncrementalSync_UpsertsActiveTasks(t *testing.T) { +func TestFetchTasks_FetchesAndSavesTasks(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() - // Seed DB with an existing task (simulating previous full sync) - existingTask := models.Task{ID: "existing-1", Content: "Old task", Priority: 1} - if err := h.store.SaveTasks([]models.Task{existingTask}); err != nil { - t.Fatalf("Failed to seed task: %v", err) - } - // Set a sync token so fetchTasks uses incremental sync - if err := h.store.SetSyncToken("todoist", "previous-token"); err != nil { - t.Fatalf("Failed to set sync token: %v", err) - } - - mock := &syncAwareMockTodoist{ - syncResponse: &api.TodoistSyncResponse{ - SyncToken: "new-token-1", - FullSync: false, - Items: []api.SyncItemResponse{ - {ID: "new-1", Content: "New task", Priority: 2}, - {ID: "existing-1", Content: "Updated task", Priority: 3}, - }, - Projects: []api.SyncProjectResponse{}, + h.todoistClient = &mockTodoistClient{ + tasks: []models.Task{ + {ID: "t1", Content: "Task one", Priority: 1}, + {ID: "t2", Content: "Task two", Priority: 2}, }, } - h.todoistClient = mock tasks, err := h.fetchTasks(context.Background(), false) if err != nil { t.Fatalf("fetchTasks returned error: %v", err) } - - // Should have 2 tasks: the upserted existing-1 (updated) and new-1 if len(tasks) != 2 { t.Fatalf("Expected 2 tasks, got %d", len(tasks)) } - // Verify the existing task was updated - taskMap := make(map[string]models.Task) - for _, task := range tasks { - taskMap[task.ID] = task - } - if taskMap["existing-1"].Content != "Updated task" { - t.Errorf("Expected existing-1 content 'Updated task', got %q", taskMap["existing-1"].Content) + // Verify tasks are persisted in the store + stored, err := h.store.GetTasks() + if err != nil { + t.Fatalf("Failed to get tasks from store: %v", err) } - if taskMap["new-1"].Content != "New task" { - t.Errorf("Expected new-1 content 'New task', got %q", taskMap["new-1"].Content) + if len(stored) != 2 { + t.Errorf("Expected 2 stored tasks, got %d", len(stored)) } } -func TestFetchTasks_IncrementalSync_DeletesCompletedAndDeletedTasks(t *testing.T) { +func TestFetchTasks_ReturnsCachedTasksWhenValid(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() - // Seed DB with tasks - seeds := []models.Task{ - {ID: "keep-1", Content: "Keep me"}, - {ID: "complete-1", Content: "Will be completed"}, - {ID: "delete-1", Content: "Will be deleted"}, - } - if err := h.store.SaveTasks(seeds); err != nil { + // Seed cache with tasks and mark it valid + cached := []models.Task{{ID: "cached-1", Content: "Cached task"}} + if err := h.store.SaveTasks(cached); err != nil { t.Fatalf("Failed to seed tasks: %v", err) } - if err := h.store.SetSyncToken("todoist", "prev-token"); err != nil { - t.Fatalf("Failed to set sync token: %v", err) + if err := h.store.UpdateCacheMetadata(store.CacheKeyTodoistTasks, 60); err != nil { + t.Fatalf("Failed to set cache metadata: %v", err) } - mock := &syncAwareMockTodoist{ - syncResponse: &api.TodoistSyncResponse{ - SyncToken: "new-token-2", - FullSync: false, - Items: []api.SyncItemResponse{ - {ID: "complete-1", Content: "Will be completed", IsCompleted: true}, - {ID: "delete-1", Content: "Will be deleted", IsDeleted: true}, - }, - Projects: []api.SyncProjectResponse{}, - }, + // API would return different tasks — should not be called + h.todoistClient = &mockTodoistClient{ + tasks: []models.Task{{ID: "api-1", Content: "API task"}}, } - h.todoistClient = mock tasks, err := h.fetchTasks(context.Background(), false) if err != nil { t.Fatalf("fetchTasks returned error: %v", err) } - - // Only keep-1 should remain - if len(tasks) != 1 { - t.Fatalf("Expected 1 task, got %d: %+v", len(tasks), tasks) - } - if tasks[0].ID != "keep-1" { - t.Errorf("Expected remaining task ID 'keep-1', got %q", tasks[0].ID) + if len(tasks) != 1 || tasks[0].ID != "cached-1" { + t.Errorf("Expected cached task, got %+v", tasks) } } -func TestFetchTasks_IncrementalSync_StoresNewSyncToken(t *testing.T) { +func TestFetchTasks_ForceRefresh_BypassesCache(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() - if err := h.store.SetSyncToken("todoist", "old-token"); err != nil { - t.Fatalf("Failed to set sync token: %v", err) - } - - mock := &syncAwareMockTodoist{ - syncResponse: &api.TodoistSyncResponse{ - SyncToken: "brand-new-token", - FullSync: false, - Items: []api.SyncItemResponse{}, - Projects: []api.SyncProjectResponse{}, - }, - } - h.todoistClient = mock - - _, err := h.fetchTasks(context.Background(), false) - if err != nil { - t.Fatalf("fetchTasks returned error: %v", err) - } - - // Verify the new sync token was stored - token, err := h.store.GetSyncToken("todoist") - if err != nil { - t.Fatalf("Failed to get sync token: %v", err) + // Seed cache and mark it valid + if err := h.store.SaveTasks([]models.Task{{ID: "old-1", Content: "Old"}}); err != nil { + t.Fatalf("Failed to seed tasks: %v", err) } - if token != "brand-new-token" { - t.Errorf("Expected sync token 'brand-new-token', got %q", token) + if err := h.store.UpdateCacheMetadata(store.CacheKeyTodoistTasks, 60); err != nil { + t.Fatalf("Failed to set cache metadata: %v", err) } -} - -func TestFetchTasks_IncrementalSync_UsesSavedSyncToken(t *testing.T) { - h, cleanup := setupTestHandler(t) - defer cleanup() - // Set a known sync token - if err := h.store.SetSyncToken("todoist", "my-saved-token"); err != nil { - t.Fatalf("Failed to set sync token: %v", err) + h.todoistClient = &mockTodoistClient{ + tasks: []models.Task{{ID: "fresh-1", Content: "Fresh"}}, } - mock := &syncAwareMockTodoist{ - syncResponse: &api.TodoistSyncResponse{ - SyncToken: "next-token", - FullSync: false, - Items: []api.SyncItemResponse{}, - Projects: []api.SyncProjectResponse{}, - }, - } - h.todoistClient = mock - - _, err := h.fetchTasks(context.Background(), false) + tasks, err := h.fetchTasks(context.Background(), true) if err != nil { t.Fatalf("fetchTasks returned error: %v", err) } - - // Verify the saved token was passed to Sync - if len(mock.receivedTokens) != 1 { - t.Fatalf("Expected 1 Sync call, got %d", len(mock.receivedTokens)) - } - if mock.receivedTokens[0] != "my-saved-token" { - t.Errorf("Expected Sync to receive token 'my-saved-token', got %q", mock.receivedTokens[0]) + if len(tasks) != 1 || tasks[0].ID != "fresh-1" { + t.Errorf("Expected fresh task from API, got %+v", tasks) } } -func TestFetchTasks_ForceRefresh_ClearsSyncToken(t *testing.T) { +func TestFetchTasks_FallsBackToCacheOnAPIError(t *testing.T) { h, cleanup := setupTestHandler(t) defer cleanup() - if err := h.store.SetSyncToken("todoist", "existing-token"); err != nil { - t.Fatalf("Failed to set sync token: %v", err) + // Seed stale cache + if err := h.store.SaveTasks([]models.Task{{ID: "stale-1", Content: "Stale"}}); err != nil { + t.Fatalf("Failed to seed tasks: %v", err) } - mock := &syncAwareMockTodoist{ - syncResponse: &api.TodoistSyncResponse{ - SyncToken: "fresh-token", - FullSync: true, - Items: []api.SyncItemResponse{}, - Projects: []api.SyncProjectResponse{}, - }, - } - h.todoistClient = mock + h.todoistClient = &mockTodoistClient{err: fmt.Errorf("API error")} - _, err := h.fetchTasks(context.Background(), true) + tasks, err := h.fetchTasks(context.Background(), false) if err != nil { - t.Fatalf("fetchTasks returned error: %v", err) - } - - // forceRefresh should send empty token (full sync) - if len(mock.receivedTokens) != 1 { - t.Fatalf("Expected 1 Sync call, got %d", len(mock.receivedTokens)) + t.Fatalf("Expected fallback to cache, got error: %v", err) } - if mock.receivedTokens[0] != "" { - t.Errorf("Expected empty sync token for forceRefresh, got %q", mock.receivedTokens[0]) + if len(tasks) != 1 || tasks[0].ID != "stale-1" { + t.Errorf("Expected stale cached task as fallback, got %+v", tasks) } } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 33edbf2..166cd63 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -1229,7 +1229,7 @@ func (s *Store) GetCompletedTasks(limit int) ([]models.CompletedTask, error) { for rows.Next() { var task models.CompletedTask var dueDate sql.NullString - var completedAt string + var completedAt time.Time if err := rows.Scan(&task.ID, &task.Source, &task.SourceID, &task.Title, &dueDate, &completedAt); err != nil { return nil, err @@ -1240,9 +1240,7 @@ func (s *Store) GetCompletedTasks(limit int) ([]models.CompletedTask, error) { task.DueDate = &t } } - if t, err := time.Parse("2006-01-02 15:04:05", completedAt); err == nil { - task.CompletedAt = t - } + task.CompletedAt = completedAt tasks = append(tasks, task) } |
