Skip to content

Commit 2a07206

Browse files
e2e: playwright spec for AdminCustomersPage (8 scenarios) (#51)
Adds browser-level coverage for the founder admin console at /app/admin/customers — route gating, table render, search debounce, filter pills, drawer + tabs, promo issuance, tier change with typed PROMOTE confirmation, and USD/INR currency toggle with localStorage persistence across reload. Every backend call is page.route()-mocked via four new fixture helpers (mockAdminListResponse, mockAdminDetailResponse, mockAdminTierChange, mockAdminPromoIssue) plus an installAdminAPIFake convenience that flips /auth/me to an admin response and registers all four mocks. No real backend. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 340cec9 commit 2a07206

2 files changed

Lines changed: 545 additions & 0 deletions

File tree

e2e/admin-customers.spec.ts

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
// admin-customers.spec.ts — Playwright coverage for the AdminCustomersPage
2+
// founder console at /app/admin/customers. The unit tests
3+
// (src/pages/AdminCustomersPage.test.tsx) already cover render-level branches
4+
// against vi-mocked api modules; this suite locks in the full browser surface:
5+
// router gate, real fetch wiring through buildAdminURL(), drawer + modal
6+
// flows, and the cross-mount currency preference in real localStorage.
7+
//
8+
// Every backend call is mocked via page.route() — see fixtures.ts. No real
9+
// agent API contact.
10+
11+
import { expect, test } from '@playwright/test'
12+
import {
13+
FAKE_ADMIN_CUSTOMERS,
14+
FAKE_ADMIN_PATH_PREFIX,
15+
installAPIFake,
16+
installAdminAPIFake,
17+
signIn,
18+
} from './fixtures'
19+
20+
const ADMIN_ROUTE = '/app/admin/customers'
21+
22+
test.describe('AdminCustomersPage', () => {
23+
test.beforeEach(async ({ page }) => {
24+
// Each Playwright test runs in its own browser context — localStorage
25+
// starts empty by default, so we don't need to clear the currency key
26+
// here. (We deliberately avoid an addInitScript that would also fire on
27+
// reload, which would wipe the INR preference test 8 just persisted.)
28+
await signIn(page)
29+
})
30+
31+
// ─── 1. non-admin route gating ─────────────────────────────────────────
32+
test('non-admin user gets redirected (route does not leak)', async ({ page }) => {
33+
// installAPIFake's /auth/me returns is_platform_admin absent →
34+
// AdminCustomersPage renders <Navigate to="/" /> and we end up on the
35+
// public marketing page. The admin surface never paints.
36+
await installAPIFake(page)
37+
await page.goto(ADMIN_ROUTE)
38+
// The page-level testid never appears.
39+
await expect(page.getByTestId('admin-customers-page')).toHaveCount(0)
40+
// We land on "/" (the marketing page) — assert URL, not content, so a
41+
// future copy refresh doesn't break the gate test.
42+
await expect(page).toHaveURL(/\/$/)
43+
})
44+
45+
// ─── 2. admin user lands on page with table ────────────────────────────
46+
test('admin user sees the customers table with sortable headers', async ({
47+
page,
48+
}) => {
49+
await installAPIFake(page)
50+
await installAdminAPIFake(page)
51+
await page.goto(ADMIN_ROUTE)
52+
53+
await expect(page.getByTestId('admin-customers-page')).toBeVisible()
54+
await expect(page.getByTestId('admin-customers-table')).toBeVisible()
55+
await expect(page.getByTestId('admin-customers-count')).toContainText(
56+
String(FAKE_ADMIN_CUSTOMERS.length),
57+
)
58+
// Sortable headers expose aria-sort + testid-prefixed by column key.
59+
for (const k of [
60+
'email',
61+
'tier',
62+
'mrr',
63+
'storage',
64+
'deployments',
65+
'last_active',
66+
'created_at',
67+
]) {
68+
await expect(page.getByTestId(`admin-sort-${k}`)).toBeVisible()
69+
}
70+
// Default sort is mrr desc — verify the aria-sort attribute reflects it.
71+
await expect(page.getByTestId('admin-sort-mrr')).toHaveAttribute(
72+
'aria-sort',
73+
'descending',
74+
)
75+
// Clicking the email header re-sorts ascending; the alphabetically-first
76+
// row (agent@temp.dev → t_agent) bubbles up to the top.
77+
await page.getByTestId('admin-sort-email').click()
78+
await expect(page.getByTestId('admin-sort-email')).toHaveAttribute(
79+
'aria-sort',
80+
'ascending',
81+
)
82+
const firstRow = page
83+
.locator('[data-testid^="admin-customer-row-"]')
84+
.first()
85+
await expect(firstRow).toHaveAttribute(
86+
'data-testid',
87+
'admin-customer-row-t_agent',
88+
)
89+
})
90+
91+
// ─── 3. search by email (debounced refetch) ────────────────────────────
92+
test('search filters to founder@x.com when typing "fou"', async ({ page }) => {
93+
await installAPIFake(page)
94+
await installAdminAPIFake(page)
95+
await page.goto(ADMIN_ROUTE)
96+
await expect(
97+
page.getByTestId('admin-customer-row-t_founder'),
98+
).toBeVisible()
99+
100+
// The page debounces by re-firing listAdminCustomers on each keystroke
101+
// (the unit test verifies the search param). We wait for the request to
102+
// land + the rows to re-render.
103+
const searchReq = page.waitForRequest((req) =>
104+
req
105+
.url()
106+
.includes(`/api/v1/${FAKE_ADMIN_PATH_PREFIX}/customers`) &&
107+
req.url().includes('q=fou'),
108+
)
109+
await page.getByTestId('admin-customers-search').fill('fou')
110+
await searchReq
111+
112+
// Only the founder row remains.
113+
await expect(
114+
page.getByTestId('admin-customer-row-t_founder'),
115+
).toBeVisible()
116+
await expect(page.getByTestId('admin-customer-row-t_dev')).toHaveCount(0)
117+
await expect(page.getByTestId('admin-customer-row-t_agent')).toHaveCount(0)
118+
})
119+
120+
// ─── 4. filter pills (tier filter) ─────────────────────────────────────
121+
test('filter pill "Pro" narrows to pro-tier rows then "All" restores', async ({
122+
page,
123+
}) => {
124+
await installAPIFake(page)
125+
await installAdminAPIFake(page)
126+
await page.goto(ADMIN_ROUTE)
127+
await expect(
128+
page.getByTestId('admin-customer-row-t_founder'),
129+
).toBeVisible()
130+
131+
// Click Pro pill → only the pro-tier row survives the server-side filter.
132+
const proReq = page.waitForRequest((req) =>
133+
req.url().includes(`/customers`) && req.url().includes('tier=pro'),
134+
)
135+
await page.getByTestId('admin-filter-pro').click()
136+
await proReq
137+
await expect(
138+
page.getByTestId('admin-customer-row-t_founder'),
139+
).toBeVisible()
140+
await expect(page.getByTestId('admin-customer-row-t_dev')).toHaveCount(0)
141+
await expect(page.getByTestId('admin-customer-row-t_agent')).toHaveCount(0)
142+
143+
// Click All pill → no tier query param, all rows return.
144+
await page.getByTestId('admin-filter-all').click()
145+
await expect(
146+
page.getByTestId('admin-customer-row-t_founder'),
147+
).toBeVisible()
148+
await expect(page.getByTestId('admin-customer-row-t_dev')).toBeVisible()
149+
await expect(page.getByTestId('admin-customer-row-t_agent')).toBeVisible()
150+
})
151+
152+
// ─── 5. row click opens detail drawer ──────────────────────────────────
153+
test('clicking a row opens the detail drawer with email + tier + tabs', async ({
154+
page,
155+
}) => {
156+
await installAPIFake(page)
157+
await installAdminAPIFake(page)
158+
await page.goto(ADMIN_ROUTE)
159+
await expect(
160+
page.getByTestId('admin-customer-row-t_founder'),
161+
).toBeVisible()
162+
163+
await page.getByTestId('admin-customer-row-t_founder').click()
164+
165+
const drawer = page.getByTestId('customer-drawer')
166+
await expect(drawer).toBeVisible()
167+
await expect(page.getByTestId('drawer-email')).toContainText('founder@x.com')
168+
// Tabs render after detail fetch resolves.
169+
for (const t of ['overview', 'resources', 'activity', 'promos']) {
170+
await expect(page.getByTestId(`drawer-tab-${t}`)).toBeVisible()
171+
}
172+
// Default tab is overview — the overview grid shows up.
173+
await expect(page.getByTestId('drawer-overview')).toBeVisible()
174+
// Switch to resources tab — the resources table renders the mocked row.
175+
await page.getByTestId('drawer-tab-resources').click()
176+
await expect(page.getByTestId('drawer-resources')).toBeVisible()
177+
})
178+
179+
// ─── 6. issue promo modal + copy button ────────────────────────────────
180+
test('"Issue promo" submits with percent_off=15 and surfaces a code', async ({
181+
page,
182+
}) => {
183+
await installAPIFake(page)
184+
await installAdminAPIFake(page)
185+
await page.goto(ADMIN_ROUTE)
186+
187+
await page.getByTestId('admin-customer-row-t_founder').click()
188+
await expect(page.getByTestId('customer-drawer')).toBeVisible()
189+
await page.getByTestId('drawer-issue-promo').click()
190+
191+
const modal = page.getByTestId('issue-promo-modal')
192+
await expect(modal).toBeVisible()
193+
194+
// Defaults already set kind=percent_off, value=15. Reset just to be explicit.
195+
await page.getByTestId('promo-kind').selectOption('percent_off')
196+
await page.getByTestId('promo-value').fill('15')
197+
await page.getByTestId('promo-applies-to').fill('1')
198+
await page.getByTestId('promo-valid-days').fill('30')
199+
200+
// Capture the POST body so we lock in the contract.
201+
const promoReq = page.waitForRequest((req) =>
202+
req.url().endsWith(`/customers/t_founder/promo`) &&
203+
req.method() === 'POST',
204+
)
205+
await page.getByTestId('promo-submit').click()
206+
const req = await promoReq
207+
expect(req.postDataJSON()).toMatchObject({
208+
kind: 'percent_off',
209+
value: 15,
210+
applies_to: 1,
211+
valid_for_days: 30,
212+
})
213+
214+
// Issued state — code appears with a Copy button.
215+
await expect(page.getByTestId('promo-issued')).toBeVisible()
216+
await expect(page.getByTestId('promo-issued-code')).toContainText(
217+
'FOUNDER-MAY26',
218+
)
219+
await expect(page.getByTestId('promo-copy')).toBeVisible()
220+
})
221+
222+
// ─── 7. tier change modal with typed PROMOTE confirmation ──────────────
223+
test('"Promote tier" requires typing PROMOTE before submit enables', async ({
224+
page,
225+
}) => {
226+
await installAPIFake(page)
227+
await installAdminAPIFake(page)
228+
await page.goto(ADMIN_ROUTE)
229+
230+
// Open the drawer on the founder row. The mocked detail response carries
231+
// team.tier='pro' (which overrides the summary tier on display), so any
232+
// promotion needs to target a tier above 'pro' — we pick 'team' so the
233+
// confirmation word resolves to PROMOTE.
234+
await page.getByTestId('admin-customer-row-t_founder').click()
235+
await expect(page.getByTestId('customer-drawer')).toBeVisible()
236+
// Wait for the detail fetch to resolve before opening the tier modal —
237+
// currentTier is sourced from detail.team.tier as soon as it lands.
238+
await expect(page.getByTestId('drawer-overview')).toBeVisible()
239+
await page.getByTestId('drawer-change-tier').click()
240+
241+
const modal = page.getByTestId('tier-change-modal')
242+
await expect(modal).toBeVisible()
243+
244+
// Pick "team" — up-tier from "pro", so the confirm input renders with
245+
// the PROMOTE word.
246+
await page.getByTestId('tier-select').selectOption('team')
247+
await expect(page.getByTestId('tier-confirm-word')).toContainText('PROMOTE')
248+
249+
// Reason field is mandatory.
250+
await page.getByTestId('tier-reason').fill('founder demo upgrade')
251+
252+
// Submit is disabled until the confirm word matches.
253+
await expect(page.getByTestId('tier-submit')).toBeDisabled()
254+
await page.getByTestId('tier-confirm-input').fill('PROMOT') // not yet
255+
await expect(page.getByTestId('tier-submit')).toBeDisabled()
256+
await page.getByTestId('tier-confirm-input').fill('PROMOTE')
257+
await expect(page.getByTestId('tier-submit')).toBeEnabled()
258+
259+
// Submit fires the POST; drawer refetches detail after onChanged().
260+
const tierReq = page.waitForRequest((req) =>
261+
req.url().endsWith(`/customers/t_founder/tier`) && req.method() === 'POST',
262+
)
263+
await page.getByTestId('tier-submit').click()
264+
const req = await tierReq
265+
expect(req.postDataJSON()).toMatchObject({
266+
tier: 'team',
267+
reason: 'founder demo upgrade',
268+
})
269+
270+
// Modal closes on success.
271+
await expect(page.getByTestId('tier-change-modal')).toHaveCount(0)
272+
})
273+
274+
// ─── 8. currency toggle (USD default, INR switch, localStorage persist) ──
275+
test('currency toggle defaults to USD, switches to INR, persists on reload', async ({
276+
page,
277+
}) => {
278+
await installAPIFake(page)
279+
await installAdminAPIFake(page)
280+
await page.goto(ADMIN_ROUTE)
281+
282+
// USD is the default — the founder row's MRR cell shows a $-prefixed value.
283+
const mrr = page.getByTestId('admin-customer-mrr-t_founder')
284+
await expect(mrr).toContainText('$')
285+
286+
// Switch to INR — every MRR cell flips to ₹.
287+
await page.getByTestId('admin-currency-INR').click()
288+
await expect(page.getByTestId('admin-currency-INR')).toHaveAttribute(
289+
'aria-pressed',
290+
'true',
291+
)
292+
await expect(mrr).toContainText('₹')
293+
294+
// Persisted in localStorage — write was synchronous on click.
295+
const stored = await page.evaluate(() =>
296+
localStorage.getItem('instant.admin.currency'),
297+
)
298+
expect(stored).toBe('INR')
299+
300+
// Reload — currency preference survives. The MRR cell is still ₹.
301+
await page.reload()
302+
await expect(
303+
page.getByTestId('admin-customer-row-t_founder'),
304+
).toBeVisible()
305+
await expect(
306+
page.getByTestId('admin-currency-INR'),
307+
).toHaveAttribute('aria-pressed', 'true')
308+
await expect(
309+
page.getByTestId('admin-customer-mrr-t_founder'),
310+
).toContainText('₹')
311+
})
312+
})

0 commit comments

Comments
 (0)