Files
ai-workflow-skill/internal/httpapi/router_test.go
T

197 lines
5.4 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"ai-workflow-skill/internal/app"
dbpkg "ai-workflow-skill/packages/coord-core/db"
"ai-workflow-skill/packages/coord-core/store"
)
func TestRouterExposesReadOnlyWebEndpoints(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
dbPath := filepath.Join(t.TempDir(), "coord.db")
sqlDB, err := dbpkg.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open db: %v", err)
}
defer sqlDB.Close()
if err := dbpkg.ApplyMigrations(ctx, sqlDB); err != nil {
t.Fatalf("apply migrations: %v", err)
}
orchStore := store.NewOrchStore(sqlDB)
inboxStore := store.NewInboxStore(sqlDB)
_, err = orchStore.CreateRun(ctx, store.CreateRunInput{
RunID: "run_web_001",
Goal: "Build the web control plane",
Summary: "Initial HTTP slice",
})
if err != nil {
t.Fatalf("create run: %v", err)
}
_, err = orchStore.AddTask(ctx, store.AddTaskInput{
RunID: "run_web_001",
TaskID: "T1",
Title: "Implement read API",
Summary: "Expose run state over HTTP",
DefaultTo: "worker-a",
})
if err != nil {
t.Fatalf("add task T1: %v", err)
}
_, err = orchStore.AddTask(ctx, store.AddTaskInput{
RunID: "run_web_001",
TaskID: "T2",
Title: "Build React shell",
Summary: "Scaffold the frontend workspace",
DefaultTo: "worker-b",
})
if err != nil {
t.Fatalf("add task T2: %v", err)
}
dispatch, err := orchStore.DispatchTask(ctx, store.DispatchInput{
RunID: "run_web_001",
TaskID: "T1",
ToAgent: "worker-a",
Body: "Expose the initial HTTP API.",
})
if err != nil {
t.Fatalf("dispatch task: %v", err)
}
if _, err := inboxStore.ClaimThread(ctx, store.ClaimInput{
ThreadID: dispatch.Attempt.ThreadID,
Agent: "worker-a",
LeaseSeconds: 300,
}); err != nil {
t.Fatalf("claim thread: %v", err)
}
if _, _, err := inboxStore.UpdateThreadStatus(ctx, store.UpdateInput{
ThreadID: dispatch.Attempt.ThreadID,
Agent: "worker-a",
Status: "blocked",
Summary: "Need the API shape",
Body: "Confirm whether run detail should include blocked tasks.",
}); err != nil {
t.Fatalf("mark thread blocked: %v", err)
}
if _, err := orchStore.ReconcileRun(ctx, "run_web_001"); err != nil {
t.Fatalf("reconcile run: %v", err)
}
handler := NewRouter(app.NewWebService(sqlDB))
assertStatusAndJSONField(t, handler, "/health", http.StatusOK, []string{"status"}, "ok")
assertStatusAndJSONField(t, handler, "/api/runs", http.StatusOK, []string{"runs", "0", "run", "run_id"}, "run_web_001")
assertStatusAndJSONField(t, handler, "/api/runs/run_web_001", http.StatusOK, []string{"run", "run", "run_id"}, "run_web_001")
assertStatusAndJSONField(t, handler, "/api/runs/run_web_001/tasks", http.StatusOK, []string{"tasks", "0", "task_id"}, "T1")
assertStatusAndJSONField(t, handler, "/api/runs/run_web_001/blocked", http.StatusOK, []string{"blocked", "0", "task", "task_id"}, "T1")
assertStatusAndJSONField(t, handler, "/api/threads/"+dispatch.Attempt.ThreadID, http.StatusOK, []string{"thread", "thread", "thread_id"}, dispatch.Attempt.ThreadID)
}
func TestRouterMapsNotFoundErrors(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
dbPath := filepath.Join(t.TempDir(), "coord.db")
sqlDB, err := dbpkg.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open db: %v", err)
}
defer sqlDB.Close()
if err := dbpkg.ApplyMigrations(ctx, sqlDB); err != nil {
t.Fatalf("apply migrations: %v", err)
}
handler := NewRouter(app.NewWebService(sqlDB))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/runs/missing-run", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
code := nestedString(t, payload, "error", "code")
if code != "not_found" {
t.Fatalf("expected not_found error code, got %q", code)
}
}
func assertStatusAndJSONField(t *testing.T, handler http.Handler, path string, wantStatus int, fieldPath []string, want string) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
handler.ServeHTTP(rec, req)
if rec.Code != wantStatus {
t.Fatalf("GET %s: expected status %d, got %d", path, wantStatus, rec.Code)
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("GET %s: decode response: %v", path, err)
}
got := nestedString(t, payload, fieldPath...)
if got != want {
t.Fatalf("GET %s: expected %q at %v, got %q", path, want, fieldPath, got)
}
}
func nestedString(t *testing.T, value any, path ...string) string {
t.Helper()
current := value
for _, part := range path {
switch typed := current.(type) {
case map[string]any:
current = typed[part]
case []any:
if len(part) != 1 || part[0] < '0' || part[0] > '9' {
t.Fatalf("path segment %q is not a numeric index", part)
}
index := int(part[0] - '0')
if index >= len(typed) {
t.Fatalf("index %d out of range for path %v", index, path)
}
current = typed[index]
default:
t.Fatalf("unsupported type %T at path %v", current, path)
}
}
got, ok := current.(string)
if !ok {
t.Fatalf("expected string at path %v, got %T", path, current)
}
return got
}