Also fixes ListTasks ?q= filter 500 (duplicate $%d placeholder rendered as %!d(MISSING)) found in P6 smoke; adds TestTaskListQueryFilter regression.
262 lines
9.4 KiB
Go
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")
|
|
}
|