mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 02:46:45 +08:00
Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]>
314 lines
10 KiB
Go
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)
|
|
}
|
|
}
|