From 945c34590677e161a711ccaff0e1077197b1b178 Mon Sep 17 00:00:00 2001 From: Doot Agent Date: Mon, 6 Jul 2026 00:00:59 +0000 Subject: feat: remove Todoist integration entirely Native tasks (native_tasks table) fully replace Todoist. All Todoist API code, store functions, handlers, routes, templates, and tests have been removed. Migration 021 drops the now-unused tasks cache table. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/interfaces.go | 11 -- internal/api/research_test.go | 241 ------------------------- internal/api/todoist.go | 256 --------------------------- internal/api/todoist_test.go | 399 ------------------------------------------ 4 files changed, 907 deletions(-) delete mode 100644 internal/api/research_test.go delete mode 100644 internal/api/todoist.go delete mode 100644 internal/api/todoist_test.go (limited to 'internal/api') diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go index 0bd67b6..c5e154d 100644 --- a/internal/api/interfaces.go +++ b/internal/api/interfaces.go @@ -7,16 +7,6 @@ import ( "task-dashboard/internal/models" ) -// TodoistAPI defines the interface for Todoist operations -type TodoistAPI interface { - GetTasks(ctx context.Context) ([]models.Task, error) - GetProjects(ctx context.Context) ([]models.Project, error) - CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) - 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 -} - // TrelloAPI defines the interface for Trello operations type TrelloAPI interface { GetBoards(ctx context.Context) ([]models.Board, error) @@ -59,7 +49,6 @@ type ClaudomatorClient interface { // Ensure concrete types implement interfaces var ( - _ TodoistAPI = (*TodoistClient)(nil) _ TrelloAPI = (*TrelloClient)(nil) _ PlanToEatAPI = (*PlanToEatClient)(nil) _ GoogleCalendarAPI = (*GoogleCalendarClient)(nil) diff --git a/internal/api/research_test.go b/internal/api/research_test.go deleted file mode 100644 index f2519e2..0000000 --- a/internal/api/research_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "testing" - "time" -) - -// Research tests for API optimization -// Run with: go test -v -run TestResearch ./internal/api/ -// Requires TODOIST_API_KEY and TRELLO_API_KEY/TRELLO_TOKEN environment variables - -func TestTodoistSyncResearch(t *testing.T) { - apiKey := os.Getenv("TODOIST_API_KEY") - if apiKey == "" { - t.Skip("TODOIST_API_KEY not set, skipping research test") - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Todoist Sync API endpoint - syncURL := "https://api.todoist.com/sync/v9/sync" - - // First sync with "*" to get all data - params := url.Values{} - params.Set("sync_token", "*") - params.Set("resource_types", `["items"]`) // "items" = tasks in Sync API - - req, err := http.NewRequestWithContext(ctx, "POST", syncURL, nil) - if err != nil { - t.Fatalf("Failed to create request: %v", err) - } - req.URL.RawQuery = params.Encode() - req.Header.Set("Authorization", "Bearer "+apiKey) - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - t.Fatalf("Failed to call Sync API: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - t.Fatalf("Failed to read response: %v", err) - } - - if resp.StatusCode != http.StatusOK { - t.Fatalf("Sync API error (status %d): %s", resp.StatusCode, string(body)) - } - - // Parse response - var syncResp map[string]interface{} - if err := json.Unmarshal(body, &syncResp); err != nil { - t.Fatalf("Failed to parse response: %v", err) - } - - // Log findings - t.Logf("=== TODOIST SYNC API RESEARCH ===") - t.Logf("Response size: %d bytes", len(body)) - - if syncToken, ok := syncResp["sync_token"].(string); ok { - t.Logf("sync_token: %s... (length: %d)", syncToken[:min(20, len(syncToken))], len(syncToken)) - } - - if items, ok := syncResp["items"].([]interface{}); ok { - t.Logf("Number of items (tasks): %d", len(items)) - if len(items) > 0 { - // Show structure of first item - firstItem, _ := json.MarshalIndent(items[0], "", " ") - t.Logf("First item structure:\n%s", string(firstItem)) - } - } - - // List top-level keys - t.Logf("Top-level response keys:") - for key := range syncResp { - t.Logf(" - %s", key) - } - - // Save sync_token for incremental sync test - syncToken := syncResp["sync_token"].(string) - - // Test incremental sync (should return minimal data if nothing changed) - t.Logf("\n=== INCREMENTAL SYNC TEST ===") - params2 := url.Values{} - params2.Set("sync_token", syncToken) - params2.Set("resource_types", `["items"]`) - - req2, _ := http.NewRequestWithContext(ctx, "POST", syncURL, nil) - req2.URL.RawQuery = params2.Encode() - req2.Header.Set("Authorization", "Bearer "+apiKey) - - resp2, err := client.Do(req2) - if err != nil { - t.Fatalf("Incremental sync failed: %v", err) - } - defer func() { _ = resp2.Body.Close() }() - - body2, _ := io.ReadAll(resp2.Body) - t.Logf("Incremental response size: %d bytes (vs full: %d bytes)", len(body2), len(body)) - - var syncResp2 map[string]interface{} - _ = json.Unmarshal(body2, &syncResp2) - if items2, ok := syncResp2["items"].([]interface{}); ok { - t.Logf("Incremental items count: %d", len(items2)) - } -} - -func TestTrelloOptimizationResearch(t *testing.T) { - apiKey := os.Getenv("TRELLO_API_KEY") - token := os.Getenv("TRELLO_TOKEN") - if apiKey == "" || token == "" { - t.Skip("TRELLO_API_KEY or TRELLO_TOKEN not set, skipping research test") - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - client := &http.Client{Timeout: 30 * time.Second} - baseURL := "https://api.trello.com/1" - - // Test 1: Full response (current implementation) - t.Logf("=== TRELLO OPTIMIZATION RESEARCH ===") - - params := url.Values{} - params.Set("key", apiKey) - params.Set("token", token) - params.Set("filter", "open") - - start := time.Now() - req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/members/me/boards?%s", baseURL, params.Encode()), nil) - resp, err := client.Do(req) - if err != nil { - t.Fatalf("Failed to fetch boards: %v", err) - } - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - fullDuration := time.Since(start) - - t.Logf("Test 1 - Full boards response:") - t.Logf(" Size: %d bytes", len(body)) - t.Logf(" Duration: %v", fullDuration) - - var boards []map[string]interface{} - _ = json.Unmarshal(body, &boards) - t.Logf(" Board count: %d", len(boards)) - if len(boards) > 0 { - t.Logf(" Fields in first board: %v", getKeys(boards[0])) - } - - // Test 2: Limited fields - params2 := url.Values{} - params2.Set("key", apiKey) - params2.Set("token", token) - params2.Set("filter", "open") - params2.Set("fields", "id,name") // Only essential fields - - start = time.Now() - req2, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/members/me/boards?%s", baseURL, params2.Encode()), nil) - resp2, err := client.Do(req2) - if err != nil { - t.Fatalf("Failed to fetch limited boards: %v", err) - } - body2, _ := io.ReadAll(resp2.Body) - _ = resp2.Body.Close() - limitedDuration := time.Since(start) - - t.Logf("\nTest 2 - Limited fields (id,name):") - t.Logf(" Size: %d bytes (%.1f%% of full)", len(body2), float64(len(body2))/float64(len(body))*100) - t.Logf(" Duration: %v", limitedDuration) - - // Test 3: Batch request with cards included - if len(boards) > 0 { - boardID := boards[0]["id"].(string) - - params3 := url.Values{} - params3.Set("key", apiKey) - params3.Set("token", token) - params3.Set("cards", "visible") - params3.Set("card_fields", "id,name,idList,due,url") - params3.Set("lists", "open") - params3.Set("list_fields", "id,name") - - start = time.Now() - req3, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/boards/%s?%s", baseURL, boardID, params3.Encode()), nil) - resp3, err := client.Do(req3) - if err != nil { - t.Fatalf("Failed to fetch board with cards: %v", err) - } - body3, _ := io.ReadAll(resp3.Body) - _ = resp3.Body.Close() - batchDuration := time.Since(start) - - t.Logf("\nTest 3 - Single board with cards/lists embedded:") - t.Logf(" Size: %d bytes", len(body3)) - t.Logf(" Duration: %v", batchDuration) - - var boardWithCards map[string]interface{} - _ = json.Unmarshal(body3, &boardWithCards) - if cards, ok := boardWithCards["cards"].([]interface{}); ok { - t.Logf(" Cards count: %d", len(cards)) - } - if lists, ok := boardWithCards["lists"].([]interface{}); ok { - t.Logf(" Lists count: %d", len(lists)) - } - } - - // Test 4: Check for webhooks/since parameter - t.Logf("\nTest 4 - Checking for 'since' parameter support:") - params4 := url.Values{} - params4.Set("key", apiKey) - params4.Set("token", token) - params4.Set("filter", "open") - params4.Set("since", time.Now().Add(-24*time.Hour).Format(time.RFC3339)) - - req4, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/members/me/boards?%s", baseURL, params4.Encode()), nil) - resp4, err := client.Do(req4) - if err != nil { - t.Logf(" 'since' parameter: Error - %v", err) - } else { - body4, _ := io.ReadAll(resp4.Body) - _ = resp4.Body.Close() - t.Logf(" 'since' parameter: Status %d, Size %d bytes", resp4.StatusCode, len(body4)) - } -} - -func getKeys(m map[string]interface{}) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -} diff --git a/internal/api/todoist.go b/internal/api/todoist.go deleted file mode 100644 index 2454233..0000000 --- a/internal/api/todoist.go +++ /dev/null @@ -1,256 +0,0 @@ -package api - -import ( - "context" - "fmt" - "strings" - "time" - - "task-dashboard/internal/config" - "task-dashboard/internal/models" -) - -const ( - todoistBaseURL = "https://api.todoist.com/api/v1" -) - -// TodoistClient handles interactions with the Todoist API -type TodoistClient struct { - BaseClient - apiKey string -} - -// NewTodoistClient creates a new Todoist API client -func NewTodoistClient(apiKey string) *TodoistClient { - return &TodoistClient{ - BaseClient: NewBaseClient(todoistBaseURL), - apiKey: apiKey, - } -} - -func (c *TodoistClient) authHeaders() map[string]string { - return map[string]string{"Authorization": "Bearer " + c.apiKey} -} - -// todoistTaskResponse represents the API response structure -type todoistTaskResponse 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"` - URL string `json:"url"` - CreatedAt string `json:"created_at"` -} - -// todoistProjectResponse represents the project API response -type todoistProjectResponse struct { - ID string `json:"id"` - Name string `json:"name"` -} - - -// 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 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) - projectMap := make(map[string]string) - if err == nil { - for _, proj := range projects { - projectMap[proj.ID] = proj.Name - } - } - - // Convert to our model - tasks := make([]models.Task, 0, len(apiTasks)) - for _, apiTask := range apiTasks { - task := models.Task{ - ID: apiTask.ID, - Content: apiTask.Content, - Description: apiTask.Description, - ProjectID: apiTask.ProjectID, - ProjectName: projectMap[apiTask.ProjectID], - Priority: apiTask.Priority, - Completed: false, - Labels: apiTask.Labels, - URL: apiTask.URL, - } - - if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { - task.CreatedAt = createdAt - } - - task.DueDate = parseDueDate(apiTask.Due) - if apiTask.Due != nil { - task.IsRecurring = apiTask.Due.IsRecurring - } - tasks = append(tasks, task) - } - - 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 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 { - projects = append(projects, models.Project{ - ID: apiProj.ID, - Name: apiProj.Name, - }) - } - - return projects, nil -} - -// 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} - if projectID != "" { - payload["project_id"] = projectID - } - if dueDate != nil { - payload["due_date"] = dueDate.Format("2006-01-02") - } - if priority > 0 { - payload["priority"] = priority - } - - var apiTask todoistTaskResponse - if err := c.Post(ctx, "/tasks", c.authHeaders(), payload, &apiTask); err != nil { - return nil, fmt.Errorf("failed to create task: %w", err) - } - - task := &models.Task{ - ID: apiTask.ID, - Content: apiTask.Content, - Description: apiTask.Description, - ProjectID: apiTask.ProjectID, - Priority: apiTask.Priority, - Completed: false, - Labels: apiTask.Labels, - URL: apiTask.URL, - } - - if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { - task.CreatedAt = createdAt - } - - task.DueDate = parseDueDate(apiTask.Due) - return task, nil -} - -// UpdateTask updates a task with the specified changes -func (c *TodoistClient) UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error { - path := fmt.Sprintf("/tasks/%s", taskID) - if err := c.Post(ctx, path, c.authHeaders(), updates, nil); err != nil { - return fmt.Errorf("failed to update task: %w", err) - } - return nil -} - -// CompleteTask marks a task as complete in Todoist -func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error { - path := fmt.Sprintf("/tasks/%s/close", taskID) - if err := c.PostEmpty(ctx, path, c.authHeaders()); err != nil { - return fmt.Errorf("failed to complete task: %w", err) - } - return nil -} - -// ReopenTask marks a completed task as active in Todoist -func (c *TodoistClient) ReopenTask(ctx context.Context, taskID string) error { - path := fmt.Sprintf("/tasks/%s/reopen", taskID) - if err := c.PostEmpty(ctx, path, c.authHeaders()); err != nil { - return fmt.Errorf("failed to reopen task: %w", err) - } - return nil -} - -// parseDueDate parses due date from API response -// dueInfo represents the due date structure from Todoist API -type dueInfo struct { - Date string `json:"date"` - Datetime string `json:"datetime"` - IsRecurring bool `json:"is_recurring"` -} - -func parseDueDate(due *dueInfo) *time.Time { - if due == nil { - return nil - } - var dueDate time.Time - var err error - if due.Datetime != "" { - // RFC3339 includes timezone, then convert to display timezone - dueDate, err = time.Parse(time.RFC3339, due.Datetime) - if err == nil { - dueDate = config.ToDisplayTZ(dueDate) - } - } else if due.Date != "" { - // Todoist may put a local datetime (no tz offset) in the date field - // e.g. "2026-03-22T19:00:00" for recurring tasks with a set time. - // Fall back to date-only "2006-01-02" if no T is present. - if strings.Contains(due.Date, "T") { - dueDate, err = config.ParseDateTimeInDisplayTZ("2006-01-02T15:04:05", due.Date) - } else { - dueDate, err = config.ParseDateInDisplayTZ(due.Date) - } - } - if err != nil { - return nil - } - return &dueDate -} diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go deleted file mode 100644 index d2c8da5..0000000 --- a/internal/api/todoist_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -// newTestTodoistClient creates a TodoistClient for testing with custom base URL -func newTestTodoistClient(baseURL, apiKey string) *TodoistClient { - client := NewTodoistClient(apiKey) - client.BaseURL = baseURL - return client -} - -func TestTodoistClient_CreateTask(t *testing.T) { - // Mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verify method and path - if r.Method != "POST" { - t.Errorf("Expected POST request, got %s", r.Method) - } - if r.URL.Path != "/tasks" { - t.Errorf("Expected path /tasks, got %s", r.URL.Path) - } - - // Verify Authorization header - if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { - t.Errorf("Expected Bearer token in Authorization header") - } - - // Parse JSON body - var payload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Errorf("Failed to decode request body: %v", err) - } - - // Verify content - if payload["content"] != "Test Task" { - t.Errorf("Expected content=Test Task, got %v", payload["content"]) - } - - if payload["project_id"] != "project-123" { - t.Errorf("Expected project_id=project-123, got %v", payload["project_id"]) - } - - // Return mock response - response := todoistTaskResponse{ - ID: "task-456", - Content: "Test Task", - Description: "", - ProjectID: "project-123", - Priority: 1, - Labels: []string{}, - URL: "https://todoist.com/task/456", - CreatedAt: time.Now().Format(time.RFC3339), - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - // Create client with mock server URL - client := newTestTodoistClient(server.URL, "test-key") - - // Test CreateTask - ctx := context.Background() - task, err := client.CreateTask(ctx, "Test Task", "project-123", nil, 0) - - if err != nil { - t.Fatalf("CreateTask failed: %v", err) - } - - // Verify response - if task.ID != "task-456" { - t.Errorf("Expected task ID task-456, got %s", task.ID) - } - if task.Content != "Test Task" { - t.Errorf("Expected task content Test Task, got %s", task.Content) - } - if task.ProjectID != "project-123" { - t.Errorf("Expected project ID project-123, got %s", task.ProjectID) - } -} - -func TestTodoistClient_CreateTask_WithDueDate(t *testing.T) { - dueDate := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) - - // Mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Parse JSON body - var payload map[string]interface{} - _ = json.NewDecoder(r.Body).Decode(&payload) - - // Verify due_date - if payload["due_date"] != "2026-01-15" { - t.Errorf("Expected due_date=2026-01-15, got %v", payload["due_date"]) - } - - // Return mock response with due date - response := todoistTaskResponse{ - ID: "task-789", - Content: "Task with Due Date", - ProjectID: "project-456", - URL: "https://todoist.com/task/789", - CreatedAt: time.Now().Format(time.RFC3339), - Due: &dueInfo{ - Date: "2026-01-15", - Datetime: "", - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - // Create client - client := newTestTodoistClient(server.URL, "test-key") - - // Test CreateTask with due date - ctx := context.Background() - task, err := client.CreateTask(ctx, "Task with Due Date", "project-456", &dueDate, 0) - - if err != nil { - t.Fatalf("CreateTask failed: %v", err) - } - - // Verify due date is set - if task.DueDate == nil { - t.Error("Expected due date to be set") - } else { - expectedDate := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) - if !task.DueDate.Equal(expectedDate) { - t.Errorf("Expected due date %v, got %v", expectedDate, *task.DueDate) - } - } -} - -func TestParseDueDate_LocalDatetimeInDateField(t *testing.T) { - // Todoist REST API v1 puts local datetime (no tz offset) in the "date" field - // when datetime is not in UTC (e.g. recurring tasks with a set time) - // e.g. due={date="2026-03-22T19:00:00" datetime="" is_recurring=true} - due := &dueInfo{ - Date: "2026-03-22T19:00:00", - Datetime: "", - IsRecurring: true, - } - result := parseDueDate(due) - if result == nil { - t.Fatal("parseDueDate returned nil for date field containing local datetime — must parse YYYY-MM-DDTHH:MM:SS format") - } - if result.Hour() != 19 || result.Minute() != 0 { - t.Errorf("Expected 19:00, got %02d:%02d", result.Hour(), result.Minute()) - } -} - -func TestParseDueDate_MicrosecondDatetime(t *testing.T) { - // Todoist REST API v1 returns datetime with microseconds: "2023-01-15T10:00:00.000000Z" - // time.RFC3339 cannot parse fractional seconds — parseDueDate must use RFC3339Nano - due := &dueInfo{ - Date: "2023-01-15", - Datetime: "2023-01-15T10:00:00.000000Z", - IsRecurring: true, - } - result := parseDueDate(due) - if result == nil { - t.Fatal("parseDueDate returned nil for datetime with microseconds — RFC3339Nano required") - } - if result.Hour() != 10 || result.Minute() != 0 { - t.Errorf("Expected 10:00, got %02d:%02d", result.Hour(), result.Minute()) - } -} - -func TestParseDueDate_RFC3339Datetime(t *testing.T) { - // Standard RFC3339 without fractional seconds should also work - due := &dueInfo{ - Date: "2023-01-15", - Datetime: "2023-01-15T10:00:00Z", - } - result := parseDueDate(due) - if result == nil { - t.Fatal("parseDueDate returned nil for standard RFC3339 datetime") - } -} - -func TestTodoistClient_UsesAPIv1BaseURL(t *testing.T) { - client := NewTodoistClient("test-key") - const want = "https://api.todoist.com/api/v1" - if client.BaseURL != want { - t.Errorf("expected base URL %q, got %q — Todoist REST v2 is deprecated (HTTP 410)", want, client.BaseURL) - } -} - -func TestTodoistClient_CompleteTask(t *testing.T) { - // Mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verify method and path - if r.Method != "POST" { - t.Errorf("Expected POST request, got %s", r.Method) - } - expectedPath := "/tasks/task-123/close" - if r.URL.Path != expectedPath { - t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) - } - - // Verify Authorization header - if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { - t.Errorf("Expected Bearer token in Authorization header") - } - - // Return 204 No Content - w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() - - // Create client - client := newTestTodoistClient(server.URL, "test-key") - - // Test CompleteTask - ctx := context.Background() - err := client.CompleteTask(ctx, "task-123") - - if err != nil { - t.Fatalf("CompleteTask failed: %v", err) - } -} - -func TestTodoistClient_CompleteTask_Error(t *testing.T) { - // Mock server that returns error - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"error":"Task not found"}`)) - })) - defer server.Close() - - // Create client - client := newTestTodoistClient(server.URL, "test-key") - - // Test CompleteTask with error - ctx := context.Background() - err := client.CompleteTask(ctx, "invalid-task") - - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "404") { - t.Errorf("Expected error to contain status 404, got: %v", err) - } -} - -func TestTodoistClient_GetProjects(t *testing.T) { - // Mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verify method and path - if r.Method != "GET" { - t.Errorf("Expected GET request, got %s", r.Method) - } - if r.URL.Path != "/projects" { - t.Errorf("Expected path /projects, got %s", r.URL.Path) - } - - // Return mock response - response := todoistProjectsPage{ - Results: []todoistProjectResponse{ - {ID: "proj-1", Name: "Project 1"}, - {ID: "proj-2", Name: "Project 2"}, - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - // Create client - client := newTestTodoistClient(server.URL, "test-key") - - // Test GetProjects - ctx := context.Background() - projects, err := client.GetProjects(ctx) - - if err != nil { - t.Fatalf("GetProjects failed: %v", err) - } - - // Verify response - if len(projects) != 2 { - t.Errorf("Expected 2 projects, got %d", len(projects)) - } - - if projects[0].ID != "proj-1" || projects[0].Name != "Project 1" { - t.Errorf("Project 1 mismatch: got ID=%s Name=%s", projects[0].ID, projects[0].Name) - } - - if projects[1].ID != "proj-2" || projects[1].Name != "Project 2" { - t.Errorf("Project 2 mismatch: got ID=%s Name=%s", projects[1].ID, projects[1].Name) - } -} - -func TestTodoistClient_GetTasks(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Errorf("Expected GET, got %s", r.Method) - } - - w.Header().Set("Content-Type", "application/json") - - // GetTasks also calls GetProjects internally - if r.URL.Path == "/projects" { - response := todoistProjectsPage{ - Results: []todoistProjectResponse{ - {ID: "proj-1", Name: "Project 1"}, - }, - } - json.NewEncoder(w).Encode(response) - return - } - - if r.URL.Path == "/tasks" { - 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 - } - - t.Errorf("Unexpected path: %s", r.URL.Path) - })) - defer server.Close() - - client := newTestTodoistClient(server.URL, "test-key") - tasks, err := client.GetTasks(context.Background()) - if err != nil { - t.Fatalf("GetTasks failed: %v", err) - } - - if len(tasks) != 2 { - t.Errorf("Expected 2 tasks, got %d", len(tasks)) - } -} - -func TestTodoistClient_ReopenTask(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) - } - expectedPath := "/tasks/task-123/reopen" - if r.URL.Path != expectedPath { - t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) - } - - w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() - - client := newTestTodoistClient(server.URL, "test-key") - err := client.ReopenTask(context.Background(), "task-123") - if err != nil { - t.Fatalf("ReopenTask failed: %v", err) - } -} - -func TestTodoistClient_UpdateTask(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) - } - expectedPath := "/tasks/task-123" - if r.URL.Path != expectedPath { - t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) - } - - var payload map[string]interface{} - json.NewDecoder(r.Body).Decode(&payload) - if payload["content"] != "Updated Content" { - t.Errorf("Expected content 'Updated Content', got %v", payload["content"]) - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"id": "task-123"}) - })) - defer server.Close() - - client := newTestTodoistClient(server.URL, "test-key") - err := client.UpdateTask(context.Background(), "task-123", map[string]interface{}{"content": "Updated Content"}) - if err != nil { - t.Fatalf("UpdateTask failed: %v", err) - } -} - -- cgit v1.2.3