mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
Follow-ups to #1765 review nits: - Tighten the per-turn prompt and AGENTS.md workflow instructions so that "exit with no output" only applies when the trigger is from another agent AND no actual work was produced this turn. If the agent did real work, the standard "post results as a comment" rule still applies — a result reply is not a noise comment. - Add TestAgentExplicitMentionStillTriggers as a positive control documenting 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 content still enqueues a task for the mentioned agent and does not self-trigger.
2185 lines
73 KiB
Go
2185 lines
73 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/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"
|
|
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
|
|
|
|
const (
|
|
handlerTestEmail = "handler-test@multica.ai"
|
|
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})
|
|
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)
|
|
}
|
|
|
|
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, last_seen_at
|
|
)
|
|
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, now())
|
|
RETURNING id
|
|
`, workspaceID, "Handler Test Runtime", "handler_test_runtime", "Handler test runtime").Scan(&runtimeID); err != nil {
|
|
return "", "", err
|
|
}
|
|
testRuntimeID = runtimeID
|
|
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO agent (
|
|
workspace_id, name, description, runtime_mode, runtime_config,
|
|
runtime_id, visibility, max_concurrent_tasks, owner_id
|
|
)
|
|
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
|
|
`, workspaceID, "Handler Test Agent", runtimeID, userID); err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
return userID, workspaceID, nil
|
|
}
|
|
|
|
func cleanupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) error {
|
|
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 handlerTestRuntimeID(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT id FROM agent_runtime WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
|
|
testWorkspaceID,
|
|
).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("failed to load handler test runtime: %v", err)
|
|
}
|
|
|
|
return runtimeID
|
|
}
|
|
|
|
func createHandlerTestAgent(t *testing.T, name string, mcpConfig []byte) string {
|
|
t.Helper()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO agent (
|
|
workspace_id, name, description, runtime_mode, runtime_config,
|
|
runtime_id, visibility, max_concurrent_tasks, owner_id,
|
|
instructions, custom_env, custom_args, mcp_config
|
|
)
|
|
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'private', 1, $4, '', '{}'::jsonb, '[]'::jsonb, $5)
|
|
RETURNING id
|
|
`, testWorkspaceID, name, handlerTestRuntimeID(t), testUserID, mcpConfig).Scan(&agentID); err != nil {
|
|
t.Fatalf("failed to create handler test agent: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
|
|
})
|
|
|
|
return agentID
|
|
}
|
|
|
|
func fetchAgentMcpConfig(t *testing.T, agentID string) []byte {
|
|
t.Helper()
|
|
|
|
var mcpConfig []byte
|
|
if err := testPool.QueryRow(context.Background(), `SELECT mcp_config FROM agent WHERE id = $1`, agentID).Scan(&mcpConfig); err != nil {
|
|
t.Fatalf("failed to load agent mcp_config: %v", err)
|
|
}
|
|
|
|
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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Issue to delete by identifier",
|
|
"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.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.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("DELETE", "/api/issues/"+created.Identifier, nil)
|
|
req = withURLParam(req, "id", created.Identifier)
|
|
testHandler.DeleteIssue(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteIssue by identifier: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// 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
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT COUNT(*) FROM issue WHERE id = $1`, created.ID,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count query: %v", err)
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/issues/not-a-uuid-or-identifier", nil)
|
|
req = withURLParam(req, "id", "not-a-uuid-or-identifier")
|
|
testHandler.DeleteIssue(w, 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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Issue with no explicit status",
|
|
})
|
|
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.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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Explicit backlog issue",
|
|
"status": "backlog",
|
|
})
|
|
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.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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Sub-issue inheritance project",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var project ProjectResponse
|
|
json.NewDecoder(w.Body).Decode(&project)
|
|
projectID = project.ID
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Parent with project",
|
|
"project_id": projectID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Child without explicit project",
|
|
"parent_issue_id": parentID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue child: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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)
|
|
}
|
|
}()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Parent project",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var parentProject ProjectResponse
|
|
json.NewDecoder(w.Body).Decode(&parentProject)
|
|
parentProjectID = parentProject.ID
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Child explicit project",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject child: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var childProject ProjectResponse
|
|
json.NewDecoder(w.Body).Decode(&childProject)
|
|
childProjectID = childProject.ID
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Parent with project",
|
|
"project_id": parentProjectID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Child with explicit project",
|
|
"parent_issue_id": parentID,
|
|
"project_id": childProjectID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue child: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestCreateIssueRejectsNonexistentMemberAssignee covers the bug where any
|
|
// well-formed UUID was accepted as assignee_id without checking workspace
|
|
// membership.
|
|
func TestCreateIssueRejectsNonexistentMemberAssignee(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
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",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 for nonexistent member, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
w := httptest.NewRecorder()
|
|
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",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 for nonexistent agent, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Lone assignee_type",
|
|
"assignee_type": "member",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 when only assignee_type is set, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateIssueRejectsAssigneeIDWithoutType is the symmetric case.
|
|
func TestCreateIssueRejectsAssigneeIDWithoutType(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Lone assignee_id",
|
|
"assignee_id": testUserID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 when only assignee_id is set, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateIssueRejectsUnknownAssigneeType guards against typos like
|
|
// "members" or "user" that previously sneaked through.
|
|
func TestCreateIssueRejectsUnknownAssigneeType(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Bogus assignee_type",
|
|
"assignee_type": "user",
|
|
"assignee_id": testUserID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 for unknown assignee_type, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateIssueAcceptsValidMemberAssignee is the positive control — the
|
|
// validator must not block legitimate workspace members.
|
|
func TestCreateIssueAcceptsValidMemberAssignee(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Valid member assignee",
|
|
"assignee_type": "member",
|
|
"assignee_id": testUserID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue: expected 201 for valid member assignee, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Malformed assignee_id only",
|
|
"assignee_id": "not-a-uuid",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 for malformed assignee_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateIssueRejectsMalformedAttachmentIDBeforeWrite(t *testing.T) {
|
|
var before int
|
|
if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&before); err != nil {
|
|
t.Fatalf("count issues before: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Malformed attachment issue",
|
|
"attachment_ids": []string{"not-a-uuid"},
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 for malformed attachment_ids, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var after int
|
|
if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&after); err != nil {
|
|
t.Fatalf("count issues after: %v", err)
|
|
}
|
|
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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Update malformed assignee target",
|
|
})
|
|
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)
|
|
defer func() {
|
|
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
|
|
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
|
|
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
|
|
}()
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
|
|
"assignee_id": "not-a-uuid",
|
|
})
|
|
req = withURLParam(req, "id", created.ID)
|
|
testHandler.UpdateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateIssue: expected 400 for malformed assignee_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdateIssueRejectsNonexistentMemberAssignee verifies the same gap is
|
|
// closed on the update path — UpdateIssue previously only validated agents.
|
|
func TestUpdateIssueRejectsNonexistentMemberAssignee(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Update assignee target",
|
|
})
|
|
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)
|
|
defer func() {
|
|
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
|
|
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
|
|
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
|
|
}()
|
|
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.UpdateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateIssue: expected 400 for nonexistent member, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Issue to unassign",
|
|
"assignee_type": "member",
|
|
"assignee_id": testUserID,
|
|
})
|
|
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)
|
|
defer func() {
|
|
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
|
|
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
|
|
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
|
|
}()
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
|
|
"assignee_type": nil,
|
|
"assignee_id": nil,
|
|
})
|
|
req = withURLParam(req, "id", created.ID)
|
|
testHandler.UpdateIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateIssue: expected 200 for unassign, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Comment test issue",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
issueID := issue.ID
|
|
|
|
// Create comment
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "Test comment from Go test",
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.CreateComment(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// List comments
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/issues/"+issueID+"/comments", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.ListComments(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListComments: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
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
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("DELETE", "/api/issues/"+issueID, nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.DeleteIssue(w, req)
|
|
}
|
|
|
|
func TestCreateCommentRejectsMalformedParentID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Comment malformed parent issue",
|
|
})
|
|
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)
|
|
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.CreateComment(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateComment: expected 400 for malformed parent_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("DELETE", "/api/issues/"+issue.ID, nil)
|
|
req = withURLParam(req, "id", issue.ID)
|
|
testHandler.DeleteIssue(w, req)
|
|
}
|
|
|
|
func TestGetChatSessionRejectsMalformedSessionID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/chat/sessions/not-a-uuid", nil)
|
|
req = withURLParam(req, "sessionId", "not-a-uuid")
|
|
testHandler.GetChatSession(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("GetChatSession: expected 400 for malformed sessionId, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateAutopilotRejectsMalformedAssigneeID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots", map[string]any{
|
|
"title": "Malformed assignee autopilot",
|
|
"assignee_id": "not-a-uuid",
|
|
"execution_mode": "run_only",
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateAutopilot: expected 400 for malformed assignee_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateAutopilotRejectsMalformedID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/autopilots/not-a-uuid", map[string]any{
|
|
"title": "Malformed autopilot id",
|
|
})
|
|
req = withURLParam(req, "id", "not-a-uuid")
|
|
testHandler.UpdateAutopilot(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateAutopilot: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateAgentRejectsMalformedAgentID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/agents/not-a-uuid", map[string]any{
|
|
"name": "Malformed agent id",
|
|
})
|
|
req = withURLParam(req, "id", "not-a-uuid")
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateAgent: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateAgentRejectsMalformedRuntimeID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/agents", map[string]any{
|
|
"name": "Malformed runtime agent",
|
|
"runtime_id": "not-a-uuid",
|
|
})
|
|
testHandler.CreateAgent(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateAgent: expected 400 for malformed runtime_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateAgentRejectsMalformedRuntimeID(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "Handler Malformed Runtime Update", nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
|
"runtime_id": "not-a-uuid",
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateAgent: expected 400 for malformed runtime_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreatePinRejectsMalformedItemID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/pins", map[string]any{
|
|
"item_type": "issue",
|
|
"item_id": "not-a-uuid",
|
|
})
|
|
testHandler.CreatePin(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreatePin: expected 400 for malformed item_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateWorkspaceRejectsMalformedID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/workspaces/not-a-uuid", map[string]any{
|
|
"name": "Malformed workspace id",
|
|
})
|
|
req = withURLParam(req, "id", "not-a-uuid")
|
|
testHandler.UpdateWorkspace(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateWorkspace: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateMemberRejectsMalformedMemberID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PATCH", "/api/workspaces/"+testWorkspaceID+"/members/not-a-uuid", map[string]any{
|
|
"role": "member",
|
|
})
|
|
req = withURLParams(req, "id", testWorkspaceID, "memberId", "not-a-uuid")
|
|
testHandler.UpdateMember(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateMember: expected 400 for malformed memberId, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRevokeInvitationRejectsMalformedInvitationID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/invitations/not-a-uuid", nil)
|
|
req = withURLParams(req, "id", testWorkspaceID, "invitationId", "not-a-uuid")
|
|
testHandler.RevokeInvitation(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("RevokeInvitation: expected 400 for malformed invitationId, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetMyInvitationRejectsMalformedID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/invitations/not-a-uuid", nil)
|
|
req = withURLParam(req, "id", "not-a-uuid")
|
|
testHandler.GetMyInvitation(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("GetMyInvitation: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAddReactionRejectsMalformedCommentID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/comments/not-a-uuid/reactions", map[string]any{
|
|
"emoji": "thumbs_up",
|
|
})
|
|
req = withURLParam(req, "commentId", "not-a-uuid")
|
|
testHandler.AddReaction(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("AddReaction: expected 400 for malformed commentId, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateCommentRejectsMalformedCommentID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/comments/not-a-uuid", map[string]any{
|
|
"content": "updated",
|
|
})
|
|
req = withURLParam(req, "commentId", "not-a-uuid")
|
|
testHandler.UpdateComment(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateComment: expected 400 for malformed commentId, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestMarkInboxReadRejectsMalformedItemID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/inbox/not-a-uuid/read", nil)
|
|
req = withURLParam(req, "id", "not-a-uuid")
|
|
testHandler.MarkInboxRead(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("MarkInboxRead: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRevokePersonalAccessTokenRejectsMalformedID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/tokens/not-a-uuid", nil)
|
|
req = withURLParam(req, "id", "not-a-uuid")
|
|
testHandler.RevokePersonalAccessToken(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("RevokePersonalAccessToken: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
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,
|
|
},
|
|
{
|
|
name: "import starter content workspace_id",
|
|
req: newRequest("POST", "/api/onboarding/starter-content/import", map[string]any{
|
|
"workspace_id": "not-a-uuid",
|
|
"project": map[string]any{
|
|
"title": "Getting Started",
|
|
},
|
|
}),
|
|
handle: testHandler.ImportStarterContent,
|
|
},
|
|
}
|
|
|
|
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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/daemon/deregister", map[string]any{
|
|
"runtime_ids": []string{"not-a-uuid"},
|
|
})
|
|
testHandler.DaemonDeregister(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("DaemonDeregister: expected 400 for malformed runtime_ids, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetIssueGCCheckRejectsMalformedIssueID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/daemon/issues/not-a-uuid/gc-check", nil)
|
|
req = withURLParam(req, "issueId", "not-a-uuid")
|
|
testHandler.GetIssueGCCheck(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("GetIssueGCCheck: expected 400 for malformed issueId, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetAgentSkillsRejectsMalformedSkillID(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "Handler Malformed Skill Assignment", nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/agents/"+agentID+"/skills", map[string]any{
|
|
"skill_ids": []string{"not-a-uuid"},
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.SetAgentSkills(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("SetAgentSkills: expected 400 for malformed skill_ids, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAgentCRUD(t *testing.T) {
|
|
// List agents
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/agents?workspace_id="+testWorkspaceID, nil)
|
|
testHandler.ListAgents(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListAgents: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
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
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
|
"status": "idle",
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
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"}`))
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
|
"name": "Handler Mcp Preserve Updated",
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var updated AgentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("UpdateAgent: decode response: %v", err)
|
|
}
|
|
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"}`))
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
|
"mcp_config": nil,
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var updated AgentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("UpdateAgent: decode response: %v", err)
|
|
}
|
|
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"}`))
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
|
"mcp_config": map[string]any{"preset": "new"},
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var updated AgentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("UpdateAgent: decode response: %v", err)
|
|
}
|
|
assertJSONEqual(t, updated.McpConfig, `{"preset":"new"}`)
|
|
assertJSONEqual(t, fetchAgentMcpConfig(t, agentID), `{"preset":"new"}`)
|
|
}
|
|
|
|
func TestCreateAgentMcpConfigNullStoresSQLNull(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
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{},
|
|
})
|
|
testHandler.CreateAgent(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAgent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var created AgentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("CreateAgent: decode response: %v", err)
|
|
}
|
|
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 := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/workspaces", nil)
|
|
testHandler.ListWorkspaces(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListWorkspaces: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
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
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/workspaces/"+wsID, nil)
|
|
req = withURLParam(req, "id", wsID)
|
|
testHandler.GetWorkspace(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetWorkspace: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/workspaces", map[string]string{
|
|
"name": "Handler Create Workspace Requested",
|
|
"slug": slug,
|
|
})
|
|
testHandler.CreateWorkspace(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateWorkspace: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var created WorkspaceResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("CreateWorkspace: decode response: %v", err)
|
|
}
|
|
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)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/workspaces", map[string]string{
|
|
"name": "Duplicate Handler Workspace",
|
|
"slug": handlerTestWorkspaceSlug,
|
|
})
|
|
testHandler.CreateWorkspace(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("CreateWorkspace: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var count int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM workspace WHERE slug = $1`, retriedSlug).Scan(&count); err != nil {
|
|
t.Fatalf("CreateWorkspace: check retried slug: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("CreateWorkspace: expected no fallback slug %q, got %d rows", retriedSlug, count)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorkspaceInvalidSlugReturnsBadRequest(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/workspaces", map[string]string{
|
|
"name": "Invalid Slug Workspace",
|
|
"slug": "invalid slug",
|
|
})
|
|
testHandler.CreateWorkspace(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateWorkspace: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSendCode(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
body := map[string]string{"email": "sendcode-test@multica.ai"}
|
|
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`, "sendcode-test@multica.ai")
|
|
})
|
|
}
|
|
|
|
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": "dberror-test@multica.ai"}
|
|
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 = "ratelimit-test@multica.ai"
|
|
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 = "verify-test@multica.ai"
|
|
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 = "dev-code-disabled-test@multica.ai"
|
|
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 = "dev-code-enabled-test@multica.ai"
|
|
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 = "dev-code-production-test@multica.ai"
|
|
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 = "wrong-code-test@multica.ai"
|
|
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 = "bruteforce-test@multica.ai"
|
|
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 = "workspace-verify-test@multica.ai"
|
|
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",
|
|
},
|
|
{
|
|
name: "valid agent ID returns agent",
|
|
agentIDHeader: agentID,
|
|
wantActorType: "agent",
|
|
wantIsAgent: true,
|
|
},
|
|
{
|
|
name: "non-existent agent ID returns member",
|
|
agentIDHeader: "00000000-0000-0000-0000-000000000099",
|
|
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)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Backlog no-trigger test",
|
|
"status": "backlog",
|
|
"assignee_type": "agent",
|
|
"assignee_id": agentID,
|
|
})
|
|
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)
|
|
|
|
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.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Backlog trigger test",
|
|
"status": "backlog",
|
|
"assignee_type": "agent",
|
|
"assignee_id": agentID,
|
|
})
|
|
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)
|
|
|
|
// Move the issue from backlog to todo — should trigger.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
|
|
"status": "todo",
|
|
})
|
|
req = withURLParam(req, "id", created.ID)
|
|
testHandler.UpdateIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
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)
|
|
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("DaemonRegister: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "workspace not found") {
|
|
t.Fatalf("DaemonRegister: expected workspace not found error, got %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestAgentReplyDoesNotInheritParentMentions verifies that agent-authored
|
|
// replies do NOT inherit parent-comment mentions, preventing agent-to-agent
|
|
// re-trigger loops (e.g. "No reply needed" chains). Member-authored replies
|
|
// still inherit parent mentions as expected.
|
|
func TestAgentReplyDoesNotInheritParentMentions(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.
|
|
// With the fix, this must NOT inherit the parent mention of Agent B.
|
|
w = postComment(issueID, map[string]any{
|
|
"content": "No reply needed — just an acknowledgment.",
|
|
"parent_id": parentComment.ID,
|
|
}, map[string]string{"X-Agent-ID": agentA})
|
|
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.
|
|
// This SHOULD inherit the parent mention and re-trigger Agent 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 (parent inheritance allowed), got %d", countTasks(agentB))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Agent explicit handoff 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)
|
|
})
|
|
|
|
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.
|
|
explicitMention := fmt.Sprintf("[@Agent B](mention://agent/%s) please take it from here", agentB)
|
|
w = httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": explicitMention,
|
|
})
|
|
r = withURLParam(r, "id", issueID)
|
|
r.Header.Set("X-Agent-ID", agentA)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("agent A handoff: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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)
|
|
}
|
|
}
|