From e107192be5efb65807c7da3b6aa99ce3555944d0 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 13 Jan 2026 14:18:24 -1000 Subject: Implement Todoist write operations - API layer (Part 1) Add CreateTask and CompleteTask methods to Todoist API client: Models: - Add Project struct (ID, Name) to types.go - Add Projects []Project field to DashboardData API Interface: - Change GetProjects signature to return []models.Project - Ensure CreateTask and CompleteTask are defined Todoist Client: - Add baseURL field for testability - Refactor GetProjects to return []models.Project - Update GetTasks to build project map from new GetProjects - Implement CreateTask with JSON payload support - Implement CompleteTask using POST to /tasks/{id}/close Tests: - Create comprehensive todoist_test.go - Test CreateTask, CreateTask with due date, CompleteTask - Test error handling and GetProjects - Update mock client in handlers tests All tests pass. Ready for handlers and UI integration. Co-Authored-By: Claude Sonnet 4.5 --- internal/api/todoist_test.go | 264 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 internal/api/todoist_test.go (limited to 'internal/api/todoist_test.go') diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go new file mode 100644 index 0000000..56b1484 --- /dev/null +++ b/internal/api/todoist_test.go @@ -0,0 +1,264 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +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 := &TodoistClient{ + apiKey: "test-key", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // 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 + dueStruct := struct { + Date string `json:"date"` + Datetime string `json:"datetime"` + }{ + Date: "2026-01-15", + Datetime: "", + } + 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: &dueStruct, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client + client := &TodoistClient{ + apiKey: "test-key", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // 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 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 := &TodoistClient{ + apiKey: "test-key", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // 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 := &TodoistClient{ + apiKey: "test-key", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // 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 := []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 := &TodoistClient{ + apiKey: "test-key", + baseURL: server.URL, + httpClient: &http.Client{}, + } + + // 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) + } +} -- cgit v1.2.3