@@ -68,6 +68,12 @@ vi.mock('../api', async () => {
6868 fetchBilling : vi . fn ( ) ,
6969 listInvoices : vi . fn ( ) ,
7070 listResources : vi . fn ( ) ,
71+ // §10.20: BillingPage's Usage panel now reads fetchBillingUsage() (a
72+ // server-side cached aggregate) instead of listResources(). The
73+ // listResources mock above stays in the module-level mock for the
74+ // pre-§10.20 tests that still reference it; new tests should drive
75+ // fetchBillingUsage.
76+ fetchBillingUsage : vi . fn ( ) ,
7177 createCheckout : vi . fn ( ) ,
7278 cancelSubscription : vi . fn ( ) ,
7379 }
@@ -104,13 +110,45 @@ function mockHappyBilling() {
104110 ok : true ,
105111 invoices : FIXTURE_INVOICES ,
106112 } )
107- // Default: no resources → Usage panel renders zeroes. Tests that care
108- // about specific usage figures override this themselves .
113+ // Pre-§10.20 tests still mock listResources; new code path doesn't
114+ // call it, so this resolves to an unused empty list .
109115 ; ( api . listResources as any ) . mockResolvedValue ( {
110116 ok : true ,
111117 items : [ ] ,
112118 total : 0 ,
113119 } )
120+ // §10.20: default zero-usage server response. Tests that pin specific
121+ // usage figures override this with a payload carrying real bytes/counts.
122+ ; ( api . fetchBillingUsage as any ) . mockResolvedValue ( makeUsageResp ( { } ) )
123+ }
124+
125+ /** §10.20 test helper — build a BillingUsage response with optional overrides
126+ * per metric. Unspecified metrics default to {bytes:0, limit_bytes:-1} or
127+ * {count:0, limit:-1} matching the server's "no row" shape. */
128+ function makeUsageResp ( over : Partial < {
129+ postgres_bytes : number
130+ redis_bytes : number
131+ mongodb_bytes : number
132+ deployments : number
133+ webhooks : number
134+ vault : number
135+ members : number
136+ } > ) {
137+ return {
138+ ok : true ,
139+ freshness_seconds : 30 ,
140+ // Pin as_of so the "as of Ns ago" footnote renders deterministically.
141+ as_of : new Date ( Date . now ( ) - 5000 ) . toISOString ( ) ,
142+ usage : {
143+ postgres : { bytes : over . postgres_bytes ?? 0 , limit_bytes : 1024 * 1024 * 1024 } ,
144+ redis : { bytes : over . redis_bytes ?? 0 , limit_bytes : 50 * 1024 * 1024 } ,
145+ mongodb : { bytes : over . mongodb_bytes ?? 0 , limit_bytes : 100 * 1024 * 1024 } ,
146+ deployments : { count : over . deployments ?? 0 , limit : 1 } ,
147+ webhooks : { count : over . webhooks ?? 0 , limit : 1000 } ,
148+ vault : { count : over . vault ?? 0 , limit : 20 } ,
149+ members : { count : over . members ?? 1 , limit : 1 } ,
150+ } ,
151+ }
114152}
115153
116154/** Wait for the page to finish its initial load (skeleton → real content). */
@@ -180,7 +218,7 @@ describe('BillingPage — backend-down error state (§10.21)', () => {
180218 it ( 'renders the billing-error banner when fetchBilling rejects (no fixture fallback)' , async ( ) => {
181219 ; ( api . fetchBilling as any ) . mockRejectedValue ( Object . assign ( new Error ( 'Razorpay is not configured' ) , { status : 503 } ) )
182220 ; ( api . listInvoices as any ) . mockResolvedValue ( { ok : true , invoices : [ ] } )
183- ; ( api . listResources as any ) . mockResolvedValue ( { ok : true , items : [ ] , total : 0 } )
221+ ; ( api . fetchBillingUsage as any ) . mockResolvedValue ( makeUsageResp ( { } ) )
184222 render ( < BillingPage /> )
185223 await waitFor ( ( ) => {
186224 expect ( screen . getByTestId ( 'billing-error' ) ) . toBeTruthy ( )
@@ -193,7 +231,7 @@ describe('BillingPage — backend-down error state (§10.21)', () => {
193231 it ( 'surfaces the error message on the billing-error banner' , async ( ) => {
194232 ; ( api . fetchBilling as any ) . mockRejectedValue ( new Error ( 'Razorpay is not configured' ) )
195233 ; ( api . listInvoices as any ) . mockResolvedValue ( { ok : true , invoices : [ ] } )
196- ; ( api . listResources as any ) . mockResolvedValue ( { ok : true , items : [ ] , total : 0 } )
234+ ; ( api . fetchBillingUsage as any ) . mockResolvedValue ( makeUsageResp ( { } ) )
197235 const { container } = render ( < BillingPage /> )
198236 await waitFor ( ( ) => {
199237 expect ( screen . getByTestId ( 'billing-error' ) ) . toBeTruthy ( )
@@ -208,6 +246,9 @@ describe('BillingPage — initial render', () => {
208246 ; ( api . fetchBilling as any ) . mockReturnValue ( new Promise ( ( ) => { } ) ) // never resolves
209247 ; ( api . listInvoices as any ) . mockReturnValue ( new Promise ( ( ) => { } ) )
210248 ; ( api . listResources as any ) . mockReturnValue ( new Promise ( ( ) => { } ) )
249+ // §10.20: BillingPage calls fetchBillingUsage now; it must return a
250+ // pending promise (never resolves) so the skeleton state holds.
251+ ; ( api . fetchBillingUsage as any ) . mockReturnValue ( new Promise ( ( ) => { } ) )
211252 const { container } = render ( < BillingPage /> )
212253 expect ( container . querySelector ( '.skel' ) ) . toBeTruthy ( )
213254 } )
@@ -453,61 +494,38 @@ describe('BillingPage — userEvent integration', () => {
453494 } )
454495} )
455496
456- // ─── Usage panel — real data from listResources() (§10.1) ───────────────
457- // The old Usage panel hardcoded "47 / 500", "163 / 256", "1.64 / 2 GB", etc.
458- // We now aggregate ctx.resources by type. These tests pin the contract:
459- // (a) values move when listResources moves,
460- // (b) the old fixture numbers no longer appear in the DOM.
461- describe ( 'BillingPage — Usage panel reflects listResources()' , ( ) => {
462- // Minimal Resource fixture factory — keeps the test contained.
463- function makePgResource ( id : string , mb : number ) {
464- return {
465- id,
466- token : id ,
467- resource_type : 'postgres' ,
468- tier : 'hobby' ,
469- status : 'active' ,
470- name : id ,
471- env : 'production' ,
472- storage_bytes : mb * 1024 * 1024 ,
473- storage_limit_bytes : 1024 * 1024 * 1024 ,
474- storage_exceeded : false ,
475- expires_at : null ,
476- created_at : new Date ( ) . toISOString ( ) ,
477- }
478- }
479-
480- it ( 'aggregates two postgres resources totalling 100 MB into one UsageRow' , async ( ) => {
497+ // ─── Usage panel — server-side cached aggregate (§10.20) ────────────────
498+ // The Usage panel reads /api/v1/billing/usage (cached 30s in Redis with
499+ // singleflight on the server). These tests pin the contract:
500+ // (a) values reflect the server response, not a client-side aggregate,
501+ // (b) BillingPage does NOT call listResources() for usage data,
502+ // (c) the `as_of` footnote renders so the eventual-consistency tradeoff
503+ // is visible to users.
504+ describe ( 'BillingPage — Usage panel reflects fetchBillingUsage() (§10.20)' , ( ) => {
505+ it ( 'renders postgres bytes (100 MB / 1 GB) from the server response' , async ( ) => {
481506 mockTier = 'hobby'
482507 mockHappyBilling ( )
483- ; ( api . listResources as any ) . mockResolvedValue ( {
484- ok : true ,
485- items : [ makePgResource ( 'p_a' , 40 ) , makePgResource ( 'p_b' , 60 ) ] ,
486- total : 2 ,
487- } )
508+ ; ( api . fetchBillingUsage as any ) . mockResolvedValue ( makeUsageResp ( {
509+ postgres_bytes : 100 * 1024 * 1024 ,
510+ } ) )
488511 const { container } = render ( < BillingPage /> )
489512 await waitForLoaded ( )
490- // hobby postgres limit is 1024 MB → renders as "1 GB".
491513 await waitFor ( ( ) => {
492514 const text = container . textContent ?? ''
493515 expect ( text ) . toContain ( '100' )
494516 expect ( text ) . toContain ( '1 GB' )
495517 } )
496518 } )
497519
498- it ( 'renders 0 for the resource-driven UsageRows when the resource list is empty ' , async ( ) => {
520+ it ( 'renders zeroes when the server reports no usage ' , async ( ) => {
499521 mockTier = 'hobby'
500522 mockHappyBilling ( )
501- ; ( api . listResources as any ) . mockResolvedValue ( { ok : true , items : [ ] , total : 0 } )
502523 const { container } = render ( < BillingPage /> )
503524 await waitForLoaded ( )
504525 await waitFor ( ( ) => {
505526 const rows = container . querySelectorAll ( '.usage-row' )
506527 // 6 usage rows: postgres, redis, mongo, deployments, webhooks, team seats.
507528 expect ( rows . length ) . toBe ( 6 )
508- // Resource-aggregated rows (postgres / redis / mongo / deployments /
509- // webhooks) must read "0 / …" when the list is empty. Team seats is a
510- // separate constant for now (no member-list endpoint) and is exempt.
511529 const resourceRowKeys = [ 'postgres' , 'redis' , 'mongo' , 'deployments' , 'webhooks' ]
512530 resourceRowKeys . forEach ( ( key ) => {
513531 const row = Array . from ( rows ) . find ( ( r ) => r . querySelector ( '.k' ) ?. textContent === key )
@@ -525,6 +543,35 @@ describe('BillingPage — Usage panel reflects listResources()', () => {
525543 await waitForLoaded ( )
526544 expect ( container . textContent ) . not . toMatch ( / \b 4 7 \b / )
527545 } )
546+
547+ // §10.20 / §14: critical contract — the page must NOT round-trip to
548+ // /resources for usage data anymore. Catches accidental reintroductions
549+ // of the client-side aggregate.
550+ it ( 'does not call listResources() for usage data' , async ( ) => {
551+ mockTier = 'hobby'
552+ mockHappyBilling ( )
553+ render ( < BillingPage /> )
554+ await waitForLoaded ( )
555+ // Wait a tick to make sure any in-flight effect has a chance to fire.
556+ await new Promise ( ( r ) => setTimeout ( r , 50 ) )
557+ expect ( ( api . listResources as any ) . mock ?. calls ?. length ?? 0 ) . toBe ( 0 )
558+ // The new cached aggregate, on the other hand, must be called exactly once.
559+ expect ( ( api . fetchBillingUsage as any ) . mock ?. calls ?. length ?? 0 ) . toBe ( 1 )
560+ } )
561+
562+ // §10.20 / §13: the eventual-consistency footnote must render so users
563+ // can see when the snapshot was computed.
564+ it ( 'renders the "as of Ns ago" footnote when the cached payload arrives' , async ( ) => {
565+ mockTier = 'hobby'
566+ mockHappyBilling ( )
567+ const { getByTestId } = render ( < BillingPage /> )
568+ await waitForLoaded ( )
569+ await waitFor ( ( ) => {
570+ const footnote = getByTestId ( 'billing-usage-as-of' )
571+ expect ( footnote . textContent ) . toMatch ( / a s o f / )
572+ expect ( footnote . textContent ) . toMatch ( / c a c h e d 3 0 s / )
573+ } )
574+ } )
528575} )
529576
530577// ─── §10.8 cleanups: card expiry leak, invoice status, update mailto ────
0 commit comments