@@ -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+ } )
0 commit comments