Files
ai-workflow/inbox/internal/httpapi/resources_test.go
T

221 lines
7.3 KiB
Go

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