diff options
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/interfaces.go | 2 | ||||
| -rw-r--r-- | internal/api/todoist.go | 139 | ||||
| -rw-r--r-- | internal/api/todoist_test.go | 264 |
3 files changed, 387 insertions, 18 deletions
diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go index 31da0a8..db7e6c0 100644 --- a/internal/api/interfaces.go +++ b/internal/api/interfaces.go @@ -10,7 +10,7 @@ import ( // TodoistAPI defines the interface for Todoist operations type TodoistAPI interface { GetTasks(ctx context.Context) ([]models.Task, error) - GetProjects(ctx context.Context) (map[string]string, error) + GetProjects(ctx context.Context) ([]models.Project, error) CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) CompleteTask(ctx context.Context, taskID string) error } diff --git a/internal/api/todoist.go b/internal/api/todoist.go index be59e73..511753d 100644 --- a/internal/api/todoist.go +++ b/internal/api/todoist.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "encoding/json" "fmt" @@ -18,13 +19,15 @@ const ( // TodoistClient handles interactions with the Todoist API type TodoistClient struct { apiKey string + baseURL string httpClient *http.Client } // NewTodoistClient creates a new Todoist API client func NewTodoistClient(apiKey string) *TodoistClient { return &TodoistClient{ - apiKey: apiKey, + apiKey: apiKey, + baseURL: todoistBaseURL, httpClient: &http.Client{ Timeout: 30 * time.Second, }, @@ -55,7 +58,7 @@ type todoistProjectResponse struct { // GetTasks fetches all active tasks from Todoist func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { - req, err := http.NewRequestWithContext(ctx, "GET", todoistBaseURL+"/tasks", nil) + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/tasks", nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -80,9 +83,12 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { // Fetch projects to get project names projects, err := c.GetProjects(ctx) - if err != nil { - // If we can't get projects, continue with empty project names - projects = make(map[string]string) + projectMap := make(map[string]string) + if err == nil { + // Build map of project ID to name + for _, proj := range projects { + projectMap[proj.ID] = proj.Name + } } // Convert to our model @@ -93,7 +99,7 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { Content: apiTask.Content, Description: apiTask.Description, ProjectID: apiTask.ProjectID, - ProjectName: projects[apiTask.ProjectID], + ProjectName: projectMap[apiTask.ProjectID], Priority: apiTask.Priority, Completed: false, Labels: apiTask.Labels, @@ -124,9 +130,9 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { return tasks, nil } -// GetProjects fetches all projects and returns a map of project ID to name -func (c *TodoistClient) GetProjects(ctx context.Context) (map[string]string, error) { - req, err := http.NewRequestWithContext(ctx, "GET", todoistBaseURL+"/projects", nil) +// GetProjects fetches all projects +func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/projects", nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -149,10 +155,13 @@ func (c *TodoistClient) GetProjects(ctx context.Context) (map[string]string, err return nil, fmt.Errorf("failed to decode response: %w", err) } - // Convert to map - projects := make(map[string]string, len(apiProjects)) - for _, project := range apiProjects { - projects[project.ID] = project.Name + // Convert to model + 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 @@ -160,12 +169,108 @@ func (c *TodoistClient) GetProjects(ctx context.Context) (map[string]string, err // 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) { - // This will be implemented in Phase 2 - return nil, fmt.Errorf("not implemented yet") + // Prepare request payload + 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 + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + // Create POST request + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/tasks", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/json") + + // Execute request + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to create task: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) + } + + // Decode response + var apiTask todoistTaskResponse + if err := json.NewDecoder(resp.Body).Decode(&apiTask); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + // Convert to our model + 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, + } + + // Parse created_at + if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { + task.CreatedAt = createdAt + } + + // Parse due date + if apiTask.Due != nil { + var taskDueDate time.Time + if apiTask.Due.Datetime != "" { + taskDueDate, err = time.Parse(time.RFC3339, apiTask.Due.Datetime) + } else if apiTask.Due.Date != "" { + taskDueDate, err = time.Parse("2006-01-02", apiTask.Due.Date) + } + if err == nil { + task.DueDate = &taskDueDate + } + } + + return task, nil } // CompleteTask marks a task as complete in Todoist func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error { - // This will be implemented in Phase 2 - return fmt.Errorf("not implemented yet") + // Create POST request to close endpoint + url := fmt.Sprintf("%s/tasks/%s/close", c.baseURL, taskID) + req, err := http.NewRequestWithContext(ctx, "POST", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + // Execute request + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to complete task: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) + } + + return nil } 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) + } +} |
