Files
multica/server/internal/handler/handler_test.go
T
Jiayuan Zhang 877a71568f test(server): add internal/testutil and migrate the eight heaviest handler suites (MUL-6361) (#7160)
internal/handler held roughly 1000 hand-written `INSERT ... RETURNING id` blocks with a matching `t.Cleanup(DELETE ...)`, and roughly 1200 copies of the recorder / call / status-check / decode quartet. Each copy was an independent statement of the same contract, so changing a shared one meant editing every copy.

internal/testutil holds the two halves of that contract once: Fixture builds rows for a test workspace and deletes them again when the test ends; Call runs one handler and records what it wrote, with Want asserting the status and JSON decoding the payload.

The eight heaviest suites move onto it: 19,622 lines -> 16,984 (-13.4%). Assertions whose message says something the shared one cannot stay where the test wrote them.

Verified by comparing what actually ran: the package's test inventory is identical before and after, 2822 PASS / 44 SKIP / 0 FAIL over the same test names, and go test -race is green for both packages.
2026-08-19 03:09:59 +08:00

3711 lines
132 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/realtime"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/testutil"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
var testHandler *Handler
var testPool *pgxpool.Pool
var testUserID string
var testWorkspaceID string
var testRuntimeID string
// dbfx builds the rows these tests act on and removes them again when the test
// that asked for them ends. New tests build fixtures through it rather than
// writing an INSERT and a matching DELETE by hand; see internal/testutil.
var dbfx *testutil.Fixture
const (
handlerTestEmail = "[email protected]"
handlerTestName = "Handler Test User"
handlerTestWorkspaceSlug = "handler-tests"
)
func TestMain(m *testing.M) {
ctx := context.Background()
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
}
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
fmt.Printf("Skipping tests: could not connect to database: %v\n", err)
os.Exit(0)
}
if err := pool.Ping(ctx); err != nil {
fmt.Printf("Skipping tests: database not reachable: %v\n", err)
pool.Close()
os.Exit(0)
}
queries := db.New(pool)
hub := realtime.NewHub()
go hub.Run()
bus := events.New()
emailSvc := service.NewEmailService()
testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil, analytics.NoopClient{}, Config{AllowSignup: true})
// httptest.NewRequest defaults RemoteAddr to 192.0.2.1, so every webhook
// test in the suite shares one IP bucket. With the production default
// (30/min) the budget runs out partway through the suite and unrelated
// downstream tests see a 429 from the IP gate instead of the response
// they're asserting. Tests that exercise rate limiting deliberately
// swap in a tight limiter with t.Cleanup; this generous default keeps
// the rest of the suite hermetic.
testHandler.WebhookRateLimiter = NewMemoryWebhookRateLimiter(WebhookRateLimit{Limit: 1_000_000, Window: time.Minute})
testHandler.WebhookIPRateLimiter = NewMemoryWebhookIPRateLimiter(WebhookRateLimit{Limit: 1_000_000, Window: time.Minute})
testHandler.WebhookAbsoluteIPRateLimiter = NewMemoryWebhookAbsoluteIPRateLimiter(WebhookRateLimit{Limit: 1_000_000, Window: time.Minute})
testHandler.InvitationRateLimiters = NewMemoryInvitationRateLimiters(InvitationRateLimits{
Actor: SlidingWindowRateLimit{Limit: 1_000_000, Window: time.Minute},
Workspace: SlidingWindowRateLimit{Limit: 1_000_000, Window: time.Minute},
Recipient: SlidingWindowRateLimit{Limit: 1_000_000, Window: time.Minute},
})
testPool = pool
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
if err != nil {
fmt.Printf("Failed to set up handler test fixture: %v\n", err)
pool.Close()
os.Exit(1)
}
dbfx = testutil.New(pool, testWorkspaceID, testUserID)
code := m.Run()
if err := cleanupHandlerTestFixture(context.Background(), pool); err != nil {
fmt.Printf("Failed to clean up handler test fixture: %v\n", err)
if code == 0 {
code = 1
}
}
pool.Close()
os.Exit(code)
}
func setupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) (string, string, error) {
if err := cleanupHandlerTestFixture(ctx, pool); err != nil {
return "", "", err
}
var userID string
if err := pool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ($1, $2)
RETURNING id
`, handlerTestName, handlerTestEmail).Scan(&userID); err != nil {
return "", "", err
}
var workspaceID string
if err := pool.QueryRow(ctx, `
INSERT INTO workspace (name, slug, description, issue_prefix)
VALUES ($1, $2, $3, $4)
RETURNING id
`, "Handler Tests", handlerTestWorkspaceSlug, "Temporary workspace for handler tests", "HAN").Scan(&workspaceID); err != nil {
return "", "", err
}
if _, err := pool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'owner')
`, workspaceID, userID); err != nil {
return "", "", err
}
var runtimeID string
if err := pool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, owner_id, last_seen_at
)
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, $5, now())
RETURNING id
`, workspaceID, "Handler Test Runtime", "handler_test_runtime", "Handler test runtime", userID).Scan(&runtimeID); err != nil {
return "", "", err
}
testRuntimeID = runtimeID
var seededAgentID string
if err := pool.QueryRow(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, permission_mode, max_concurrent_tasks, owner_id
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 'public_to', 1, $4)
RETURNING id
`, workspaceID, "Handler Test Agent", runtimeID, userID).Scan(&seededAgentID); err != nil {
return "", "", err
}
// MUL-3963: the seeded workspace-visible agent is invocable by workspace
// members and A2A triggers, so seed its workspace invocation target.
if _, err := pool.Exec(ctx, `
INSERT INTO agent_invocation_target (agent_id, target_type, target_id)
VALUES ($1, 'workspace', $2)
ON CONFLICT (agent_id, target_type, target_id) DO NOTHING
`, seededAgentID, workspaceID); err != nil {
return "", "", err
}
return userID, workspaceID, nil
}
func cleanupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) error {
var hasClientUsageTable bool
if err := pool.QueryRow(ctx, `SELECT to_regclass('client_usage_daily') IS NOT NULL`).Scan(&hasClientUsageTable); err != nil {
return err
}
if hasClientUsageTable {
if _, err := pool.Exec(ctx, `DELETE FROM client_usage_daily WHERE user_id IN (SELECT id FROM "user" WHERE email = $1)`, handlerTestEmail); err != nil {
return err
}
}
if _, err := pool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, handlerTestWorkspaceSlug); err != nil {
return err
}
if _, err := pool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, handlerTestEmail); err != nil {
return err
}
return nil
}
func newRequest(method, path string, body any) *http.Request {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", testUserID)
req.Header.Set("X-Workspace-ID", testWorkspaceID)
return req
}
func withURLParam(req *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
func setWorkspaceIssuePrefixForTest(t *testing.T, prefix string) {
t.Helper()
var previous string
dbfx.QueryRow(t, `SELECT issue_prefix FROM workspace WHERE id = $1`, testWorkspaceID).Scan(&previous)
dbfx.Exec(t, `UPDATE workspace SET issue_prefix = $1 WHERE id = $2`, prefix, testWorkspaceID)
t.Cleanup(func() {
testPool.Exec(context.Background(), `UPDATE workspace SET issue_prefix = $1 WHERE id = $2`, previous, testWorkspaceID)
})
}
func handlerTestRuntimeID(t *testing.T) string {
t.Helper()
var runtimeID string
dbfx.QueryRow(t,
`SELECT id FROM agent_runtime WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&runtimeID)
return runtimeID
}
func createHandlerTestAgent(t *testing.T, name string, mcpConfig []byte) string {
t.Helper()
agentID := dbfx.Agent(t, name, handlerTestRuntimeID(t), testutil.Cols{
"visibility": "workspace",
"permission_mode": "public_to",
"instructions": "",
"custom_env": testutil.Raw("'{}'::jsonb"),
"custom_args": testutil.Raw("'[]'::jsonb"),
"mcp_config": mcpConfig,
})
// Generic test agents are workspace-invocable (MUL-3963): seed the
// matching workspace invocation target so canInvokeAgent admits workspace
// members and A2A triggers, mirroring the pre-permission-model behavior
// where a workspace-visible agent could be triggered by anyone in the
// workspace. Dedicated private-agent tests use privateAgentTestFixture.
dbfx.Exec(t, `
INSERT INTO agent_invocation_target (agent_id, target_type, target_id)
VALUES ($1, 'workspace', $2)
ON CONFLICT (agent_id, target_type, target_id) DO NOTHING
`, agentID, testWorkspaceID)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
})
return agentID
}
// createHandlerTestTaskForAgent seeds a running agent_task_queue row for the
// given agent (with no associated issue) and returns the task UUID. Used by
// tests that need to set X-Task-ID alongside X-Agent-ID — resolveActor now
// requires the pair to be present and consistent before granting "agent"
// actor identity.
func createHandlerTestTaskForAgent(t *testing.T, agentID string) string {
return createHandlerTestTaskForAgentOnIssue(t, agentID, "")
}
// createHandlerTestTaskForAgentOnIssue seeds a running agent_task_queue row
// for the given agent, optionally bound to an issue (pass "" to leave
// issue_id NULL). The bound-issue form is needed by the self-loop guard
// test, which compares the calling task's issue_id against the promoted
// issue — only a same-issue match counts as a true self-loop.
//
// Status is 'running' because X-Task-ID is something a currently-executing
// task sends. Using 'running' also keeps the seed outside the
// idx_one_pending_task_per_issue_agent unique index (queued/dispatched only)
// and outside callers' `status='queued'` count assertions, so tests can
// assert that the handler did or did not enqueue a NEW task without
// double-counting the seed.
func createHandlerTestTaskForAgentOnIssue(t *testing.T, agentID, issueID string) string {
t.Helper()
var issueArg any
if issueID == "" {
issueArg = nil
} else {
issueArg = issueID
}
taskID := dbfx.Task(t, agentID, testutil.Cols{
"runtime_id": handlerTestRuntimeID(t),
"status": "running",
"issue_id": issueArg,
"started_at": testutil.Raw("now()"),
})
return taskID
}
func fetchAgentMcpConfig(t *testing.T, agentID string) []byte {
t.Helper()
var mcpConfig []byte
dbfx.QueryRow(t, `SELECT mcp_config FROM agent WHERE id = $1`, agentID).Scan(&mcpConfig)
return mcpConfig
}
func assertJSONEqual(t *testing.T, got []byte, want string) {
t.Helper()
var gotValue any
if err := json.Unmarshal(got, &gotValue); err != nil {
t.Fatalf("failed to unmarshal got JSON %q: %v", string(got), err)
}
var wantValue any
if err := json.Unmarshal([]byte(want), &wantValue); err != nil {
t.Fatalf("failed to unmarshal want JSON %q: %v", want, err)
}
gotJSON, err := json.Marshal(gotValue)
if err != nil {
t.Fatalf("failed to marshal normalized got JSON: %v", err)
}
wantJSON, err := json.Marshal(wantValue)
if err != nil {
t.Fatalf("failed to marshal normalized want JSON: %v", err)
}
if string(gotJSON) != string(wantJSON) {
t.Fatalf("expected JSON %s, got %s", string(wantJSON), string(gotJSON))
}
}
func TestIssueCRUD(t *testing.T) {
// Create
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Test issue from Go test",
"status": "todo",
"priority": "medium",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
if created.Title != "Test issue from Go test" {
t.Fatalf("CreateIssue: expected title 'Test issue from Go test', got '%s'", created.Title)
}
if created.Status != "todo" {
t.Fatalf("CreateIssue: expected status 'todo', got '%s'", created.Status)
}
issueID := created.ID
// Get
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.GetIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
var fetched IssueResponse
json.NewDecoder(w.Body).Decode(&fetched)
if fetched.ID != issueID {
t.Fatalf("GetIssue: expected id '%s', got '%s'", issueID, fetched.ID)
}
// Update - partial (only status)
w = httptest.NewRecorder()
status := "in_progress"
req = newRequest("PUT", "/api/issues/"+issueID, map[string]any{
"status": status,
})
req = withURLParam(req, "id", issueID)
testHandler.UpdateIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
var updated IssueResponse
json.NewDecoder(w.Body).Decode(&updated)
if updated.Status != "in_progress" {
t.Fatalf("UpdateIssue: expected status 'in_progress', got '%s'", updated.Status)
}
if updated.Title != "Test issue from Go test" {
t.Fatalf("UpdateIssue: title should be preserved, got '%s'", updated.Title)
}
if updated.Priority != "medium" {
t.Fatalf("UpdateIssue: priority should be preserved, got '%s'", updated.Priority)
}
// List
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues?workspace_id="+testWorkspaceID, nil)
testHandler.ListIssues(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListIssues: expected 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]any
json.NewDecoder(w.Body).Decode(&listResp)
issues := listResp["issues"].([]any)
if len(issues) == 0 {
t.Fatal("ListIssues: expected at least 1 issue")
}
// Delete
w = httptest.NewRecorder()
req = newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.DeleteIssue(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("DeleteIssue: expected 204, got %d: %s", w.Code, w.Body.String())
}
// Verify deleted
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.GetIssue(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("GetIssue after delete: expected 404, got %d", w.Code)
}
}
// TestDeleteIssueByIdentifier guards against #1661 — DELETE /api/issues/{id}
// must actually delete the row when the path segment is a human-readable
// identifier ("HAN-42") rather than a UUID. Before the PR #1680 + MUL-1410
// refactor, parseUUID(rawString) silently produced a zero UUID, the SQL
// DELETE matched nothing, and the handler still returned 204.
//
// Also asserts the issue:deleted WS event payload carries the resolved UUID,
// not the raw identifier — frontend caches key by UUID and would otherwise
// leave stale entries on other clients after an identifier-path delete.
func TestDeleteIssueByIdentifier(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Issue to delete by identifier",
"status": "todo",
"priority": "medium",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
if created.Identifier == "" {
t.Fatalf("CreateIssue: expected identifier to be populated, got empty")
}
// Capture the issue:deleted event payload via the bus.
gotPayload := make(chan map[string]any, 1)
testHandler.Bus.Subscribe(protocol.EventIssueDeleted, func(e events.Event) {
if payload, ok := e.Payload.(map[string]any); ok {
select {
case gotPayload <- payload:
default:
}
}
})
// Delete using the human-readable identifier (e.g. "HAN-1") rather than the UUID.
req = newRequest("DELETE", "/api/issues/"+created.Identifier, nil)
req = withURLParam(req, "id", created.Identifier)
w = testutil.Call(t, testHandler.DeleteIssue, req).Want(http.StatusNoContent)
// Verify the row is actually gone — the silent-data-loss bug would have
// returned 204 here too, but the row would still exist.
var count int
dbfx.QueryRow(t,
`SELECT COUNT(*) FROM issue WHERE id = $1`, created.ID,
).Scan(&count)
if count != 0 {
t.Fatalf("DeleteIssue by identifier returned 204 but row still exists (count=%d) — silent-data-loss regression", count)
}
// Event payload must carry the resolved UUID, not the identifier string.
select {
case payload := <-gotPayload:
issueID, _ := payload["issue_id"].(string)
if issueID != created.ID {
t.Fatalf("issue:deleted event payload issue_id = %q; want resolved UUID %q (must not leak identifier %q)", issueID, created.ID, created.Identifier)
}
case <-time.After(2 * time.Second):
t.Fatal("did not receive issue:deleted event within timeout")
}
}
// TestGetIssueByIdentifierEnforcesPrefix covers the contract behind the
// human-readable issue URL `/{ws}/issues/{key}`: the prefix is part of the key,
// not decoration. Identifier resolution used to compare the number only, so
// every prefix with the right number opened the same issue — which means no
// identifier URL could be treated as canonical, and a mistyped or stale prefix
// silently opened someone else's link target.
func TestGetIssueByIdentifierEnforcesPrefix(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Issue addressed by identifier",
"status": "todo",
"priority": "medium",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
idx := strings.LastIndex(created.Identifier, "-")
if idx <= 0 {
t.Fatalf("CreateIssue: unexpected identifier %q", created.Identifier)
}
prefix, number := created.Identifier[:idx], created.Identifier[idx+1:]
// The workspace's own prefix resolves, in either case — a hand-typed
// lowercase key from a chat message must open the same issue.
for _, id := range []string{created.Identifier, strings.ToLower(created.Identifier)} {
req = newRequest("GET", "/api/issues/"+id, nil)
req = withURLParam(req, "id", id)
w = testutil.Call(t, testHandler.GetIssue, req)
if w.Code != http.StatusOK {
t.Fatalf("GetIssue(%q): expected 200, got %d: %s", id, w.Code, w.Body.String())
}
var got IssueResponse
json.NewDecoder(w.Body).Decode(&got)
if got.ID != created.ID {
t.Fatalf("GetIssue(%q): resolved to %s, want %s", id, got.ID, created.ID)
}
}
// A foreign prefix carrying the right number must not resolve.
foreign := prefix + "X-" + number
req = newRequest("GET", "/api/issues/"+foreign, nil)
req = withURLParam(req, "id", foreign)
w = testutil.Call(t, testHandler.GetIssue, req)
if w.Code != http.StatusNotFound {
t.Fatalf("GetIssue(%q): expected 404 for a foreign prefix, got %d: %s", foreign, w.Code, w.Body.String())
}
}
// TestDeleteIssueRejectsInvalidUUID verifies that a path segment that is
// neither a valid UUID nor a valid identifier returns 404 (not 204) — the
// handler must never silently succeed on malformed input.
func TestDeleteIssueRejectsInvalidUUID(t *testing.T) {
req := newRequest("DELETE", "/api/issues/not-a-uuid-or-identifier", nil)
req = withURLParam(req, "id", "not-a-uuid-or-identifier")
w := testutil.Call(t, testHandler.DeleteIssue, req)
if w.Code == http.StatusNoContent {
t.Fatalf("DeleteIssue with invalid id: must not return 204; got %d", w.Code)
}
if w.Code != http.StatusNotFound {
t.Fatalf("DeleteIssue with invalid id: expected 404, got %d: %s", w.Code, w.Body.String())
}
}
// TestCreateIssueDefaultStatusIsTodo verifies that issues created without an
// explicit status default to "todo" so the daemon picks them up immediately.
// Before this fix the default was "backlog", which daemons ignore.
func TestCreateIssueDefaultStatusIsTodo(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Issue with no explicit status",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
if created.Status != "todo" {
t.Fatalf("CreateIssue: expected default status 'todo', got '%s'", created.Status)
}
// Cleanup
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
// TestCreateIssueExplicitBacklogPreserved verifies that explicitly requesting
// "backlog" status is still respected — only the implicit default changed.
func TestCreateIssueExplicitBacklogPreserved(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Explicit backlog issue",
"status": "backlog",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
if created.Status != "backlog" {
t.Fatalf("CreateIssue: expected explicit 'backlog' to be preserved, got '%s'", created.Status)
}
// Cleanup
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
// TestCreateIssueRejectsCrossWorkspaceParent guards the workspace
// boundary check that lives in service.IssueService.Create. A request
// that pins parent_issue_id to an issue in a foreign workspace must be
// rejected before the row is created — this is the structural reason
// IssueService owns the parent lookup (not the HTTP handler). The test
// inserts a foreign workspace + issue directly via SQL, then drives the
// request through the regular handler entry point.
func TestCreateIssueRejectsCrossWorkspaceParent(t *testing.T) {
otherWorkspaceID := dbfx.Insert(t, "workspace", testutil.Cols{
"name": "Cross-workspace parent test",
"slug": "xwp-parent-test",
"description": "Foreign workspace",
"issue_prefix": "XWP",
})
foreignParentID := dbfx.Issue(t, "Foreign parent", testutil.Cols{
"workspace_id": otherWorkspaceID,
"number": 1,
})
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Should be rejected",
"parent_issue_id": foreignParentID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
if !strings.Contains(w.Body.String(), "parent issue not found in this workspace") {
t.Fatalf("CreateIssue with foreign parent: expected boundary error message, got %s", w.Body.String())
}
var count int
dbfx.QueryRow(t,
`SELECT COUNT(*) FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, "Should be rejected",
).Scan(&count)
if count != 0 {
t.Fatalf("rejected create still wrote a row (count=%d) — service-layer boundary check failed", count)
}
}
// TestCreateIssueRejectsCrossWorkspaceProject mirrors the parent test for
// the project workspace boundary. Same reasoning: future create entries
// (Lark /issue, MCP, API keys) must inherit this guard from the service
// without re-implementing it.
func TestCreateIssueRejectsCrossWorkspaceProject(t *testing.T) {
otherWorkspaceID := dbfx.Insert(t, "workspace", testutil.Cols{
"name": "Cross-workspace project test",
"slug": "xwp-project-test",
"description": "Foreign workspace",
"issue_prefix": "XWP",
})
foreignProjectID := dbfx.Project(t, "Foreign project", testutil.Cols{
"workspace_id": otherWorkspaceID,
})
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Should be rejected",
"project_id": foreignProjectID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
if !strings.Contains(w.Body.String(), "project not found in this workspace") {
t.Fatalf("CreateIssue with foreign project: expected boundary error message, got %s", w.Body.String())
}
var count int
dbfx.QueryRow(t,
`SELECT COUNT(*) FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, "Should be rejected",
).Scan(&count)
if count != 0 {
t.Fatalf("rejected create still wrote a row (count=%d) — service-layer boundary check failed", count)
}
}
func TestCreateSubIssueInheritsParentProject(t *testing.T) {
var projectID, parentID, childID string
defer func() {
for _, issueID := range []string{childID, parentID} {
if issueID == "" {
continue
}
req := newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.DeleteIssue(httptest.NewRecorder(), req)
}
if projectID != "" {
req := newRequest("DELETE", "/api/projects/"+projectID, nil)
req = withURLParam(req, "id", projectID)
testHandler.DeleteProject(httptest.NewRecorder(), req)
}
}()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Sub-issue inheritance project",
})
w := testutil.Call(t, testHandler.CreateProject, req).Want(http.StatusCreated)
var project ProjectResponse
json.NewDecoder(w.Body).Decode(&project)
projectID = project.ID
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Parent with project",
"project_id": projectID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var parent IssueResponse
json.NewDecoder(w.Body).Decode(&parent)
parentID = parent.ID
if parent.ProjectID == nil || *parent.ProjectID != projectID {
t.Fatalf("CreateIssue parent: expected project_id %q, got %v", projectID, parent.ProjectID)
}
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Child without explicit project",
"parent_issue_id": parentID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var child IssueResponse
json.NewDecoder(w.Body).Decode(&child)
childID = child.ID
if child.ParentIssueID == nil || *child.ParentIssueID != parentID {
t.Fatalf("CreateIssue child: expected parent_issue_id %q, got %v", parentID, child.ParentIssueID)
}
if child.ProjectID == nil || *child.ProjectID != projectID {
t.Fatalf("CreateIssue child: expected inherited project_id %q, got %v", projectID, child.ProjectID)
}
}
func TestCreateSubIssueUsesExplicitProjectOverParentProject(t *testing.T) {
var parentProjectID, childProjectID, parentID, childID string
defer func() {
for _, issueID := range []string{childID, parentID} {
if issueID == "" {
continue
}
req := newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.DeleteIssue(httptest.NewRecorder(), req)
}
for _, projectID := range []string{childProjectID, parentProjectID} {
if projectID == "" {
continue
}
req := newRequest("DELETE", "/api/projects/"+projectID, nil)
req = withURLParam(req, "id", projectID)
testHandler.DeleteProject(httptest.NewRecorder(), req)
}
}()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Parent project",
})
w := testutil.Call(t, testHandler.CreateProject, req).Want(http.StatusCreated)
var parentProject ProjectResponse
json.NewDecoder(w.Body).Decode(&parentProject)
parentProjectID = parentProject.ID
req = newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Child explicit project",
})
w = testutil.Call(t, testHandler.CreateProject, req).Want(http.StatusCreated)
var childProject ProjectResponse
json.NewDecoder(w.Body).Decode(&childProject)
childProjectID = childProject.ID
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Parent with project",
"project_id": parentProjectID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var parent IssueResponse
json.NewDecoder(w.Body).Decode(&parent)
parentID = parent.ID
if parent.ProjectID == nil || *parent.ProjectID != parentProjectID {
t.Fatalf("CreateIssue parent: expected project_id %q, got %v", parentProjectID, parent.ProjectID)
}
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Child with explicit project",
"parent_issue_id": parentID,
"project_id": childProjectID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var child IssueResponse
json.NewDecoder(w.Body).Decode(&child)
childID = child.ID
if child.ParentIssueID == nil || *child.ParentIssueID != parentID {
t.Fatalf("CreateIssue child: expected parent_issue_id %q, got %v", parentID, child.ParentIssueID)
}
if child.ProjectID == nil || *child.ProjectID != childProjectID {
t.Fatalf("CreateIssue child: expected explicit project_id %q, got %v", childProjectID, child.ProjectID)
}
}
func TestCreateIssueRejectsActiveDuplicate(t *testing.T) {
ctx := context.Background()
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
var projectID, parentID, issueID, duplicateID string
defer func() {
for _, id := range []string{duplicateID, issueID, parentID} {
if id != "" {
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, id)
}
}
if projectID != "" {
testPool.Exec(ctx, `DELETE FROM project WHERE id = $1`, projectID)
}
}()
dbfx.QueryRow(t, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id
`, testWorkspaceID, "Duplicate guard project "+suffix).Scan(&projectID)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Duplicate guard parent " + suffix,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var parent IssueResponse
w.JSON(&parent)
parentID = parent.ID
title := "SH-PM-SYNTH-01 Synthesize recommendation-to-shortlist planning outputs " + suffix
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "in_progress",
"parent_issue_id": parentID,
"project_id": projectID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var original IssueResponse
w.JSON(&original)
issueID = original.ID
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": " sh-pm-synth-01 synthesize recommendation-to-shortlist planning outputs " + suffix + " ",
"parent_issue_id": parentID,
"project_id": projectID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusConflict)
var conflict struct {
Code string `json:"code"`
Error string `json:"error"`
Issue IssueResponse `json:"issue"`
}
w.JSON(&conflict)
if conflict.Code != "active_duplicate_issue" {
t.Fatalf("code = %q, want active_duplicate_issue", conflict.Code)
}
if conflict.Issue.ID != issueID || conflict.Issue.Status != "in_progress" {
t.Fatalf("conflict issue = %#v, want original %s in_progress", conflict.Issue, issueID)
}
if !strings.Contains(conflict.Error, original.Identifier+" "+title) || !strings.Contains(conflict.Error, "allow_duplicate=true") || !strings.Contains(conflict.Error, "--allow-duplicate") {
t.Fatalf("unexpected duplicate message: %q", conflict.Error)
}
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"parent_issue_id": parentID,
"project_id": projectID,
"allow_duplicate": true,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var duplicate IssueResponse
w.JSON(&duplicate)
duplicateID = duplicate.ID
if duplicateID == issueID {
t.Fatalf("allow duplicate returned original issue id %s", duplicateID)
}
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"parent_issue_id": parentID,
"project_id": projectID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusConflict)
w.JSON(&conflict)
if conflict.Issue.ID != issueID {
t.Fatalf("conflict issue = %s, want oldest active issue %s", conflict.Issue.ID, issueID)
}
}
func TestCreateIssueAllowsDuplicateAfterCancelled(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Cancelled duplicate guard %d", time.Now().UnixNano())
var firstID, secondID string
defer func() {
for _, id := range []string{secondID, firstID} {
if id != "" {
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, id)
}
}
}()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "cancelled",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var first IssueResponse
w.JSON(&first)
firstID = first.ID
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var second IssueResponse
w.JSON(&second)
secondID = second.ID
if secondID == firstID {
t.Fatalf("new issue reused cancelled issue id %s", secondID)
}
}
func TestCreateIssueAllowsDuplicateAfterDone(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Done duplicate guard %d", time.Now().UnixNano())
var firstID, secondID string
defer func() {
for _, id := range []string{secondID, firstID} {
if id != "" {
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, id)
}
}
}()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "done",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var first IssueResponse
w.JSON(&first)
firstID = first.ID
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var second IssueResponse
w.JSON(&second)
secondID = second.ID
if secondID == firstID {
t.Fatalf("new issue reused done issue id %s", secondID)
}
}
func TestTriggerAutopilotAllowsActiveDuplicateIssue(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Autopilot duplicate issue %d", time.Now().UnixNano())
var autopilotID string
defer func() {
if autopilotID != "" {
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
}
testPool.Exec(ctx, `DELETE FROM issue WHERE workspace_id = $1 AND title = $2`, testWorkspaceID, title)
}()
var agentID string
dbfx.QueryRow(t, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "todo",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var existing IssueResponse
w.JSON(&existing)
req = newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Duplicate title autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
"issue_title_template": title,
})
w = testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusCreated)
var autopilot AutopilotResponse
w.JSON(&autopilot)
autopilotID = autopilot.ID
req = newRequest("POST", "/api/autopilots/"+autopilotID+"/trigger?workspace_id="+testWorkspaceID, nil)
req = withURLParam(req, "id", autopilotID)
w = testutil.Call(t, testHandler.TriggerAutopilot, req).Want(http.StatusOK)
var run AutopilotRunResponse
w.JSON(&run)
if run.Status != "issue_created" {
t.Fatalf("run status = %q, want issue_created", run.Status)
}
if run.IssueID == nil {
t.Fatal("run issue_id is nil, want newly created issue")
}
if *run.IssueID == existing.ID {
t.Fatalf("run reused existing issue %s, want a new issue", existing.ID)
}
if run.FailureReason != nil {
t.Fatalf("run failure_reason = %q, want nil", *run.FailureReason)
}
var count int
dbfx.QueryRow(t, `SELECT count(*) FROM issue WHERE workspace_id = $1 AND title = $2`, testWorkspaceID, title).Scan(&count)
if count != 2 {
t.Fatalf("autopilot should create a new same-title issue, got %d matching issues", count)
}
}
func TestScheduledAutopilotAllowsActiveDuplicateIssue(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Scheduled autopilot duplicate issue %d", time.Now().UnixNano())
var autopilotID string
defer func() {
if autopilotID != "" {
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
}
testPool.Exec(ctx, `DELETE FROM issue WHERE workspace_id = $1 AND title = $2`, testWorkspaceID, title)
}()
var agentID string
dbfx.QueryRow(t, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "todo",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var existing IssueResponse
w.JSON(&existing)
req = newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Scheduled duplicate title autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
"issue_title_template": title,
})
w = testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusCreated)
var autopilot AutopilotResponse
w.JSON(&autopilot)
autopilotID = autopilot.ID
queries := db.New(testPool)
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilotID))
if err != nil {
t.Fatalf("GetAutopilot: %v", err)
}
run, err := testHandler.AutopilotService.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "schedule", nil)
if err != nil {
t.Fatalf("DispatchAutopilot schedule duplicate: %v", err)
}
if run == nil || run.Status != "issue_created" {
t.Fatalf("dispatch result = %+v, want status issue_created", run)
}
newIssueID := uuidToString(run.IssueID)
if newIssueID == "" {
t.Fatal("run issue_id is empty, want newly created issue")
}
if newIssueID == existing.ID {
t.Fatalf("run reused existing issue %s, want a new issue", existing.ID)
}
if run.FailureReason.Valid {
t.Fatalf("run failure_reason = %q, want empty", run.FailureReason.String)
}
var count int
dbfx.QueryRow(t, `SELECT count(*) FROM issue WHERE workspace_id = $1 AND title = $2`, testWorkspaceID, title).Scan(&count)
if count != 2 {
t.Fatalf("autopilot should create a new same-title issue, got %d matching issues", count)
}
}
// TestAutopilotCreatedIssueCreatorIsAssigneeAgent locks in that an issue spawned
// by an autopilot reports the assignee agent — not the human who configured the
// autopilot — as its creator. The matching issue:created event must carry the
// same actor identity so downstream activity / notification listeners stay in
// sync with the issue row.
func TestAutopilotCreatedIssueCreatorIsAssigneeAgent(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Autopilot creator attribution %d", time.Now().UnixNano())
var autopilotID, issueID string
defer func() {
if issueID != "" {
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
}
if autopilotID != "" {
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
}
}()
var agentID string
dbfx.QueryRow(t, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID)
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Creator attribution autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
"issue_title_template": title,
})
w := testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusCreated)
var autopilot AutopilotResponse
w.JSON(&autopilot)
autopilotID = autopilot.ID
if autopilot.CreatedByType != "member" || autopilot.CreatedByID != testUserID {
t.Fatalf("autopilot created_by = %s/%s, want member/%s", autopilot.CreatedByType, autopilot.CreatedByID, testUserID)
}
gotEvent := make(chan events.Event, 1)
testHandler.Bus.Subscribe(protocol.EventIssueCreated, func(e events.Event) {
select {
case gotEvent <- e:
default:
}
})
queries := db.New(testPool)
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilotID))
if err != nil {
t.Fatalf("GetAutopilot: %v", err)
}
run, err := testHandler.AutopilotService.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "manual", nil)
if err != nil {
t.Fatalf("DispatchAutopilot: %v", err)
}
if run == nil || run.Status != "issue_created" {
t.Fatalf("dispatch result = %+v, want status issue_created", run)
}
var creatorType, creatorID string
dbfx.QueryRow(t, `
SELECT id, creator_type, creator_id
FROM issue
WHERE workspace_id = $1 AND title = $2
ORDER BY created_at DESC
LIMIT 1
`, testWorkspaceID, title).Scan(&issueID, &creatorType, &creatorID)
if creatorType != "agent" {
t.Fatalf("issue creator_type = %q, want agent", creatorType)
}
if creatorID != agentID {
t.Fatalf("issue creator_id = %q, want assignee agent %q", creatorID, agentID)
}
select {
case ev := <-gotEvent:
if ev.ActorType != "agent" {
t.Fatalf("issue:created ActorType = %q, want agent", ev.ActorType)
}
if ev.ActorID != agentID {
t.Fatalf("issue:created ActorID = %q, want %q", ev.ActorID, agentID)
}
case <-time.After(2 * time.Second):
t.Fatal("did not receive issue:created event")
}
}
func TestAutopilotCreateIssueAssociatesConfiguredProject(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Autopilot project issue %d", time.Now().UnixNano())
var autopilotID, issueID, projectID string
defer func() {
if issueID != "" {
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
}
if autopilotID != "" {
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
}
if projectID != "" {
testPool.Exec(ctx, `DELETE FROM project WHERE id = $1`, projectID)
}
}()
dbfx.QueryRow(t, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id::text
`, testWorkspaceID, "Autopilot project target").Scan(&projectID)
var agentID string
dbfx.QueryRow(t, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID)
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Project-linked autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
"issue_title_template": title,
"project_id": projectID,
})
w := testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusCreated)
var autopilot AutopilotResponse
w.JSON(&autopilot)
autopilotID = autopilot.ID
if autopilot.ProjectID == nil || *autopilot.ProjectID != projectID {
t.Fatalf("autopilot project_id = %v, want %q", autopilot.ProjectID, projectID)
}
queries := db.New(testPool)
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilotID))
if err != nil {
t.Fatalf("GetAutopilot: %v", err)
}
run, err := testHandler.AutopilotService.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "manual", nil)
if err != nil {
t.Fatalf("DispatchAutopilot: %v", err)
}
if run == nil || !run.IssueID.Valid {
t.Fatalf("dispatch run = %+v, want linked issue", run)
}
issueID = uuidToString(run.IssueID)
var issueProjectID *string
dbfx.QueryRow(t, `
SELECT project_id::text
FROM issue
WHERE id = $1
`, issueID).Scan(&issueProjectID)
if issueProjectID == nil || *issueProjectID != projectID {
t.Fatalf("created issue project_id = %v, want %q", issueProjectID, projectID)
}
}
func TestAutopilotDispatchUsesCurrentProjectBinding(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Autopilot stale project issue %d", time.Now().UnixNano())
var autopilotID, issueID, projectAID, projectBID string
defer func() {
if issueID != "" {
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
}
if autopilotID != "" {
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
}
if projectAID != "" {
testPool.Exec(ctx, `DELETE FROM project WHERE id = $1`, projectAID)
}
if projectBID != "" {
testPool.Exec(ctx, `DELETE FROM project WHERE id = $1`, projectBID)
}
}()
dbfx.QueryRow(t, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id::text
`, testWorkspaceID, "Autopilot stale project A").Scan(&projectAID)
dbfx.QueryRow(t, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id::text
`, testWorkspaceID, "Autopilot stale project B").Scan(&projectBID)
var agentID string
dbfx.QueryRow(t, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID)
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Stale-project autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
"issue_title_template": title,
"project_id": projectAID,
})
w := testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusCreated)
var created AutopilotResponse
w.JSON(&created)
autopilotID = created.ID
queries := db.New(testPool)
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilotID))
if err != nil {
t.Fatalf("GetAutopilot: %v", err)
}
req = newRequest("PATCH", "/api/autopilots/"+autopilotID+"?workspace_id="+testWorkspaceID, map[string]any{
"project_id": projectBID,
})
req = withURLParam(req, "id", autopilotID)
w = testutil.Call(t, testHandler.UpdateAutopilot, req).Want(http.StatusOK)
run, err := testHandler.AutopilotService.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "manual", nil)
if err != nil {
t.Fatalf("DispatchAutopilot: %v", err)
}
if run == nil || !run.IssueID.Valid {
t.Fatalf("dispatch run = %+v, want linked issue", run)
}
issueID = uuidToString(run.IssueID)
var issueProjectID *string
dbfx.QueryRow(t, `
SELECT project_id::text
FROM issue
WHERE id = $1
`, issueID).Scan(&issueProjectID)
if issueProjectID == nil || *issueProjectID != projectBID {
t.Fatalf("created issue project_id = %v, want refreshed %q", issueProjectID, projectBID)
}
}
func TestUpdateAutopilotCanSetAndClearProject(t *testing.T) {
ctx := context.Background()
var autopilotID, projectID string
defer func() {
if autopilotID != "" {
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
}
if projectID != "" {
testPool.Exec(ctx, `DELETE FROM project WHERE id = $1`, projectID)
}
}()
dbfx.QueryRow(t, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id::text
`, testWorkspaceID, "Autopilot update project target").Scan(&projectID)
var agentID string
dbfx.QueryRow(t, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID)
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Project update autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
})
w := testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusCreated)
var created AutopilotResponse
w.JSON(&created)
autopilotID = created.ID
if created.ProjectID != nil {
t.Fatalf("new autopilot project_id = %v, want nil", created.ProjectID)
}
req = newRequest("PATCH", "/api/autopilots/"+autopilotID+"?workspace_id="+testWorkspaceID, map[string]any{
"project_id": projectID,
})
req = withURLParam(req, "id", autopilotID)
w = testutil.Call(t, testHandler.UpdateAutopilot, req).Want(http.StatusOK)
var updated AutopilotResponse
w.JSON(&updated)
if updated.ProjectID == nil || *updated.ProjectID != projectID {
t.Fatalf("updated project_id = %v, want %q", updated.ProjectID, projectID)
}
req = newRequest("PATCH", "/api/autopilots/"+autopilotID+"?workspace_id="+testWorkspaceID, map[string]any{
"project_id": nil,
})
req = withURLParam(req, "id", autopilotID)
w = testutil.Call(t, testHandler.UpdateAutopilot, req).Want(http.StatusOK)
var cleared AutopilotResponse
w.JSON(&cleared)
if cleared.ProjectID != nil {
t.Fatalf("cleared project_id = %v, want nil", cleared.ProjectID)
}
}
// TestCreateIssueRejectsNonexistentMemberAssignee covers the bug where any
// well-formed UUID was accepted as assignee_id without checking workspace
// membership.
func TestCreateIssueRejectsNonexistentMemberAssignee(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Ghost member assignee",
"assignee_type": "member",
"assignee_id": "00000000-0000-0000-0000-000000000000",
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
}
// TestCreateIssueRejectsNonexistentAgentAssignee verifies the same check on
// the agent branch — previously rejected with 403 "agent not found"; we want a
// consistent 400 from the new validator.
func TestCreateIssueRejectsNonexistentAgentAssignee(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Ghost agent assignee",
"assignee_type": "agent",
"assignee_id": "00000000-0000-0000-0000-000000000000",
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
}
// TestCreateIssueRejectsAssigneeTypeWithoutID rejects requests where only one
// of the two fields was supplied — historically this would create an issue
// with an inconsistent state.
func TestCreateIssueRejectsAssigneeTypeWithoutID(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Lone assignee_type",
"assignee_type": "member",
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
}
// TestCreateIssueRejectsAssigneeIDWithoutType is the symmetric case.
func TestCreateIssueRejectsAssigneeIDWithoutType(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Lone assignee_id",
"assignee_id": testUserID,
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
}
// TestCreateIssueRejectsUnknownAssigneeType guards against typos like
// "members" or "user" that previously sneaked through.
func TestCreateIssueRejectsUnknownAssigneeType(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Bogus assignee_type",
"assignee_type": "user",
"assignee_id": testUserID,
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
}
// TestCreateIssueAcceptsValidMemberAssignee is the positive control — the
// validator must not block legitimate workspace members.
func TestCreateIssueAcceptsValidMemberAssignee(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Valid member assignee",
"assignee_type": "member",
"assignee_id": testUserID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
// TestCreateIssueRejectsMalformedAssigneeID covers the case where parseUUID
// silently produces an invalid pgtype.UUID and the validator would otherwise
// treat (no type + unparseable id) as "no assignee" and accept the request.
func TestCreateIssueRejectsMalformedAssigneeID(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Malformed assignee_id only",
"assignee_id": "not-a-uuid",
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
}
func TestCreateIssueRejectsMalformedAttachmentIDBeforeWrite(t *testing.T) {
var before int
dbfx.QueryRow(t, `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&before)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Malformed attachment issue",
"attachment_ids": []string{"not-a-uuid"},
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
var after int
dbfx.QueryRow(t, `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&after)
if after != before {
t.Fatalf("CreateIssue: malformed attachment_ids should not create issue, count before=%d after=%d", before, after)
}
}
// TestUpdateIssueRejectsMalformedAssigneeID is the equivalent for the update
// path, where the same parseUUID-shaped gap existed on a previously-unassigned
// issue.
func TestUpdateIssueRejectsMalformedAssigneeID(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Update malformed assignee target",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
defer func() {
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}()
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
"assignee_id": "not-a-uuid",
})
req = withURLParam(req, "id", created.ID)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusBadRequest)
}
// TestUpdateIssueRejectsNonexistentMemberAssignee verifies the same gap is
// closed on the update path — UpdateIssue previously only validated agents.
func TestUpdateIssueRejectsNonexistentMemberAssignee(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Update assignee target",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
defer func() {
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}()
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
"assignee_type": "member",
"assignee_id": "00000000-0000-0000-0000-000000000000",
})
req = withURLParam(req, "id", created.ID)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusBadRequest)
}
// TestUpdateIssueAllowsExplicitUnassign verifies that sending null for both
// fields still works after the new validator landed — clearing the assignee
// must not be misclassified as a mismatched pair.
func TestUpdateIssueAllowsExplicitUnassign(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Issue to unassign",
"assignee_type": "member",
"assignee_id": testUserID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
defer func() {
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}()
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
"assignee_type": nil,
"assignee_id": nil,
})
req = withURLParam(req, "id", created.ID)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
var updated IssueResponse
json.NewDecoder(w.Body).Decode(&updated)
if updated.AssigneeType != nil || updated.AssigneeID != nil {
t.Fatalf("UpdateIssue: expected assignee cleared, got type=%v id=%v", updated.AssigneeType, updated.AssigneeID)
}
}
func TestCommentCRUD(t *testing.T) {
// Create an issue first
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Comment test issue",
})
w := testutil.Call(t, testHandler.CreateIssue, req)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
// Create comment
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "Test comment from Go test",
})
req = withURLParam(req, "id", issueID)
w = testutil.Call(t, testHandler.CreateComment, req).Want(http.StatusCreated)
// List comments
req = newRequest("GET", "/api/issues/"+issueID+"/comments", nil)
req = withURLParam(req, "id", issueID)
w = testutil.Call(t, testHandler.ListComments, req).Want(http.StatusOK)
var comments []CommentResponse
json.NewDecoder(w.Body).Decode(&comments)
if len(comments) != 1 {
t.Fatalf("ListComments: expected 1 comment, got %d", len(comments))
}
if comments[0].Content != "Test comment from Go test" {
t.Fatalf("ListComments: expected content 'Test comment from Go test', got '%s'", comments[0].Content)
}
// Cleanup
req = newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
w = testutil.Call(t, testHandler.DeleteIssue, req)
}
func TestCommentWritePathsPreserveIssueIdentifiers(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("requires DB")
}
setWorkspaceIssuePrefixForTest(t, "MUL")
issueID := dbfx.Insert(t, "issue", testutil.Cols{
"workspace_id": testWorkspaceID,
"creator_type": "member",
"creator_id": testUserID,
"title": "preserve bare issue identifiers",
"number": 3310,
})
explicitMention := fmt.Sprintf("[MUL-3310](mention://issue/%s)", issueID)
createCases := []string{
"MUL-3310",
"issue/MUL-3310",
"feature/MUL-3310",
explicitMention,
}
var firstCommentID string
for _, content := range createCases {
req := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": content,
})
req = withURLParam(req, "id", issueID)
w := testutil.Call(t, testHandler.CreateComment, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment(%q): expected 201, got %d: %s", content, w.Code, w.Body.String())
}
var created CommentResponse
w.JSON(&created)
if created.Content != content {
t.Fatalf("CreateComment(%q) stored %q", content, created.Content)
}
if firstCommentID == "" {
firstCommentID = created.ID
}
}
updatedContent := "updated MUL-3310 issue/MUL-3310 feature/MUL-3310 " + explicitMention
req := newRequest("PUT", "/api/comments/"+firstCommentID, map[string]any{
"content": updatedContent,
})
req = withURLParam(req, "commentId", firstCommentID)
w := testutil.Call(t, testHandler.UpdateComment, req).Want(http.StatusOK)
var updated CommentResponse
w.JSON(&updated)
if updated.Content != updatedContent {
t.Fatalf("UpdateComment stored %q, want %q", updated.Content, updatedContent)
}
}
func TestCreateCommentRejectsMalformedParentID(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Comment malformed parent issue",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
req = newRequest("POST", "/api/issues/"+issue.ID+"/comments", map[string]any{
"content": "bad parent",
"parent_id": "not-a-uuid",
})
req = withURLParam(req, "id", issue.ID)
w = testutil.Call(t, testHandler.CreateComment, req).Want(http.StatusBadRequest)
req = newRequest("DELETE", "/api/issues/"+issue.ID, nil)
req = withURLParam(req, "id", issue.ID)
w = testutil.Call(t, testHandler.DeleteIssue, req)
}
func TestGetChatSessionRejectsMalformedSessionID(t *testing.T) {
req := newRequest("GET", "/api/chat/sessions/not-a-uuid", nil)
req = withURLParam(req, "sessionId", "not-a-uuid")
testutil.Call(t, testHandler.GetChatSession, req).Want(http.StatusBadRequest)
}
func TestCreateAutopilotRejectsMalformedAssigneeID(t *testing.T) {
req := newRequest("POST", "/api/autopilots", map[string]any{
"title": "Malformed assignee autopilot",
"assignee_id": "not-a-uuid",
"execution_mode": "run_only",
})
testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusBadRequest)
}
func TestUpdateAutopilotRejectsMalformedID(t *testing.T) {
req := newRequest("PUT", "/api/autopilots/not-a-uuid", map[string]any{
"title": "Malformed autopilot id",
})
req = withURLParam(req, "id", "not-a-uuid")
testutil.Call(t, testHandler.UpdateAutopilot, req).Want(http.StatusBadRequest)
}
func TestUpdateAgentRejectsMalformedAgentID(t *testing.T) {
req := newRequest("PUT", "/api/agents/not-a-uuid", map[string]any{
"name": "Malformed agent id",
})
req = withURLParam(req, "id", "not-a-uuid")
testutil.Call(t, testHandler.UpdateAgent, req).Want(http.StatusBadRequest)
}
func TestCreateAgentRejectsMalformedRuntimeID(t *testing.T) {
req := newRequest("POST", "/api/agents", map[string]any{
"name": "Malformed runtime agent",
"runtime_id": "not-a-uuid",
})
testutil.Call(t, testHandler.CreateAgent, req).Want(http.StatusBadRequest)
}
func TestUpdateAgentRejectsMalformedRuntimeID(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Malformed Runtime Update", nil)
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
"runtime_id": "not-a-uuid",
})
req = withURLParam(req, "id", agentID)
testutil.Call(t, testHandler.UpdateAgent, req).Want(http.StatusBadRequest)
}
func TestCreatePinRejectsMalformedItemID(t *testing.T) {
req := newRequest("POST", "/api/pins", map[string]any{
"item_type": "issue",
"item_id": "not-a-uuid",
})
testutil.Call(t, testHandler.CreatePin, req).Want(http.StatusBadRequest)
}
func TestUpdateWorkspaceRejectsMalformedID(t *testing.T) {
req := newRequest("PUT", "/api/workspaces/not-a-uuid", map[string]any{
"name": "Malformed workspace id",
})
req = withURLParam(req, "id", "not-a-uuid")
testutil.Call(t, testHandler.UpdateWorkspace, req).Want(http.StatusBadRequest)
}
func TestUpdateMemberRejectsMalformedMemberID(t *testing.T) {
req := newRequest("PATCH", "/api/workspaces/"+testWorkspaceID+"/members/not-a-uuid", map[string]any{
"role": "member",
})
req = withURLParams(req, "id", testWorkspaceID, "memberId", "not-a-uuid")
testutil.Call(t, testHandler.UpdateMember, req).Want(http.StatusBadRequest)
}
func TestRevokeInvitationRejectsMalformedInvitationID(t *testing.T) {
req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/invitations/not-a-uuid", nil)
req = withURLParams(req, "id", testWorkspaceID, "invitationId", "not-a-uuid")
testutil.Call(t, testHandler.RevokeInvitation, req).Want(http.StatusBadRequest)
}
func TestGetMyInvitationRejectsMalformedID(t *testing.T) {
req := newRequest("GET", "/api/invitations/not-a-uuid", nil)
req = withURLParam(req, "id", "not-a-uuid")
testutil.Call(t, testHandler.GetMyInvitation, req).Want(http.StatusBadRequest)
}
func TestAddReactionRejectsMalformedCommentID(t *testing.T) {
req := newRequest("POST", "/api/comments/not-a-uuid/reactions", map[string]any{
"emoji": "thumbs_up",
})
req = withURLParam(req, "commentId", "not-a-uuid")
testutil.Call(t, testHandler.AddReaction, req).Want(http.StatusBadRequest)
}
func TestUpdateCommentRejectsMalformedCommentID(t *testing.T) {
req := newRequest("PUT", "/api/comments/not-a-uuid", map[string]any{
"content": "updated",
})
req = withURLParam(req, "commentId", "not-a-uuid")
testutil.Call(t, testHandler.UpdateComment, req).Want(http.StatusBadRequest)
}
func TestMarkInboxReadRejectsMalformedItemID(t *testing.T) {
req := newRequest("POST", "/api/inbox/not-a-uuid/read", nil)
req = withURLParam(req, "id", "not-a-uuid")
testutil.Call(t, testHandler.MarkInboxRead, req).Want(http.StatusBadRequest)
}
func TestRevokePersonalAccessTokenRejectsMalformedID(t *testing.T) {
req := newRequest("DELETE", "/api/tokens/not-a-uuid", nil)
req = withURLParam(req, "id", "not-a-uuid")
testutil.Call(t, testHandler.RevokePersonalAccessToken, req).Want(http.StatusBadRequest)
}
func TestRequestBodyUUIDFieldsRejectMalformed(t *testing.T) {
tests := []struct {
name string
req *http.Request
handle func(http.ResponseWriter, *http.Request)
}{
{
name: "daemon register workspace_id",
req: newRequest("POST", "/api/daemon/register", map[string]any{
"workspace_id": "not-a-uuid",
"daemon_id": "daemon-malformed-workspace",
"runtimes": []map[string]any{
{"name": "codex", "type": "codex", "status": "online"},
},
}),
handle: testHandler.DaemonRegister,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
tt.handle(w, tt.req)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: expected 400 for malformed body UUID, got %d: %s", tt.name, w.Code, w.Body.String())
}
})
}
}
func TestDaemonDeregisterRejectsMalformedRuntimeID(t *testing.T) {
req := newRequest("POST", "/api/daemon/deregister", map[string]any{
"runtime_ids": []string{"not-a-uuid"},
})
testutil.Call(t, testHandler.DaemonDeregister, req).Want(http.StatusBadRequest)
}
func TestGetIssueGCCheckRejectsMalformedIssueID(t *testing.T) {
req := newRequest("GET", "/api/daemon/issues/not-a-uuid/gc-check", nil)
req = withURLParam(req, "issueId", "not-a-uuid")
testutil.Call(t, testHandler.GetIssueGCCheck, req).Want(http.StatusBadRequest)
}
func TestBatchIssueGCCheckRejectsInvalidRequests(t *testing.T) {
h := &Handler{}
workspaceID := "00000000-0000-0000-0000-000000000001"
tooMany := make([]string, maxIssueGCBatchSize+1)
for i := range tooMany {
tooMany[i] = "00000000-0000-0000-0000-000000000002"
}
tests := []struct {
name string
workspaceID string
body any
}{
{name: "malformed workspace", workspaceID: "not-a-uuid", body: map[string]any{"issue_ids": []string{}}},
{name: "malformed issue", workspaceID: workspaceID, body: map[string]any{"issue_ids": []string{"not-a-uuid"}}},
{name: "too many issues", workspaceID: workspaceID, body: map[string]any{"issue_ids": tooMany}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := newDaemonTokenRequest("POST", "/api/daemon/workspaces/"+tt.workspaceID+"/issues/gc-check", tt.body,
tt.workspaceID, "test-daemon")
req = withURLParam(req, "workspaceId", tt.workspaceID)
testutil.Call(t, h.BatchIssueGCCheck, req).Want(http.StatusBadRequest)
})
}
}
func TestSetAgentSkillsRejectsMalformedSkillID(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Malformed Skill Assignment", nil)
req := newRequest("PUT", "/api/agents/"+agentID+"/skills", map[string]any{
"skill_ids": []string{"not-a-uuid"},
})
req = withURLParam(req, "id", agentID)
testutil.Call(t, testHandler.SetAgentSkills, req).Want(http.StatusBadRequest)
}
func TestAddAgentSkillsPreservesExistingAssignments(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Add Skill Preserves Existing", nil)
existingSkillID := insertHandlerTestSkill(t, "add-preserve-existing", "existing body")
newSkillID := insertHandlerTestSkill(t, "add-preserve-new", "new body")
dbfx.Exec(t,
`INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)`,
agentID, existingSkillID,
)
req := newRequest("POST", "/api/agents/"+agentID+"/skills/add", map[string]any{
"skill_ids": []string{newSkillID},
})
req = withURLParam(req, "id", agentID)
w := testutil.Call(t, testHandler.AddAgentSkills, req).Want(http.StatusOK)
var resp []SkillSummaryResponse
w.JSON(&resp)
assertSkillIDsPresent(t, resp, existingSkillID, newSkillID)
assertAgentSkillRowCount(t, agentID, 2)
}
func TestAddAgentSkillsAddsMultipleAndIsIdempotent(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Add Multiple Skills", nil)
skillA := insertHandlerTestSkill(t, "add-multiple-a", "a body")
skillB := insertHandlerTestSkill(t, "add-multiple-b", "b body")
for attempt := 0; attempt < 2; attempt++ {
req := newRequest("POST", "/api/agents/"+agentID+"/skills/add", map[string]any{
"skill_ids": []string{skillA, skillB},
})
req = withURLParam(req, "id", agentID)
w := testutil.Call(t, testHandler.AddAgentSkills, req)
if w.Code != http.StatusOK {
t.Fatalf("AddAgentSkills attempt %d: expected 200, got %d: %s", attempt+1, w.Code, w.Body.String())
}
}
assertAgentSkillRowCount(t, agentID, 2)
}
func TestAddAgentSkillsRejectsMalformedSkillID(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Add Malformed Skill Assignment", nil)
req := newRequest("POST", "/api/agents/"+agentID+"/skills/add", map[string]any{
"skill_ids": []string{"not-a-uuid"},
})
req = withURLParam(req, "id", agentID)
testutil.Call(t, testHandler.AddAgentSkills, req).Want(http.StatusBadRequest)
}
func TestAddAgentSkillsRejectsCrossWorkspaceSkillID(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Add Cross Workspace Skill", nil)
foreignSkillID := insertHandlerTestSkillInForeignWorkspace(t, "add-cross-workspace", "foreign body")
req := newRequest("POST", "/api/agents/"+agentID+"/skills/add", map[string]any{
"skill_ids": []string{foreignSkillID},
})
req = withURLParam(req, "id", agentID)
testutil.Call(t, testHandler.AddAgentSkills, req).Want(http.StatusNotFound)
assertAgentSkillRowCount(t, agentID, 0)
}
func insertHandlerTestSkillInForeignWorkspace(t *testing.T, namePrefix, content string) string {
t.Helper()
slug := "foreign-skill-" + strings.ToLower(strings.ReplaceAll(t.Name(), "_", "-"))
workspaceID := dbfx.Insert(t, "workspace", testutil.Cols{
"name": "Foreign Skill Workspace " + t.Name(),
"slug": slug,
"description": "",
"issue_prefix": "FSW",
})
name := namePrefix + "-" + t.Name()
skillID := dbfx.Insert(t, "skill", testutil.Cols{
"workspace_id": workspaceID,
"name": name,
"description": "fixture",
"content": content,
"config": testutil.Raw("'{}'::jsonb"),
"created_by": testUserID,
})
return skillID
}
func assertSkillIDsPresent(t *testing.T, skills []SkillSummaryResponse, wantIDs ...string) {
t.Helper()
got := make(map[string]bool, len(skills))
for _, s := range skills {
got[s.ID] = true
}
for _, want := range wantIDs {
if !got[want] {
t.Fatalf("response missing skill %s; got %+v", want, skills)
}
}
}
func assertAgentSkillRowCount(t *testing.T, agentID string, want int) {
t.Helper()
var got int
dbfx.QueryRow(t,
`SELECT COUNT(*) FROM agent_skill WHERE agent_id = $1`,
agentID,
).Scan(&got)
if got != want {
t.Fatalf("agent_skill row count: got %d, want %d", got, want)
}
}
func TestAgentCRUD(t *testing.T) {
// List agents
w := testutil.Call(t, testHandler.ListAgents, newRequest("GET", "/api/agents?workspace_id="+testWorkspaceID, nil)).Want(http.StatusOK)
var agents []AgentResponse
json.NewDecoder(w.Body).Decode(&agents)
if len(agents) == 0 {
t.Fatal("ListAgents: expected at least 1 agent")
}
// Update agent status
agentID := agents[0].ID
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
"status": "idle",
})
req = withURLParam(req, "id", agentID)
w = testutil.Call(t, testHandler.UpdateAgent, req).Want(http.StatusOK)
var updated AgentResponse
json.NewDecoder(w.Body).Decode(&updated)
if updated.Status != "idle" {
t.Fatalf("UpdateAgent: expected status 'idle', got '%s'", updated.Status)
}
if updated.Name != agents[0].Name {
t.Fatalf("UpdateAgent: name should be preserved, got '%s'", updated.Name)
}
}
func TestUpdateAgentMcpConfigAbsentPreservesValue(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Mcp Preserve", []byte(`{"preset":"keep"}`))
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
"name": "Handler Mcp Preserve Updated",
})
req = withURLParam(req, "id", agentID)
w := testutil.Call(t, testHandler.UpdateAgent, req).Want(http.StatusOK)
var updated AgentResponse
w.JSON(&updated)
assertJSONEqual(t, updated.McpConfig, `{"preset":"keep"}`)
assertJSONEqual(t, fetchAgentMcpConfig(t, agentID), `{"preset":"keep"}`)
}
func TestUpdateAgentMcpConfigNullClearsValue(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Mcp Clear", []byte(`{"preset":"clear"}`))
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
"mcp_config": nil,
})
req = withURLParam(req, "id", agentID)
w := testutil.Call(t, testHandler.UpdateAgent, req).Want(http.StatusOK)
var updated AgentResponse
w.JSON(&updated)
assertJSONEqual(t, updated.McpConfig, `null`)
if fetchAgentMcpConfig(t, agentID) != nil {
t.Fatalf("UpdateAgent: expected DB mcp_config to be SQL NULL")
}
}
func TestUpdateAgentMcpConfigObjectUpdatesValue(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Mcp Update", []byte(`{"preset":"old"}`))
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
"mcp_config": map[string]any{"preset": "new"},
})
req = withURLParam(req, "id", agentID)
w := testutil.Call(t, testHandler.UpdateAgent, req).Want(http.StatusOK)
var updated AgentResponse
w.JSON(&updated)
assertJSONEqual(t, updated.McpConfig, `{"preset":"new"}`)
assertJSONEqual(t, fetchAgentMcpConfig(t, agentID), `{"preset":"new"}`)
}
func TestCreateAgentMcpConfigNullStoresSQLNull(t *testing.T) {
req := newRequest("POST", "/api/agents", map[string]any{
"name": "Handler Mcp Create Null",
"runtime_id": handlerTestRuntimeID(t),
"mcp_config": nil,
"custom_env": map[string]string{},
"custom_args": []string{},
})
w := testutil.Call(t, testHandler.CreateAgent, req).Want(http.StatusCreated)
var created AgentResponse
w.JSON(&created)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, created.ID)
})
assertJSONEqual(t, created.McpConfig, `null`)
if fetchAgentMcpConfig(t, created.ID) != nil {
t.Fatalf("CreateAgent: expected DB mcp_config to be SQL NULL")
}
}
func TestWorkspaceCRUD(t *testing.T) {
// List workspaces
w := testutil.Call(t, testHandler.ListWorkspaces, newRequest("GET", "/api/workspaces", nil)).Want(http.StatusOK)
var workspaces []WorkspaceResponse
json.NewDecoder(w.Body).Decode(&workspaces)
if len(workspaces) == 0 {
t.Fatal("ListWorkspaces: expected at least 1 workspace")
}
// Get workspace
wsID := workspaces[0].ID
req := newRequest("GET", "/api/workspaces/"+wsID, nil)
req = withURLParam(req, "id", wsID)
w = testutil.Call(t, testHandler.GetWorkspace, req).Want(http.StatusOK)
}
func TestCreateWorkspaceUsesRequestedSlug(t *testing.T) {
const slug = "handler-create-workspace-requested"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug)
})
req := newRequest("POST", "/api/workspaces", map[string]string{
"name": "Handler Create Workspace Requested",
"slug": slug,
})
w := testutil.Call(t, testHandler.CreateWorkspace, req).Want(http.StatusCreated)
var created WorkspaceResponse
w.JSON(&created)
if created.Slug != slug {
t.Fatalf("CreateWorkspace: expected slug %q, got %q", slug, created.Slug)
}
}
func TestCreateWorkspaceSlugConflictReturnsConflict(t *testing.T) {
ctx := context.Background()
retriedSlug := handlerTestWorkspaceSlug + "-2"
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, retriedSlug)
})
req := newRequest("POST", "/api/workspaces", map[string]string{
"name": "Duplicate Handler Workspace",
"slug": handlerTestWorkspaceSlug,
})
testutil.Call(t, testHandler.CreateWorkspace, req).Want(http.StatusConflict)
var count int
dbfx.QueryRow(t, `SELECT count(*) FROM workspace WHERE slug = $1`, retriedSlug).Scan(&count)
if count != 0 {
t.Fatalf("CreateWorkspace: expected no fallback slug %q, got %d rows", retriedSlug, count)
}
}
func TestCreateWorkspaceInvalidSlugReturnsBadRequest(t *testing.T) {
req := newRequest("POST", "/api/workspaces", map[string]string{
"name": "Invalid Slug Workspace",
"slug": "invalid slug",
})
testutil.Call(t, testHandler.CreateWorkspace, req).Want(http.StatusBadRequest)
}
func TestSendCode(t *testing.T) {
w := httptest.NewRecorder()
body := map[string]string{"email": "[email protected]"}
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["message"] == "" {
t.Fatal("SendCode: expected non-empty message")
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM verification_code WHERE email = $1`, "[email protected]")
})
}
func TestSendCodeDbError(t *testing.T) {
// We can't easily mock the DB here without changing architecture,
// but we can simulate a DB error by closing the pool temporarily or
// using a cancelled context if the query respects it.
// Create a handler with a "broken" queries object is hard because it's a struct.
// Instead, let's use a context that is already cancelled.
ctx, cancel := context.WithCancel(context.Background())
cancel()
w := httptest.NewRecorder()
body := map[string]string{"email": "[email protected]"}
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
testHandler.SendCode(w, req)
// If the DB query respects the cancelled context, it should return an error.
// pgx usually returns context.Canceled which is not what isNotFound checks for.
if w.Code != http.StatusInternalServerError {
t.Fatalf("SendCode (db error): expected 500, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "failed to lookup user" {
t.Fatalf("SendCode (db error): expected error message 'failed to lookup user', got '%s'", resp["error"])
}
}
func TestSendCodeRateLimit(t *testing.T) {
const email = "[email protected]"
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM verification_code WHERE email = $1`, email)
})
// First request should succeed
w := httptest.NewRecorder()
body := map[string]string{"email": email}
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SendCode (first): expected 200, got %d: %s", w.Code, w.Body.String())
}
// Second request within 60s should be rate limited
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(body)
req = httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("SendCode (second): expected 429, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCode(t *testing.T) {
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
if err == nil {
workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID)
if listErr == nil {
for _, workspace := range workspaces {
_ = testHandler.Queries.DeleteWorkspace(ctx, workspace.ID)
}
}
}
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
// Send code first
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Read code from DB
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
if err != nil {
t.Fatalf("GetLatestVerificationCode: %v", err)
}
// Verify with correct code
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("VerifyCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp LoginResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Token == "" {
t.Fatal("VerifyCode: expected non-empty token")
}
if resp.User.Email != email {
t.Fatalf("VerifyCode: expected email '%s', got '%s'", email, resp.User.Email)
}
}
func createVerificationCodeForTest(t *testing.T, email, code string) {
t.Helper()
_, err := testPool.Exec(context.Background(), `
INSERT INTO verification_code (email, code, expires_at)
VALUES ($1, $2, now() + interval '10 minutes')
`, email, code)
if err != nil {
t.Fatalf("create verification code: %v", err)
}
}
func TestVerifyCodeRejectsDevCodeUnlessExplicitlyConfigured(t *testing.T) {
t.Setenv(devVerificationCodeEnv, "")
t.Setenv("APP_ENV", "")
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
})
createVerificationCodeForTest(t, email, "123456")
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "888888"})
req := httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("VerifyCode (disabled dev code): expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCodeAcceptsConfiguredDevCodeOutsideProduction(t *testing.T) {
t.Setenv(devVerificationCodeEnv, "888888")
t.Setenv("APP_ENV", "development")
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
createVerificationCodeForTest(t, email, "123456")
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "888888"})
req := httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("VerifyCode (enabled dev code): expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCodeRejectsConfiguredDevCodeInProduction(t *testing.T) {
t.Setenv(devVerificationCodeEnv, "888888")
t.Setenv("APP_ENV", "production")
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
})
createVerificationCodeForTest(t, email, "123456")
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "888888"})
req := httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("VerifyCode (production dev code): expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCodeWrongCode(t *testing.T) {
t.Setenv(devVerificationCodeEnv, "")
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
})
// Send code
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
// Verify with wrong code
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "000000"})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("VerifyCode (wrong code): expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCodeBruteForceProtection(t *testing.T) {
t.Setenv(devVerificationCodeEnv, "")
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
})
// Send code
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Read actual code so we can try it after lockout
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
if err != nil {
t.Fatalf("GetLatestVerificationCode: %v", err)
}
// Exhaust all 5 attempts with wrong codes
for i := 0; i < 5; i++ {
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "000000"})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("attempt %d: expected 400, got %d", i+1, w.Code)
}
}
// Now even the correct code should be rejected (code is locked out)
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("after lockout: expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCodeNewUserHasNoWorkspace(t *testing.T) {
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
// Send code
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
// Read code from DB
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
if err != nil {
t.Fatalf("GetLatestVerificationCode: %v", err)
}
// Verify
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("VerifyCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
// New users should have no workspaces (/workspaces/new creates one)
workspaces, err := testHandler.Queries.ListWorkspaces(ctx, user.ID)
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 0 {
t.Fatalf("ListWorkspaces: expected 0 workspaces for new user, got %d", len(workspaces))
}
}
func TestResolveActor(t *testing.T) {
ctx := context.Background()
// Look up the agent created by the test fixture.
var agentID string
err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, "Handler Test Agent",
).Scan(&agentID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Create a task for the agent so we can test X-Task-ID validation.
var issueID string
err = testPool.QueryRow(ctx,
`INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number, position)
VALUES ($1, 'resolveActor test', 'todo', 'none', 'member', $2, 9999, 0)
RETURNING id`, testWorkspaceID, testUserID,
).Scan(&issueID)
if err != nil {
t.Fatalf("failed to create test issue: %v", err)
}
// Look up runtime_id for the agent.
var runtimeID string
err = testPool.QueryRow(ctx, `SELECT runtime_id FROM agent WHERE id = $1`, agentID).Scan(&runtimeID)
if err != nil {
t.Fatalf("failed to get agent runtime_id: %v", err)
}
var taskID string
err = testPool.QueryRow(ctx,
`INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority)
VALUES ($1, $2, $3, 'queued', 0)
RETURNING id`, agentID, runtimeID, issueID,
).Scan(&taskID)
if err != nil {
t.Fatalf("failed to create test task: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
tests := []struct {
name string
agentIDHeader string
taskIDHeader string
wantActorType string
wantIsAgent bool
}{
{
name: "no headers returns member",
wantActorType: "member",
},
{
// X-Agent-ID without X-Task-ID is not trusted — otherwise a
// workspace member who guesses an agent's UUID could impersonate
// it and bypass the private-agent gate. See resolveActor for the
// rationale.
name: "agent ID without task ID returns member",
agentIDHeader: agentID,
wantActorType: "member",
},
{
name: "non-existent agent ID with task returns member",
agentIDHeader: "00000000-0000-0000-0000-000000000099",
taskIDHeader: taskID,
wantActorType: "member",
},
{
name: "valid agent + valid task returns agent",
agentIDHeader: agentID,
taskIDHeader: taskID,
wantActorType: "agent",
wantIsAgent: true,
},
{
name: "valid agent + wrong task returns member",
agentIDHeader: agentID,
taskIDHeader: "00000000-0000-0000-0000-000000000099",
wantActorType: "member",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := newRequest("GET", "/test", nil)
if tt.agentIDHeader != "" {
req.Header.Set("X-Agent-ID", tt.agentIDHeader)
}
if tt.taskIDHeader != "" {
req.Header.Set("X-Task-ID", tt.taskIDHeader)
}
actorType, actorID := testHandler.resolveActor(req, testUserID, testWorkspaceID)
if actorType != tt.wantActorType {
t.Errorf("actorType = %q, want %q", actorType, tt.wantActorType)
}
if tt.wantIsAgent {
if actorID != tt.agentIDHeader {
t.Errorf("actorID = %q, want agent %q", actorID, tt.agentIDHeader)
}
} else {
if actorID != testUserID {
t.Errorf("actorID = %q, want user %q", actorID, testUserID)
}
}
})
}
}
// TestBacklogNoTriggerOnCreate verifies that creating a backlog issue with an
// agent assignee does NOT enqueue a task — backlog is a parking lot.
func TestBacklogNoTriggerOnCreate(t *testing.T) {
ctx := context.Background()
var agentID string
err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, "Handler Test Agent",
).Scan(&agentID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Backlog no-trigger test",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": agentID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
var taskCount int
err = testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1`,
created.ID,
).Scan(&taskCount)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
if taskCount != 0 {
t.Fatalf("expected no tasks for backlog issue on creation, got %d", taskCount)
}
// Cleanup
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
// TestBacklogToTodoTriggersAgent verifies that moving an agent-assigned issue
// from "backlog" to "todo" enqueues exactly one agent task (none on creation,
// one on status transition).
func TestBacklogToTodoTriggersAgent(t *testing.T) {
ctx := context.Background()
var agentID string
err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, "Handler Test Agent",
).Scan(&agentID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Create a backlog issue assigned to the agent — should NOT trigger.
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Backlog trigger test",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": agentID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
// Move the issue from backlog to todo — should trigger.
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
"status": "todo",
})
req = withURLParam(req, "id", created.ID)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
// Verify exactly one task was enqueued (from the status transition, not creation).
var taskCount int
err = testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, agentID,
).Scan(&taskCount)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
if taskCount != 1 {
t.Fatalf("expected exactly 1 task after backlog->todo transition, got %d", taskCount)
}
// Cleanup
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
// TestBacklogToTodoByAgentTriggersDifferentAssignee verifies that the
// documented sub-task chain works: when an agent (parent / Step 1) promotes
// a backlog issue assigned to a different agent (child / Step 2), the
// child's task is enqueued. Previously the backlog→active trigger was
// gated on `actorType == "member"`, which silently dropped agent-driven
// promotions and broke the serial sub-task workflow.
func TestBacklogToTodoByAgentTriggersDifferentAssignee(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
// Parent agent (the actor) + child agent (the assignee).
parentAgent := createHandlerTestAgent(t, "Backlog Parent Agent", nil)
childAgent := createHandlerTestAgent(t, "Backlog Child Agent", nil)
parentTask := createHandlerTestTaskForAgent(t, parentAgent)
// Create a backlog issue assigned to the child agent — should NOT trigger
// on creation (backlog parking-lot rule).
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Serial sub-task Step 2",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": childAgent,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Parent agent promotes backlog → todo on behalf of the X-Task it is
// currently running. Must enqueue exactly one task for the child agent.
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{"status": "todo"})
req = withURLParam(req, "id", created.ID)
req.Header.Set("X-Agent-ID", parentAgent)
req.Header.Set("X-Task-ID", parentTask)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
var childTasks int
dbfx.QueryRow(t,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, childAgent,
).Scan(&childTasks)
if childTasks != 1 {
t.Fatalf("expected exactly 1 task enqueued for child agent after agent-driven backlog→todo, got %d", childTasks)
}
}
// TestBacklogToTodoByAgentSameIssueDoesNotSelfTrigger verifies the
// task-issue-scoped self-loop guard: an agent whose CURRENT task is
// running on issue I and who flips I from backlog to an active status
// must NOT enqueue itself for I again. Without this guard the agent
// would re-trigger every cycle it completed on I and immediately
// re-enter the same path.
//
// This is the true self-loop case (calling task is on the SAME issue
// being promoted). The complementary case — same agent, DIFFERENT
// issue — is the documented serial chain and is covered by
// TestBacklogToTodoByAgentSameAgentDifferentIssue.
func TestBacklogToTodoByAgentSameIssueDoesNotSelfTrigger(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
selfAgent := createHandlerTestAgent(t, "Backlog Self Agent", nil)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Self-promoted backlog",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": selfAgent,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Task bound to the SAME issue being promoted — true self-loop.
selfTask := createHandlerTestTaskForAgentOnIssue(t, selfAgent, created.ID)
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{"status": "todo"})
req = withURLParam(req, "id", created.ID)
req.Header.Set("X-Agent-ID", selfAgent)
req.Header.Set("X-Task-ID", selfTask)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
var tasks int
dbfx.QueryRow(t,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, selfAgent,
).Scan(&tasks)
if tasks != 0 {
t.Fatalf("expected no self-trigger when agent promotes the same issue its task is running on, got %d queued tasks", tasks)
}
}
// TestBacklogToTodoByAgentSameAgentDifferentIssue verifies the documented
// same-agent serial chain still fires: when an agent is running a task on
// issue I1 and promotes a DIFFERENT backlog issue I2 (also assigned to
// itself), I2 must be enqueued. This was over-blocked by the previous
// agent-id-based self-loop guard, which made the same-agent serial
// workflow silently break.
func TestBacklogToTodoByAgentSameAgentDifferentIssue(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID := createHandlerTestAgent(t, "Backlog Same-Agent Chain", nil)
// Step 1 issue — the one the agent is currently working on.
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Step 1 (running)",
"status": "in_progress",
"assignee_type": "agent",
"assignee_id": agentID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var step1 IssueResponse
json.NewDecoder(w.Body).Decode(&step1)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, step1.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, step1.ID)
})
// Step 2 issue — backlog, also assigned to the same agent.
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Step 2 (backlog)",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": agentID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var step2 IssueResponse
json.NewDecoder(w.Body).Decode(&step2)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, step2.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, step2.ID)
})
// Task is running on step1 — promoting step2 is NOT a self-loop.
step1Task := createHandlerTestTaskForAgentOnIssue(t, agentID, step1.ID)
req = newRequest("PUT", "/api/issues/"+step2.ID, map[string]any{"status": "todo"})
req = withURLParam(req, "id", step2.ID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", step1Task)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
var step2Tasks int
dbfx.QueryRow(t,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
step2.ID, agentID,
).Scan(&step2Tasks)
if step2Tasks != 1 {
t.Fatalf("expected exactly 1 task enqueued on step2 for same-agent serial chain, got %d", step2Tasks)
}
}
// TestAssignIssueToSelfWithActiveTargetRunDoesNotDuplicate covers the direct
// assignment form of #6947: an agent already working on an issue may claim its
// ownership, but that ownership write must not enqueue a second run for the
// same (issue, agent) pair.
func TestAssignIssueToSelfWithActiveTargetRunDoesNotDuplicate(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "Self Assign Active Target", nil)
issue := createIssueForTest(t, map[string]any{"title": "self-assign active target", "status": "todo"})
runningTask := createHandlerTestTaskForAgentOnIssue(t, agentID, issue.ID)
previewReq := newRequest("POST", "/api/issues/preview-trigger?workspace_id="+testWorkspaceID, map[string]any{
"issue_ids": []string{issue.ID},
"assignee_type": "agent",
"assignee_id": agentID,
})
previewReq.Header.Set("X-Agent-ID", agentID)
previewReq.Header.Set("X-Task-ID", runningTask)
previewRecorder := testutil.Call(t, testHandler.PreviewIssueTrigger, previewReq).Want(http.StatusOK)
var preview IssueTriggerPreviewResponse
previewRecorder.JSON(&preview)
if preview.TotalCount != 0 {
t.Fatalf("preview promised a duplicate self-assignment run: %+v", preview)
}
req := withURLParam(newRequest("PUT", "/api/issues/"+issue.ID, map[string]any{
"assignee_type": "agent",
"assignee_id": agentID,
}), "id", issue.ID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", runningTask)
testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
if got := queuedTaskCountFor(t, issue.ID, agentID); got != 0 {
t.Fatalf("self-assignment duplicated an active target run: got %d queued task(s)", got)
}
if got := taskStatus(t, runningTask); got != "running" {
t.Fatalf("existing target run must survive ownership claim, got status %q", got)
}
}
func TestShouldSuppressActiveSelfAssignmentFailsClosedOnLookupError(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
const agentID = "1c331d0b-94fd-412a-a7cc-6a209add00a1"
const issueID = "34c44eb2-bd85-455f-b47c-f39cc7b0b913"
ctx, cancel := context.WithCancel(context.Background())
cancel()
if !testHandler.shouldSuppressActiveSelfAssignment(
ctx,
"agent",
agentID,
parseUUID(issueID),
parseUUID(agentID),
) {
t.Fatal("lookup failure must fail closed and suppress duplicate enqueue")
}
}
// TestAssignDifferentIssueToSelfStillEnqueues locks in the intentional
// cross-issue handoff behavior: an agent working on I1 may assign fresh I2 to
// itself, and I2 still gets a queued run.
func TestAssignDifferentIssueToSelfStillEnqueues(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "Cross Issue Self Assign", nil)
source := createIssueForTest(t, map[string]any{"title": "cross-issue source", "status": "todo"})
target := createIssueForTest(t, map[string]any{"title": "cross-issue target", "status": "todo"})
sourceTask := createHandlerTestTaskForAgentOnIssue(t, agentID, source.ID)
req := withURLParam(newRequest("PUT", "/api/issues/"+target.ID, map[string]any{
"assignee_type": "agent",
"assignee_id": agentID,
}), "id", target.ID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", sourceTask)
testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
if got := queuedTaskCountFor(t, target.ID, agentID); got != 1 {
t.Fatalf("cross-issue self-assignment should enqueue one run, got %d", got)
}
}
// TestBatchAssignFreshIssuesToSelfEnqueuesEach protects triage/autopilot
// batches: being busy on a source issue is not a global self-assignment ban.
// Every fresh target keeps the existing enqueue behavior.
func TestBatchAssignFreshIssuesToSelfEnqueuesEach(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "Batch Fresh Self Assign", nil)
source := createIssueForTest(t, map[string]any{"title": "batch source", "status": "todo"})
target1 := createIssueForTest(t, map[string]any{"title": "batch target one", "status": "todo"})
target2 := createIssueForTest(t, map[string]any{"title": "batch target two", "status": "todo"})
sourceTask := createHandlerTestTaskForAgentOnIssue(t, agentID, source.ID)
req := newRequest("PATCH", "/api/issues/batch?workspace_id="+testWorkspaceID, map[string]any{
"issue_ids": []string{target1.ID, target2.ID},
"updates": map[string]any{
"assignee_type": "agent",
"assignee_id": agentID,
},
})
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", sourceTask)
testutil.Call(t, testHandler.BatchUpdateIssues, req).Want(http.StatusOK)
for _, target := range []IssueResponse{target1, target2} {
if got := queuedTaskCountFor(t, target.ID, agentID); got != 1 {
t.Fatalf("fresh target %s should enqueue one run, got %d", target.ID, got)
}
}
}
// TestAssignActiveIssueToDifferentAgentStillEnqueues verifies the duplicate
// guard never turns a real agent-to-agent transfer into an ownership-only
// update. The old task survives per #4963 and the new assignee gets a run.
func TestAssignActiveIssueToDifferentAgentStillEnqueues(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
actorAgent := createHandlerTestAgent(t, "Active Transfer Actor", nil)
targetAgent := createHandlerTestAgent(t, "Active Transfer Target", nil)
issue := createIssueForTest(t, map[string]any{"title": "active transfer", "status": "todo"})
actorTask := createHandlerTestTaskForAgentOnIssue(t, actorAgent, issue.ID)
req := withURLParam(newRequest("PUT", "/api/issues/"+issue.ID, map[string]any{
"assignee_type": "agent",
"assignee_id": targetAgent,
}), "id", issue.ID)
req.Header.Set("X-Agent-ID", actorAgent)
req.Header.Set("X-Task-ID", actorTask)
testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
if got := queuedTaskCountFor(t, issue.ID, targetAgent); got != 1 {
t.Fatalf("new assignee should get one queued run, got %d", got)
}
if got := taskStatus(t, actorTask); got != "running" {
t.Fatalf("old active task must survive transfer, got status %q", got)
}
}
// TestBatchBacklogToTodoByAgentTriggersAssignee mirrors the single-update
// serial-chain test on the BatchUpdateIssues path. Earlier the
// member-only gate would silently drop agent-driven batch promotions; the
// task-issue self-loop guard must let cross-issue (same-agent) batch
// promotions through.
func TestBatchBacklogToTodoByAgentTriggersAssignee(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
parentAgent := createHandlerTestAgent(t, "Batch Parent Agent", nil)
childAgent := createHandlerTestAgent(t, "Batch Child Agent", nil)
parentTask := createHandlerTestTaskForAgent(t, parentAgent)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Batch backlog child",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": childAgent,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Drive the batch endpoint with the same agent identity headers.
req = newRequest("PATCH", "/api/issues/batch?workspace_id="+testWorkspaceID, map[string]any{
"issue_ids": []string{created.ID},
"updates": map[string]any{"status": "todo"},
})
req.Header.Set("X-Agent-ID", parentAgent)
req.Header.Set("X-Task-ID", parentTask)
w = testutil.Call(t, testHandler.BatchUpdateIssues, req).Want(http.StatusOK)
var childTasks int
dbfx.QueryRow(t,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, childAgent,
).Scan(&childTasks)
if childTasks != 1 {
t.Fatalf("expected exactly 1 task enqueued for child agent after batch agent-driven backlog→todo, got %d", childTasks)
}
}
// TestBacklogToTodoByAgentTriggersSquadLeader covers the squad branch of
// the backlog→active trigger when the actor is an agent: the leader agent
// of a squad must wake when one of its squad-assigned backlog issues is
// promoted by another agent (or by the leader itself acting from a task
// on a different issue). The task-issue self-loop guard must allow this —
// only a true same-issue self-loop should be suppressed.
func TestBacklogToTodoByAgentTriggersSquadLeader(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
leaderAgent := createHandlerTestAgent(t, "Backlog Squad Leader", nil)
driverAgent := createHandlerTestAgent(t, "Backlog Squad Driver", nil)
driverTask := createHandlerTestTaskForAgent(t, driverAgent)
squadID := dbfx.Squad(t, "Backlog Trigger Squad", leaderAgent)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Squad backlog issue",
"status": "backlog",
"assignee_type": "squad",
"assignee_id": squadID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Driver agent (not the leader, task is on no specific issue) promotes
// the squad-assigned backlog issue. Squad leader must be enqueued.
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{"status": "todo"})
req = withURLParam(req, "id", created.ID)
req.Header.Set("X-Agent-ID", driverAgent)
req.Header.Set("X-Task-ID", driverTask)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
var leaderTasks int
dbfx.QueryRow(t,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, leaderAgent,
).Scan(&leaderTasks)
if leaderTasks != 1 {
t.Fatalf("expected exactly 1 squad-leader task after agent-driven backlog→todo on squad issue, got %d", leaderTasks)
}
}
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
req := httptest.NewRequest("POST", "/api/daemon/register", bytes.NewBufferString(`{
"workspace_id":"00000000-0000-0000-0000-000000000001",
"daemon_id":"local-daemon",
"device_name":"test-machine",
"runtimes":[{"name":"Local Codex","type":"codex","version":"1.0.0","status":"online"}]
}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", testUserID)
w := testutil.Call(t, testHandler.DaemonRegister, req).Want(http.StatusNotFound)
if !strings.Contains(w.Body.String(), "workspace not found") {
t.Fatalf("DaemonRegister: expected workspace not found error, got %s", w.Body.String())
}
}
// TestRootMentionOwnerRoutesMemberReplyButNotAgentReply verifies that a member
// root @mention owns later member replies, while agent-authored acknowledgments
// still do not self-expand the route.
func TestRootMentionOwnerRoutesMemberReplyButNotAgentReply(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
// Create two agents.
agentA := createHandlerTestAgent(t, "Loop Agent A", nil)
agentB := createHandlerTestAgent(t, "Loop Agent B", nil)
// Create an unassigned issue so on_comment doesn't fire.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Agent mention inheritance test",
"status": "todo",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
// Helper: count queued tasks for a given agent on this issue.
countTasks := func(agentID string) int {
var n int
err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
issueID, agentID,
).Scan(&n)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
return n
}
// Helper: cancel all tasks for an agent on this issue.
cancelTasks := func(agentID string) {
_, err := testPool.Exec(ctx,
`UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1 AND agent_id = $2`,
issueID, agentID,
)
if err != nil {
t.Fatalf("failed to cancel tasks: %v", err)
}
}
postComment := func(issueID string, body map[string]any, headers map[string]string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", body)
r = withURLParam(r, "id", issueID)
for k, v := range headers {
r.Header.Set(k, v)
}
testHandler.CreateComment(w, r)
return w
}
// 1. Member posts top-level comment mentioning Agent B.
mentionB := fmt.Sprintf("[@Agent B](mention://agent/%s) please review", agentB)
w = postComment(issueID, map[string]any{"content": mentionB}, nil)
if w.Code != http.StatusCreated {
t.Fatalf("member mention comment: expected 201, got %d: %s", w.Code, w.Body.String())
}
var parentComment CommentResponse
json.NewDecoder(w.Body).Decode(&parentComment)
if countTasks(agentB) != 1 {
t.Fatalf("expected 1 task for Agent B after member mention, got %d", countTasks(agentB))
}
// 2. Cancel Agent B's task so it's free to be re-triggered.
cancelTasks(agentB)
if countTasks(agentB) != 0 {
t.Fatalf("expected 0 tasks for Agent B after cancel, got %d", countTasks(agentB))
}
// 3. Agent A posts a reply in the same thread with NO mentions.
// Agent-authored comments still do not inherit the root mention of Agent B.
// resolveActor requires X-Task-ID paired with X-Agent-ID to trust the
// agent identity, so we seed a task that belongs to agent A.
agentATask := createHandlerTestTaskForAgent(t, agentA)
w = postComment(issueID, map[string]any{
"content": "No reply needed — just an acknowledgment.",
"parent_id": parentComment.ID,
}, map[string]string{"X-Agent-ID": agentA, "X-Task-ID": agentATask})
if w.Code != http.StatusCreated {
t.Fatalf("agent A reply: expected 201, got %d: %s", w.Code, w.Body.String())
}
if countTasks(agentB) != 0 {
t.Fatalf("expected 0 tasks for Agent B after agent reply (no parent inheritance), got %d", countTasks(agentB))
}
// 4. Cancel any stray tasks.
cancelTasks(agentB)
// 5. Member posts a reply in the same thread with NO mentions.
// The member-authored root @mention owns the thread, so this routes to B.
w = postComment(issueID, map[string]any{
"content": "Thanks for the review.",
"parent_id": parentComment.ID,
}, nil)
if w.Code != http.StatusCreated {
t.Fatalf("member reply: expected 201, got %d: %s", w.Code, w.Body.String())
}
if countTasks(agentB) != 1 {
t.Fatalf("expected 1 task for Agent B after member reply (root owner), got %d", countTasks(agentB))
}
}
// TestMemberReplyToAgentRootDoesNotInheritParentMentions is the regression
// for MUL-1535. When an agent posts a comment that @mentions another agent
// (e.g. J posting a PR completion that @mentions a reviewer agent), a later
// member reply in the same thread with no explicit mentions must NOT inherit
// the @reviewer mention. The reviewer was a one-shot delegation; subsequent
// member follow-ups are directed at the assignee, not the reviewer.
func TestMemberReplyToAgentRootDoesNotInheritParentMentions(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
jAgent := createHandlerTestAgent(t, "J", nil)
reviewerAgent := createHandlerTestAgent(t, "Reviewer", nil)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "PR review delegation no-leak test",
"status": "todo",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
countTasks := func(agentID string) int {
var n int
err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
issueID, agentID,
).Scan(&n)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
return n
}
// 1. Agent J posts a PR-completion comment that @mentions Reviewer for review.
// This is a deliberate handoff and must enqueue a task for Reviewer.
// X-Task-ID is required alongside X-Agent-ID for resolveActor to grant
// the "agent" actor identity (defense against header forgery).
jAgentTask := createHandlerTestTaskForAgent(t, jAgent)
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": fmt.Sprintf("PR ready. [@Reviewer](mention://agent/%s) please review this.", reviewerAgent),
})
r = withURLParam(r, "id", issueID)
r.Header.Set("X-Agent-ID", jAgent)
r.Header.Set("X-Task-ID", jAgentTask)
w = testutil.Call(t, testHandler.CreateComment, r).Want(http.StatusCreated)
var rootComment CommentResponse
json.NewDecoder(w.Body).Decode(&rootComment)
if got := countTasks(reviewerAgent); got != 1 {
t.Fatalf("expected 1 task for Reviewer after explicit mention, got %d", got)
}
// Cancel reviewer's task so it's free to be re-triggered if the bug returns.
dbfx.Exec(t,
`UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1 AND agent_id = $2`,
issueID, reviewerAgent,
)
// 2. Member posts a plain follow-up reply under J's PR comment, with no
// explicit mentions. The pre-fix code path inherited mentions from the
// parent regardless of the parent author, which re-triggered Reviewer.
// With the fix, the reply must NOT inherit because the parent was
// authored by an agent.
r = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "How do I test this after merging?",
"parent_id": rootComment.ID,
})
r = withURLParam(r, "id", issueID)
w = testutil.Call(t, testHandler.CreateComment, r).Want(http.StatusCreated)
if got := countTasks(reviewerAgent); got != 0 {
t.Fatalf("expected 0 tasks for Reviewer after plain member reply (no inheritance from agent root), got %d", got)
}
}
// TestNestedMemberReplyUsesDirectParentForThreadOwnership is the regression
// for parent-root write normalization leaking root mentions into plain nested
// replies. Stored parent_id keeps the direct parent, and trigger logic routes
// to that direct agent parent rather than the thread root's explicit mention.
func TestNestedMemberReplyUsesDirectParentForMentionInheritance(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
assigneeAgent := createHandlerTestAgent(t, "Nested Mention Assignee", nil)
mentionedAgent := createHandlerTestAgent(t, "Nested Mention Target", nil)
parentAgent := createHandlerTestAgent(t, "Nested Direct Parent", nil)
var number int
dbfx.QueryRow(t, `
UPDATE workspace
SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1
WHERE id = $1 RETURNING issue_counter
`, testWorkspaceID).Scan(&number)
issueID := dbfx.Insert(t, "issue", testutil.Cols{
"workspace_id": testWorkspaceID,
"creator_type": "member",
"creator_id": testUserID,
"title": "nested mention inheritance regression",
"assignee_type": "agent",
"assignee_id": assigneeAgent,
"number": number,
})
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
countQueued := func(agentID string) int {
t.Helper()
var n int
dbfx.QueryRow(t, `
SELECT count(*) FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'
`, issueID, agentID).Scan(&n)
return n
}
postMemberComment := func(body map[string]any) CommentResponse {
t.Helper()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", body)
r = withURLParam(r, "id", issueID)
w := testutil.Call(t, testHandler.CreateComment, r).Want(http.StatusCreated)
var resp CommentResponse
w.JSON(&resp)
return resp
}
root := postMemberComment(map[string]any{
"content": fmt.Sprintf("[@Mentioned](mention://agent/%s) please look", mentionedAgent),
})
if got := countQueued(mentionedAgent); got != 1 {
t.Fatalf("expected root mention to queue mentioned agent once, got %d", got)
}
dbfx.Exec(t, `UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1`, issueID)
directParentID := dbfx.Comment(t, issueID, "looks like redirect config", testutil.Cols{
"author_type": "agent",
"author_id": parentAgent,
"parent_id": root.ID,
})
nested := postMemberComment(map[string]any{
"content": "can you also check session expiry?",
"parent_id": directParentID,
})
if nested.ParentID == nil || *nested.ParentID != directParentID {
t.Fatalf("stored nested reply parent_id should keep direct parent %s, got %v", directParentID, nested.ParentID)
}
if got := countQueued(mentionedAgent); got != 0 {
t.Fatalf("plain nested reply must not inherit root mention from non-direct parent; got %d queued tasks", got)
}
if got := countQueued(parentAgent); got != 1 {
t.Fatalf("plain nested reply should route to direct agent parent; got %d queued tasks", got)
}
}
// TestNestedMemberReplyUnderMemberSkipsAssigneeFallback verifies that a nested
// reply whose direct parent is human-owned neither routes to a sibling agent
// reply nor falls back to the issue assignee. A sibling agent comment alone
// does not establish a conversation owner for the member-authored root.
func TestNestedMemberReplyUnderMemberSkipsAssigneeFallback(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
assigneeAgent := createHandlerTestAgent(t, "Nested Participation Assignee", nil)
var number int
dbfx.QueryRow(t, `
UPDATE workspace
SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1
WHERE id = $1 RETURNING issue_counter
`, testWorkspaceID).Scan(&number)
issueID := dbfx.Insert(t, "issue", testutil.Cols{
"workspace_id": testWorkspaceID,
"creator_type": "member",
"creator_id": testUserID,
"title": "nested participation regression",
"assignee_type": "agent",
"assignee_id": assigneeAgent,
"number": number,
})
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
countAssigneeQueued := func() int {
t.Helper()
var n int
dbfx.QueryRow(t, `
SELECT count(*) FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'
`, issueID, assigneeAgent).Scan(&n)
return n
}
postMemberComment := func(body map[string]any) CommentResponse {
t.Helper()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", body)
r = withURLParam(r, "id", issueID)
w := testutil.Call(t, testHandler.CreateComment, r).Want(http.StatusCreated)
var resp CommentResponse
w.JSON(&resp)
return resp
}
rootID := dbfx.Comment(t, issueID, "this cache question is for humans")
dbfx.Exec(t, `
INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content, parent_id)
VALUES ($1, $2, 'agent', $3, 'expiration policy is the issue', $4)
`, testWorkspaceID, issueID, assigneeAgent, rootID)
humanParentID := dbfx.Comment(t, issueID, "I have seen this too", testutil.Cols{
"parent_id": rootID,
})
nested := postMemberComment(map[string]any{
"content": "what should the expiration be?",
"parent_id": humanParentID,
})
if nested.ParentID == nil || *nested.ParentID != humanParentID {
t.Fatalf("stored nested reply parent_id should keep direct parent %s, got %v", humanParentID, nested.ParentID)
}
if got := countAssigneeQueued(); got != 0 {
t.Fatalf("plain nested human reply queued assignee tasks = %d, want 0", got)
}
}
// TestAgentExplicitMentionStillTriggers documents the boundary the structural
// fix preserves: suppressing implicit parent-mention inheritance for agent
// authors does NOT block deliberate handoffs. An agent that explicitly
// @mentions another agent in its own comment content still enqueues a task
// for that mentioned agent.
func TestAgentExplicitMentionStillTriggers(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentA := createHandlerTestAgent(t, "Handoff Agent A", nil)
agentB := createHandlerTestAgent(t, "Handoff Agent B", nil)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Agent explicit handoff test",
"status": "todo",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
countTasks := func(agentID string) int {
var n int
err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
issueID, agentID,
).Scan(&n)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
return n
}
// Agent A posts a top-level comment that explicitly @mentions Agent B —
// a deliberate handoff. This must enqueue a task for Agent B, and must
// not enqueue a self-trigger for Agent A. resolveActor requires
// X-Task-ID to grant "agent" identity; without it the self-trigger
// suppression (authorType=="agent") would not fire.
agentATask := createHandlerTestTaskForAgent(t, agentA)
explicitMention := fmt.Sprintf("[@Agent B](mention://agent/%s) please take it from here", agentB)
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": explicitMention,
})
r = withURLParam(r, "id", issueID)
r.Header.Set("X-Agent-ID", agentA)
r.Header.Set("X-Task-ID", agentATask)
w = testutil.Call(t, testHandler.CreateComment, r).Want(http.StatusCreated)
if got := countTasks(agentB); got != 1 {
t.Fatalf("expected 1 task for Agent B after explicit mention by Agent A, got %d", got)
}
if got := countTasks(agentA); got != 0 {
t.Fatalf("expected 0 tasks for Agent A (no self-trigger on own mention), got %d", got)
}
}
func TestCreateSkillSkipsSkillMdFile(t *testing.T) {
if testPool == nil {
t.Skip("no database available")
}
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/skills", CreateSkillRequest{
Name: "test-skill-create-skip-skillmd",
Content: "# SKILL.md content",
Files: []CreateSkillFileRequest{
{Path: "README.md", Content: "readme"},
{Path: "SKILL.md", Content: "should be skipped"},
{Path: "helper.go", Content: "package main"},
},
})
rec := httptest.NewRecorder()
testHandler.CreateSkill(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
var resp SkillWithFilesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
// Should only have README.md and helper.go, not SKILL.md
if len(resp.Files) != 2 {
t.Fatalf("expected 2 files, got %d", len(resp.Files))
}
for _, f := range resp.Files {
if strings.EqualFold(f.Path, "SKILL.md") {
t.Fatalf("SKILL.md should not be in response files")
}
}
// Verify DB state directly
ctx := context.Background()
rows, err := testPool.Query(ctx, "SELECT path FROM skill_file WHERE skill_id = $1", resp.ID)
if err != nil {
t.Fatalf("query skill_file: %v", err)
}
defer rows.Close()
var paths []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
t.Fatalf("scan path: %v", err)
}
paths = append(paths, p)
}
if len(paths) != 2 {
t.Fatalf("expected 2 rows in skill_file, got %d", len(paths))
}
for _, p := range paths {
if strings.EqualFold(p, "SKILL.md") {
t.Fatalf("SKILL.md should not be stored in skill_file")
}
}
}
func TestUpdateSkillSkipsSkillMdFile(t *testing.T) {
if testPool == nil {
t.Skip("no database available")
}
// Create a skill first
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/skills", CreateSkillRequest{
Name: "test-skill-update-skip-skillmd",
Content: "# SKILL.md content",
Files: []CreateSkillFileRequest{
{Path: "README.md", Content: "readme"},
},
})
rec := httptest.NewRecorder()
testHandler.CreateSkill(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create skill: expected 201, got %d: %s", rec.Code, rec.Body.String())
}
var createResp SkillWithFilesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &createResp); err != nil {
t.Fatalf("unmarshal create response: %v", err)
}
// Update with SKILL.md in files
updateReq := newRequest(http.MethodPut, "/api/skills/"+createResp.ID, UpdateSkillRequest{
Name: strPtr("updated-name"),
Content: strPtr("updated content"),
Files: []CreateSkillFileRequest{
{Path: "README.md", Content: "updated readme"},
{Path: "SKILL.md", Content: "should be skipped"},
{Path: "new.go", Content: "package main"},
},
})
updateReq = withURLParam(updateReq, "id", createResp.ID)
updateRec := httptest.NewRecorder()
testHandler.UpdateSkill(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("update skill: expected 200, got %d: %s", updateRec.Code, updateRec.Body.String())
}
var updateResp SkillWithFilesResponse
if err := json.Unmarshal(updateRec.Body.Bytes(), &updateResp); err != nil {
t.Fatalf("unmarshal update response: %v", err)
}
if len(updateResp.Files) != 2 {
t.Fatalf("expected 2 files after update, got %d", len(updateResp.Files))
}
for _, f := range updateResp.Files {
if strings.EqualFold(f.Path, "SKILL.md") {
t.Fatalf("SKILL.md should not be in updated response files")
}
}
// Verify DB state
ctx := context.Background()
rows, err := testPool.Query(ctx, "SELECT path FROM skill_file WHERE skill_id = $1", createResp.ID)
if err != nil {
t.Fatalf("query skill_file: %v", err)
}
defer rows.Close()
var paths []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
t.Fatalf("scan path: %v", err)
}
paths = append(paths, p)
}
if len(paths) != 2 {
t.Fatalf("expected 2 rows in skill_file after update, got %d", len(paths))
}
for _, p := range paths {
if strings.EqualFold(p, "SKILL.md") {
t.Fatalf("SKILL.md should not be stored in skill_file after update")
}
}
}
func strPtr(s string) *string {
return &s
}
func TestUpsertSkillFileRejectsSkillMd(t *testing.T) {
if testPool == nil {
t.Skip("no database available")
}
// Create a skill first
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/skills", CreateSkillRequest{
Name: "test-skill-upsert-reject-skillmd",
Content: "# SKILL.md content",
})
rec := httptest.NewRecorder()
testHandler.CreateSkill(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create skill: expected 201, got %d: %s", rec.Code, rec.Body.String())
}
var createResp SkillWithFilesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &createResp); err != nil {
t.Fatalf("unmarshal create response: %v", err)
}
// Try to upsert SKILL.md
upsertReq := newRequest(http.MethodPut, "/api/skills/"+createResp.ID+"/files", CreateSkillFileRequest{
Path: "SKILL.md",
Content: "should be rejected",
})
upsertReq = withURLParam(upsertReq, "id", createResp.ID)
upsertRec := httptest.NewRecorder()
testHandler.UpsertSkillFile(upsertRec, upsertReq)
if upsertRec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", upsertRec.Code, upsertRec.Body.String())
}
if !strings.Contains(upsertRec.Body.String(), "SKILL.md is reserved") {
t.Fatalf("expected error message about reserved SKILL.md, got: %s", upsertRec.Body.String())
}
}