221 lines
7.3 KiB
Go
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))
|
|
}
|
|
}
|