Files
multica/server/internal/handler/handler_test.go
Naiyuan Qing 6d6bc5a6f2 fix(routing): rename /new-workspace to /workspaces/new + extend reserved slug list (#1188)
* fix(routing): rename /new-workspace to /workspaces/new + extend reserved slug list

Two related changes:

1. Rename the global workspace-creation route from /new-workspace to
   /workspaces/new. The hyphenated word-group `new-workspace` is a
   common user workspace name (last deploy was blocked by a real user
   with exactly this slug). Industry consensus from auditing Linear,
   Vercel, Notion, Slack, GitHub: zero major SaaS uses hyphenated
   word-group root routes — they all use single words or `/{noun}/{verb}`
   pairs. Reserving the noun `workspaces` automatically protects the
   entire `/workspaces/*` subtree, so future workspace-related routes
   (`/workspaces/{id}/edit`, `/workspaces/{id}/billing`, etc.) need no
   additional reserved slugs or audit migrations.

2. Extend the reserved slug list to cover the minimal set recommended by
   the URL-design audit: full auth flow vocab, RFC 2142 mailbox names
   (postmaster, abuse, noreply...), hostname confusables (mail, ftp,
   static, cdn...), and likely-future platform routes (docs, support,
   status, legal, privacy, terms, security, etc.). Production data
   audit confirmed zero conflicts for every newly added slug, so
   migration 047 (the safety net) passes cleanly.

Slugs intentionally NOT added despite being in scope of the audit:
admin, multica, new, setup, www. Each has one production workspace
already using it; adding them now would block deploy. They will be
handled in a follow-up PR via owner outreach + targeted rename.

Also adds a CLAUDE.md convention rule: new global routes MUST use a
single word or `/{noun}/{verb}` pair, never hyphenated word groups.
This prevents the pattern from regenerating itself.

This PR does NOT resolve the currently-blocked prd deploy — that requires
the existing `slug='new-workspace'` workspace (owner: Dhruv Raina) to be
renamed by ops. After that workspace is renamed and migration 046 passes,
this PR's migration 047 will also pass on its first run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* review: drop migration 046, sweep stale comments, drive reserved test from map

Address code review on PR #1188:

1. Delete migration 046 (audit_new_workspace_slug). It audits "new-workspace"
   which is no longer a reserved slug after this PR's rename. Removing 046
   has an unexpected upside: it directly unblocks the currently-stuck prd
   deploy. Migration 046 had never successfully applied (it was the source
   of the deploy block); the audit-only nature means down-rollback is a
   no-op. The user workspace previously caught by 046 (slug='new-workspace',
   owner: Dhruv Raina) is now safe — `new-workspace` is no longer reserved,
   so the slug correctly resolves to that workspace and the global route
   `/workspaces/new` doesn't shadow it.

2. Refactor workspace_test.go to drive its reserved-slug list from the
   reservedSlugs map directly via `for slug := range reservedSlugs`. The
   previous hand-copied list was already drifting (40-ish entries vs 58 in
   the map). Now drift is impossible.

3. Sweep ~10 stale `/new-workspace` references in code comments to
   `/workspaces/new`. Comments only — runtime unchanged. The references
   in reserved-slugs.ts/workspace_reserved_slugs.go and CLAUDE.md are
   intentionally kept as anti-pattern examples ("don't add hyphenated
   word-group root routes like /new-workspace").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:21:20 +08:00

1106 lines
35 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"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"
)
var testHandler *Handler
var testPool *pgxpool.Pool
var testUserID string
var testWorkspaceID 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)
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
}
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 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)
}
}
// 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)
}
}
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 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 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 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 TestVerifyCodeWrongCode(t *testing.T) {
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) {
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())
}
}