Skip to content

Commit c55d88e

Browse files
Merge pull request #40 from InstaNode-dev/pricing/p1-ab-button-fresh
experiments: A/B render of upgrade button label/color per /auth/me variant (P1)
2 parents 4e61cb3 + 9b5952c commit c55d88e

8 files changed

Lines changed: 609 additions & 21 deletions

File tree

src/api/index.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
fetchStackFamily,
3131
listDeployments,
3232
getDeployment,
33+
reportExperimentConverted,
3334
validatePromotion,
3435
} from './index'
3536
// §10.21: FIXTURE_BILLING / FIXTURE_INVOICES imports retired. The 503
@@ -607,6 +608,41 @@ describe('fetchMe()', () => {
607608
m.mockResolvedValueOnce(jsonResponse({ error: 'boom' }, { status: 500 }))
608609
await expect(fetchMe()).rejects.toBeDefined()
609610
})
611+
612+
it('passes through the experiments map from the agent API', async () => {
613+
// P1 pricing experiment — /auth/me now embeds a server-bucketed
614+
// experiments map. The dashboard's UpgradeButton component reads
615+
// `me.experiments.upgrade_button` to decide which variant to render.
616+
const m = installFetch()
617+
m.mockResolvedValueOnce(jsonResponse({
618+
ok: true,
619+
user_id: 'u_xyz',
620+
team_id: 't_xyz',
621+
email: 'agent@instanode.dev',
622+
tier: 'pro',
623+
trial_ends_at: null,
624+
experiments: { upgrade_button: 'urgent' },
625+
}))
626+
const r = await fetchMe()
627+
expect(r.experiments).toEqual({ upgrade_button: 'urgent' })
628+
})
629+
630+
it('omits experiments cleanly when the agent API does not return the field', async () => {
631+
// Older API builds (pre-P1) don't return an experiments field.
632+
// The dashboard must handle that without throwing — UpgradeButton
633+
// falls back to "control" via normalizeVariant().
634+
const m = installFetch()
635+
m.mockResolvedValueOnce(jsonResponse({
636+
ok: true,
637+
user_id: 'u_xyz',
638+
team_id: 't_xyz',
639+
email: 'agent@instanode.dev',
640+
tier: 'pro',
641+
trial_ends_at: null,
642+
}))
643+
const r = await fetchMe()
644+
expect(r.experiments).toBeUndefined()
645+
})
610646
})
611647

612648
// ─── listResources() / deleteResource() (smoke for shape adaptation) ─────
@@ -999,3 +1035,48 @@ describe('fetchStackFamily()', () => {
9991035
expect(String(url)).toContain('/api/v1/stacks/stk%20weird%2Fslug/family')
10001036
})
10011037
})
1038+
1039+
// ─── reportExperimentConverted() ─────────────────────────────────────────
1040+
describe('reportExperimentConverted()', () => {
1041+
it('POSTs the right payload to /api/v1/experiments/converted', async () => {
1042+
const m = installFetch()
1043+
m.mockResolvedValueOnce(jsonResponse({ ok: true }))
1044+
await reportExperimentConverted({
1045+
experiment: 'upgrade_button',
1046+
variant: 'urgent',
1047+
action: 'checkout_started',
1048+
})
1049+
const [url, init] = m.mock.calls[0]
1050+
expect(String(url)).toContain('/api/v1/experiments/converted')
1051+
expect((init as any).method).toBe('POST')
1052+
expect(JSON.parse((init as any).body)).toEqual({
1053+
experiment: 'upgrade_button',
1054+
variant: 'urgent',
1055+
action: 'checkout_started',
1056+
})
1057+
})
1058+
1059+
it('swallows network errors (analytics tail must not wag the conversion dog)', async () => {
1060+
const m = installFetch()
1061+
m.mockRejectedValueOnce(new Error('offline'))
1062+
// Must NOT throw. If it does, the test fails by surfacing the rejection.
1063+
await reportExperimentConverted({
1064+
experiment: 'upgrade_button',
1065+
variant: 'control',
1066+
action: 'checkout_started',
1067+
})
1068+
})
1069+
1070+
it('swallows 400 from a stale-variant rejection', async () => {
1071+
const m = installFetch()
1072+
m.mockResolvedValueOnce(jsonResponse(
1073+
{ ok: false, error: 'variant_mismatch' },
1074+
{ status: 400 },
1075+
))
1076+
await reportExperimentConverted({
1077+
experiment: 'upgrade_button',
1078+
variant: 'control',
1079+
action: 'checkout_started',
1080+
})
1081+
})
1082+
})

src/api/index.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ async function call<T>(
143143

144144
// ─── Auth / me ───────────────────────────────────────────────────────────
145145
// GET /auth/me on the agent API returns:
146-
// { ok, user_id, team_id, email, tier, trial_ends_at }
146+
// { ok, user_id, team_id, email, tier, trial_ends_at, experiments }
147147
// The dashboard expected { user, team } — we adapt here so the rest of
148148
// the dashboard still consumes the richer fixture shape.
149149
export async function fetchMe(): Promise<AuthMeResponse> {
@@ -154,6 +154,11 @@ export async function fetchMe(): Promise<AuthMeResponse> {
154154
email: string
155155
tier: string
156156
trial_ends_at: string | null
157+
/** A/B-test bucket per registered experiment, e.g.
158+
* `{ upgrade_button: "urgent" }`. Older API builds omit this
159+
* field entirely — callers must treat undefined as "no
160+
* experiment, render control variant". */
161+
experiments?: Record<string, string>
157162
}
158163
// No try/catch — errors propagate. The previous fixture fallback masked
159164
// backend outages by serving the `aanya@acme.dev` mock identity, which
@@ -184,6 +189,33 @@ export async function fetchMe(): Promise<AuthMeResponse> {
184189
tier: me.tier as any,
185190
created_at: '',
186191
},
192+
experiments: me.experiments,
193+
}
194+
}
195+
196+
// ─── A/B-experiment conversion ───────────────────────────────────────────
197+
// reportExperimentConverted — fires POST /api/v1/experiments/converted to
198+
// record that the user took the conversion action on a server-bucketed
199+
// experiment (e.g. clicked the Upgrade button). Best-effort:
200+
// - swallows every error (network down, 400 from a stale variant, etc.)
201+
// - never blocks navigation; callers race it against a short timeout
202+
// and proceed regardless.
203+
//
204+
// The matching server-side endpoint writes an audit_log row with
205+
// kind="experiment.conversion" and metadata={experiment, variant,
206+
// action_taken}. See api/internal/handlers/experiments.go.
207+
export async function reportExperimentConverted(input: {
208+
experiment: string
209+
variant: string
210+
action: string
211+
}): Promise<void> {
212+
try {
213+
await call<{ ok: boolean }>('/api/v1/experiments/converted', {
214+
method: 'POST',
215+
body: JSON.stringify(input),
216+
})
217+
} catch {
218+
/* analytics tail must not wag the conversion dog */
187219
}
188220
}
189221

src/api/types.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -231,20 +231,19 @@ export interface OverviewStats {
231231
}
232232

233233
// ---------- Auth/me response (locked) ----------
234+
// `experiments` is the server-bucketed A/B map (keyed by experiment
235+
// name → variant). The dashboard reads this to render variant copy
236+
// and to know which variant to send back when firing the conversion
237+
// endpoint. Missing or {} means "no experiments running" — every
238+
// consumer treats absent variants as "control" so the field is
239+
// safe to omit on older API builds.
234240
//
235-
// `experiments` is an optional bag of A/B-test variants the agent API
236-
// attaches to the /auth/me response. The dashboard reads `upgrade_cta`
237-
// (set by track P1) to vary the upgrade button label across visitors.
238-
// Modelled as optional + permissive so the dashboard ships before the
239-
// API side lands without forcing a coordinated rollout.
241+
// Known keys today (track P1): "upgrade_button" → "control" | "urgent" | "value".
242+
// The variant→label/style mapping lives in src/components/UpgradeButton.tsx;
243+
// U2's UpgradePromptCard composes that component so it doesn't read variants directly.
240244
export interface AuthMeResponse {
241245
user: User
242246
team: DashboardTeam
243247
access_token?: string
244-
experiments?: {
245-
upgrade_cta?: {
246-
variant?: string
247-
label?: string
248-
}
249-
}
248+
experiments?: Record<string, string>
250249
}

0 commit comments

Comments
 (0)