Files
Agent-System/internal/controlcenter/server/server_test.go
T
weichen 6714081b12 docs(control-center): P6 design spec, agent integration guide, acceptance report
Also fixes ListTasks ?q= filter 500 (duplicate $%d placeholder rendered as
%!d(MISSING)) found in P6 smoke; adds TestTaskListQueryFilter regression.
2026-09-17 12:59:12 +00:00

262 lines
9.4 KiB
Go

package server
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/controlcenter/config"
"chat-agent-system/internal/controlcenter/store"
)
const (
testConsoleToken = "test-console-token"
testAgentToken = "test-agent-token-a"
testAgentTokenB = "test-agent-token-b"
)
func newTestServer(t *testing.T) (*httptest.Server, *store.Store) {
t.Helper()
dsn := os.Getenv("TEST_CC_DB_DSN")
if dsn == "" {
t.Skip("TEST_CC_DB_DSN not set")
}
_, thisFile, _, _ := runtime.Caller(0)
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "deploy", "control-center", "goose")
ctx := t.Context()
require.NoError(t, store.Migrate(ctx, dsn, migrationsDir))
st, err := store.Connect(ctx, dsn)
require.NoError(t, err)
t.Cleanup(st.Close)
require.NoError(t, RegisterAgent(ctx, st, "agent-a", "test", testAgentToken))
require.NoError(t, RegisterAgent(ctx, st, "agent-b", "test", testAgentTokenB))
srv := New(config.Config{ConsoleToken: testConsoleToken}, st)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, st
}
func doReq(t *testing.T, method, url, token string, body any) (int, []byte) {
t.Helper()
var rdr io.Reader
if body != nil {
raw, err := json.Marshal(body)
require.NoError(t, err)
rdr = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, url, rdr)
require.NoError(t, err)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, raw
}
func decodeBody(t *testing.T, raw []byte) map[string]any {
t.Helper()
var m map[string]any
require.NoError(t, json.Unmarshal(raw, &m))
return m
}
func slug(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
// createAssignedTask makes a task and assigns it to agent-a, returning its id.
func createAssignedTask(t *testing.T, ts *httptest.Server, testingRequired bool) string {
t.Helper()
code, raw := doReq(t, "POST", ts.URL+"/api/v1/tasks", testConsoleToken, map[string]any{
"slug": slug("it"), "title": "integration test task", "testing_required": testingRequired,
})
require.Equal(t, http.StatusCreated, code, string(raw))
id := decodeBody(t, raw)["id"].(string)
code, raw = doReq(t, "PATCH", ts.URL+"/api/v1/tasks/"+id, testConsoleToken, map[string]any{"assign": "agent-a"})
require.Equal(t, http.StatusOK, code, string(raw))
return id
}
func claimTask(t *testing.T, ts *httptest.Server, id string) {
t.Helper()
code, raw := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/claim", testAgentToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
require.Equal(t, "in_progress", decodeBody(t, raw)["stage_state"])
}
func TestTaskListQueryFilter(t *testing.T) {
ts, _ := newTestServer(t)
s := slug("qfilter")
code, raw := doReq(t, "POST", ts.URL+"/api/v1/tasks", testConsoleToken, map[string]any{
"slug": s, "title": "query filter regression task",
})
require.Equal(t, http.StatusCreated, code, string(raw))
code, raw = doReq(t, "GET", ts.URL+"/api/v1/tasks?q="+s, testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
var list []map[string]any
require.NoError(t, json.Unmarshal(raw, &list))
require.Len(t, list, 1)
assert.Equal(t, s, list[0]["slug"])
}
func TestAgentHasNoAdvanceRoute(t *testing.T) {
ts, _ := newTestServer(t)
id := createAssignedTask(t, ts, true)
claimTask(t, ts, id)
code, _ := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/stages/advance", testAgentToken, map[string]any{})
// The SPA catch-all "GET /" makes the mux answer 405 (Allow: GET) for
// unmatched POST paths; either way the agent must have no advance route.
assert.Contains(t, []int{http.StatusNotFound, http.StatusMethodNotAllowed}, code,
"agents must not have any stage-advance route")
// And the task is untouched.
code, raw := doReq(t, "GET", ts.URL+"/api/v1/tasks/"+id, testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "in_progress", decodeBody(t, raw)["stage_state"])
}
func TestAdvanceRequestGate(t *testing.T) {
ts, _ := newTestServer(t)
id := createAssignedTask(t, ts, true)
claimTask(t, ts, id)
code, raw := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/advance-request", testAgentToken,
map[string]any{"summary": "proposal ready"})
require.Equal(t, http.StatusOK, code, string(raw))
m := decodeBody(t, raw)
assert.Equal(t, "awaiting_review", m["stage_state"])
assert.Equal(t, "proposal", m["stage"], "advance-request must never move the stage")
// Second request is rejected: no longer in_progress.
code, _ = doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/advance-request", testAgentToken,
map[string]any{"summary": "again"})
assert.Equal(t, http.StatusConflict, code)
}
func TestDecisionValidation(t *testing.T) {
ts, _ := newTestServer(t)
id := createAssignedTask(t, ts, true)
claimTask(t, ts, id)
url := ts.URL + "/api/v1/agent/tasks/" + id + "/decisions"
code, _ := doReq(t, "POST", url, testAgentToken, map[string]any{"question": "", "suggestions": []any{map[string]any{"option": "a", "rationale": "b", "impact": "c"}}})
assert.Equal(t, http.StatusBadRequest, code, "empty question")
code, _ = doReq(t, "POST", url, testAgentToken, map[string]any{"question": "q", "suggestions": []any{}})
assert.Equal(t, http.StatusBadRequest, code, "no suggestions")
code, _ = doReq(t, "POST", url, testAgentToken, map[string]any{
"question": "q",
"suggestions": []any{map[string]any{"option": "a", "rationale": "", "impact": "c"}},
})
assert.Equal(t, http.StatusBadRequest, code, "suggestion missing rationale")
}
func TestClaimConflict(t *testing.T) {
ts, _ := newTestServer(t)
id := createAssignedTask(t, ts, true)
// Assigned to agent-a; agent-b must not claim it.
code, _ := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/claim", testAgentTokenB, nil)
assert.Equal(t, http.StatusConflict, code)
claimTask(t, ts, id)
// Re-claim while the lease is held is a conflict too.
code, _ = doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/claim", testAgentToken, nil)
assert.Equal(t, http.StatusConflict, code)
}
func TestDecisionResolveUnblocks(t *testing.T) {
ts, _ := newTestServer(t)
id := createAssignedTask(t, ts, true)
claimTask(t, ts, id)
code, raw := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/decisions", testAgentToken, map[string]any{
"question": "which option?",
"suggestions": []any{
map[string]any{"option": "A", "rationale": "simpler", "impact": "less code"},
map[string]any{"option": "B", "rationale": "flexible", "impact": "more code"},
},
})
require.Equal(t, http.StatusCreated, code, string(raw))
decisionID := decodeBody(t, raw)["id"].(string)
code, raw = doReq(t, "GET", ts.URL+"/api/v1/tasks/"+id, testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
require.Equal(t, "blocked_pending_decision", decodeBody(t, raw)["status"])
// Resolve by picking suggestion 1.
code, raw = doReq(t, "POST", ts.URL+"/api/v1/decisions/"+decisionID+"/resolve", testConsoleToken,
map[string]any{"suggestion_index": 1})
require.Equal(t, http.StatusOK, code, string(raw))
assert.Equal(t, "resolved", decodeBody(t, raw)["status"])
assert.Equal(t, "B", decodeBody(t, raw)["resolution"])
code, raw = doReq(t, "GET", ts.URL+"/api/v1/tasks/"+id, testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "in_progress", decodeBody(t, raw)["status"], "resolve unblocks the task")
}
func TestConsoleAdvanceSkipsTestingAndFinishes(t *testing.T) {
ts, _ := newTestServer(t)
id := createAssignedTask(t, ts, false)
claimTask(t, ts, id)
expectedStages := []string{"analysis", "design", "review", "implementation", "evaluation", "confirmation"}
for _, want := range expectedStages {
code, raw := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/advance-request", testAgentToken,
map[string]any{"summary": "ready"})
require.Equal(t, http.StatusOK, code, string(raw))
code, raw = doReq(t, "POST", ts.URL+"/api/v1/tasks/"+id+"/stages/advance", testConsoleToken,
map[string]any{"note": "ok"})
require.Equal(t, http.StatusOK, code, string(raw))
m := decodeBody(t, raw)
assert.Equal(t, false, m["done"])
assert.Equal(t, want, m["task"].(map[string]any)["stage"])
assert.Equal(t, "assigned", m["task"].(map[string]any)["stage_state"])
code, raw = doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/claim", testAgentToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
}
// Final advance out of confirmation finishes the task.
code, raw := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/advance-request", testAgentToken,
map[string]any{"summary": "ready"})
require.Equal(t, http.StatusOK, code, string(raw))
code, raw = doReq(t, "POST", ts.URL+"/api/v1/tasks/"+id+"/stages/advance", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
m := decodeBody(t, raw)
assert.Equal(t, true, m["done"])
task := m["task"].(map[string]any)
assert.Equal(t, "done", task["status"])
assert.Equal(t, "approved", task["stage_state"])
// Console advance from a wrong state is rejected.
id2 := createAssignedTask(t, ts, false)
code, _ = doReq(t, "POST", ts.URL+"/api/v1/tasks/"+id2+"/stages/advance", testConsoleToken, nil)
assert.Equal(t, http.StatusConflict, code, "advance requires awaiting_review")
}