Files
multica/server/internal/handler/onboarding_test.go
Jiayuan Zhang a61a8ecfed feat(onboarding): merge About-you step, collect source after agents deliver value (#5786)
Flow drops from five steps to three: role + use_case merge into a
single About-you screen (one Skip covers both; Continue stamps skip
markers on whichever group was left unanswered), and the source
question leaves onboarding entirely.

Source is now collected only by the workspace source-backfill prompt,
which additionally waits until agents/squads have completed at least
SOURCE_BACKFILL_MIN_AGENT_DONE_ISSUES (3) issues in the workspace —
attribution is asked after Multica has visibly delivered value, not
before. The count rides a limit:1 issues query keyed under
issueKeys.all so realtime invalidations keep it fresh, enabled only
for users who still owe an answer.

Server: questionnaire complete() narrows to role + use_case so the
funnel step doesn't stall on the now-deferred source; a new
metrics-only onboarding_source_submitted event (+ Prometheus counter)
tracks the backfill prompt's answer/decline transition once per user.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 01:57:44 +08:00

787 lines
25 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
)
// newWaitlistTestUser inserts a fresh user row, returns its id, and
// registers a cleanup. Uses the test pool directly so we don't depend
// on the handler-under-test for fixture setup.
func newWaitlistTestUser(t *testing.T, email string) string {
t.Helper()
ctx := context.Background()
var userID string
if err := testPool.QueryRow(ctx,
`INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id`,
"Waitlist Test", email,
).Scan(&userID); err != nil {
t.Fatalf("insert test user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, userID)
})
return userID
}
func newWaitlistRequest(userID string, body map[string]string) *http.Request {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(
"POST", "/api/me/onboarding/cloud-waitlist", &buf,
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID)
return req
}
func TestJoinCloudWaitlistRecordsEmailAndReason(t *testing.T) {
userID := newWaitlistTestUser(t, "waitlist-ok@multica.ai")
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, map[string]string{
"email": "Someone@Example.COM",
"reason": "evaluating for our team",
})
testHandler.JoinCloudWaitlist(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var (
waitlistEmail *string
waitlistReason *string
onboardedAt *time.Time
)
if err := testPool.QueryRow(context.Background(), `
SELECT cloud_waitlist_email, cloud_waitlist_reason, onboarded_at
FROM "user" WHERE id = $1
`, userID).Scan(&waitlistEmail, &waitlistReason, &onboardedAt); err != nil {
t.Fatalf("lookup user: %v", err)
}
if waitlistEmail == nil || *waitlistEmail != "someone@example.com" {
t.Fatalf("email not normalized/stored: %v", waitlistEmail)
}
if waitlistReason == nil || *waitlistReason != "evaluating for our team" {
t.Fatalf("reason not stored: %v", waitlistReason)
}
// Waitlist is a pure side effect — onboarding is NOT marked
// complete here. The user still has to pick a real Step 3
// path (CLI / Skip) before onboarded_at gets set.
if onboardedAt != nil {
t.Fatalf("onboarded_at should stay NULL, got %v", *onboardedAt)
}
}
func TestJoinCloudWaitlistAllowsEmptyReason(t *testing.T) {
userID := newWaitlistTestUser(t, "waitlist-noreason@multica.ai")
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, map[string]string{
"email": "noreason@example.com",
})
testHandler.JoinCloudWaitlist(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var waitlistReason *string
if err := testPool.QueryRow(context.Background(),
`SELECT cloud_waitlist_reason FROM "user" WHERE id = $1`, userID,
).Scan(&waitlistReason); err != nil {
t.Fatalf("lookup user: %v", err)
}
if waitlistReason != nil {
t.Fatalf("expected NULL reason, got %q", *waitlistReason)
}
}
func TestJoinCloudWaitlistMissingEmailReturns400(t *testing.T) {
userID := newWaitlistTestUser(t, "waitlist-missing@multica.ai")
cases := []map[string]string{
{}, // empty body
{"email": ""}, // blank
{"email": " "}, // whitespace only
{"reason": "no email here"},
}
for i, body := range cases {
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, body)
testHandler.JoinCloudWaitlist(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("case %d: expected 400, got %d: %s", i, w.Code, w.Body.String())
}
}
}
func TestJoinCloudWaitlistRejectsOverlongReason(t *testing.T) {
userID := newWaitlistTestUser(t, "waitlist-long@multica.ai")
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, map[string]string{
"email": "long@example.com",
"reason": strings.Repeat("x", cloudWaitlistReasonMaxLen+1),
})
testHandler.JoinCloudWaitlist(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for overlong reason, got %d", w.Code)
}
}
func TestJoinCloudWaitlistSecondCallOverwrites(t *testing.T) {
userID := newWaitlistTestUser(t, "waitlist-overwrite@multica.ai")
// First submission.
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, map[string]string{
"email": "first@example.com",
"reason": "first",
})
testHandler.JoinCloudWaitlist(w, req)
if w.Code != http.StatusOK {
t.Fatalf("first call: expected 200, got %d", w.Code)
}
// Second submission with different values.
w = httptest.NewRecorder()
req = newWaitlistRequest(userID, map[string]string{
"email": "second@example.com",
"reason": "changed my mind",
})
testHandler.JoinCloudWaitlist(w, req)
if w.Code != http.StatusOK {
t.Fatalf("second call: expected 200, got %d", w.Code)
}
var (
waitlistEmail string
waitlistReason string
onboardedAt *time.Time
)
if err := testPool.QueryRow(context.Background(), `
SELECT cloud_waitlist_email, cloud_waitlist_reason, onboarded_at
FROM "user" WHERE id = $1
`, userID).Scan(&waitlistEmail, &waitlistReason, &onboardedAt); err != nil {
t.Fatalf("lookup user: %v", err)
}
if waitlistEmail != "second@example.com" {
t.Fatalf("second email should overwrite; got %q", waitlistEmail)
}
if waitlistReason != "changed my mind" {
t.Fatalf("second reason should overwrite; got %q", waitlistReason)
}
// onboarded_at is never touched by the waitlist path — stays NULL
// across any number of submissions.
if onboardedAt != nil {
t.Fatalf("onboarded_at should stay NULL, got %v", *onboardedAt)
}
}
// ---------------------------------------------------------------------------
// Shim endpoint tests — guard the BootstrapOnboarding* handlers that were
// restored for desktop < v3 compatibility. Once telemetry confirms no
// pre-v3 desktops remain, delete both these tests AND the handlers in
// onboarding_shim.go in the same commit.
// ---------------------------------------------------------------------------
func TestBootstrapOnboardingRuntimeCreatesSingleGuideIssue(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `
DELETE FROM agent_task_queue
WHERE agent_id IN (
SELECT id FROM agent
WHERE workspace_id = $1 AND name = $2
)
`, testWorkspaceID, onboardingAssistantName)
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, onboardingIssueTitle,
)
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, onboardingAssistantName,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
testUserID,
)
})
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, onboardingIssueTitle,
)
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, onboardingAssistantName,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
testUserID,
)
body := map[string]string{
"workspace_id": testWorkspaceID,
"runtime_id": testRuntimeID,
}
w := httptest.NewRecorder()
testHandler.BootstrapOnboardingRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
if w.Code != http.StatusOK {
t.Fatalf("BootstrapOnboardingRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp bootstrapOnboardingRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.WorkspaceID != testWorkspaceID || resp.AgentID == "" || resp.IssueID == "" {
t.Fatalf("unexpected response: %+v", resp)
}
var (
agentName string
agentRuntime string
instructions string
avatarURL *string
)
if err := testPool.QueryRow(ctx, `
SELECT name, runtime_id, instructions, avatar_url
FROM agent
WHERE id = $1
`, resp.AgentID).Scan(&agentName, &agentRuntime, &instructions, &avatarURL); err != nil {
t.Fatalf("lookup assistant: %v", err)
}
if agentName != onboardingAssistantName {
t.Fatalf("agent name = %q, want %q", agentName, onboardingAssistantName)
}
if agentRuntime != testRuntimeID {
t.Fatalf("agent runtime = %q, want %q", agentRuntime, testRuntimeID)
}
if !strings.Contains(instructions, "built-in AI assistant") {
t.Fatalf("assistant instructions were not seeded with the new identity: %q", instructions)
}
if avatarURL == nil || *avatarURL != onboardingAssistantAvatarURL {
t.Fatalf("agent avatar_url = %v, want seeded Multica Helper avatar", avatarURL)
}
var (
issueTitle string
assigneeType string
assigneeID string
issueStatus string
issuePriority string
)
if err := testPool.QueryRow(ctx, `
SELECT title, assignee_type, assignee_id, status, priority
FROM issue
WHERE id = $1
`, resp.IssueID).Scan(&issueTitle, &assigneeType, &assigneeID, &issueStatus, &issuePriority); err != nil {
t.Fatalf("lookup onboarding issue: %v", err)
}
if issueTitle != onboardingIssueTitle {
t.Fatalf("issue title = %q, want %q", issueTitle, onboardingIssueTitle)
}
if assigneeType != "agent" || assigneeID != resp.AgentID {
t.Fatalf("issue assignee = %s/%s, want agent/%s", assigneeType, assigneeID, resp.AgentID)
}
if issueStatus != "todo" || issuePriority != "high" {
t.Fatalf("issue status/priority = %s/%s, want todo/high", issueStatus, issuePriority)
}
var (
onboardedAt *time.Time
starterContentState *string
)
if err := testPool.QueryRow(ctx, `
SELECT onboarded_at, starter_content_state
FROM "user"
WHERE id = $1
`, testUserID).Scan(&onboardedAt, &starterContentState); err != nil {
t.Fatalf("lookup user onboarding state: %v", err)
}
if onboardedAt == nil {
t.Fatal("expected onboarded_at to be set")
}
if starterContentState == nil || *starterContentState != "imported" {
t.Fatalf("starter_content_state = %v, want imported", starterContentState)
}
var taskCount int
if err := testPool.QueryRow(ctx, `
SELECT count(*)
FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2
`, resp.IssueID, resp.AgentID).Scan(&taskCount); err != nil {
t.Fatalf("count queued tasks: %v", err)
}
if taskCount == 0 {
t.Fatal("expected onboarding issue to enqueue an agent task")
}
w2 := httptest.NewRecorder()
testHandler.BootstrapOnboardingRuntime(w2, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
if w2.Code != http.StatusOK {
t.Fatalf("second BootstrapOnboardingRuntime: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var resp2 bootstrapOnboardingRuntimeResponse
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
t.Fatalf("decode second response: %v", err)
}
if resp2.AgentID != resp.AgentID || resp2.IssueID != resp.IssueID {
t.Fatalf("bootstrap should be idempotent: first=%+v second=%+v", resp, resp2)
}
}
func TestBootstrapOnboardingRuntime_WithStarterPrompt(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `
DELETE FROM agent_task_queue
WHERE agent_id IN (
SELECT id FROM agent
WHERE workspace_id = $1 AND name = $2
)
`, testWorkspaceID, onboardingAssistantName)
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, onboardingIssueTitle,
)
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, onboardingAssistantName,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
testUserID,
)
})
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, onboardingIssueTitle,
)
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, onboardingAssistantName,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
testUserID,
)
const wantPrompt = "Introduce Multica to me, please."
body := map[string]string{
"workspace_id": testWorkspaceID,
"runtime_id": testRuntimeID,
"starter_prompt": wantPrompt,
}
w := httptest.NewRecorder()
testHandler.BootstrapOnboardingRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
if w.Code != http.StatusOK {
t.Fatalf("BootstrapOnboardingRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp bootstrapOnboardingRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
var description *string
if err := testPool.QueryRow(ctx, `
SELECT description FROM issue WHERE id = $1
`, resp.IssueID).Scan(&description); err != nil {
t.Fatalf("lookup issue description: %v", err)
}
if description == nil || *description != wantPrompt {
t.Fatalf("issue description = %v, want %q", description, wantPrompt)
}
}
func TestBootstrapOnboardingRuntime_NoStarterPrompt(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `
DELETE FROM agent_task_queue
WHERE agent_id IN (
SELECT id FROM agent
WHERE workspace_id = $1 AND name = $2
)
`, testWorkspaceID, onboardingAssistantName)
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, onboardingIssueTitle,
)
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, onboardingAssistantName,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
testUserID,
)
})
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, onboardingIssueTitle,
)
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, onboardingAssistantName,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
testUserID,
)
body := map[string]string{
"workspace_id": testWorkspaceID,
"runtime_id": testRuntimeID,
}
w := httptest.NewRecorder()
testHandler.BootstrapOnboardingRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
if w.Code != http.StatusOK {
t.Fatalf("BootstrapOnboardingRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp bootstrapOnboardingRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
var description *string
if err := testPool.QueryRow(ctx, `
SELECT description FROM issue WHERE id = $1
`, resp.IssueID).Scan(&description); err != nil {
t.Fatalf("lookup issue description: %v", err)
}
if description == nil || *description != onboardingIssueDescription {
t.Fatalf("issue description = %v, want fallback onboardingIssueDescription", description)
}
}
func TestBootstrapOnboardingNoRuntimeCreatesSingleGuideIssue(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, noRuntimeIssueTitle,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = NULL WHERE id = $1`,
testUserID,
)
})
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, noRuntimeIssueTitle,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = 'en' WHERE id = $1`,
testUserID,
)
body := map[string]string{
"workspace_id": testWorkspaceID,
}
w := httptest.NewRecorder()
testHandler.BootstrapOnboardingNoRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/no-runtime-bootstrap", body))
if w.Code != http.StatusOK {
t.Fatalf("BootstrapOnboardingNoRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp bootstrapOnboardingNoRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.WorkspaceID != testWorkspaceID || resp.IssueID == "" {
t.Fatalf("unexpected response: %+v", resp)
}
var (
issueTitle string
assigneeType string
assigneeID string
issueStatus string
issuePriority string
description string
)
if err := testPool.QueryRow(ctx, `
SELECT title, assignee_type, assignee_id, status, priority, description
FROM issue
WHERE id = $1
`, resp.IssueID).Scan(&issueTitle, &assigneeType, &assigneeID, &issueStatus, &issuePriority, &description); err != nil {
t.Fatalf("lookup no-runtime onboarding issue: %v", err)
}
if issueTitle != noRuntimeIssueTitle {
t.Fatalf("issue title = %q, want %q", issueTitle, noRuntimeIssueTitle)
}
if assigneeType != "member" || assigneeID != testUserID {
t.Fatalf("issue assignee = %s/%s, want member/%s", assigneeType, assigneeID, testUserID)
}
if issueStatus != "todo" || issuePriority != "high" {
t.Fatalf("issue status/priority = %s/%s, want todo/high", issueStatus, issuePriority)
}
for _, want := range []string{
"Try Multica first",
"https://multica.ai/docs/install-agent-runtime",
"npm i -g @openai/codex",
} {
if !strings.Contains(description, want) {
t.Fatalf("issue description missing %q: %q", want, description)
}
}
if !strings.Contains(description, "Agents need a runtime before they can execute work") {
t.Fatalf("issue description was not seeded: %q", description)
}
var (
onboardedAt *time.Time
starterContentState *string
)
if err := testPool.QueryRow(ctx, `
SELECT onboarded_at, starter_content_state
FROM "user"
WHERE id = $1
`, testUserID).Scan(&onboardedAt, &starterContentState); err != nil {
t.Fatalf("lookup user onboarding state: %v", err)
}
if onboardedAt == nil {
t.Fatal("expected onboarded_at to be set")
}
if starterContentState == nil || *starterContentState != "imported" {
t.Fatalf("starter_content_state = %v, want imported", starterContentState)
}
var taskCount int
if err := testPool.QueryRow(ctx, `
SELECT count(*)
FROM agent_task_queue
WHERE issue_id = $1
`, resp.IssueID).Scan(&taskCount); err != nil {
t.Fatalf("count queued tasks: %v", err)
}
if taskCount != 0 {
t.Fatalf("expected no agent tasks for no-runtime issue, got %d", taskCount)
}
w2 := httptest.NewRecorder()
testHandler.BootstrapOnboardingNoRuntime(w2, newRequest(http.MethodPost, "/api/me/onboarding/no-runtime-bootstrap", body))
if w2.Code != http.StatusOK {
t.Fatalf("second BootstrapOnboardingNoRuntime: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var resp2 bootstrapOnboardingNoRuntimeResponse
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
t.Fatalf("decode second response: %v", err)
}
if resp2.IssueID != resp.IssueID {
t.Fatalf("bootstrap should be idempotent: first=%+v second=%+v", resp, resp2)
}
}
func TestBootstrapOnboardingNoRuntimeUsesChineseGuideForChineseUsers(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, noRuntimeIssueTitle,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = NULL WHERE id = $1`,
testUserID,
)
})
testPool.Exec(ctx,
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, noRuntimeIssueTitle,
)
testPool.Exec(ctx,
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = 'zh-Hans' WHERE id = $1`,
testUserID,
)
body := map[string]string{
"workspace_id": testWorkspaceID,
}
w := httptest.NewRecorder()
testHandler.BootstrapOnboardingNoRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/no-runtime-bootstrap", body))
if w.Code != http.StatusOK {
t.Fatalf("BootstrapOnboardingNoRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp bootstrapOnboardingNoRuntimeResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
var description string
if err := testPool.QueryRow(ctx, `
SELECT description
FROM issue
WHERE id = $1
`, resp.IssueID).Scan(&description); err != nil {
t.Fatalf("lookup no-runtime onboarding issue: %v", err)
}
for _, want := range []string{
"先体验项目管理功能",
"https://multica.ai/docs/install-agent-runtime",
"中文用户建议先装 Kimi CLI",
"kimi --version",
} {
if !strings.Contains(description, want) {
t.Fatalf("Chinese issue description missing %q: %q", want, description)
}
}
}
// counterValue sums a named Prometheus counter across all label
// combinations on the given BusinessMetrics. Events are metrics-only
// since MUL-4127, so PatchOnboarding's emissions are observable ONLY
// through these counters — there is no PostHog capture to record.
func counterValue(t *testing.T, m *obsmetrics.BusinessMetrics, name string) float64 {
t.Helper()
reg := prometheus.NewRegistry()
for _, c := range m.Collectors() {
if err := reg.Register(c); err != nil {
t.Fatalf("register collector: %v", err)
}
}
families, err := reg.Gather()
if err != nil {
t.Fatalf("gather metrics: %v", err)
}
for _, mf := range families {
if mf.GetName() != name {
continue
}
var sum float64
for _, mm := range mf.GetMetric() {
sum += mm.GetCounter().GetValue()
}
return sum
}
return 0
}
func patchOnboardingAs(t *testing.T, h *Handler, userID, questionnaire string) {
t.Helper()
body := `{"questionnaire": ` + questionnaire + `}`
req := httptest.NewRequest("PATCH", "/api/me/onboarding", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID)
w := httptest.NewRecorder()
h.PatchOnboarding(w, req)
if w.Code != http.StatusOK {
t.Fatalf("patch onboarding: expected 200, got %d: %s", w.Code, w.Body.String())
}
}
// The in-flow questionnaire is role + use_case only (MUL-5159): its
// funnel counter must move without source, and source's own counter
// must move exactly once when source resolves later via the workspace
// backfill prompt.
func TestPatchOnboardingSplitsQuestionnaireAndSourceEvents(t *testing.T) {
userID := newWaitlistTestUser(t, "onboarding-event-split@multica.ai")
m := obsmetrics.NewBusinessMetrics()
h := *testHandler
h.Metrics = m
const questionnaireCounter = "multica_onboarding_questionnaire_submitted_total"
const sourceCounter = "multica_onboarding_source_submitted_total"
const inFlow = `{"source":[],"source_other":null,"source_skipped":false,` +
`"role":"engineer","role_other":null,"role_skipped":false,` +
`"use_case":["ship_code"],"use_case_other":null,"use_case_skipped":false,"version":2}`
// Role + use_case resolved, source untouched → questionnaire counter
// moves now; source counter must NOT.
patchOnboardingAs(t, &h, userID, inFlow)
if got := counterValue(t, m, questionnaireCounter); got != 1 {
t.Fatalf("expected questionnaire counter 1 after role+use_case resolve, got %v", got)
}
if got := counterValue(t, m, sourceCounter); got != 0 {
t.Fatalf("expected source counter 0 while source unresolved, got %v", got)
}
// Idempotency: replaying the same answers re-emits nothing.
patchOnboardingAs(t, &h, userID, inFlow)
if got := counterValue(t, m, questionnaireCounter); got != 1 {
t.Fatalf("expected questionnaire counter to stay at 1 after replay, got %v", got)
}
// Source lands later (the backfill prompt merges it into the row).
const withSource = `{"source":["search"],"source_other":null,"source_skipped":false,` +
`"role":"engineer","role_other":null,"role_skipped":false,` +
`"use_case":["ship_code"],"use_case_other":null,"use_case_skipped":false,"version":2}`
patchOnboardingAs(t, &h, userID, withSource)
if got := counterValue(t, m, sourceCounter); got != 1 {
t.Fatalf("expected source counter 1 after source resolves, got %v", got)
}
if got := counterValue(t, m, questionnaireCounter); got != 1 {
t.Fatalf("expected questionnaire counter to stay at 1 after source lands, got %v", got)
}
// Replaying the source answer re-emits nothing.
patchOnboardingAs(t, &h, userID, withSource)
if got := counterValue(t, m, sourceCounter); got != 1 {
t.Fatalf("expected source counter to stay at 1 after replay, got %v", got)
}
}
// Declining every question at once (the About-you Skip writes both
// role and use_case skip markers; a backfill Skip writes source's) is
// a resolution too: both counters move exactly once.
func TestPatchOnboardingAllSkippedResolvesBothCounters(t *testing.T) {
userID := newWaitlistTestUser(t, "onboarding-source-skip@multica.ai")
m := obsmetrics.NewBusinessMetrics()
h := *testHandler
h.Metrics = m
const skipped = `{"source":[],"source_other":null,"source_skipped":true,` +
`"role":null,"role_other":null,"role_skipped":true,` +
`"use_case":[],"use_case_other":null,"use_case_skipped":true,"version":2}`
patchOnboardingAs(t, &h, userID, skipped)
if got := counterValue(t, m, "multica_onboarding_questionnaire_submitted_total"); got != 1 {
t.Fatalf("expected questionnaire counter 1 for all-skip, got %v", got)
}
if got := counterValue(t, m, "multica_onboarding_source_submitted_total"); got != 1 {
t.Fatalf("expected source counter 1 for an explicit decline, got %v", got)
}
}