@@ -641,35 +641,36 @@ describe('changePlan()', () => {
641641 } )
642642} )
643643
644- // ─── validatePromotion() (P3) ─────────────────────────────────────── ─────
645- // Until api ships POST /api/v1/billing/promotion/validate, this helper
646- // falls back to a small set of seed codes on a 404 . The mock + fallback
647- // path together must:
648- // - return a Promotion shape when the api responds 200
649- // - return the seed Promotion for a known seed code when the api 404s
650- // - throw promotion_not_found for an unknown code when the api 404s
651- // - propagate non-404 errors (e.g. 410 expired) untouched
652- describe ( 'validatePromotion() (P3 )' , ( ) => {
653- it ( 'returns the api Promotion on a 200 response' , async ( ) => {
644+ // ─── validatePromotion() (P1-3 — LIVE endpoint, 200 ok:false contract) ─────
645+ // POST /api/v1/billing/promotion/validate is LIVE. Its quirk: VALIDATION
646+ // FAILURES come back as 200 with ok:false (NOT a 4xx) . The helper must:
647+ // - return a Promotion when the api responds 200 ok:true + discount
648+ // - REJECT a 200 ok:false body (invalid / expired / wrong-plan) — the bug
649+ // this fixes: those used to surface as a phantom "valid" promo
650+ // - propagate true non-2xx (400/401/429/5xx) untouched
651+ // - reject empty input before any network call
652+ describe ( 'validatePromotion() (P1-3 )' , ( ) => {
653+ it ( 'returns the api Promotion on a 200 ok:true response' , async ( ) => {
654654 const m = installFetch ( )
655655 m . mockResolvedValueOnce ( jsonResponse ( {
656656 ok : true ,
657657 code : 'PARTNER25' ,
658- discount : { kind : 'percent_off' , value : 25 , applies_to : 6 , unit : 'months' } ,
659- valid_until : '2026-12-31T00:00:00Z ' ,
658+ discount : { kind : 'percent_off' , value : 25 , applies_to : [ 'pro' ] , max_uses : 100 } ,
659+ valid_until : '2026-12-31T23:59:59Z ' ,
660660 } ) )
661661 const r = await validatePromotion ( 'PARTNER25' , 'pro' )
662662 expect ( r . promotion . code ) . toBe ( 'PARTNER25' )
663- expect ( r . promotion . discount ) . toEqual ( { kind : 'percent_off' , value : 25 , applies_to : 6 , unit : 'months' } )
664- expect ( r . promotion . valid_until ) . toBe ( '2026-12-31T00:00:00Z' )
663+ expect ( r . promotion . discount . kind ) . toBe ( 'percent_off' )
664+ expect ( r . promotion . discount . value ) . toBe ( 25 )
665+ expect ( r . promotion . valid_until ) . toBe ( '2026-12-31T23:59:59Z' )
665666 } )
666667
667668 it ( 'POSTs {code, plan} to /api/v1/billing/promotion/validate (uppercased + trimmed)' , async ( ) => {
668669 const m = installFetch ( )
669670 m . mockResolvedValueOnce ( jsonResponse ( {
670671 ok : true ,
671672 code : 'TWITTER15' ,
672- discount : { kind : 'percent_off' , value : 15 , applies_to : 3 , unit : 'months' } ,
673+ discount : { kind : 'percent_off' , value : 15 , applies_to : [ 'pro' ] } ,
673674 valid_until : '2026-09-01T00:00:00Z' ,
674675 } ) )
675676 await validatePromotion ( ' twitter15 ' , 'pro' )
@@ -679,39 +680,77 @@ describe('validatePromotion() (P3)', () => {
679680 expect ( JSON . parse ( init . body as string ) ) . toEqual ( { code : 'TWITTER15' , plan : 'pro' } )
680681 } )
681682
682- it ( 'falls back to the seed table when the api 404s on a known seed code' , async ( ) => {
683+ // REGRESSION GUARD (P1-3): a 200 ok:false body is a REJECTION. Before the
684+ // fix this resolved to a "valid" promo with an undefined discount. It must
685+ // now throw, carrying the api's error code + message.
686+ it ( 'rejects a 200 ok:false body (invalid code) instead of reporting valid' , async ( ) => {
683687 const m = installFetch ( )
684- m . mockResolvedValueOnce ( jsonResponse (
685- { error : 'not_found' , message : 'no such route' } ,
686- { status : 404 , statusText : 'Not Found' } ,
687- ) )
688- const r = await validatePromotion ( 'TWITTER15' , 'pro' )
689- expect ( r . promotion . code ) . toBe ( 'TWITTER15' )
690- expect ( r . promotion . discount . kind ) . toBe ( 'percent_off' )
691- expect ( r . promotion . discount . value ) . toBe ( 15 )
688+ m . mockResolvedValueOnce ( jsonResponse ( {
689+ ok : false ,
690+ error : 'promotion_not_found' ,
691+ message : 'Code not found.' ,
692+ agent_action : 'Tell the user this promo code is invalid.' ,
693+ } ) )
694+ await expect ( validatePromotion ( 'NONEXISTENT' , 'pro' ) ) . rejects . toMatchObject ( {
695+ code : 'promotion_not_found' ,
696+ message : 'Code not found.' ,
697+ } )
698+ // It DID hit the live endpoint (no dead seed fallback short-circuit).
699+ expect ( m ) . toHaveBeenCalledTimes ( 1 )
692700 } )
693701
694- it ( 'throws promotion_not_found on 404 for an unknown code' , async ( ) => {
702+ it ( 'rejects a 200 ok:false body for a wrong-plan / expired code' , async ( ) => {
695703 const m = installFetch ( )
696- m . mockResolvedValueOnce ( jsonResponse (
697- { error : 'not_found' } ,
698- { status : 404 , statusText : 'Not Found' } ,
699- ) )
700- await expect ( validatePromotion ( 'NONEXISTENT' , 'pro' ) ) . rejects . toMatchObject ( {
701- status : 404 ,
702- code : 'promotion_not_found' ,
704+ m . mockResolvedValueOnce ( jsonResponse ( {
705+ ok : false ,
706+ error : 'promotion_invalid' ,
707+ message : "Promotion code \"OLDCODE\" is not valid for the pro plan." ,
708+ } ) )
709+ await expect ( validatePromotion ( 'OLDCODE' , 'pro' ) ) . rejects . toMatchObject ( {
710+ code : 'promotion_invalid' ,
711+ } )
712+ } )
713+
714+ // Defensive: a malformed 200 ok:true WITHOUT a discount is still a rejection
715+ // (we never surface a phantom valid promo with an empty discount).
716+ it ( 'rejects a 200 ok:true body that is missing the discount field' , async ( ) => {
717+ const m = installFetch ( )
718+ m . mockResolvedValueOnce ( jsonResponse ( { ok : true , code : 'WEIRD' } ) )
719+ await expect ( validatePromotion ( 'WEIRD' , 'pro' ) ) . rejects . toMatchObject ( {
720+ code : 'promotion_invalid' ,
703721 } )
704722 } )
705723
706- it ( 'propagates 410 expired errors with the api message ' , async ( ) => {
724+ it ( 'propagates a true 4xx (e.g. 429 rate-limited) untouched ' , async ( ) => {
707725 const m = installFetch ( )
708726 m . mockResolvedValueOnce ( jsonResponse (
709- { error : 'promotion_expired ' , message : 'This code has expired .' } ,
710- { status : 410 , statusText : 'Gone ' } ,
727+ { error : 'rate_limit_exceeded ' , message : 'Too many validations .' } ,
728+ { status : 429 , statusText : 'Too Many Requests ' } ,
711729 ) )
712- await expect ( validatePromotion ( 'OLDCODE' , 'pro' ) ) . rejects . toMatchObject ( {
713- status : 410 ,
714- code : 'promotion_expired' ,
730+ await expect ( validatePromotion ( 'ANYCODE' , 'pro' ) ) . rejects . toMatchObject ( {
731+ status : 429 ,
732+ code : 'rate_limit_exceeded' ,
733+ } )
734+ } )
735+
736+ it ( 'falls back to the normalised input code when the api omits code on success' , async ( ) => {
737+ const m = installFetch ( )
738+ m . mockResolvedValueOnce ( jsonResponse ( {
739+ ok : true ,
740+ discount : { kind : 'percent_off' , value : 10 , applies_to : [ 'pro' ] } ,
741+ // no `code`, no `valid_until` (never-expiring code)
742+ } ) )
743+ const r = await validatePromotion ( 'partner10' , 'pro' )
744+ expect ( r . promotion . code ) . toBe ( 'PARTNER10' )
745+ expect ( r . promotion . valid_until ) . toBeUndefined ( )
746+ } )
747+
748+ it ( 'uses default reason copy when a 200 ok:false body omits error/message' , async ( ) => {
749+ const m = installFetch ( )
750+ m . mockResolvedValueOnce ( jsonResponse ( { ok : false } ) )
751+ await expect ( validatePromotion ( 'BARE' , 'pro' ) ) . rejects . toMatchObject ( {
752+ code : 'promotion_invalid' ,
753+ message : 'This promotion code is not valid for the selected plan.' ,
715754 } )
716755 } )
717756
@@ -1065,6 +1104,23 @@ describe('listStacks() env field', () => {
10651104 const r = await listStacks ( )
10661105 expect ( r . items [ 0 ] . env ) . toBe ( 'production' )
10671106 } )
1107+
1108+ // REGRESSION GUARD (P1-2): list rows from the api carry 'healthy' for live
1109+ // stacks. listStacks() must normalise it to 'running' so list/detail status
1110+ // comparisons (and the StatusPill) agree with the StackStatus type.
1111+ it ( "normalises 'healthy' list rows to 'running'" , async ( ) => {
1112+ const m = installFetch ( )
1113+ m . mockResolvedValueOnce ( jsonResponse ( {
1114+ ok : true ,
1115+ items : [
1116+ { stack_id : 'stk-live' , name : 'demo' , status : 'healthy' , tier : 'pro' , env : 'production' , created_at : 'x' } ,
1117+ { stack_id : 'stk-deploying' , name : 'demo2' , status : 'deploying' , tier : 'pro' , env : 'production' , created_at : 'y' } ,
1118+ ] ,
1119+ } ) )
1120+ const r = await listStacks ( )
1121+ expect ( r . items [ 0 ] . status ) . toBe ( 'running' )
1122+ expect ( r . items [ 1 ] . status ) . toBe ( 'running' )
1123+ } )
10681124} )
10691125
10701126// ─── listDeployments() — GET /api/v1/deployments adapter ─────────────────
@@ -1796,6 +1852,19 @@ describe('createStack()', () => {
17961852 expect ( r . stack . status ) . toBe ( 'building' )
17971853 expect ( r . stack . url ) . toBeNull ( )
17981854 } )
1855+
1856+ // P1-2: a rare synchronous cached-build returns 'healthy'. createStack() must
1857+ // normalise it to 'running' so StackCreatePage.onSubmit skips to the live
1858+ // stage instead of polling.
1859+ it ( "normalises a synchronous 'healthy' response to 'running'" , async ( ) => {
1860+ const m = installFetch ( )
1861+ m . mockResolvedValueOnce ( jsonResponse ( {
1862+ ok : true , slug : 'cached-1' , status : 'healthy' ,
1863+ url : 'https://cached-1.deployment.instanode.dev' ,
1864+ } ) )
1865+ const r = await createStack ( fakeFile ( 'a.tar.gz' , 100 ) , { } )
1866+ expect ( r . stack . status ) . toBe ( 'running' )
1867+ } )
17991868} )
18001869
18011870// ─── fetchStackStatus() — GET /api/v1/stacks/:slug polling helper (D09/C06) ──
@@ -1853,6 +1922,71 @@ describe('fetchStackStatus()', () => {
18531922 m . mockResolvedValueOnce ( jsonResponse ( { error : 'internal' } , { status : 500 } ) )
18541923 await expect ( fetchStackStatus ( 's1' ) ) . rejects . toMatchObject ( { status : 500 } )
18551924 } )
1925+
1926+ // REGRESSION GUARD (P1-2): the api emits 'healthy' for a LIVE stack, but the
1927+ // dashboard's StackStatus / StackCreatePage poll only treat 'running' as
1928+ // live. Without normalisation a successful deploy spins to the 5-min poll
1929+ // timeout. fetchStackStatus() must map 'healthy' → 'running' so the
1930+ // create-page poll's `status === 'running'` live-test trips.
1931+ it ( "normalises a live stack's 'healthy' status to 'running'" , async ( ) => {
1932+ const m = installFetch ( )
1933+ m . mockResolvedValueOnce ( jsonResponse ( {
1934+ ok : true ,
1935+ stack_id : 'live-stack' ,
1936+ name : 'shipped' ,
1937+ status : 'healthy' , // ← raw api live state
1938+ tier : 'pro' ,
1939+ services : [ { name : 'app' , url : 'https://live-stack.deployment.instanode.dev' , status : 'healthy' } ] ,
1940+ } ) )
1941+ const r = await fetchStackStatus ( 'live-stack' )
1942+ expect ( r . stack ?. status ) . toBe ( 'running' )
1943+ expect ( r . stack ?. url ) . toBe ( 'https://live-stack.deployment.instanode.dev' )
1944+ } )
1945+
1946+ it ( "normalises 'deploying' (rolling out) to 'running'" , async ( ) => {
1947+ const m = installFetch ( )
1948+ m . mockResolvedValueOnce ( jsonResponse ( {
1949+ ok : true , stack_id : 'rolling' , status : 'deploying' , tier : 'pro' ,
1950+ } ) )
1951+ const r = await fetchStackStatus ( 'rolling' )
1952+ expect ( r . stack ?. status ) . toBe ( 'running' )
1953+ } )
1954+
1955+ it ( "passes 'failed' through unchanged so the poll can stop" , async ( ) => {
1956+ const m = installFetch ( )
1957+ m . mockResolvedValueOnce ( jsonResponse ( {
1958+ ok : true , stack_id : 'broke' , status : 'failed' , tier : 'pro' ,
1959+ } ) )
1960+ const r = await fetchStackStatus ( 'broke' )
1961+ expect ( r . stack ?. status ) . toBe ( 'failed' )
1962+ } )
1963+
1964+ it ( "maps an unknown/empty status to 'building' (spinner, not crash)" , async ( ) => {
1965+ const m = installFetch ( )
1966+ m . mockResolvedValueOnce ( jsonResponse ( {
1967+ ok : true , stack_id : 'mystery' , status : 'pending-something' , tier : 'pro' ,
1968+ } ) )
1969+ const r = await fetchStackStatus ( 'mystery' )
1970+ expect ( r . stack ?. status ) . toBe ( 'building' )
1971+ } )
1972+
1973+ it ( "maps 'deleting' to 'stopped' (terminal-ish, no compute)" , async ( ) => {
1974+ const m = installFetch ( )
1975+ m . mockResolvedValueOnce ( jsonResponse ( {
1976+ ok : true , stack_id : 'going-away' , status : 'deleting' , tier : 'pro' ,
1977+ } ) )
1978+ const r = await fetchStackStatus ( 'going-away' )
1979+ expect ( r . stack ?. status ) . toBe ( 'stopped' )
1980+ } )
1981+
1982+ it ( "passes 'building' through unchanged (still in flight)" , async ( ) => {
1983+ const m = installFetch ( )
1984+ m . mockResolvedValueOnce ( jsonResponse ( {
1985+ ok : true , stack_id : 'wip' , status : 'building' , tier : 'pro' ,
1986+ } ) )
1987+ const r = await fetchStackStatus ( 'wip' )
1988+ expect ( r . stack ?. status ) . toBe ( 'building' )
1989+ } )
18561990} )
18571991
18581992// ─── APIError tier-wall envelope (P2-W2-16) ──────────────────────────────
0 commit comments