@@ -104,15 +104,31 @@ describe('DeploymentsPage — empty state', () => {
104104 expect ( text ) . toMatch ( / \/ d e p l o y \/ n e w / )
105105 } )
106106
107- it ( 'renders the same honest empty state when listDeployments rejects' , async ( ) => {
108- // The page swallows errors and falls back to the empty state — better
109- // than rendering a fabricated row. The contract test in index.test.ts
110- // asserts the API helper *does* propagate so a future surface could
111- // render a real error banner; this surface chooses empty for now.
107+ it ( 'surfaces a real error banner (not "No deployments yet") when listDeployments rejects' , async ( ) => {
108+ // T15 P2-7 fix: a 429 or 5xx must NOT collapse to "No deployments
109+ // yet" — that lies about platform state and looks like normal empty
110+ // state. The page renders a dedicated error banner via retryHint and
111+ // an honest empty list under it. Regression guard: catch any future
112+ // refactor that re-swallows the error.
112113 mockListDeployments . mockRejectedValueOnce ( new Error ( 'network' ) )
113114 render ( withRouter ( < DeploymentsPage /> ) )
114- const empty = await screen . findByTestId ( 'deployments-empty' )
115- expect ( empty . textContent ) . toMatch ( / N o d e p l o y m e n t s y e t / )
115+ const banner = await screen . findByTestId ( 'deployments-error' )
116+ expect ( banner . textContent ) . toMatch ( / C o u l d n o t l o a d d e p l o y m e n t s / )
117+ } )
118+
119+ it ( 'renders a 429 rate-limit hint with the Retry-After seconds' , async ( ) => {
120+ // Regression guard for T15 P2-7: DeploymentsPage now consumes
121+ // retryHint just like TeamPage. A rejected fetch carrying status=429
122+ // + retryAfter must render the user-friendly retry hint, not a raw
123+ // error string.
124+ const rateLimited : Error & { status ?: number ; retryAfter ?: number } = new Error ( 'rate limited' )
125+ rateLimited . status = 429
126+ rateLimited . retryAfter = 30
127+ mockListDeployments . mockRejectedValueOnce ( rateLimited )
128+ render ( withRouter ( < DeploymentsPage /> ) )
129+ const banner = await screen . findByTestId ( 'deployments-error' )
130+ expect ( banner . textContent ) . toMatch ( / T o o m a n y r e q u e s t s / i)
131+ expect ( banner . textContent ) . toMatch ( / 3 0 s e c o n d s / i)
116132 } )
117133} )
118134
@@ -135,14 +151,22 @@ describe('DeploymentsPage — non-empty state', () => {
135151 expect ( container . querySelector ( '[data-testid="deployments-empty"]' ) ) . toBeNull ( ) ,
136152 )
137153
138- // Each row is a <Link to="/deployments/:app_id"> — we route by app_id
139- // (not the UUID `id`) because GET /api/v1/deployments/:id on the
140- // agent API resolves `:id` against the app_id column. Routing by
141- // UUID would 404.
142- const links = Array . from ( container . querySelectorAll ( 'a[href^="/deployments/"]' ) )
154+ // Each row is a <Link to="/app/deployments/:app_id"> — we route by
155+ // app_id (not the UUID `id`) because GET /api/v1/deployments/:id on
156+ // the agent API resolves `:id` against the app_id column. Routing
157+ // by UUID would 404. Linking to /app/deployments/* directly (rather
158+ // than the unprefixed legacy path) avoids the
159+ // LegacyDeploymentRedirect render→Navigate→render double-hop on
160+ // every row click — regression guard for T15 P2-2.
161+ const links = Array . from ( container . querySelectorAll ( 'a[href^="/app/deployments/"]' ) )
143162 expect ( links . length ) . toBe ( 2 )
144- expect ( links . map ( ( a ) => a . getAttribute ( 'href' ) ) ) . toContain ( '/deployments/app-a' )
145- expect ( links . map ( ( a ) => a . getAttribute ( 'href' ) ) ) . toContain ( '/deployments/app-b' )
163+ expect ( links . map ( ( a ) => a . getAttribute ( 'href' ) ) ) . toContain ( '/app/deployments/app-a' )
164+ expect ( links . map ( ( a ) => a . getAttribute ( 'href' ) ) ) . toContain ( '/app/deployments/app-b' )
165+ // Negative assertion: no unprefixed /deployments/* links. The legacy
166+ // unprefixed route exists for external bookmarks only; internal nav
167+ // must skip the redirect.
168+ const legacyLinks = Array . from ( container . querySelectorAll ( 'a[href^="/deployments/"]' ) )
169+ expect ( legacyLinks . length ) . toBe ( 0 )
146170
147171 // URL column renders the hostname (https:// stripped). Use textContent
148172 // on the full page rather than scoping to row — the row uses CSS grid
@@ -229,4 +253,23 @@ describe('DeploymentsPage — private deploy section, tier-gated', () => {
229253 expect ( toggle . checked ) . toBe ( false )
230254 expect ( screen . queryByTestId ( 'ip-allow-list' ) ) . toBeNull ( )
231255 } )
256+
257+ // T15 P2-3 regression guard: DeploymentsPage must read the
258+ // private-deploy tier gate from team.tier, NOT user.tier. The team is
259+ // the billing entity; reading from user.tier was a latent divergence
260+ // that would silently break the gate the moment the API splits the
261+ // two fields. Construct a `me` shape where team.tier='pro' but
262+ // user.tier='hobby' — under the bug, the page would render the
263+ // hobby upsell; under the fix it must render the pro configurator.
264+ it ( 'uses team.tier (not user.tier) for the private-deploy gate' , async ( ) => {
265+ mockMe = {
266+ user : { id : 'u' , email : 'me@test' , tier : 'hobby' , team_id : 't' , created_at : '' } ,
267+ team : { id : 't' , slug : 't' , name : 't' , owner_id : 'u' , member_count : 1 , tier : 'pro' , created_at : '' } ,
268+ }
269+ render ( withRouter ( < DeploymentsPage /> ) )
270+ await waitFor ( ( ) => screen . getByTestId ( 'private-deploy-section' ) )
271+ // team.tier=pro → configurator renders, upsell does NOT.
272+ expect ( screen . getByTestId ( 'private-deploy-configurator' ) ) . toBeTruthy ( )
273+ expect ( screen . queryByTestId ( 'private-deploy-upsell' ) ) . toBeNull ( )
274+ } )
232275} )
0 commit comments