Files
multica/server/internal/handler/onboarding_test.go
Naiyuan Qing 7b3cad664b revert(self-host): remove source channel reporting (#4799)
* Revert "test(onboarding): cover official source reporting controls (#4782)"

This reverts commit fc88c7720f.

* Revert "fix(self-host): restore official source report endpoint (#4781)"

This reverts commit ad1afdd48d.

* Revert "feat(self-host): collect anonymous source channels mul-3878 (#4741)"

This reverts commit 26142d74aa.
2026-07-01 17:53:16 +08:00

665 lines
21 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// 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)
}
}
}