Files
multica/server/internal/handler/onboarding_test.go
2026-10-16 16:41:46 +08:00

314 lines
10 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, "[email protected]")
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, map[string]string{
"email": "[email protected]",
"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 != "[email protected]" {
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, "[email protected]")
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, map[string]string{
"email": "[email protected]",
})
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, "[email protected]")
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, "[email protected]")
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, map[string]string{
"email": "[email protected]",
"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, "[email protected]")
// First submission.
w := httptest.NewRecorder()
req := newWaitlistRequest(userID, map[string]string{
"email": "[email protected]",
"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": "[email protected]",
"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 != "[email protected]" {
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)
}
}
// 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, "[email protected]")
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, "[email protected]")
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)
}
}