From ef81db73c115c46471b76e75bade04920b5e4ac1 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 20 Feb 2026 07:21:59 +0000 Subject: [PATCH] feat(cli): add status summary, task submission, and log streaming CLI backing functions for core agent commands: - GetStatus/FormatStatus aggregates registry + client + allowance data - SubmitTask + Client.CreateTask for task creation - StreamLogs polls task updates to io.Writer Co-Authored-By: Virgil --- client.go | 37 +++++++ logs.go | 47 +++++++++ logs_test.go | 139 +++++++++++++++++++++++++ status.go | 135 +++++++++++++++++++++++++ status_test.go | 270 +++++++++++++++++++++++++++++++++++++++++++++++++ submit.go | 35 +++++++ submit_test.go | 134 ++++++++++++++++++++++++ 7 files changed, 797 insertions(+) create mode 100644 logs.go create mode 100644 logs_test.go create mode 100644 status.go create mode 100644 status_test.go create mode 100644 submit.go create mode 100644 submit_test.go diff --git a/client.go b/client.go index 0782a2e..9cec2f2 100644 --- a/client.go +++ b/client.go @@ -295,6 +295,43 @@ func (c *Client) checkResponse(resp *http.Response) error { } } +// CreateTask creates a new task via POST /api/tasks. +func (c *Client) CreateTask(ctx context.Context, task Task) (*Task, error) { + const op = "agentic.Client.CreateTask" + + data, err := json.Marshal(task) + if err != nil { + return nil, log.E(op, "failed to marshal task", err) + } + + endpoint := c.BaseURL + "/api/tasks" + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return nil, log.E(op, "failed to create request", err) + } + + c.setHeaders(req) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, log.E(op, "request failed", err) + } + defer func() { _ = resp.Body.Close() }() + + if err := c.checkResponse(resp); err != nil { + return nil, log.E(op, "API error", err) + } + + var created Task + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return nil, log.E(op, "failed to decode response", err) + } + + return &created, nil +} + // Ping tests the connection to the API server. func (c *Client) Ping(ctx context.Context) error { const op = "agentic.Client.Ping" diff --git a/logs.go b/logs.go new file mode 100644 index 0000000..ff11411 --- /dev/null +++ b/logs.go @@ -0,0 +1,47 @@ +package agentic + +import ( + "context" + "fmt" + "io" + "time" + + "forge.lthn.ai/core/go/pkg/log" +) + +// StreamLogs polls a task's status and writes updates to writer. It polls at +// the given interval until the task reaches a terminal state (completed or +// blocked) or the context is cancelled. Returns ctx.Err() on cancellation. +func StreamLogs(ctx context.Context, client *Client, taskID string, interval time.Duration, writer io.Writer) error { + const op = "agentic.StreamLogs" + + if taskID == "" { + return log.E(op, "task ID is required", nil) + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + task, err := client.GetTask(ctx, taskID) + if err != nil { + // Write the error but continue polling -- transient failures + // should not stop the stream. + _, _ = fmt.Fprintf(writer, "[%s] Error: %s\n", time.Now().UTC().Format("2006-01-02 15:04:05"), err) + continue + } + + line := fmt.Sprintf("[%s] Status: %s", time.Now().UTC().Format("2006-01-02 15:04:05"), task.Status) + _, _ = fmt.Fprintln(writer, line) + + // Stop on terminal states. + if task.Status == StatusCompleted || task.Status == StatusBlocked { + return nil + } + } + } +} diff --git a/logs_test.go b/logs_test.go new file mode 100644 index 0000000..acfdfca --- /dev/null +++ b/logs_test.go @@ -0,0 +1,139 @@ +package agentic + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStreamLogs_Good_CompletedTask(t *testing.T) { + var calls atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/tasks/task-1", r.URL.Path) + n := calls.Add(1) + + task := Task{ID: "task-1"} + switch { + case n <= 2: + task.Status = StatusInProgress + default: + task.Status = StatusCompleted + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(task) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + var buf bytes.Buffer + + err := StreamLogs(context.Background(), client, "task-1", 10*time.Millisecond, &buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "Status: in_progress") + assert.Contains(t, output, "Status: completed") + assert.GreaterOrEqual(t, int(calls.Load()), 3) +} + +func TestStreamLogs_Good_BlockedTask(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + task := Task{ID: "task-2", Status: StatusBlocked} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(task) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + var buf bytes.Buffer + + err := StreamLogs(context.Background(), client, "task-2", 10*time.Millisecond, &buf) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Status: blocked") +} + +func TestStreamLogs_Good_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + task := Task{ID: "task-3", Status: StatusInProgress} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(task) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + var buf bytes.Buffer + + ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + defer cancel() + + err := StreamLogs(ctx, client, "task-3", 20*time.Millisecond, &buf) + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.Contains(t, buf.String(), "Status: in_progress") +} + +func TestStreamLogs_Good_TransientErrorContinues(t *testing.T) { + var calls atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := calls.Add(1) + + if n == 1 { + // First call: server error. + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(APIError{Message: "transient"}) + return + } + // Second call: completed. + task := Task{ID: "task-4", Status: StatusCompleted} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(task) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + var buf bytes.Buffer + + err := StreamLogs(context.Background(), client, "task-4", 10*time.Millisecond, &buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "Error:") + assert.Contains(t, output, "Status: completed") +} + +func TestStreamLogs_Bad_EmptyTaskID(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + var buf bytes.Buffer + + err := StreamLogs(context.Background(), client, "", 10*time.Millisecond, &buf) + assert.Error(t, err) + assert.Contains(t, err.Error(), "task ID is required") +} + +func TestStreamLogs_Good_ImmediateCancel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + task := Task{ID: "task-5", Status: StatusInProgress} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(task) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + var buf bytes.Buffer + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + + err := StreamLogs(ctx, client, "task-5", 10*time.Millisecond, &buf) + require.ErrorIs(t, err, context.Canceled) +} diff --git a/status.go b/status.go new file mode 100644 index 0000000..6c5cc35 --- /dev/null +++ b/status.go @@ -0,0 +1,135 @@ +package agentic + +import ( + "context" + "fmt" + "sort" + "strings" + + "forge.lthn.ai/core/go/pkg/log" +) + +// StatusSummary aggregates status from the agent registry, task client, and +// allowance service for CLI display. +type StatusSummary struct { + // Agents is the list of registered agents. + Agents []AgentInfo + // PendingTasks is the count of tasks with StatusPending. + PendingTasks int + // InProgressTasks is the count of tasks with StatusInProgress. + InProgressTasks int + // AllowanceRemaining maps agent ID to remaining daily tokens. -1 means unlimited. + AllowanceRemaining map[string]int64 +} + +// GetStatus aggregates status from the registry, client, and allowance service. +// Any of registry, client, or allowanceSvc can be nil -- those sections are +// simply skipped. Returns what we can collect without failing on nil components. +func GetStatus(ctx context.Context, registry AgentRegistry, client *Client, allowanceSvc *AllowanceService) (*StatusSummary, error) { + const op = "agentic.GetStatus" + + summary := &StatusSummary{ + AllowanceRemaining: make(map[string]int64), + } + + // Collect agents from registry. + if registry != nil { + summary.Agents = registry.List() + } + + // Count tasks by status via client. + if client != nil { + pending, err := client.ListTasks(ctx, ListOptions{Status: StatusPending}) + if err != nil { + return nil, log.E(op, "failed to list pending tasks", err) + } + summary.PendingTasks = len(pending) + + inProgress, err := client.ListTasks(ctx, ListOptions{Status: StatusInProgress}) + if err != nil { + return nil, log.E(op, "failed to list in-progress tasks", err) + } + summary.InProgressTasks = len(inProgress) + } + + // Collect allowance remaining per agent. + if allowanceSvc != nil { + for _, agent := range summary.Agents { + check, err := allowanceSvc.Check(agent.ID, "") + if err != nil { + // Skip agents whose allowance cannot be resolved. + continue + } + summary.AllowanceRemaining[agent.ID] = check.RemainingTokens + } + } + + return summary, nil +} + +// FormatStatus renders the summary as a human-readable table string suitable +// for CLI output. +func FormatStatus(s *StatusSummary) string { + var b strings.Builder + + // Count agents by status. + available := 0 + busy := 0 + for _, a := range s.Agents { + switch a.Status { + case AgentAvailable: + available++ + case AgentBusy: + busy++ + } + } + + total := len(s.Agents) + statusParts := make([]string, 0, 2) + if available > 0 { + statusParts = append(statusParts, fmt.Sprintf("%d available", available)) + } + if busy > 0 { + statusParts = append(statusParts, fmt.Sprintf("%d busy", busy)) + } + offline := total - available - busy + if offline > 0 { + statusParts = append(statusParts, fmt.Sprintf("%d offline", offline)) + } + + if len(statusParts) > 0 { + fmt.Fprintf(&b, "Agents: %d (%s)\n", total, strings.Join(statusParts, ", ")) + } else { + fmt.Fprintf(&b, "Agents: %d\n", total) + } + + fmt.Fprintf(&b, "Tasks: %d pending, %d in progress\n", s.PendingTasks, s.InProgressTasks) + + if len(s.Agents) > 0 { + // Sort agents by ID for deterministic output. + agents := make([]AgentInfo, len(s.Agents)) + copy(agents, s.Agents) + sort.Slice(agents, func(i, j int) bool { return agents[i].ID < agents[j].ID }) + + fmt.Fprintf(&b, "%-16s%-12s%-8s%s\n", "Agent", "Status", "Load", "Remaining") + for _, a := range agents { + load := fmt.Sprintf("%d/%d", a.CurrentLoad, a.MaxLoad) + if a.MaxLoad == 0 { + load = fmt.Sprintf("%d/-", a.CurrentLoad) + } + + remaining := "unknown" + if tokens, ok := s.AllowanceRemaining[a.ID]; ok { + if tokens < 0 { + remaining = "unlimited" + } else { + remaining = fmt.Sprintf("%d tokens", tokens) + } + } + + fmt.Fprintf(&b, "%-16s%-12s%-8s%s\n", a.ID, string(a.Status), load, remaining) + } + } + + return b.String() +} diff --git a/status_test.go b/status_test.go new file mode 100644 index 0000000..f62fea9 --- /dev/null +++ b/status_test.go @@ -0,0 +1,270 @@ +package agentic + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- GetStatus tests --- + +func TestGetStatus_Good_AllNil(t *testing.T) { + summary, err := GetStatus(context.Background(), nil, nil, nil) + require.NoError(t, err) + assert.Empty(t, summary.Agents) + assert.Equal(t, 0, summary.PendingTasks) + assert.Equal(t, 0, summary.InProgressTasks) + assert.Empty(t, summary.AllowanceRemaining) +} + +func TestGetStatus_Good_RegistryOnly(t *testing.T) { + reg := NewMemoryRegistry() + _ = reg.Register(AgentInfo{ + ID: "virgil", + Name: "Virgil", + Status: AgentAvailable, + LastHeartbeat: time.Now().UTC(), + MaxLoad: 5, + }) + _ = reg.Register(AgentInfo{ + ID: "charon", + Name: "Charon", + Status: AgentBusy, + CurrentLoad: 3, + MaxLoad: 5, + LastHeartbeat: time.Now().UTC(), + }) + + summary, err := GetStatus(context.Background(), reg, nil, nil) + require.NoError(t, err) + assert.Len(t, summary.Agents, 2) + assert.Equal(t, 0, summary.PendingTasks) + assert.Equal(t, 0, summary.InProgressTasks) +} + +func TestGetStatus_Good_FullSummary(t *testing.T) { + // Set up mock server returning task counts. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + status := r.URL.Query().Get("status") + w.Header().Set("Content-Type", "application/json") + switch status { + case "pending": + tasks := []Task{ + {ID: "t1", Status: StatusPending}, + {ID: "t2", Status: StatusPending}, + {ID: "t3", Status: StatusPending}, + } + _ = json.NewEncoder(w).Encode(tasks) + case "in_progress": + tasks := []Task{ + {ID: "t4", Status: StatusInProgress}, + } + _ = json.NewEncoder(w).Encode(tasks) + default: + _ = json.NewEncoder(w).Encode([]Task{}) + } + })) + defer server.Close() + + reg := NewMemoryRegistry() + _ = reg.Register(AgentInfo{ + ID: "virgil", + Name: "Virgil", + Status: AgentAvailable, + LastHeartbeat: time.Now().UTC(), + MaxLoad: 5, + }) + _ = reg.Register(AgentInfo{ + ID: "charon", + Name: "Charon", + Status: AgentBusy, + CurrentLoad: 3, + MaxLoad: 5, + LastHeartbeat: time.Now().UTC(), + }) + + store := NewMemoryStore() + _ = store.SetAllowance(&AgentAllowance{ + AgentID: "virgil", + DailyTokenLimit: 50000, + }) + _ = store.SetAllowance(&AgentAllowance{ + AgentID: "charon", + DailyTokenLimit: 50000, + }) + // Simulate charon has used 38000 tokens. + _ = store.IncrementUsage("charon", 38000, 0) + + svc := NewAllowanceService(store) + client := NewClient(server.URL, "test-token") + + summary, err := GetStatus(context.Background(), reg, client, svc) + require.NoError(t, err) + assert.Len(t, summary.Agents, 2) + assert.Equal(t, 3, summary.PendingTasks) + assert.Equal(t, 1, summary.InProgressTasks) + assert.Equal(t, int64(50000), summary.AllowanceRemaining["virgil"]) + assert.Equal(t, int64(12000), summary.AllowanceRemaining["charon"]) +} + +func TestGetStatus_Good_UnlimitedAllowance(t *testing.T) { + reg := NewMemoryRegistry() + _ = reg.Register(AgentInfo{ + ID: "darbs", + Name: "Darbs", + Status: AgentAvailable, + LastHeartbeat: time.Now().UTC(), + MaxLoad: 3, + }) + + store := NewMemoryStore() + // DailyTokenLimit 0 means unlimited. + _ = store.SetAllowance(&AgentAllowance{ + AgentID: "darbs", + DailyTokenLimit: 0, + }) + svc := NewAllowanceService(store) + + summary, err := GetStatus(context.Background(), reg, nil, svc) + require.NoError(t, err) + // Unlimited: Check returns RemainingTokens = -1. + assert.Equal(t, int64(-1), summary.AllowanceRemaining["darbs"]) +} + +func TestGetStatus_Good_AllowanceSkipsUnknownAgents(t *testing.T) { + reg := NewMemoryRegistry() + _ = reg.Register(AgentInfo{ + ID: "unknown-agent", + Name: "Unknown", + Status: AgentAvailable, + LastHeartbeat: time.Now().UTC(), + }) + + store := NewMemoryStore() + // No allowance set for "unknown-agent" -- GetAllowance will error. + svc := NewAllowanceService(store) + + summary, err := GetStatus(context.Background(), reg, nil, svc) + require.NoError(t, err) + // AllowanceRemaining should not have an entry for unknown-agent. + _, exists := summary.AllowanceRemaining["unknown-agent"] + assert.False(t, exists) +} + +func TestGetStatus_Bad_ClientError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(APIError{Message: "server error"}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + summary, err := GetStatus(context.Background(), nil, client, nil) + assert.Error(t, err) + assert.Nil(t, summary) + assert.Contains(t, err.Error(), "pending tasks") +} + +// --- FormatStatus tests --- + +func TestFormatStatus_Good_Empty(t *testing.T) { + s := &StatusSummary{ + AllowanceRemaining: make(map[string]int64), + } + output := FormatStatus(s) + assert.Contains(t, output, "Agents: 0") + assert.Contains(t, output, "Tasks: 0 pending, 0 in progress") + // No agent table rows when there are no agents — only the summary lines. + assert.NotContains(t, output, "Status") +} + +func TestFormatStatus_Good_FullTable(t *testing.T) { + s := &StatusSummary{ + Agents: []AgentInfo{ + {ID: "virgil", Status: AgentAvailable, CurrentLoad: 0, MaxLoad: 5}, + {ID: "charon", Status: AgentBusy, CurrentLoad: 3, MaxLoad: 5}, + {ID: "darbs", Status: AgentAvailable, CurrentLoad: 0, MaxLoad: 3}, + }, + PendingTasks: 5, + InProgressTasks: 2, + AllowanceRemaining: map[string]int64{ + "virgil": 45000, + "charon": 12000, + "darbs": -1, + }, + } + + output := FormatStatus(s) + assert.Contains(t, output, "Agents: 3 (2 available, 1 busy)") + assert.Contains(t, output, "Tasks: 5 pending, 2 in progress") + assert.Contains(t, output, "virgil") + assert.Contains(t, output, "available") + assert.Contains(t, output, "45000 tokens") + assert.Contains(t, output, "charon") + assert.Contains(t, output, "busy") + assert.Contains(t, output, "12000 tokens") + assert.Contains(t, output, "darbs") + assert.Contains(t, output, "unlimited") + + // Verify deterministic sort order (agents sorted by ID). + lines := strings.Split(output, "\n") + var agentLines []string + for _, line := range lines { + if strings.HasPrefix(line, "charon") || strings.HasPrefix(line, "darbs") || strings.HasPrefix(line, "virgil") { + agentLines = append(agentLines, line) + } + } + require.Len(t, agentLines, 3) + assert.True(t, strings.HasPrefix(agentLines[0], "charon")) + assert.True(t, strings.HasPrefix(agentLines[1], "darbs")) + assert.True(t, strings.HasPrefix(agentLines[2], "virgil")) +} + +func TestFormatStatus_Good_OfflineAgent(t *testing.T) { + s := &StatusSummary{ + Agents: []AgentInfo{ + {ID: "offline-bot", Status: AgentOffline, CurrentLoad: 0, MaxLoad: 5}, + }, + AllowanceRemaining: map[string]int64{ + "offline-bot": 30000, + }, + } + + output := FormatStatus(s) + assert.Contains(t, output, "1 offline") + assert.Contains(t, output, "offline-bot") +} + +func TestFormatStatus_Good_UnlimitedMaxLoad(t *testing.T) { + s := &StatusSummary{ + Agents: []AgentInfo{ + {ID: "unlimited", Status: AgentAvailable, CurrentLoad: 2, MaxLoad: 0}, + }, + AllowanceRemaining: map[string]int64{ + "unlimited": -1, + }, + } + + output := FormatStatus(s) + assert.Contains(t, output, "2/-") + assert.Contains(t, output, "unlimited") +} + +func TestFormatStatus_Good_UnknownAllowance(t *testing.T) { + s := &StatusSummary{ + Agents: []AgentInfo{ + {ID: "mystery", Status: AgentAvailable, MaxLoad: 5}, + }, + AllowanceRemaining: make(map[string]int64), + } + + output := FormatStatus(s) + assert.Contains(t, output, "unknown") +} diff --git a/submit.go b/submit.go new file mode 100644 index 0000000..517434c --- /dev/null +++ b/submit.go @@ -0,0 +1,35 @@ +package agentic + +import ( + "context" + "time" + + "forge.lthn.ai/core/go/pkg/log" +) + +// SubmitTask creates a new task with the given parameters via the API client. +// It validates that title is non-empty, sets CreatedAt to the current time, +// and delegates creation to client.CreateTask. +func SubmitTask(ctx context.Context, client *Client, title, description string, labels []string, priority TaskPriority) (*Task, error) { + const op = "agentic.SubmitTask" + + if title == "" { + return nil, log.E(op, "title is required", nil) + } + + task := Task{ + Title: title, + Description: description, + Labels: labels, + Priority: priority, + Status: StatusPending, + CreatedAt: time.Now().UTC(), + } + + created, err := client.CreateTask(ctx, task) + if err != nil { + return nil, log.E(op, "failed to create task", err) + } + + return created, nil +} diff --git a/submit_test.go b/submit_test.go new file mode 100644 index 0000000..a00c78f --- /dev/null +++ b/submit_test.go @@ -0,0 +1,134 @@ +package agentic + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Client.CreateTask tests --- + +func TestClient_CreateTask_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/tasks", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + var task Task + err := json.NewDecoder(r.Body).Decode(&task) + require.NoError(t, err) + assert.Equal(t, "New feature", task.Title) + assert.Equal(t, PriorityHigh, task.Priority) + + // Return the task with an assigned ID. + task.ID = "task-new-1" + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(task) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + task := Task{ + Title: "New feature", + Description: "Build something great", + Priority: PriorityHigh, + Labels: []string{"feature"}, + Status: StatusPending, + } + + created, err := client.CreateTask(context.Background(), task) + require.NoError(t, err) + assert.Equal(t, "task-new-1", created.ID) + assert.Equal(t, "New feature", created.Title) + assert.Equal(t, PriorityHigh, created.Priority) +} + +func TestClient_CreateTask_Bad_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(APIError{Message: "validation failed"}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + task := Task{Title: "Bad task"} + + created, err := client.CreateTask(context.Background(), task) + assert.Error(t, err) + assert.Nil(t, created) + assert.Contains(t, err.Error(), "validation failed") +} + +// --- SubmitTask tests --- + +func TestSubmitTask_Good_AllFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var task Task + err := json.NewDecoder(r.Body).Decode(&task) + require.NoError(t, err) + assert.Equal(t, "Implement login", task.Title) + assert.Equal(t, "OAuth2 login flow", task.Description) + assert.Equal(t, []string{"auth", "frontend"}, task.Labels) + assert.Equal(t, PriorityHigh, task.Priority) + assert.Equal(t, StatusPending, task.Status) + assert.False(t, task.CreatedAt.IsZero()) + + task.ID = "task-submit-1" + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(task) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + created, err := SubmitTask(context.Background(), client, "Implement login", "OAuth2 login flow", []string{"auth", "frontend"}, PriorityHigh) + require.NoError(t, err) + assert.Equal(t, "task-submit-1", created.ID) + assert.Equal(t, "Implement login", created.Title) +} + +func TestSubmitTask_Good_MinimalFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var task Task + _ = json.NewDecoder(r.Body).Decode(&task) + task.ID = "task-minimal" + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(task) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + created, err := SubmitTask(context.Background(), client, "Simple task", "", nil, PriorityLow) + require.NoError(t, err) + assert.Equal(t, "task-minimal", created.ID) +} + +func TestSubmitTask_Bad_EmptyTitle(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + created, err := SubmitTask(context.Background(), client, "", "description", nil, PriorityMedium) + assert.Error(t, err) + assert.Nil(t, created) + assert.Contains(t, err.Error(), "title is required") +} + +func TestSubmitTask_Bad_ClientError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(APIError{Message: "internal error"}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + created, err := SubmitTask(context.Background(), client, "Good title", "", nil, PriorityMedium) + assert.Error(t, err) + assert.Nil(t, created) + assert.Contains(t, err.Error(), "create task") +}