package httpapi import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "inbox/internal/base/timeutil" sqlitestore "inbox/internal/store/sqlite" ) func TestResourceAPIFullFlow(t *testing.T) { clock := timeutil.FixedClock{Time: time.Date(2026, 3, 13, 18, 0, 0, 0, time.UTC)} store, err := sqlitestore.OpenInMemory(clock) if err != nil { t.Fatalf("OpenInMemory() error = %v", err) } defer store.Close() handler := NewHandler(store, clock) workspaceRoot := t.TempDir() roleResp := mustDoJSONResponse(t, handler, http.MethodPut, "/api/v2/config/roles/leader", map[string]any{ "title": "Leader", "is_enabled": true, "is_builtin": true, "updated_by": "tester", }) _ = roleResp mustDoJSON(t, handler, http.MethodPut, "/api/v2/config/roles/worker", map[string]any{ "title": "Worker", "is_enabled": true, "is_builtin": true, "updated_by": "tester", }) mustDoJSON(t, handler, http.MethodPut, "/api/v2/config/roles/worker/config", map[string]any{ "config_toml": "model = \"gpt-5.4\"", "auth_json": "{\"OPENAI_API_KEY\":\"token-1\"}", "updated_by": "tester", }) projectResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/projects", map[string]any{ "slug": "demo", "name": "Demo", "root_path": workspaceRoot, "default_branch": "main", }) var project struct { ID string `json:"id"` } decodeBody(t, projectResp.Body.Bytes(), &project) workspaceResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/workspaces", map[string]any{ "project_id": project.ID, "slug": "main", "name": "Main", "root_path": workspaceRoot, "base_branch": "main", "worktree_branch": "worktree/main", }) var ws struct { ID string `json:"id"` } decodeBody(t, workspaceResp.Body.Bytes(), &ws) topicResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/topics", map[string]any{ "workspace_id": ws.ID, "title": "Signup Flow", "space": "workflow", "status": "execution", }) var topic struct { ID string `json:"id"` } decodeBody(t, topicResp.Body.Bytes(), &topic) msgResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/topics/"+topic.ID+"/messages", map[string]any{ "workspace_id": ws.ID, "from_role_name": "leader", "to_expr": "worker", "type": "decision", "stage": "execution", "body_markdown": "Please implement signup.", }) var msg struct { ID string `json:"id"` } decodeBody(t, msgResp.Body.Bytes(), &msg) laneResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/topics/"+topic.ID+"/lanes", map[string]any{ "workspace_id": ws.ID, "name": "UI Chain", "slug": "ui-chain", "status": "ready", "base_branch": "main", "created_by_role_name": "leader", }) var laneRecord struct { ID string `json:"id"` } decodeBody(t, laneResp.Body.Bytes(), &laneRecord) if laneRecord.ID == "" { t.Fatal("expected lane id") } taskResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/topics/"+topic.ID+"/tasks", map[string]any{ "workspace_id": ws.ID, "lane_id": laneRecord.ID, "title": "Implement signup flow", "body_markdown": "Create leader-managed signup execution.", "status": "ready", "priority": 9, "task_order": 1, "created_by_role_name": "leader", }) var taskRecord struct { ID string `json:"id"` } decodeBody(t, taskResp.Body.Bytes(), &taskRecord) if taskRecord.ID == "" { t.Fatal("expected task id") } runResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/topics/"+topic.ID+"/workflow-runs", map[string]any{ "workspace_id": ws.ID, "role_name": "worker", "stage": "execution", "mode": "once", "request_message_id": msg.ID, }) var run struct { ID string `json:"id"` ConfigSnapshotJSON string `json:"config_snapshot_json"` } decodeBody(t, runResp.Body.Bytes(), &run) if run.ID == "" || run.ConfigSnapshotJSON == "" || run.ConfigSnapshotJSON == "{}" { t.Fatalf("unexpected workflow run response: %#v", run) } runDetailReq := httptest.NewRequest(http.MethodGet, "/api/v2/workflow-runs/"+run.ID, nil) runDetailRec := httptest.NewRecorder() handler.ServeHTTP(runDetailRec, runDetailReq) if runDetailRec.Code != http.StatusOK { t.Fatalf("GET workflow run status = %d body=%s", runDetailRec.Code, runDetailRec.Body.String()) } mustDoJSONResponse(t, handler, http.MethodPatch, "/api/v2/workflow-runs/"+run.ID, map[string]any{ "status": "succeeded", "exit_code": 0, }) mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/workflow-runs/"+run.ID+"/logs", map[string]any{ "stream": "stdout", "content": "step 1", }) mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/workflow-runs/"+run.ID+"/logs", map[string]any{ "stream": "stderr", "content": "step 2", }) checkTopicList := httptest.NewRequest(http.MethodGet, "/api/v2/topics?workspace_id="+ws.ID, nil) checkTopicRec := httptest.NewRecorder() handler.ServeHTTP(checkTopicRec, checkTopicList) if checkTopicRec.Code != http.StatusOK { t.Fatalf("GET /api/v2/topics status = %d body=%s", checkTopicRec.Code, checkTopicRec.Body.String()) } checkRuns := httptest.NewRequest(http.MethodGet, "/api/v2/topics/"+topic.ID+"/workflow-runs", nil) checkRunsRec := httptest.NewRecorder() handler.ServeHTTP(checkRunsRec, checkRuns) if checkRunsRec.Code != http.StatusOK { t.Fatalf("GET workflow runs status = %d body=%s", checkRunsRec.Code, checkRunsRec.Body.String()) } checkRunLogs := httptest.NewRequest(http.MethodGet, "/api/v2/workflow-runs/"+run.ID+"/logs?after_seq=0", nil) checkRunLogsRec := httptest.NewRecorder() handler.ServeHTTP(checkRunLogsRec, checkRunLogs) if checkRunLogsRec.Code != http.StatusOK { t.Fatalf("GET workflow run logs status = %d body=%s", checkRunLogsRec.Code, checkRunLogsRec.Body.String()) } checkLanes := httptest.NewRequest(http.MethodGet, "/api/v2/topics/"+topic.ID+"/lanes", nil) checkLanesRec := httptest.NewRecorder() handler.ServeHTTP(checkLanesRec, checkLanes) if checkLanesRec.Code != http.StatusOK { t.Fatalf("GET lanes status = %d body=%s", checkLanesRec.Code, checkLanesRec.Body.String()) } checkTasks := httptest.NewRequest(http.MethodGet, "/api/v2/topics/"+topic.ID+"/tasks", nil) checkTasksRec := httptest.NewRecorder() handler.ServeHTTP(checkTasksRec, checkTasks) if checkTasksRec.Code != http.StatusOK { t.Fatalf("GET tasks status = %d body=%s", checkTasksRec.Code, checkTasksRec.Body.String()) } } func mustDoJSONResponse(t *testing.T, handler http.Handler, method, path string, payload map[string]any) *httptest.ResponseRecorder { t.Helper() body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal payload: %v", err) } req := httptest.NewRequest(method, path, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK && rec.Code != http.StatusCreated { t.Fatalf("%s %s status = %d body=%s", method, path, rec.Code, rec.Body.String()) } return rec } func decodeBody(t *testing.T, body []byte, dst any) { t.Helper() if err := json.Unmarshal(body, dst); err != nil { t.Fatalf("decode body: %v body=%s", err, string(body)) } }