Skip to content

Commit c031fce

Browse files
test(e2e): mocked-contract Playwright gate for the ClaimPage conversion journey (#213)
Adds e2e/claim-conversion.spec.ts — a browser-level Playwright spec that drives the REAL ClaimPage route (/claim?t=) + REAL src/api client with the network mocked at the page.route() boundary, so it runs on every web PR (mocked config, VITE_NO_PROXY=1) without minting real resources. Fills the gap between the three existing claim layers without duplicating any: - ClaimPage.test.tsx (vitest) stubs the `../api` MODULE — never exercises the SPA→fetch wiring (URL/method/body, response→error mapping in call()). - live-claim-deploy.spec.ts drives the api-direct (request fixture), never renders ClaimPage, runs only on the scheduled/on-demand live suite. - auth-roundtrip.spec.ts covers the cookie-exchange seam, not claim UI. Coverage (10 tests): preview rendered from the upgrade token; malformed-token dead-end; empty-email client guard; email → POST /claim {jwt,email} → payment funnel; live HH:MM:SS countdown from the resource TTL; Hobby/Pro checkout CTA plan wiring + short_url redirect; inline checkout-failure (no redirect); and the claim error states (409 already_claimed, account_exists → error, no funnel). All 10 pass in mock mode (chromium). npm run gate green (tsc + build + 1147 vitest). No live-claim-deploy duplication; no real resources minted. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c786f07 commit c031fce

1 file changed

Lines changed: 390 additions & 0 deletions

File tree

e2e/claim-conversion.spec.ts

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
/* claim-conversion.spec.ts — mocked-contract Playwright gate for the ClaimPage
2+
* conversion journey (the anonymous→claimed→checkout money path), driven through
3+
* the REAL SPA route + REAL api client (src/api) with the network mocked at the
4+
* page.route() boundary.
5+
*
6+
* ── Why this exists alongside the tests we already have ───────────────────────
7+
* Three layers cover the claim flow today; this spec fills the one gap between
8+
* them, and is deliberately scoped NOT to duplicate any of them:
9+
*
10+
* 1. src/pages/ClaimPage.test.tsx (vitest + Testing Library) — mocks the
11+
* `../api` MODULE wholesale (claim/createCheckout/listResources are vi.fn()).
12+
* It proves the component's state machine, but the real fetch wiring (URL
13+
* shape, method, request body, the response→error mapping in api/index.ts
14+
* call()) is stubbed out. A rename of the /claim path or the checkout body
15+
* contract would NOT red that test.
16+
*
17+
* 2. e2e/live-claim-deploy.spec.ts (Playwright, LIVE cohort) — drives the REAL
18+
* api via the `request` fixture (api-direct: POST /claim, GET
19+
* /api/v1/resources). It never renders ClaimPage in a browser; it mints real
20+
* resources and only runs on the scheduled/on-demand live suite.
21+
*
22+
* 3. e2e/auth-roundtrip.spec.ts — the magic-link cookie-exchange seam, not the
23+
* claim conversion UI.
24+
*
25+
* THE GAP THIS SPEC CLOSES: the full ClaimPage conversion journey rendered in a
26+
* REAL browser against the REAL src/api client, with the network mocked — so it
27+
* runs on EVERY web PR (mocked playwright.config.ts, VITE_NO_PROXY=1, no minted
28+
* resources) and reds the PR if the SPA→api wiring breaks: the email→/claim POST
29+
* body, the post-claim PAT mint + resource fetch, the checkout POST + redirect,
30+
* AND the user-visible error states (409 already_claimed, account_exists, empty
31+
* email). This is the layer between "component logic with a stubbed module"
32+
* (layer 1) and "real backend, no UI" (layer 2).
33+
*
34+
* ── Mode ──────────────────────────────────────────────────────────────────────
35+
* Runs under the DEFAULT mocked config (playwright.config.ts), which boots the
36+
* Vite dev server with VITE_NO_PROXY=1. In dev mode getAPIBaseURL() returns ''
37+
* (same-origin), so every api call is http://localhost:5173/<path> and the
38+
* page.route() globs below intercept them. No upstream api is contacted.
39+
*/
40+
41+
import { expect, test, type Page, type Route } from '@playwright/test'
42+
43+
// ─── Constants (named, not scattered literals) ───────────────────────────────
44+
45+
const CLAIM_PATH = '**/claim'
46+
const RESOURCES_PATH = /\/api\/v1\/resources(\?[^/]*)?$/
47+
const API_KEYS_PATH = '**/api/v1/auth/api-keys'
48+
const CHECKOUT_PATH = '**/api/v1/billing/checkout'
49+
50+
const CLAIM_EMAIL = 'founder@example.com'
51+
const HOBBY_SHORT_URL = 'https://rzp.io/i/claim-hobby'
52+
const PRO_SHORT_URL = 'https://rzp.io/i/claim-pro'
53+
const SESSION_TOKEN = 'sess_jwt_from_claim'
54+
const PAT_KEY = 'ink_dashboard_session_pat'
55+
56+
// Error-state contract (mirrors api/internal/handlers/onboarding.go + the api
57+
// client's call() error mapping: code = body.error, message = body.message).
58+
const STATUS_CONFLICT = 409
59+
const STATUS_BAD_REQUEST = 400
60+
const ERR_ALREADY_CLAIMED = 'already_claimed'
61+
const ERR_ACCOUNT_EXISTS = 'account_exists'
62+
const MSG_ALREADY_CLAIMED = 'This claim link has already been used.'
63+
const MSG_ACCOUNT_EXISTS = 'An account already exists for this email. Sign in instead.'
64+
65+
// Resource expiry used to drive the post-claim countdown banner. ~23h so the
66+
// HH:MM:SS banner renders a real (non-placeholder) value.
67+
const EXPIRES_IN_MS = 23 * 60 * 60 * 1000
68+
69+
// ─── JWT minting (client-side preview is built by decoding ?t=, no network) ──
70+
//
71+
// ClaimPage.tsx decodeJWT() reads the `rt` (resource types) + `tok` arrays from
72+
// the JWT payload to render the preview list — it never verifies the signature
73+
// and never calls /claim/preview (that endpoint is exercised api-direct in
74+
// live-writes.spec.ts). So a structurally-valid base64url JWT with the right
75+
// payload is all we need; mirrors ClaimPage.test.tsx buildClaimJWT().
76+
function buildClaimJWT(
77+
rt: string[] = ['postgres', 'redis'],
78+
tok: string[] = ['abc12345xyz', 'def67890uvw'],
79+
): string {
80+
const b64url = (obj: unknown) =>
81+
Buffer.from(JSON.stringify(obj))
82+
.toString('base64')
83+
.replace(/\+/g, '-')
84+
.replace(/\//g, '_')
85+
.replace(/=+$/, '')
86+
const header = b64url({ alg: 'HS256', typ: 'JWT' })
87+
const payload = b64url({ rt, tok, exp: Math.floor(Date.now() / 1000) + 3600 })
88+
return `${header}.${payload}.sig`
89+
}
90+
91+
// ─── Network mocks ───────────────────────────────────────────────────────────
92+
93+
/** A successful POST /claim → session minted. Captures the request body so the
94+
* test can assert the {jwt,email} contract the SPA actually sends. */
95+
async function mockClaimSuccess(page: Page, captured: { body?: any }) {
96+
await page.route(CLAIM_PATH, (route: Route) => {
97+
if (route.request().method() !== 'POST') return route.continue()
98+
captured.body = JSON.parse(route.request().postData() ?? '{}')
99+
return route.fulfill({
100+
status: 201,
101+
contentType: 'application/json',
102+
body: JSON.stringify({
103+
ok: true,
104+
team_id: 'team_claimed_1',
105+
user_id: 'user_claimed_1',
106+
session_token: SESSION_TOKEN,
107+
}),
108+
})
109+
})
110+
}
111+
112+
/** A failing POST /claim with a specific error contract (409 already_claimed,
113+
* 400 account_exists, …). */
114+
async function mockClaimError(page: Page, status: number, error: string, message: string) {
115+
await page.route(CLAIM_PATH, (route: Route) => {
116+
if (route.request().method() !== 'POST') return route.continue()
117+
return route.fulfill({
118+
status,
119+
contentType: 'application/json',
120+
body: JSON.stringify({ ok: false, error, message }),
121+
})
122+
})
123+
}
124+
125+
/** POST /api/v1/auth/api-keys — the post-claim PAT mint (best-effort in the
126+
* page; we return a real key so the happy path exercises the success branch). */
127+
async function mockAPIKeyMint(page: Page) {
128+
await page.route(API_KEYS_PATH, (route: Route) => {
129+
if (route.request().method() !== 'POST') return route.continue()
130+
return route.fulfill({
131+
status: 201,
132+
contentType: 'application/json',
133+
body: JSON.stringify({
134+
ok: true,
135+
id: 'pat_1',
136+
name: 'dashboard-session',
137+
scopes: ['read', 'write'],
138+
created_at: new Date().toISOString(),
139+
last_used_at: null,
140+
revoked: false,
141+
key: PAT_KEY,
142+
note: 'Save this key now — it will not be shown again.',
143+
}),
144+
})
145+
})
146+
}
147+
148+
/** GET /api/v1/resources — drives the post-claim countdown banner. Two TTL
149+
* resources, soonest ~23h out. */
150+
async function mockResources(page: Page) {
151+
await page.route(RESOURCES_PATH, (route: Route) => {
152+
if (route.request().method() !== 'GET') return route.continue()
153+
return route.fulfill({
154+
status: 200,
155+
contentType: 'application/json',
156+
body: JSON.stringify({
157+
ok: true,
158+
total: 2,
159+
items: [
160+
{
161+
id: 'res_pg',
162+
token: 'tok_pg',
163+
resource_type: 'postgres',
164+
tier: 'anonymous',
165+
status: 'active',
166+
name: null,
167+
env: 'production',
168+
storage_bytes: 0,
169+
storage_limit_bytes: 1024 * 1024 * 10,
170+
storage_exceeded: false,
171+
expires_at: new Date(Date.now() + EXPIRES_IN_MS).toISOString(),
172+
created_at: new Date().toISOString(),
173+
},
174+
{
175+
id: 'res_redis',
176+
token: 'tok_redis',
177+
resource_type: 'redis',
178+
tier: 'anonymous',
179+
status: 'active',
180+
name: null,
181+
env: 'production',
182+
storage_bytes: 0,
183+
storage_limit_bytes: 1024 * 1024 * 5,
184+
storage_exceeded: false,
185+
expires_at: new Date(Date.now() + EXPIRES_IN_MS + 60_000).toISOString(),
186+
created_at: new Date().toISOString(),
187+
},
188+
],
189+
}),
190+
})
191+
})
192+
}
193+
194+
/** POST /api/v1/billing/checkout → a Razorpay short_url. Captures the plan so
195+
* the test asserts the Hobby vs Pro CTA wiring. */
196+
async function mockCheckout(page: Page, shortURL: string, captured: { plan?: string }) {
197+
await page.route(CHECKOUT_PATH, (route: Route) => {
198+
if (route.request().method() !== 'POST') return route.continue()
199+
captured.plan = JSON.parse(route.request().postData() ?? '{}').plan
200+
return route.fulfill({
201+
status: 200,
202+
contentType: 'application/json',
203+
body: JSON.stringify({ ok: true, short_url: shortURL }),
204+
})
205+
})
206+
}
207+
208+
/** Wire the full happy-path backend (claim + PAT + resources). */
209+
async function mockHappyBackend(page: Page, captured: { body?: any }) {
210+
await mockResources(page)
211+
await mockAPIKeyMint(page)
212+
await mockClaimSuccess(page, captured)
213+
}
214+
215+
async function gotoClaim(page: Page, jwt: string = buildClaimJWT()) {
216+
await page.goto(`/claim?t=${encodeURIComponent(jwt)}`)
217+
}
218+
219+
async function submitEmail(page: Page, email: string = CLAIM_EMAIL) {
220+
await page.getByTestId('claim-email').fill(email)
221+
await page.getByTestId('claim-submit').click()
222+
}
223+
224+
// ─── Pre-claim: token preview rendered from the JWT (no network) ─────────────
225+
226+
test.describe('ClaimPage conversion (mocked contract) — preview + email entry', () => {
227+
test('renders the preview list of resources parsed from the upgrade token', async ({ page }) => {
228+
await gotoClaim(page, buildClaimJWT(['postgres', 'redis', 'mongodb']))
229+
const preview = page.getByTestId('claim-preview')
230+
await expect(preview).toBeVisible()
231+
await expect(preview).toContainText('postgres')
232+
await expect(preview).toContainText('redis')
233+
await expect(preview).toContainText('mongodb')
234+
// The email form is the entry point of the conversion.
235+
await expect(page.getByTestId('claim-email')).toBeVisible()
236+
await expect(page.getByTestId('claim-submit')).toBeVisible()
237+
})
238+
239+
test('a malformed token surfaces the invalid-link state with a pricing CTA', async ({ page }) => {
240+
await page.goto('/claim?t=not-a-valid-jwt-blob')
241+
await expect(page.getByTestId('claim-invalid')).toBeVisible()
242+
await expect(page.getByTestId('claim-invalid')).toContainText(/invalid or expired/i)
243+
const pricing = page.getByTestId('claim-invalid-pricing')
244+
await expect(pricing).toHaveAttribute('href', '/pricing')
245+
// No email form on the dead-end state.
246+
await expect(page.getByTestId('claim-email')).toHaveCount(0)
247+
})
248+
249+
test('empty email is rejected client-side without firing POST /claim', async ({ page }) => {
250+
let claimFired = false
251+
await page.route(CLAIM_PATH, (route: Route) => {
252+
claimFired = true
253+
return route.fulfill({ status: 201, contentType: 'application/json', body: '{}' })
254+
})
255+
await gotoClaim(page)
256+
await page.getByTestId('claim-submit').click()
257+
await expect(page.getByTestId('claim-error')).toContainText(/email is required/i)
258+
expect(claimFired).toBe(false)
259+
})
260+
})
261+
262+
// ─── The conversion: email → /claim → funnel → checkout ──────────────────────
263+
264+
test.describe('ClaimPage conversion (mocked contract) — claim → checkout journey', () => {
265+
test('email submit POSTs {jwt,email} to /claim and lands on the payment funnel', async ({
266+
page,
267+
}) => {
268+
const captured: { body?: any } = {}
269+
await mockHappyBackend(page, captured)
270+
await gotoClaim(page)
271+
await submitEmail(page)
272+
273+
// Funnel mounts — the conversion's success navigation (no dashboard redirect:
274+
// pay-from-day-one funnels to checkout before the resources are permanent).
275+
await expect(page.getByTestId('claim-funnel')).toBeVisible()
276+
await expect(page.getByTestId('claim-checkout-hobby')).toBeVisible()
277+
await expect(page.getByTestId('claim-checkout-pro')).toBeVisible()
278+
279+
// The SPA→api request body contract: {jwt, email}. This is what the unit
280+
// test cannot assert (it stubs the module), and what a /claim rename or a
281+
// body-field rename would break.
282+
expect(captured.body?.email).toBe(CLAIM_EMAIL)
283+
expect(typeof captured.body?.jwt).toBe('string')
284+
expect((captured.body?.jwt as string).length).toBeGreaterThan(0)
285+
})
286+
287+
test('post-claim countdown banner renders a live HH:MM:SS from the resource TTL', async ({
288+
page,
289+
}) => {
290+
const captured: { body?: any } = {}
291+
await mockHappyBackend(page, captured)
292+
await gotoClaim(page)
293+
await submitEmail(page)
294+
await expect(page.getByTestId('claim-funnel')).toBeVisible()
295+
// Soonest expiry ~23h → HH:MM:SS in the 22–23h band. Poll: the value is set
296+
// by a useEffect tick that fires after the funnel paints.
297+
await expect
298+
.poll(async () => (await page.getByTestId('claim-countdown-value').textContent()) ?? '', {
299+
timeout: 5000,
300+
})
301+
.toMatch(/^2[23]:\d{2}:\d{2}$/)
302+
})
303+
304+
test('Hobby CTA calls checkout with plan="hobby" and redirects to the short_url', async ({
305+
page,
306+
}) => {
307+
const claimCap: { body?: any } = {}
308+
const checkoutCap: { plan?: string } = {}
309+
await mockHappyBackend(page, claimCap)
310+
await mockCheckout(page, HOBBY_SHORT_URL, checkoutCap)
311+
await gotoClaim(page)
312+
await submitEmail(page)
313+
await expect(page.getByTestId('claim-checkout-hobby')).toBeVisible()
314+
315+
// The page sets window.location.href to the short_url — assert the
316+
// navigation contract via waitForURL rather than racing the redirect.
317+
await Promise.all([
318+
page.waitForURL(HOBBY_SHORT_URL),
319+
page.getByTestId('claim-checkout-hobby').click(),
320+
])
321+
expect(checkoutCap.plan).toBe('hobby')
322+
})
323+
324+
test('Pro CTA calls checkout with plan="pro" and redirects to the short_url', async ({
325+
page,
326+
}) => {
327+
const claimCap: { body?: any } = {}
328+
const checkoutCap: { plan?: string } = {}
329+
await mockHappyBackend(page, claimCap)
330+
await mockCheckout(page, PRO_SHORT_URL, checkoutCap)
331+
await gotoClaim(page)
332+
await submitEmail(page)
333+
await expect(page.getByTestId('claim-checkout-pro')).toBeVisible()
334+
335+
await Promise.all([
336+
page.waitForURL(PRO_SHORT_URL),
337+
page.getByTestId('claim-checkout-pro').click(),
338+
])
339+
expect(checkoutCap.plan).toBe('pro')
340+
})
341+
342+
test('a checkout failure surfaces inline and keeps the user on the funnel (no redirect)', async ({
343+
page,
344+
}) => {
345+
const claimCap: { body?: any } = {}
346+
await mockHappyBackend(page, claimCap)
347+
await page.route(CHECKOUT_PATH, (route: Route) =>
348+
route.fulfill({
349+
status: 502,
350+
contentType: 'application/json',
351+
body: JSON.stringify({ ok: false, error: 'razorpay_error', message: 'upstream timeout' }),
352+
}),
353+
)
354+
await gotoClaim(page)
355+
await submitEmail(page)
356+
await page.getByTestId('claim-checkout-hobby').click()
357+
await expect(page.getByTestId('claim-checkout-error')).toBeVisible()
358+
await expect(page.getByTestId('claim-checkout-error')).toContainText('upstream timeout')
359+
// Still on /claim, funnel still mounted, CTA re-enabled for retry.
360+
await expect(page).toHaveURL(/\/claim/)
361+
await expect(page.getByTestId('claim-checkout-hobby')).toBeEnabled()
362+
})
363+
})
364+
365+
// ─── Error states: the contract the api emits on a failed claim ──────────────
366+
367+
test.describe('ClaimPage conversion (mocked contract) — claim error states', () => {
368+
test('409 already_claimed keeps the user on the email screen with the error', async ({
369+
page,
370+
}) => {
371+
await mockClaimError(page, STATUS_CONFLICT, ERR_ALREADY_CLAIMED, MSG_ALREADY_CLAIMED)
372+
await gotoClaim(page)
373+
await submitEmail(page)
374+
await expect(page.getByTestId('claim-error')).toBeVisible()
375+
await expect(page.getByTestId('claim-error')).toContainText(MSG_ALREADY_CLAIMED)
376+
// The funnel must NOT mount on a failed claim — no session was minted.
377+
await expect(page.getByTestId('claim-funnel')).toHaveCount(0)
378+
})
379+
380+
test('account_exists (email already registered) surfaces the error, no funnel', async ({
381+
page,
382+
}) => {
383+
await mockClaimError(page, STATUS_BAD_REQUEST, ERR_ACCOUNT_EXISTS, MSG_ACCOUNT_EXISTS)
384+
await gotoClaim(page)
385+
await submitEmail(page, 'taken@example.com')
386+
await expect(page.getByTestId('claim-error')).toBeVisible()
387+
await expect(page.getByTestId('claim-error')).toContainText(MSG_ACCOUNT_EXISTS)
388+
await expect(page.getByTestId('claim-funnel')).toHaveCount(0)
389+
})
390+
})

0 commit comments

Comments
 (0)