mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
* 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>
528 lines
16 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|