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) } }