summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorDoot Agent <agent@doot.local>2026-07-06 00:00:59 +0000
committerDoot Agent <agent@doot.local>2026-07-06 00:00:59 +0000
commit945c34590677e161a711ccaff0e1077197b1b178 (patch)
treedf26e8ba5a369b3323649c2fab95bd8e7a49b58e /internal/api
parentcd14604bbef17f696475cf8737f1e41c9fa2dd06 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/interfaces.go11
-rw-r--r--internal/api/research_test.go241
-rw-r--r--internal/api/todoist.go256
-rw-r--r--internal/api/todoist_test.go399
4 files changed, 0 insertions, 907 deletions
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)
- }
-}
-