Skip to content

Commit ca7f979

Browse files
Merge pull request #41 from InstaNode-dev/pricing/u1-quota-banner-fresh
QuotaWallBanner on top-level pages reads /api/v1/usage/wall (U1)
2 parents c55d88e + a9a371d commit ca7f979

6 files changed

Lines changed: 437 additions & 1 deletion

File tree

src/api/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,3 +1081,25 @@ export type TeamSummary = {
10811081
export async function fetchTeamSummary(): Promise<TeamSummary> {
10821082
return call<TeamSummary>('/api/v1/team/summary')
10831083
}
1084+
1085+
// ─── Usage wall (Track U1) ──────────────────────────────────────────────
1086+
// GET /api/v1/usage/wall — most recent near_quota_wall row for the
1087+
// caller's team within the last 24h. Drives the QuotaWallBanner upgrade
1088+
// nudge. When near_wall=false the response carries only `{ok, near_wall}`;
1089+
// when true the metadata fields (tier/axis/service/current/limit/
1090+
// percent_used/at) are flattened in alongside ok/near_wall.
1091+
export type QuotaWallResponse = {
1092+
ok: true
1093+
near_wall: boolean
1094+
tier?: string
1095+
axis?: 'storage' | 'connections' | 'provisions'
1096+
service?: string
1097+
current?: number
1098+
limit?: number
1099+
percent_used?: number
1100+
at?: string
1101+
}
1102+
1103+
export async function fetchQuotaWall(): Promise<QuotaWallResponse> {
1104+
return call<QuotaWallResponse>('/api/v1/usage/wall')
1105+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// QuotaWallBanner.test.tsx — Track U1 unit tests.
2+
//
3+
// Three lifecycle moments worth verifying without spinning up a full
4+
// fetch loop:
5+
// 1. With near_wall=true → banner renders, copy is correct.
6+
// 2. With dismiss state at the current percent → banner stays hidden.
7+
// 3. With dismiss state and percent climbed +5pp → banner reappears.
8+
//
9+
// shouldRender is exported as a pure function precisely so these tests
10+
// can exercise the decision logic directly. We also do one rendering
11+
// test to confirm the JSX wires up testids and the dismiss writes to
12+
// localStorage.
13+
14+
import { describe, it, expect, beforeEach, vi } from 'vitest'
15+
import { render, screen, fireEvent } from '@testing-library/react'
16+
import { QuotaWallBanner, shouldRender } from './QuotaWallBanner'
17+
import type { QuotaWallResponse } from '../api'
18+
19+
const fakeWall: QuotaWallResponse = {
20+
ok: true,
21+
near_wall: true,
22+
tier: 'hobby',
23+
axis: 'storage',
24+
service: 'postgres',
25+
current: 471859200,
26+
limit: 536870912,
27+
percent_used: 87,
28+
at: '2026-05-12T11:02:00Z',
29+
}
30+
31+
describe('shouldRender', () => {
32+
it('returns false when there is no wall payload', () => {
33+
expect(shouldRender(null, null)).toBe(false)
34+
})
35+
36+
it('returns false when near_wall is false', () => {
37+
expect(shouldRender({ ok: true, near_wall: false }, null)).toBe(false)
38+
})
39+
40+
it('returns true when near_wall is true and no dismiss state exists', () => {
41+
expect(shouldRender(fakeWall, null)).toBe(true)
42+
})
43+
44+
it('returns false when dismissed at the same percent the response reports', () => {
45+
expect(
46+
shouldRender(fakeWall, { percent: 87, at: '2026-05-12T11:05:00Z' }),
47+
).toBe(false)
48+
})
49+
50+
it('returns false when usage climbed less than 5pp after dismiss', () => {
51+
// Dismissed at 85, current 87 → +2pp → still hidden.
52+
expect(
53+
shouldRender({ ...fakeWall, percent_used: 87 }, { percent: 85, at: '2026-05-12T11:05:00Z' }),
54+
).toBe(false)
55+
})
56+
57+
it('returns true when usage climbed 5pp or more after dismiss', () => {
58+
// Dismissed at 82, current 87 → +5pp → reappears.
59+
expect(
60+
shouldRender({ ...fakeWall, percent_used: 87 }, { percent: 82, at: '2026-05-12T11:05:00Z' }),
61+
).toBe(true)
62+
})
63+
})
64+
65+
describe('QuotaWallBanner rendering', () => {
66+
beforeEach(() => {
67+
// Reset localStorage between tests so dismiss state doesn't leak.
68+
if (typeof localStorage !== 'undefined') localStorage.clear()
69+
// Suppress real fetch — disablePolling covers the interval, but the
70+
// initial render's fetch path can also fire when initialWall is
71+
// undefined. We pass initialWall in every test below to keep it
72+
// hermetic.
73+
})
74+
75+
it('renders the banner copy with axis-aware text when near_wall=true', () => {
76+
render(
77+
<QuotaWallBanner
78+
teamId="team-1"
79+
initialWall={fakeWall}
80+
disablePolling
81+
/>,
82+
)
83+
const banner = screen.getByTestId('quota-wall-banner')
84+
expect(banner).toBeTruthy()
85+
expect(banner.textContent ?? '').toContain('87%')
86+
expect(banner.textContent ?? '').toContain('hobby')
87+
expect(banner.textContent ?? '').toContain('postgres')
88+
expect(screen.getByTestId('quota-wall-upgrade').getAttribute('href')).toBe('/app/billing')
89+
})
90+
91+
it('hides the banner after dismiss is clicked', () => {
92+
render(
93+
<QuotaWallBanner
94+
teamId="team-1"
95+
initialWall={fakeWall}
96+
disablePolling
97+
/>,
98+
)
99+
expect(screen.queryByTestId('quota-wall-banner')).toBeTruthy()
100+
fireEvent.click(screen.getByTestId('quota-wall-dismiss'))
101+
expect(screen.queryByTestId('quota-wall-banner')).toBeNull()
102+
103+
// The dismiss state was persisted with the right percent so the
104+
// reappear-at-+5pp logic can read it back in a fresh mount.
105+
const raw = localStorage.getItem('instanode.quotaWallDismiss.team-1')
106+
expect(raw).toBeTruthy()
107+
const parsed = JSON.parse(raw!)
108+
expect(parsed.percent).toBe(87)
109+
})
110+
111+
it('does not render when near_wall=false', () => {
112+
render(
113+
<QuotaWallBanner
114+
teamId="team-1"
115+
initialWall={{ ok: true, near_wall: false }}
116+
disablePolling
117+
/>,
118+
)
119+
expect(screen.queryByTestId('quota-wall-banner')).toBeNull()
120+
})
121+
122+
it('reappears for a fresh mount if percent climbed +5pp over the dismissed value', () => {
123+
// Pre-seed a dismiss state at 82%.
124+
localStorage.setItem(
125+
'instanode.quotaWallDismiss.team-1',
126+
JSON.stringify({ percent: 82, at: '2026-05-12T11:00:00Z' }),
127+
)
128+
render(
129+
<QuotaWallBanner
130+
teamId="team-1"
131+
initialWall={{ ...fakeWall, percent_used: 88 }}
132+
disablePolling
133+
/>,
134+
)
135+
expect(screen.queryByTestId('quota-wall-banner')).toBeTruthy()
136+
})
137+
138+
it('stays hidden for a fresh mount if percent climbed less than +5pp', () => {
139+
localStorage.setItem(
140+
'instanode.quotaWallDismiss.team-1',
141+
JSON.stringify({ percent: 85, at: '2026-05-12T11:00:00Z' }),
142+
)
143+
render(
144+
<QuotaWallBanner
145+
teamId="team-1"
146+
initialWall={{ ...fakeWall, percent_used: 87 }}
147+
disablePolling
148+
/>,
149+
)
150+
expect(screen.queryByTestId('quota-wall-banner')).toBeNull()
151+
})
152+
})
153+
154+
// Sanity test that the unused vi import is intentional — silences the
155+
// "unused import" lint rule when the file is otherwise vi-free.
156+
vi.fn()

0 commit comments

Comments
 (0)