package httpapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "time" dbpkg "ai-workflow-skill/packages/coord-core/db" "ai-workflow-skill/packages/coord-core/store" "ai-workflow-skill/packages/operator-api/internal/app" ) // TestRouterExposesReadOnlyWebEndpoints verifies the read-only web endpoints return seeded coordination data. 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) } // TestRouterMapsNotFoundErrors verifies missing resources map to a not_found API error. 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 }