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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-20 07:21:59 +00:00
parent b03e4c1e0b
commit ef81db73c1
7 changed files with 797 additions and 0 deletions

View file

@ -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"

47
logs.go Normal file
View file

@ -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
}
}
}
}

139
logs_test.go Normal file
View file

@ -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)
}

135
status.go Normal file
View file

@ -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()
}

270
status_test.go Normal file
View file

@ -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")
}

35
submit.go Normal file
View file

@ -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
}

134
submit_test.go Normal file
View file

@ -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")
}