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 client.syncClient.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 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 := []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 := []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)}, } 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) } } 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 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"]) } }