Files
multica/server/cmd/server/integration_test.go
T
devv-eveandJiang Bohan 637bdc8eb3 feat(analytics): full PostHog pipeline + 6 funnel events (MUL-1122) (#1367)
* feat(analytics): add PostHog client with async batch shipping

Introduces server/internal/analytics, the shipping layer for the product
funnel defined in docs/analytics.md. Capture is non-blocking — events are
enqueued into a bounded channel and a background worker batches them to
PostHog's /batch/ endpoint. A broken backend drops events rather than
blocking request handlers.

Local dev and self-hosted instances run a noop client until the operator
sets POSTHOG_API_KEY. This is PR 1 of MUL-1122; signup and workspace_created
emission land in the follow-up commit so this change is independently
reviewable.

* feat(server): emit signup and workspace_created analytics events

Wires analytics.Client through handler.New and main, then emits the first
two funnel events:

- signup fires from findOrCreateUser (which now reports isNew), covering
  both the verification-code and Google OAuth entry points — a single
  emission site guarantees Google signups aren't missed.
- workspace_created fires after the CreateWorkspace transaction commits,
  with is_first_workspace computed from a post-commit ListWorkspaces count
  so we can distinguish fresh-user activation from returning-user
  expansion.

Tests use analytics.NoopClient so nothing ships from test runs. PR 1 of
MUL-1122; runtime_registered and issue_executed follow in later PRs per
the plan.

* refactor(analytics): drop is_first_workspace from workspace_created

Stamping "is this the user's first workspace?" at emit time races under
concurrent CreateWorkspace requests: two transactions committing close
together can both read a post-commit count greater than one and both emit
false. Fixing it at the SQL layer requires a schema change we don't want in
PR 1.

PostHog answers the same question exactly from the event stream (funnel on
"first time user does X" / cohort on $initial_event), so removing the
property loses no information and makes the emit side race-free.

* docs(analytics): document self-host safety defaults

Spell out why self-hosted instances never ship events upstream by default
(empty POSTHOG_API_KEY → noop client) and explain how operators can point
at their own PostHog project without any code change.

* feat(analytics): emit runtime_registered, issue_executed, team_invite_*

Three server-side funnel events, all gated on first-time state transitions
so retries and re-runs don't inflate the WAW buckets:

- runtime_registered fires from DaemonRegister when UpsertAgentRuntime
  reports (xmax = 0) — i.e. the row was inserted, not updated. Heartbeats
  and re-registrations stay silent.
- issue_executed fires from CompleteTask after an atomic
  UPDATE issue SET first_executed_at = now() WHERE id = $1 AND
  first_executed_at IS NULL flips the column for the first time. Retries,
  re-assignments, and comment-triggered follow-up tasks hit the WHERE
  clause and no-op. Carries nth_issue_for_workspace so the ≥1/≥2/≥5/≥10
  buckets filter without extra queries.
- team_invite_sent fires from CreateInvitation and team_invite_accepted
  from AcceptInvitation, closing the expansion funnel.

Adds a 050 migration for issue.first_executed_at plus a partial index so
the workspace-scoped executed-count query doesn't scan the never-executed
tail.

* feat(config): surface PostHog key via /api/config

Extends AppConfig with posthog_key / posthog_host sourced from env on
every request (so operators can rotate the key via secret refresh without
a restart). Reading the key off the server — rather than baking it into
the frontend bundle via NEXT_PUBLIC_* — means self-hosted instances
inherit the blank key automatically and never ship events upstream.

* feat(analytics): wire posthog-js identify + UTM capture on the client

Adds @multica/core/analytics — a thin wrapper around posthog-js that owns
attribution capture and identity merge. Posthog-js config comes from
/api/config (not NEXT_PUBLIC_*), so self-hosted instances whose server
returns an empty key automatically run the SDK inert.

captureSignupSource stamps a multica_signup_source cookie with UTM params
and the referrer's origin (never the full referrer — that can leak OAuth
code/state in the callback URL). The backend signup event reads this
cookie on new-user creation.

Identity flows:
- auth-initializer fires identify() right after getMe() resolves, on both
  cookie and token paths. A getConfig/getMe race is handled by buffering
  a pending identify inside the analytics module and flushing it once
  initAnalytics finishes.
- auth store calls identify() on verifyCode / loginWithGoogle /
  loginWithToken and resetAnalytics() on logout so the next login merges
  cleanly without bleeding events.

* docs(analytics): describe runtime_registered, issue_executed, invite events

Fills in the schema for the remaining funnel events. Captures the
design commentary that belongs next to the contract rather than in a PR
description — in particular why issue_executed uses the atomic
first_executed_at flip instead of counting task-terminal events, and why
runtime_registered relies on xmax = 0 rather than a query-then-write.

* fix(analytics): drop non-atomic nth_issue_for_workspace from issue_executed

Computing the workspace's Nth-issue ordinal at emit time is not atomic
under concurrent first-completions — two transactions can both run
MarkIssueFirstExecuted, then both run CountExecutedIssuesInWorkspace, and
both observe count=1 before either has committed, so both events go out
stamped as n=1. Serialising it would mean a per-workspace advisory lock
or a SERIALIZABLE-isolated tx; PostHog answers the same question exactly
at query time via row_number() partitioned by workspace_id, so the
emit-time property adds risk without adding information.

Removes the property from analytics.IssueExecuted, deletes the unused
CountExecutedIssuesInWorkspace query, and regenerates sqlc. The partial
index stays — any future workspace-scoped executed-issue query will want
it.

* fix(analytics): wire $pageview and harden signup_source cookie payload

Two frontend fixes from the PR review:

- PageviewTracker, mounted under WebProviders, fires capturePageview on
  every Next.js App Router path / query-string change. Without this the
  capturePageview helper in @multica/core/analytics was never called and
  the acquisition funnel's / → signup step was empty.
- captureSignupSource now caps each UTM / referrer value at 96 chars
  *before* JSON.stringify, and drops the whole cookie when the serialised
  payload still exceeds 512 chars. Previously the overall slice(0, 256)
  could leave a half-JSON string on the wire that neither the backend nor
  PostHog could parse.

Both capturePageview and identify now buffer a single pending call when
fired before initAnalytics resolves — otherwise the initial "/" pageview
and same-turn login identify race the /api/config fetch and get dropped.
resetAnalytics clears both buffers so a logout→login cycle stays clean.

* fix(analytics): URL-decode signup_source cookie on read

Go does not URL-decode Cookie.Value automatically, so the frontend's
JSON-then-encodeURIComponent payload was landing in PostHog as
percent-encoded garbage (%7B%22utm_source...). Unescape on read so the
backend receives the original JSON string the frontend intended, and
drop values that fail to decode or exceed the server-side cap — sending
truncated garbage is worse than sending nothing. Oversized-cookie guard
matches the frontend's SIGNUP_SOURCE_MAX_LEN.

* docs(analytics): reflect nth-issue drop, $pageview wiring, cookie encoding

Pulls the schema doc back in line with the code: issue_executed no longer
advertises nth_issue_for_workspace (with a note about why PostHog derives
it at query time instead), the frontend $pageview section names the
actual PageviewTracker component that fires it, and the signup_source
section documents the per-value cap / overall drop rule and the
encode-on-write / decode-on-read contract.

---------

Co-authored-by: Jiang Bohan <[email protected]>
2026-04-21 14:42:52 +08:00

820 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/analytics"
"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 = "[email protected]"
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, analytics.NoopClient{})
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 = "[email protected]"
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 = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
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"])
}
}