Files
multica/server/internal/handler/onboarding_test.go
Jiayuan Zhang 591e47842d refactor(onboarding): remove starter-content kit; unify install-runtime issue across mark-onboarded paths (MUL-2438) (#2884)
* refactor(onboarding): remove starter-content kit, unify install-runtime issue across mark-onboarded paths (MUL-2438)

Drops the post-onboarding ImportStarterContent / DismissStarterContent
flow (handler + routes + StarterContentPrompt + templates + locale
strings + analytics event). The bug — web onboarding seeding 6+ starter
issues without a runtime — only existed through that path; with it gone
the source disappears.

The "install a runtime" issue from BootstrapOnboardingNoRuntime is now
the canonical no-runtime onboarding seed. The title/description and a
LockAndFindActiveDuplicate-deduped seeder move to
handler/no_runtime_issue.go, and CompleteOnboarding / CreateWorkspace /
AcceptInvitation seed it whenever the workspace has no runtime yet, so
every mark-onboarded entry point lands the user on a concrete next
step.

starter_content_state column is kept and continues to be claimed as
'imported' in all five entry points so older desktop builds (which
still render the legacy dialog on NULL) don't surface it to accounts
created after this change.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): backfill starter_content_state for in-window NULL users (MUL-2438)

054 only covered pre-feature users. Anyone onboarded between then and the
starter-content kit removal could still sit at NULL, and old desktop
clients gate the legacy StarterContentPrompt on `starter_content_state
IS NULL`. The import/dismiss routes are gone, so leaving these rows NULL
would surface a dialog whose buttons 404. Mark them 'imported' to match
the new helper's claim semantics.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 18:37:48 +02:00

528 lines
16 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)
}
}
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, "onboard them inside the first issue") {
t.Fatalf("assistant instructions were not seeded: %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")
}
// starter_content_state is claimed defensively so older desktop builds
// (which still render the legacy import dialog on NULL) don't surface it.
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 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)
}
}
}