summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-02-03 15:16:35 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-02-03 15:16:35 -1000
commit25a5b7ecf9ddd31da54e91f87988b77aea857571 (patch)
tree30654edbdd966cea316a5f54a99474aad337cf58 /internal/api
parent9f35f7149d8fb790bbe8e4f0ee74f895aea1fc58 (diff)
Add comprehensive test coverage across packages
New test files: - api/http_test.go: HTTP client and error handling tests - config/config_test.go: Configuration loading and validation tests - middleware/security_test.go: Security middleware tests - models/atom_test.go: Atom model and conversion tests Expanded test coverage: - api/todoist_test.go: Todoist API client tests - api/trello_test.go: Trello API client tests - auth/auth_test.go: Authentication and CSRF tests - handlers/timeline_logic_test.go: Timeline building logic tests - store/sqlite_test.go: SQLite store operations tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/http_test.go207
-rw-r--r--internal/api/todoist_test.go183
-rw-r--r--internal/api/trello_test.go164
3 files changed, 554 insertions, 0 deletions
diff --git a/internal/api/http_test.go b/internal/api/http_test.go
new file mode 100644
index 0000000..c2c32ee
--- /dev/null
+++ b/internal/api/http_test.go
@@ -0,0 +1,207 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestNewBaseClient(t *testing.T) {
+ client := NewBaseClient("https://api.example.com")
+ if client.BaseURL != "https://api.example.com" {
+ t.Errorf("Expected BaseURL 'https://api.example.com', got '%s'", client.BaseURL)
+ }
+ if client.HTTPClient == nil {
+ t.Error("HTTPClient should not be nil")
+ }
+}
+
+func TestBaseClient_Get(t *testing.T) {
+ expected := map[string]string{"message": "hello"}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" {
+ t.Errorf("Expected GET, got %s", r.Method)
+ }
+ if r.URL.Path != "/test" {
+ t.Errorf("Expected path /test, got %s", r.URL.Path)
+ }
+ if r.Header.Get("Authorization") != "Bearer token123" {
+ t.Errorf("Expected Authorization header")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(expected)
+ }))
+ defer server.Close()
+
+ client := BaseClient{HTTPClient: server.Client(), BaseURL: server.URL}
+ var result map[string]string
+ err := client.Get(context.Background(), "/test", map[string]string{"Authorization": "Bearer token123"}, &result)
+ if err != nil {
+ t.Fatalf("Get failed: %v", err)
+ }
+ if result["message"] != "hello" {
+ t.Errorf("Expected message 'hello', got '%s'", result["message"])
+ }
+}
+
+func TestBaseClient_Get_Error(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("server error"))
+ }))
+ defer server.Close()
+
+ client := BaseClient{HTTPClient: server.Client(), BaseURL: server.URL}
+ var result map[string]string
+ err := client.Get(context.Background(), "/test", nil, &result)
+ if err == nil {
+ t.Error("Expected error for 500 response")
+ }
+}
+
+func TestBaseClient_Post(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.Header.Get("Content-Type") != "application/json" {
+ t.Error("Expected Content-Type application/json")
+ }
+
+ var body map[string]string
+ json.NewDecoder(r.Body).Decode(&body)
+ if body["name"] != "test" {
+ t.Errorf("Expected name 'test', got '%s'", body["name"])
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]int{"id": 42})
+ }))
+ defer server.Close()
+
+ client := BaseClient{HTTPClient: server.Client(), BaseURL: server.URL}
+ var result map[string]int
+ err := client.Post(context.Background(), "/create", nil, map[string]string{"name": "test"}, &result)
+ if err != nil {
+ t.Fatalf("Post failed: %v", err)
+ }
+ if result["id"] != 42 {
+ t.Errorf("Expected id 42, got %d", result["id"])
+ }
+}
+
+func TestBaseClient_Post_NilBody(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Content-Type") == "application/json" {
+ t.Error("Content-Type should not be set for nil body")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+ }))
+ defer server.Close()
+
+ client := BaseClient{HTTPClient: server.Client(), BaseURL: server.URL}
+ var result map[string]string
+ err := client.Post(context.Background(), "/action", nil, nil, &result)
+ if err != nil {
+ t.Fatalf("Post with nil body failed: %v", err)
+ }
+}
+
+func TestBaseClient_PostForm(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.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
+ t.Error("Expected form-urlencoded Content-Type")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]bool{"success": true})
+ }))
+ defer server.Close()
+
+ client := BaseClient{HTTPClient: server.Client(), BaseURL: server.URL}
+ var result map[string]bool
+ err := client.PostForm(context.Background(), "/submit", nil, "key=value", &result)
+ if err != nil {
+ t.Fatalf("PostForm failed: %v", err)
+ }
+ if !result["success"] {
+ t.Error("Expected success to be true")
+ }
+}
+
+func TestBaseClient_PostEmpty(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.Header.Get("X-Custom") != "custom-value" {
+ t.Error("Expected custom header")
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ client := BaseClient{HTTPClient: server.Client(), BaseURL: server.URL}
+ err := client.PostEmpty(context.Background(), "/trigger", map[string]string{"X-Custom": "custom-value"})
+ if err != nil {
+ t.Fatalf("PostEmpty failed: %v", err)
+ }
+}
+
+func TestBaseClient_PostEmpty_Error(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte("bad request"))
+ }))
+ defer server.Close()
+
+ client := BaseClient{HTTPClient: server.Client(), BaseURL: server.URL}
+ err := client.PostEmpty(context.Background(), "/fail", nil)
+ if err == nil {
+ t.Error("Expected error for 400 response")
+ }
+}
+
+func TestBaseClient_Put(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "PUT" {
+ t.Errorf("Expected PUT, got %s", r.Method)
+ }
+ if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
+ t.Error("Expected form-urlencoded Content-Type")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"updated": "yes"})
+ }))
+ defer server.Close()
+
+ client := BaseClient{HTTPClient: server.Client(), BaseURL: server.URL}
+ var result map[string]string
+ err := client.Put(context.Background(), "/update", nil, "field=value", &result)
+ if err != nil {
+ t.Fatalf("Put failed: %v", err)
+ }
+ if result["updated"] != "yes" {
+ t.Error("Expected updated to be yes")
+ }
+}
+
+func TestBaseClient_doJSON_DecodeError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte("invalid json"))
+ }))
+ defer server.Close()
+
+ client := BaseClient{HTTPClient: server.Client(), BaseURL: server.URL}
+ var result map[string]string
+ err := client.Get(context.Background(), "/bad-json", nil, &result)
+ if err == nil {
+ t.Error("Expected error for invalid JSON")
+ }
+}
diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go
index 7bbcc1e..f7ca719 100644
--- a/internal/api/todoist_test.go
+++ b/internal/api/todoist_test.go
@@ -246,3 +246,186 @@ func TestTodoistClient_GetProjects(t *testing.T) {
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"])
+ }
+}
diff --git a/internal/api/trello_test.go b/internal/api/trello_test.go
index d677363..a209d01 100644
--- a/internal/api/trello_test.go
+++ b/internal/api/trello_test.go
@@ -239,3 +239,167 @@ func parseFormData(data string) (map[string]string, error) {
}
return result, nil
}
+
+func TestTrelloClient_GetBoards(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)
+ }
+ if r.URL.Path != "/members/me/boards" {
+ t.Errorf("Expected path /members/me/boards, got %s", r.URL.Path)
+ }
+
+ response := []trelloBoardResponse{
+ {ID: "board-1", Name: "Board 1"},
+ {ID: "board-2", Name: "Board 2"},
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ client := newTestTrelloClient(server.URL, "test-key", "test-token")
+ boards, err := client.GetBoards(context.Background())
+ if err != nil {
+ t.Fatalf("GetBoards failed: %v", err)
+ }
+
+ if len(boards) != 2 {
+ t.Errorf("Expected 2 boards, got %d", len(boards))
+ }
+ if boards[0].ID != "board-1" {
+ t.Errorf("Expected board ID 'board-1', got '%s'", boards[0].ID)
+ }
+}
+
+func TestTrelloClient_GetCards(t *testing.T) {
+ dueDate := "2024-01-15T12:00:00Z"
+ 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")
+
+ // GetCards calls getLists internally
+ if strings.Contains(r.URL.Path, "/lists") {
+ response := []trelloListResponse{
+ {ID: "list-1", Name: "To Do"},
+ }
+ json.NewEncoder(w).Encode(response)
+ return
+ }
+
+ if strings.Contains(r.URL.Path, "/cards") {
+ response := []trelloCardResponse{
+ {ID: "card-1", Name: "Card 1", IDList: "list-1", IDBoard: "board-1", Due: &dueDate},
+ {ID: "card-2", Name: "Card 2", IDList: "list-1", IDBoard: "board-1"},
+ }
+ json.NewEncoder(w).Encode(response)
+ return
+ }
+
+ t.Errorf("Unexpected path: %s", r.URL.Path)
+ }))
+ defer server.Close()
+
+ client := newTestTrelloClient(server.URL, "test-key", "test-token")
+ cards, err := client.GetCards(context.Background(), "board-1")
+ if err != nil {
+ t.Fatalf("GetCards failed: %v", err)
+ }
+
+ if len(cards) != 2 {
+ t.Errorf("Expected 2 cards, got %d", len(cards))
+ }
+ if cards[0].DueDate == nil {
+ t.Error("Expected due date for card 1")
+ }
+}
+
+func TestTrelloClient_GetLists(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)
+ }
+ if !strings.Contains(r.URL.Path, "/boards/board-1/lists") {
+ t.Errorf("Expected path to contain /boards/board-1/lists, got %s", r.URL.Path)
+ }
+
+ response := []trelloListResponse{
+ {ID: "list-1", Name: "To Do"},
+ {ID: "list-2", Name: "Done"},
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ client := newTestTrelloClient(server.URL, "test-key", "test-token")
+ lists, err := client.GetLists(context.Background(), "board-1")
+ if err != nil {
+ t.Fatalf("GetLists failed: %v", err)
+ }
+
+ if len(lists) != 2 {
+ t.Errorf("Expected 2 lists, got %d", len(lists))
+ }
+ if lists[0].Name != "To Do" {
+ t.Errorf("Expected 'To Do', got '%s'", lists[0].Name)
+ }
+}
+
+func TestTrelloClient_GetBoardsWithCards(t *testing.T) {
+ requestCount := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestCount++
+
+ if strings.Contains(r.URL.Path, "/members/me/boards") {
+ response := []trelloBoardResponse{
+ {ID: "board-1", Name: "Board 1"},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+ return
+ }
+
+ if strings.Contains(r.URL.Path, "/lists") {
+ response := []trelloListResponse{
+ {ID: "list-1", Name: "To Do"},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+ return
+ }
+
+ if strings.Contains(r.URL.Path, "/cards") {
+ response := []trelloCardResponse{
+ {ID: "card-1", Name: "Card 1", IDList: "list-1", IDBoard: "board-1"},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+ return
+ }
+
+ t.Errorf("Unexpected path: %s", r.URL.Path)
+ }))
+ defer server.Close()
+
+ client := newTestTrelloClient(server.URL, "test-key", "test-token")
+ boards, err := client.GetBoardsWithCards(context.Background())
+ if err != nil {
+ t.Fatalf("GetBoardsWithCards failed: %v", err)
+ }
+
+ if len(boards) != 1 {
+ t.Errorf("Expected 1 board, got %d", len(boards))
+ }
+ if len(boards[0].Cards) != 1 {
+ t.Errorf("Expected 1 card, got %d", len(boards[0].Cards))
+ }
+ if boards[0].Cards[0].ListName != "To Do" {
+ t.Errorf("Expected list name 'To Do', got '%s'", boards[0].Cards[0].ListName)
+ }
+}