summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-01-13 13:58:53 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-01-13 13:58:53 -1000
commit1c79f105c960ddab2265cbfd8dfd728630b1ebfb (patch)
tree5c0c5f6bad50fb3214d75ac29de292c74fe2267d /internal/api
parent429476f5ac97f56c7f6a755d6dd565767d31dfb6 (diff)
Implement Trello write operations (Phase 3 Step 1)
Add CreateCard and UpdateCard methods to Trello API client with full testability support: - Refactor TrelloClient with configurable baseURL for testing - Replace hardcoded trelloBaseURL constant with c.baseURL - Implement CreateCard with support for description and due date - Implement UpdateCard with flexible field updates - Add comprehensive test suite using httptest.NewServer - Tests cover success cases, error handling, and edge cases All tests pass. Write operations ready for Phase 3 UI integration. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/trello.go111
-rw-r--r--internal/api/trello_test.go254
2 files changed, 354 insertions, 11 deletions
diff --git a/internal/api/trello.go b/internal/api/trello.go
index c78ebc8..5b87e30 100644
--- a/internal/api/trello.go
+++ b/internal/api/trello.go
@@ -6,7 +6,9 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
"sort"
+ "strings"
"sync"
"time"
@@ -21,14 +23,16 @@ const (
type TrelloClient struct {
apiKey string
token string
+ baseURL string
httpClient *http.Client
}
// NewTrelloClient creates a new Trello API client
func NewTrelloClient(apiKey, token string) *TrelloClient {
return &TrelloClient{
- apiKey: apiKey,
- token: token,
+ apiKey: apiKey,
+ token: token,
+ baseURL: trelloBaseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
@@ -60,7 +64,7 @@ type trelloListResponse struct {
// GetBoards fetches all boards for the authenticated user
func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) {
- url := fmt.Sprintf("%s/members/me/boards?key=%s&token=%s", trelloBaseURL, c.apiKey, c.token)
+ url := fmt.Sprintf("%s/members/me/boards?key=%s&token=%s", c.baseURL, c.apiKey, c.token)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
@@ -99,7 +103,7 @@ func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) {
// GetCards fetches all cards for a specific board
func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) {
- url := fmt.Sprintf("%s/boards/%s/cards?key=%s&token=%s", trelloBaseURL, boardID, c.apiKey, c.token)
+ url := fmt.Sprintf("%s/boards/%s/cards?key=%s&token=%s", c.baseURL, boardID, c.apiKey, c.token)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
@@ -156,7 +160,7 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C
// getLists fetches lists for a board and returns a map of list ID to name
func (c *TrelloClient) getLists(ctx context.Context, boardID string) (map[string]string, error) {
- url := fmt.Sprintf("%s/boards/%s/lists?key=%s&token=%s", trelloBaseURL, boardID, c.apiKey, c.token)
+ url := fmt.Sprintf("%s/boards/%s/lists?key=%s&token=%s", c.baseURL, boardID, c.apiKey, c.token)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
@@ -256,14 +260,99 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board,
return boards, nil
}
-// CreateCard creates a new card (for Phase 2)
+// CreateCard creates a new card in the specified list
func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description string, dueDate *time.Time) (*models.Card, error) {
- // This will be implemented in Phase 2
- return nil, fmt.Errorf("not implemented yet")
+ // Prepare request payload
+ data := url.Values{}
+ data.Set("key", c.apiKey)
+ data.Set("token", c.token)
+ data.Set("idList", listID)
+ data.Set("name", name)
+
+ if description != "" {
+ data.Set("desc", description)
+ }
+
+ if dueDate != nil {
+ data.Set("due", dueDate.Format(time.RFC3339))
+ }
+
+ // Create POST request
+ reqURL := c.baseURL + "/cards"
+ req, err := http.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ // Execute request
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create card: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ // Decode response
+ var apiCard trelloCardResponse
+ if err := json.NewDecoder(resp.Body).Decode(&apiCard); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // Convert to our model
+ card := &models.Card{
+ ID: apiCard.ID,
+ Name: apiCard.Name,
+ ListID: apiCard.IDList,
+ URL: apiCard.URL,
+ }
+
+ // Parse due date if present
+ if apiCard.Due != nil && *apiCard.Due != "" {
+ parsedDate, err := time.Parse(time.RFC3339, *apiCard.Due)
+ if err == nil {
+ card.DueDate = &parsedDate
+ }
+ }
+
+ return card, nil
}
-// UpdateCard updates a card (for Phase 2)
+// UpdateCard updates a card with the specified changes
func (c *TrelloClient) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error {
- // This will be implemented in Phase 2
- return fmt.Errorf("not implemented yet")
+ // Prepare request payload
+ data := url.Values{}
+ data.Set("key", c.apiKey)
+ data.Set("token", c.token)
+
+ // Add updates to payload
+ for key, value := range updates {
+ data.Set(key, fmt.Sprintf("%v", value))
+ }
+
+ // Create PUT request
+ reqURL := fmt.Sprintf("%s/cards/%s", c.baseURL, cardID)
+ req, err := http.NewRequestWithContext(ctx, "PUT", reqURL, strings.NewReader(data.Encode()))
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ // Execute request
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to update card: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ return nil
}
diff --git a/internal/api/trello_test.go b/internal/api/trello_test.go
new file mode 100644
index 0000000..b43b55e
--- /dev/null
+++ b/internal/api/trello_test.go
@@ -0,0 +1,254 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestTrelloClient_CreateCard(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 != "/cards" {
+ t.Errorf("Expected path /cards, got %s", r.URL.Path)
+ }
+
+ // Verify Content-Type
+ if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
+ t.Errorf("Expected Content-Type application/x-www-form-urlencoded, got %s", r.Header.Get("Content-Type"))
+ }
+
+ // Parse form data
+ body, _ := io.ReadAll(r.Body)
+ values, _ := parseFormData(string(body))
+
+ // Verify required fields
+ if values["key"] != "test-key" {
+ t.Errorf("Expected key=test-key, got %s", values["key"])
+ }
+ if values["token"] != "test-token" {
+ t.Errorf("Expected token=test-token, got %s", values["token"])
+ }
+ if values["idList"] != "list-123" {
+ t.Errorf("Expected idList=list-123, got %s", values["idList"])
+ }
+ if values["name"] != "Test Card" {
+ t.Errorf("Expected name=Test Card, got %s", values["name"])
+ }
+ if values["desc"] != "Test description" {
+ t.Errorf("Expected desc=Test description, got %s", values["desc"])
+ }
+
+ // Return mock response
+ response := trelloCardResponse{
+ ID: "card-456",
+ Name: "Test Card",
+ IDList: "list-123",
+ URL: "https://trello.com/c/card-456",
+ Desc: "Test description",
+ IDBoard: "board-789",
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ // Create client with mock server URL
+ client := &TrelloClient{
+ apiKey: "test-key",
+ token: "test-token",
+ baseURL: server.URL,
+ httpClient: &http.Client{},
+ }
+
+ // Test CreateCard
+ ctx := context.Background()
+ card, err := client.CreateCard(ctx, "list-123", "Test Card", "Test description", nil)
+
+ if err != nil {
+ t.Fatalf("CreateCard failed: %v", err)
+ }
+
+ // Verify response
+ if card.ID != "card-456" {
+ t.Errorf("Expected card ID card-456, got %s", card.ID)
+ }
+ if card.Name != "Test Card" {
+ t.Errorf("Expected card name Test Card, got %s", card.Name)
+ }
+ if card.ListID != "list-123" {
+ t.Errorf("Expected list ID list-123, got %s", card.ListID)
+ }
+ if card.URL != "https://trello.com/c/card-456" {
+ t.Errorf("Expected URL https://trello.com/c/card-456, got %s", card.URL)
+ }
+}
+
+func TestTrelloClient_CreateCard_WithDueDate(t *testing.T) {
+ dueDate := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC)
+
+ // Mock server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Parse form data
+ body, _ := io.ReadAll(r.Body)
+ values, _ := parseFormData(string(body))
+
+ // Verify due date is present
+ if values["due"] != dueDate.Format(time.RFC3339) {
+ t.Errorf("Expected due=%s, got %s", dueDate.Format(time.RFC3339), values["due"])
+ }
+
+ // Return mock response with due date
+ dueString := dueDate.Format(time.RFC3339)
+ response := trelloCardResponse{
+ ID: "card-789",
+ Name: "Card with Due Date",
+ IDList: "list-456",
+ URL: "https://trello.com/c/card-789",
+ Due: &dueString,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ // Create client
+ client := &TrelloClient{
+ apiKey: "test-key",
+ token: "test-token",
+ baseURL: server.URL,
+ httpClient: &http.Client{},
+ }
+
+ // Test CreateCard with due date
+ ctx := context.Background()
+ card, err := client.CreateCard(ctx, "list-456", "Card with Due Date", "", &dueDate)
+
+ if err != nil {
+ t.Fatalf("CreateCard failed: %v", err)
+ }
+
+ // Verify due date is set
+ if card.DueDate == nil {
+ t.Error("Expected due date to be set")
+ } else if !card.DueDate.Equal(dueDate) {
+ t.Errorf("Expected due date %v, got %v", dueDate, *card.DueDate)
+ }
+}
+
+func TestTrelloClient_UpdateCard(t *testing.T) {
+ // Mock server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Verify method and path
+ if r.Method != "PUT" {
+ t.Errorf("Expected PUT request, got %s", r.Method)
+ }
+ if !strings.HasPrefix(r.URL.Path, "/cards/") {
+ t.Errorf("Expected path to start with /cards/, got %s", r.URL.Path)
+ }
+
+ // Extract card ID from path
+ cardID := strings.TrimPrefix(r.URL.Path, "/cards/")
+ if cardID != "card-123" {
+ t.Errorf("Expected card ID card-123, got %s", cardID)
+ }
+
+ // Parse form data
+ body, _ := io.ReadAll(r.Body)
+ values, _ := parseFormData(string(body))
+
+ // Verify updated fields
+ if values["name"] != "Updated Name" {
+ t.Errorf("Expected name=Updated Name, got %s", values["name"])
+ }
+ if values["desc"] != "Updated description" {
+ t.Errorf("Expected desc=Updated description, got %s", values["desc"])
+ }
+
+ // Return 200 OK
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"id":"card-123","name":"Updated Name"}`))
+ }))
+ defer server.Close()
+
+ // Create client
+ client := &TrelloClient{
+ apiKey: "test-key",
+ token: "test-token",
+ baseURL: server.URL,
+ httpClient: &http.Client{},
+ }
+
+ // Test UpdateCard
+ ctx := context.Background()
+ updates := map[string]interface{}{
+ "name": "Updated Name",
+ "desc": "Updated description",
+ }
+
+ err := client.UpdateCard(ctx, "card-123", updates)
+
+ if err != nil {
+ t.Fatalf("UpdateCard failed: %v", err)
+ }
+}
+
+func TestTrelloClient_UpdateCard_Error(t *testing.T) {
+ // Mock server that returns error
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte(`{"error":"Invalid card ID"}`))
+ }))
+ defer server.Close()
+
+ // Create client
+ client := &TrelloClient{
+ apiKey: "test-key",
+ token: "test-token",
+ baseURL: server.URL,
+ httpClient: &http.Client{},
+ }
+
+ // Test UpdateCard with error
+ ctx := context.Background()
+ updates := map[string]interface{}{
+ "name": "Should Fail",
+ }
+
+ err := client.UpdateCard(ctx, "invalid-card", updates)
+
+ if err == nil {
+ t.Error("Expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "400") {
+ t.Errorf("Expected error to contain status 400, got: %v", err)
+ }
+}
+
+// Helper function to parse form-encoded data
+func parseFormData(data string) (map[string]string, error) {
+ values, err := url.ParseQuery(data)
+ if err != nil {
+ return nil, err
+ }
+
+ result := make(map[string]string)
+ for key, vals := range values {
+ if len(vals) > 0 {
+ result[key] = vals[0]
+ }
+ }
+ return result, nil
+}