mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
When an agent posts a comment that @mentions another agent (typically a one-shot delegation, e.g. a PR-completion comment that asks a reviewer agent to review), member follow-up replies in the same thread were auto-inheriting that mention and re-triggering the reviewer on every plain question. Same root cause: the inheritance branch only required the reply to have no mentions, not that the parent was member-authored. Tighten the guard: only inherit when the parent (thread root) is authored by a member. Member-rooted threads still inherit so a member who started by @mentioning an agent can keep replying without re-typing. Agent-authored roots are treated as one-shot — explicit @mentions in later comments still trigger normally. Extracted the decision into shouldInheritParentMentions for direct unit testing, and added an end-to-end regression (TestMemberReplyToAgentRootDoesNotInheritParentMentions) that reproduces MUL-1535: J posts a PR completion @mentioning Reviewer; a member's plain follow-up must not re-enqueue Reviewer.
2277 lines
77 KiB
Go
2277 lines
77 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))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "PR review delegation no-leak 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
|
|
}
|
|
|
|
// 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.
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("J PR completion: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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.
|
|
if _, err := testPool.Exec(ctx,
|
|
`UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1 AND agent_id = $2`,
|
|
issueID, reviewerAgent,
|
|
); err != nil {
|
|
t.Fatalf("cancel reviewer task: %v", err)
|
|
}
|
|
|
|
// 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.
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("member follow-up: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|