mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
* 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>
819 lines
23 KiB
Go
819 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/multica-ai/multica/server/internal/auth"
|
|
"github.com/multica-ai/multica/server/internal/events"
|
|
"github.com/multica-ai/multica/server/internal/realtime"
|
|
)
|
|
|
|
var (
|
|
testServer *httptest.Server
|
|
testPool *pgxpool.Pool
|
|
testToken string
|
|
testUserID string
|
|
testWorkspaceID string
|
|
)
|
|
|
|
// jwtSecret is resolved at runtime via auth.JWTSecret() so it respects
|
|
// the JWT_SECRET env var (set in .env) and stays in sync with the server.
|
|
|
|
const (
|
|
integrationTestEmail = "integration-test@multica.ai"
|
|
integrationTestName = "Integration Tester"
|
|
integrationTestWorkspaceSlug = "integration-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 integration tests: could not connect to database: %v\n", err)
|
|
os.Exit(0)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
fmt.Printf("Skipping integration tests: database not reachable: %v\n", err)
|
|
pool.Close()
|
|
os.Exit(0)
|
|
}
|
|
|
|
testPool = pool
|
|
testUserID, testWorkspaceID, err = setupIntegrationTestFixture(ctx, pool)
|
|
if err != nil {
|
|
fmt.Printf("Failed to set up integration test fixture: %v\n", err)
|
|
pool.Close()
|
|
os.Exit(1)
|
|
}
|
|
|
|
hub := realtime.NewHub()
|
|
go hub.Run()
|
|
|
|
bus := events.New()
|
|
registerListeners(bus, hub)
|
|
router := NewRouter(pool, hub, bus)
|
|
testServer = httptest.NewServer(router)
|
|
|
|
// Generate a JWT token directly for the test user
|
|
testToken, err = generateTestJWT(testUserID, integrationTestEmail, integrationTestName)
|
|
if err != nil {
|
|
fmt.Printf("Failed to generate test JWT: %v\n", err)
|
|
testServer.Close()
|
|
pool.Close()
|
|
os.Exit(1)
|
|
}
|
|
|
|
code := m.Run()
|
|
|
|
if err := cleanupIntegrationTestFixture(context.Background(), pool); err != nil {
|
|
fmt.Printf("Failed to clean up integration test fixture: %v\n", err)
|
|
if code == 0 {
|
|
code = 1
|
|
}
|
|
}
|
|
testServer.Close()
|
|
pool.Close()
|
|
os.Exit(code)
|
|
}
|
|
|
|
func setupIntegrationTestFixture(ctx context.Context, pool *pgxpool.Pool) (string, string, error) {
|
|
if err := cleanupIntegrationTestFixture(ctx, pool); err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
var userID string
|
|
if err := pool.QueryRow(ctx, `
|
|
INSERT INTO "user" (name, email)
|
|
VALUES ($1, $2)
|
|
RETURNING id
|
|
`, integrationTestName, integrationTestEmail).Scan(&userID); err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
var workspaceID string
|
|
if err := pool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id
|
|
`, "Integration Tests", integrationTestWorkspaceSlug, "Temporary workspace for router integration tests").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, "Integration Test Runtime", "integration_test_runtime", "Integration 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, "Integration Test Agent", runtimeID, userID); err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
return userID, workspaceID, nil
|
|
}
|
|
|
|
func cleanupIntegrationTestFixture(ctx context.Context, pool *pgxpool.Pool) error {
|
|
if _, err := pool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, integrationTestWorkspaceSlug); err != nil {
|
|
return err
|
|
}
|
|
if _, err := pool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, integrationTestEmail); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Helper to make authenticated requests
|
|
func authRequest(t *testing.T, method, path string, body any) *http.Response {
|
|
t.Helper()
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
bodyReader = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequest(method, testServer.URL+path, bodyReader)
|
|
if err != nil {
|
|
t.Fatalf("failed to create request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
|
req.Header.Set("X-Workspace-ID", testWorkspaceID)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func readJSON(t *testing.T, resp *http.Response, v any) {
|
|
t.Helper()
|
|
defer resp.Body.Close()
|
|
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
}
|
|
|
|
func generateTestJWT(userID, email, name string) (string, error) {
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"sub": userID,
|
|
"email": email,
|
|
"name": name,
|
|
"exp": time.Now().Add(72 * time.Hour).Unix(),
|
|
"iat": time.Now().Unix(),
|
|
})
|
|
return token.SignedString(auth.JWTSecret())
|
|
}
|
|
|
|
// ---- Health ----
|
|
|
|
func TestHealth(t *testing.T) {
|
|
resp, err := http.Get(testServer.URL + "/health")
|
|
if err != nil {
|
|
t.Fatalf("health check failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var result map[string]string
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
if result["status"] != "ok" {
|
|
t.Fatalf("expected status ok, got %s", result["status"])
|
|
}
|
|
}
|
|
|
|
// ---- Auth ----
|
|
|
|
func TestSendCodeAndVerify(t *testing.T) {
|
|
const email = "integration-sendcode@multica.ai"
|
|
ctx := context.Background()
|
|
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
|
|
var userID string
|
|
err := testPool.QueryRow(ctx, `SELECT id FROM "user" WHERE email = $1`, email).Scan(&userID)
|
|
if err == nil {
|
|
rows, queryErr := testPool.Query(ctx, `
|
|
SELECT w.id FROM workspace w JOIN member m ON m.workspace_id = w.id WHERE m.user_id = $1
|
|
`, userID)
|
|
if queryErr == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var wsID string
|
|
if rows.Scan(&wsID) == nil {
|
|
testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, wsID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
|
|
})
|
|
|
|
// Step 1: Send code
|
|
body, _ := json.Marshal(map[string]string{"email": email})
|
|
resp, err := http.Post(testServer.URL+"/auth/send-code", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("send-code failed: %v", err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("send-code: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// Read code from DB
|
|
var code string
|
|
err = testPool.QueryRow(ctx, `SELECT code FROM verification_code WHERE email = $1 ORDER BY created_at DESC LIMIT 1`, email).Scan(&code)
|
|
if err != nil {
|
|
t.Fatalf("failed to read code from DB: %v", err)
|
|
}
|
|
|
|
// Step 2: Verify code
|
|
body, _ = json.Marshal(map[string]string{"email": email, "code": code})
|
|
resp, err = http.Post(testServer.URL+"/auth/verify-code", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("verify-code failed: %v", err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Fatalf("verify-code: expected 200, got %d: %s", resp.StatusCode, respBody)
|
|
}
|
|
|
|
var loginResp struct {
|
|
Token string `json:"token"`
|
|
User struct {
|
|
Email string `json:"email"`
|
|
} `json:"user"`
|
|
}
|
|
readJSON(t, resp, &loginResp)
|
|
|
|
if loginResp.Token == "" {
|
|
t.Fatal("expected non-empty token")
|
|
}
|
|
if loginResp.User.Email != email {
|
|
t.Fatalf("expected email '%s', got '%s'", email, loginResp.User.Email)
|
|
}
|
|
|
|
// Verify the token works with /api/me
|
|
req, _ := http.NewRequest("GET", testServer.URL+"/api/me", nil)
|
|
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
|
|
meResp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("getMe failed: %v", err)
|
|
}
|
|
if meResp.StatusCode != 200 {
|
|
t.Fatalf("getMe: expected 200, got %d", meResp.StatusCode)
|
|
}
|
|
meResp.Body.Close()
|
|
}
|
|
|
|
func TestVerifyCodeNewUserHasNoWorkspace(t *testing.T) {
|
|
const email = "new-integration-verify@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)
|
|
})
|
|
|
|
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
|
|
|
|
// Send code
|
|
body, _ := json.Marshal(map[string]string{"email": email})
|
|
resp, err := http.Post(testServer.URL+"/auth/send-code", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("send-code failed: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// Read code from DB
|
|
var code string
|
|
err = testPool.QueryRow(ctx, `SELECT code FROM verification_code WHERE email = $1 ORDER BY created_at DESC LIMIT 1`, email).Scan(&code)
|
|
if err != nil {
|
|
t.Fatalf("failed to read code from DB: %v", err)
|
|
}
|
|
|
|
// Verify code
|
|
body, _ = json.Marshal(map[string]string{"email": email, "code": code})
|
|
resp, err = http.Post(testServer.URL+"/auth/verify-code", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("verify-code failed: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("verify-code: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var loginResp struct {
|
|
Token string `json:"token"`
|
|
}
|
|
readJSON(t, resp, &loginResp)
|
|
|
|
// New users should have no workspaces (/workspaces/new creates one)
|
|
req, _ := http.NewRequest("GET", testServer.URL+"/api/workspaces", nil)
|
|
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
|
|
workspacesResp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("listWorkspaces failed: %v", err)
|
|
}
|
|
defer workspacesResp.Body.Close()
|
|
|
|
if workspacesResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", workspacesResp.StatusCode)
|
|
}
|
|
|
|
var workspaces []struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
readJSON(t, workspacesResp, &workspaces)
|
|
|
|
if len(workspaces) != 0 {
|
|
t.Fatalf("expected 0 workspaces for new user, got %d", len(workspaces))
|
|
}
|
|
}
|
|
|
|
func TestProtectedRoutesRequireAuth(t *testing.T) {
|
|
paths := []string{"/api/me", "/api/issues", "/api/agents", "/api/inbox", "/api/workspaces"}
|
|
|
|
for _, path := range paths {
|
|
resp, err := http.Get(testServer.URL + path)
|
|
if err != nil {
|
|
t.Fatalf("request to %s failed: %v", path, err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 401 {
|
|
t.Fatalf("%s: expected 401, got %d", path, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInvalidJWT(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
token string
|
|
}{
|
|
{"garbage token", "not-a-jwt"},
|
|
{"empty token", ""},
|
|
{"wrong secret", func() string {
|
|
claims := jwt.MapClaims{"sub": "test", "exp": time.Now().Add(time.Hour).Unix()}
|
|
t, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("wrong"))
|
|
return t
|
|
}()},
|
|
{"expired token", func() string {
|
|
claims := jwt.MapClaims{"sub": "test", "exp": time.Now().Add(-time.Hour).Unix()}
|
|
t, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(auth.JWTSecret())
|
|
return t
|
|
}()},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", testServer.URL+"/api/me", nil)
|
|
if tc.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+tc.token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 401 {
|
|
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---- Issues CRUD through full router ----
|
|
|
|
func TestIssuesCRUDThroughRouter(t *testing.T) {
|
|
// Create
|
|
resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Integration test issue",
|
|
"status": "todo",
|
|
"priority": "high",
|
|
})
|
|
if resp.StatusCode != 201 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Fatalf("CreateIssue: expected 201, got %d: %s", resp.StatusCode, body)
|
|
}
|
|
|
|
var created map[string]any
|
|
readJSON(t, resp, &created)
|
|
issueID := created["id"].(string)
|
|
if created["title"] != "Integration test issue" {
|
|
t.Fatalf("expected title 'Integration test issue', got '%s'", created["title"])
|
|
}
|
|
|
|
// Get
|
|
resp = authRequest(t, "GET", "/api/issues/"+issueID, nil)
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("GetIssue: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var fetched map[string]any
|
|
readJSON(t, resp, &fetched)
|
|
if fetched["id"] != issueID {
|
|
t.Fatalf("expected id %s, got %s", issueID, fetched["id"])
|
|
}
|
|
|
|
// Update status only — should preserve title
|
|
resp = authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
|
|
"status": "in_progress",
|
|
})
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("UpdateIssue: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var updated map[string]any
|
|
readJSON(t, resp, &updated)
|
|
if updated["status"] != "in_progress" {
|
|
t.Fatalf("expected status 'in_progress', got '%s'", updated["status"])
|
|
}
|
|
if updated["title"] != "Integration test issue" {
|
|
t.Fatalf("title should be preserved, got '%s'", updated["title"])
|
|
}
|
|
|
|
// Update title only — should preserve status
|
|
resp = authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
|
|
"title": "Renamed integration issue",
|
|
})
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("UpdateIssue title: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var updated2 map[string]any
|
|
readJSON(t, resp, &updated2)
|
|
if updated2["title"] != "Renamed integration issue" {
|
|
t.Fatalf("expected title 'Renamed integration issue', got '%s'", updated2["title"])
|
|
}
|
|
if updated2["status"] != "in_progress" {
|
|
t.Fatalf("status should be preserved, got '%s'", updated2["status"])
|
|
}
|
|
|
|
// List
|
|
resp = authRequest(t, "GET", "/api/issues?workspace_id="+testWorkspaceID, nil)
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("ListIssues: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var listResp map[string]any
|
|
readJSON(t, resp, &listResp)
|
|
total := listResp["total"].(float64)
|
|
if total < 1 {
|
|
t.Fatal("expected at least 1 issue")
|
|
}
|
|
|
|
// Delete
|
|
resp = authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 204 {
|
|
t.Fatalf("DeleteIssue: expected 204, got %d", resp.StatusCode)
|
|
}
|
|
|
|
// Verify deleted
|
|
resp = authRequest(t, "GET", "/api/issues/"+issueID, nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 404 {
|
|
t.Fatalf("GetIssue after delete: expected 404, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// ---- Comments through full router ----
|
|
|
|
func TestCommentsThroughRouter(t *testing.T) {
|
|
// Create issue
|
|
resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Comment integration test",
|
|
})
|
|
var issue map[string]any
|
|
readJSON(t, resp, &issue)
|
|
issueID := issue["id"].(string)
|
|
|
|
// Create comment
|
|
resp = authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "Integration test comment",
|
|
"type": "comment",
|
|
})
|
|
if resp.StatusCode != 201 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Fatalf("CreateComment: expected 201, got %d: %s", resp.StatusCode, body)
|
|
}
|
|
var comment map[string]any
|
|
readJSON(t, resp, &comment)
|
|
if comment["content"] != "Integration test comment" {
|
|
t.Fatalf("expected content 'Integration test comment', got '%s'", comment["content"])
|
|
}
|
|
|
|
// Create second comment
|
|
resp = authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "Second comment",
|
|
"type": "comment",
|
|
})
|
|
resp.Body.Close()
|
|
|
|
// List comments
|
|
resp = authRequest(t, "GET", "/api/issues/"+issueID+"/comments", nil)
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("ListComments: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var comments []map[string]any
|
|
readJSON(t, resp, &comments)
|
|
if len(comments) != 2 {
|
|
t.Fatalf("expected 2 comments, got %d", len(comments))
|
|
}
|
|
|
|
// Cleanup
|
|
resp = authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// ---- Agents through full router ----
|
|
|
|
func TestAgentsThroughRouter(t *testing.T) {
|
|
// List
|
|
resp := authRequest(t, "GET", "/api/agents?workspace_id="+testWorkspaceID, nil)
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("ListAgents: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var agents []map[string]any
|
|
readJSON(t, resp, &agents)
|
|
if len(agents) < 1 {
|
|
t.Fatal("expected at least 1 agent")
|
|
}
|
|
|
|
// Get
|
|
agentID := agents[0]["id"].(string)
|
|
resp = authRequest(t, "GET", "/api/agents/"+agentID, nil)
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("GetAgent: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var agent map[string]any
|
|
readJSON(t, resp, &agent)
|
|
if agent["id"] != agentID {
|
|
t.Fatalf("expected agent id %s, got %s", agentID, agent["id"])
|
|
}
|
|
|
|
// Update status
|
|
resp = authRequest(t, "PUT", "/api/agents/"+agentID, map[string]any{
|
|
"status": "idle",
|
|
})
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("UpdateAgent: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var updated map[string]any
|
|
readJSON(t, resp, &updated)
|
|
if updated["status"] != "idle" {
|
|
t.Fatalf("expected status 'idle', got '%s'", updated["status"])
|
|
}
|
|
// Name should be preserved
|
|
if updated["name"] != agents[0]["name"] {
|
|
t.Fatalf("name should be preserved, got '%s'", updated["name"])
|
|
}
|
|
}
|
|
|
|
// ---- Workspaces through full router ----
|
|
|
|
func TestWorkspacesThroughRouter(t *testing.T) {
|
|
// List
|
|
resp := authRequest(t, "GET", "/api/workspaces", nil)
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("ListWorkspaces: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var workspaces []map[string]any
|
|
readJSON(t, resp, &workspaces)
|
|
if len(workspaces) < 1 {
|
|
t.Fatal("expected at least 1 workspace")
|
|
}
|
|
|
|
// Get
|
|
wsID := workspaces[0]["id"].(string)
|
|
resp = authRequest(t, "GET", "/api/workspaces/"+wsID, nil)
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("GetWorkspace: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var ws map[string]any
|
|
readJSON(t, resp, &ws)
|
|
if ws["id"] != wsID {
|
|
t.Fatalf("expected workspace id %s, got %s", wsID, ws["id"])
|
|
}
|
|
|
|
// Update
|
|
resp = authRequest(t, "PUT", "/api/workspaces/"+wsID, map[string]any{
|
|
"description": "Integration test update",
|
|
})
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("UpdateWorkspace: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var updated map[string]any
|
|
readJSON(t, resp, &updated)
|
|
if updated["description"] != "Integration test update" {
|
|
t.Fatalf("expected description 'Integration test update', got '%v'", updated["description"])
|
|
}
|
|
// Name should be preserved
|
|
if updated["name"] != ws["name"] {
|
|
t.Fatalf("name should be preserved")
|
|
}
|
|
|
|
// Members
|
|
resp = authRequest(t, "GET", "/api/workspaces/"+wsID+"/members", nil)
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("ListMembers: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var members []map[string]any
|
|
readJSON(t, resp, &members)
|
|
if len(members) < 1 {
|
|
t.Fatal("expected at least 1 member")
|
|
}
|
|
// Verify member has user info
|
|
if members[0]["email"] == nil || members[0]["email"] == "" {
|
|
t.Fatal("member should have email field")
|
|
}
|
|
if members[0]["role"] == nil || members[0]["role"] == "" {
|
|
t.Fatal("member should have role field")
|
|
}
|
|
}
|
|
|
|
// ---- Inbox through full router ----
|
|
|
|
func TestInboxThroughRouter(t *testing.T) {
|
|
resp := authRequest(t, "GET", "/api/inbox", nil)
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("ListInbox: expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var items []map[string]any
|
|
readJSON(t, resp, &items)
|
|
// Inbox may be empty, just verify it returns valid JSON array
|
|
if items == nil {
|
|
t.Fatal("expected non-nil inbox items array")
|
|
}
|
|
}
|
|
|
|
// ---- 404 for non-existent resources ----
|
|
|
|
func TestNonExistentResources(t *testing.T) {
|
|
fakeUUID := "00000000-0000-0000-0000-000000000000"
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
}{
|
|
{"issue", "/api/issues/" + fakeUUID},
|
|
{"agent", "/api/agents/" + fakeUUID},
|
|
{"workspace", "/api/workspaces/" + fakeUUID},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resp := authRequest(t, "GET", tc.path, nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 404 {
|
|
t.Fatalf("expected 404, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---- Invalid request bodies ----
|
|
|
|
func TestInvalidRequestBodies(t *testing.T) {
|
|
resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, nil)
|
|
defer resp.Body.Close()
|
|
// Sending nil body should fail with 400
|
|
if resp.StatusCode != 400 {
|
|
// Some handlers may return 500 for nil body, that's acceptable too
|
|
if resp.StatusCode != 500 {
|
|
t.Fatalf("expected 400 or 500, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- WebSocket integration through full router ----
|
|
|
|
func TestWebSocketIntegration(t *testing.T) {
|
|
// Connect WebSocket client (no token in URL — first-message auth)
|
|
wsURL := "ws" + strings.TrimPrefix(testServer.URL, "http") + "/ws?workspace_id=" + testWorkspaceID
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("WebSocket connection failed: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// First-message auth
|
|
authMsg, _ := json.Marshal(map[string]any{
|
|
"type": "auth",
|
|
"payload": map[string]string{"token": testToken},
|
|
})
|
|
if err := conn.WriteMessage(websocket.TextMessage, authMsg); err != nil {
|
|
t.Fatalf("failed to send auth message: %v", err)
|
|
}
|
|
|
|
// Read auth_ack
|
|
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
|
_, ack, err := conn.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("failed to read auth_ack: %v", err)
|
|
}
|
|
if !strings.Contains(string(ack), "auth_ack") {
|
|
t.Fatalf("expected auth_ack, got %s", ack)
|
|
}
|
|
conn.SetReadDeadline(time.Time{})
|
|
|
|
// Allow Hub goroutine to process the register and add client to room
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Create an issue — this should trigger a WebSocket broadcast
|
|
resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "WebSocket test issue",
|
|
"status": "todo",
|
|
})
|
|
var issue map[string]any
|
|
readJSON(t, resp, &issue)
|
|
issueID := issue["id"].(string)
|
|
|
|
// Read the WebSocket message
|
|
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
|
_, msg, err := conn.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("WebSocket read error: %v", err)
|
|
}
|
|
|
|
// Verify the message contains the issue event
|
|
var wsMsg map[string]any
|
|
if err := json.Unmarshal(msg, &wsMsg); err != nil {
|
|
t.Fatalf("failed to parse WebSocket message: %v", err)
|
|
}
|
|
if wsMsg["type"] != "issue:created" {
|
|
t.Fatalf("expected type 'issue:created', got '%s'", wsMsg["type"])
|
|
}
|
|
|
|
// Update the issue — should trigger another broadcast
|
|
resp = authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
|
|
"status": "in_progress",
|
|
})
|
|
resp.Body.Close()
|
|
|
|
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
|
_, msg, err = conn.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("WebSocket read error on update: %v", err)
|
|
}
|
|
var updateMsg map[string]any
|
|
json.Unmarshal(msg, &updateMsg)
|
|
if updateMsg["type"] != "issue:updated" {
|
|
t.Fatalf("expected type 'issue:updated', got '%s'", updateMsg["type"])
|
|
}
|
|
|
|
// Delete the issue — should trigger another broadcast
|
|
resp = authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
|
|
resp.Body.Close()
|
|
|
|
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
|
_, msg, err = conn.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("WebSocket read error on delete: %v", err)
|
|
}
|
|
var deleteMsg map[string]any
|
|
json.Unmarshal(msg, &deleteMsg)
|
|
if deleteMsg["type"] != "issue:deleted" {
|
|
t.Fatalf("expected type 'issue:deleted', got '%s'", deleteMsg["type"])
|
|
}
|
|
}
|