/** * checkout-stripe.spec.ts — Stripe Payment Element + checkout submit flow tests. * * Covers: * - Stripe.js script presence and NEURON_CFG shape * - submit-btn starts disabled; enabled after Stripe element is ready * - payment-message div for error display * - Founding: attestation checkbox + attest-warn guard * - Professional: charge timing radio buttons (now/later) * - buyer-name + buyer-email validation on submit * - Mocked full payment flow: /api/payment-intent + mock Stripe.js * - Setup mode (professional, timing=later): label switches to "Save my card" * - Decline handling: payment-message shows Stripe error * - /api/payment-intent endpoint contracts * - /api/link-customer endpoint exists and handles requests * - /api/attest endpoint (founding plan) * - Success redirect target is /account?welcome=1 * * Stripe mocking strategy: * addInitScript() injects window.Stripe BEFORE the page loads so checkout-stripe.js * picks it up. We also intercept /api/payment-intent to return a fake client_secret. * This lets us test DOM transitions, validation, and submit flow without real keys. * * For real test-card tests (4242...) the page must have a valid pk_test_ key. * Those tests are marked with [stripe-live] and are skipped when STRIPE_LIVE is not set. */ import { test, expect, type Page } from '@playwright/test'; const STRIPE_LIVE = process.env.STRIPE_LIVE === '1'; // ─── Mock helpers ───────────────────────────────────────────────────────────── /** Inject a mock window.Stripe before the page loads */ async function injectMockStripe(page: Page, opts: { confirmResult?: { error?: { message: string } }; declineMessage?: string; } = {}) { // Block the real Stripe CDN so it cannot override the addInitScript mock await page.route('https://js.stripe.com/**', (route) => route.abort()); await page.addInitScript((o) => { (window as any).Stripe = function (_key: string) { const confirmResult = o.declineMessage ? { error: { message: o.declineMessage } } : (o.confirmResult ?? {}); return { elements: function () { return { create: function (_type: string) { return { mount: function (selector: string) { const container = document.querySelector(selector); if (container) { container.innerHTML = '
Mock payment element
'; } // Fire 'ready' via the saved cb setTimeout(() => { const btn = document.getElementById('submit-btn'); if (btn) btn.disabled = false; const ld = document.querySelector('.checkout-element-loading'); if (ld) ld.remove(); }, 100); }, unmount: function () {}, on: function (event: string, cb: () => void) { if (event === 'ready') setTimeout(cb, 100); }, }; }, }; }, confirmPayment: function () { return Promise.resolve(confirmResult); }, confirmSetup: function () { return Promise.resolve(confirmResult); }, }; }; }, opts); } /** Mock /api/payment-intent to return a fake client_secret */ async function mockPaymentIntent(page: Page, overrides: Record = {}) { await page.route('/api/payment-intent', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ client_secret: 'pi_test_fake_secret_playwright_123', id: 'pi_test_fake_playwright_123', plan: 'professional', ...overrides, }), }) ); } async function mockPaymentIntentSetupMode(page: Page) { await page.route('/api/payment-intent', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ client_secret: 'seti_test_fake_secret_playwright_123', id: 'seti_test_fake_playwright_123', plan: 'professional', setup_mode: true, }), }) ); } async function mockSupabaseConfig(page: Page) { await page.route('/api/supabase-config', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ url: 'https://xyzfaketest.supabase.co', anon_key: 'fake-key' }), }) ); // Supabase getUser() call on no-session returns 401 so the else branch runs: // "for paid plans, call window.initStripe('', '')" immediately. await page.route('https://xyzfaketest.supabase.co/auth/v1/user', (route) => route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: 'not_authenticated' }), }) ); } // ─── Page structure — Stripe-specific ───────────────────────────────────────── test('[professional] Stripe.js script tag present in page', async ({ page }) => { await page.goto('/checkout?plan=professional'); const stripeScript = page.locator('script[src*="stripe.com"]'); await expect(stripeScript).toBeAttached(); }); test('[founding] Stripe.js script tag present in page', async ({ page }) => { await page.goto('/checkout?plan=founding'); const stripeScript = page.locator('script[src*="stripe.com"]'); await expect(stripeScript).toBeAttached(); }); test('[free] Stripe.js is loaded (used for age verification SetupIntent)', async ({ page }) => { // Free plan now creates a SetupIntent for age verification await page.goto('/checkout?plan=free'); const stripeScript = page.locator('script[src*="stripe.com"]'); await expect(stripeScript).toBeAttached(); }); test('[professional] NEURON_CFG.plan is set to "professional"', async ({ page }) => { await page.goto('/checkout?plan=professional'); const plan = await page.evaluate(() => (window as any).NEURON_CFG?.plan); expect(plan).toBe('professional'); }); test('[founding] NEURON_CFG.plan is set to "founding"', async ({ page }) => { await page.goto('/checkout?plan=founding'); const plan = await page.evaluate(() => (window as any).NEURON_CFG?.plan); expect(plan).toBe('founding'); }); test('[professional] NEURON_CFG.pub_key is present (may be empty if unconfigured)', async ({ page }) => { await page.goto('/checkout?plan=professional'); const cfg = await page.evaluate(() => (window as any).NEURON_CFG); expect(cfg).not.toBeNull(); expect('pub_key' in cfg).toBeTruthy(); }); test('[professional] submit-btn starts disabled', async ({ page }) => { await page.goto('/checkout?plan=professional'); const btn = page.locator('#submit-btn'); await expect(btn).toBeAttached(); // Before Stripe initialises, button is disabled const isDisabled = await btn.getAttribute('disabled'); expect(isDisabled).not.toBeNull(); }); test('[professional] payment-message div starts hidden', async ({ page }) => { await page.goto('/checkout?plan=professional'); await expect(page.locator('#payment-message')).toBeHidden(); }); test('[professional] buyer-name input is present and fillable', async ({ page }) => { await page.goto('/checkout?plan=professional'); await expect(page.locator('#buyer-name')).toBeAttached(); await page.fill('#buyer-name', 'Test User'); expect(await page.locator('#buyer-name').inputValue()).toBe('Test User'); }); test('[professional] buyer-email input is present and fillable', async ({ page }) => { await page.goto('/checkout?plan=professional'); await expect(page.locator('#buyer-email')).toBeAttached(); await page.fill('#buyer-email', 'test@example.com'); expect(await page.locator('#buyer-email').inputValue()).toBe('test@example.com'); }); // ─── Founding-specific ──────────────────────────────────────────────────────── test('[founding] attestation checkbox is present', async ({ page }) => { await page.goto('/checkout?plan=founding'); await expect(page.locator('#founding-attest-cb')).toBeAttached(); }); test('[founding] attestation checkbox starts unchecked', async ({ page }) => { await page.goto('/checkout?plan=founding'); const checked = await page.locator('#founding-attest-cb').isChecked(); expect(checked).toBe(false); }); test('[founding] attest-warn div is present (shown on submit without checking)', async ({ page }) => { await page.goto('/checkout?plan=founding'); await expect(page.locator('#attest-warn')).toBeAttached(); await expect(page.locator('#attest-warn')).toBeHidden(); }); test('[founding] attestation text contains expected copy', async ({ page }) => { await page.goto('/checkout?plan=founding'); const attestText = (await page.locator('#founding-attestation').textContent()) ?? ''; expect(attestText).toContain('good faith'); expect(attestText.toLowerCase()).toContain('founding member'); }); test('[founding] submit without attestation shows attest-warn', async ({ page }) => { await mockSupabaseConfig(page); await mockPaymentIntent(page, { plan: 'founding' }); await injectMockStripe(page); await page.goto('/checkout?plan=founding'); // Wait for Stripe mock to enable the submit button await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 5000 }); await page.fill('#buyer-name', 'Test Founder'); await page.fill('#buyer-email', 'founder@example.com'); // Do NOT check the attestation checkbox await page.locator('#payment-form').dispatchEvent('submit'); await expect(page.locator('#attest-warn')).toBeVisible({ timeout: 3000 }); }); test('[founding] submit WITH attestation does not show attest-warn', async ({ page }) => { await mockSupabaseConfig(page); await mockPaymentIntent(page, { plan: 'founding' }); await injectMockStripe(page); // Mock attest endpoint await page.route('/api/attest', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' }) ); // Mock link-customer await page.route('/api/link-customer', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: '{"linked":true}' }) ); await page.goto('/checkout?plan=founding'); await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 5000 }); await page.fill('#buyer-name', 'Test Founder'); await page.fill('#buyer-email', 'founder@example.com'); await page.locator('#founding-attest-cb').check(); await page.locator('#payment-form').dispatchEvent('submit'); // attest-warn should NOT appear await page.waitForTimeout(500); await expect(page.locator('#attest-warn')).toBeHidden(); }); // ─── Professional charge timing ─────────────────────────────────────────────── test('[professional] charge timing section is present', async ({ page }) => { await page.goto('/checkout?plan=professional'); await expect(page.locator('#timing-now')).toBeAttached(); await expect(page.locator('#timing-later')).toBeAttached(); }); test('[professional] "charge now" radio is selected by default', async ({ page }) => { await page.goto('/checkout?plan=professional'); expect(await page.locator('#timing-now').isChecked()).toBe(true); expect(await page.locator('#timing-later').isChecked()).toBe(false); }); test('[professional] selecting "later" changes radio state', async ({ page }) => { await page.goto('/checkout?plan=professional'); await page.locator('#timing-later').check(); expect(await page.locator('#timing-later').isChecked()).toBe(true); expect(await page.locator('#timing-now').isChecked()).toBe(false); }); test('[professional] setup_mode label shows "Save my card" text', async ({ page }) => { await mockSupabaseConfig(page); await mockPaymentIntentSetupMode(page); await injectMockStripe(page); await page.goto('/checkout?plan=professional'); // initStripe is called by checkout-auth.el when no session → immediately for paid plans // Wait for the submit label to update await page.waitForFunction( () => { const el = document.getElementById('submit-label'); return el && el.textContent && el.textContent.toLowerCase().includes('save'); }, { timeout: 6000 } ); const labelText = (await page.locator('#submit-label').textContent()) ?? ''; expect(labelText.toLowerCase()).toContain('save'); }); test('[founding] no charge timing section (one-time payment only)', async ({ page }) => { await page.goto('/checkout?plan=founding'); const timingNow = page.locator('#timing-now'); const count = await timingNow.count(); expect(count).toBe(0); }); // ─── Mocked payment flow — full Stripe mock ─────────────────────────────────── test('[professional] Stripe mock: payment element mounts after initStripe', async ({ page }) => { await mockSupabaseConfig(page); await mockPaymentIntent(page); await injectMockStripe(page); await page.goto('/checkout?plan=professional'); // After initStripe() runs (checkout-auth triggers it immediately for paid plans with no session) await expect(page.locator('#stripe-mock-mounted')).toBeAttached({ timeout: 8000 }); }); test('[professional] Stripe mock: submit-btn enabled after element ready', async ({ page }) => { await mockSupabaseConfig(page); await mockPaymentIntent(page); await injectMockStripe(page); await page.goto('/checkout?plan=professional'); await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 8000 }); }); test('[professional] submit without name shows error message', async ({ page }) => { await mockSupabaseConfig(page); await mockPaymentIntent(page); await injectMockStripe(page); await page.goto('/checkout?plan=professional'); await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 8000 }); // Fill email only, no name await page.fill('#buyer-email', 'test@example.com'); await page.locator('#payment-form').dispatchEvent('submit'); const msg = page.locator('#payment-message'); await expect(msg).toBeVisible({ timeout: 3000 }); const text = (await msg.textContent()) ?? ''; expect(text.toLowerCase()).toMatch(/name|email/); }); test('[professional] submit without email shows error message', async ({ page }) => { await mockSupabaseConfig(page); await mockPaymentIntent(page); await injectMockStripe(page); await page.goto('/checkout?plan=professional'); await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 8000 }); // Fill name only, no email await page.fill('#buyer-name', 'Test User'); await page.locator('#payment-form').dispatchEvent('submit'); const msg = page.locator('#payment-message'); await expect(msg).toBeVisible({ timeout: 3000 }); }); test('[professional] Stripe decline: payment-message shows decline text', async ({ page }) => { await mockSupabaseConfig(page); await mockPaymentIntent(page); await injectMockStripe(page, { declineMessage: 'Your card was declined.' }); await page.route('/api/link-customer', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: '{"linked":true}' }) ); await page.goto('/checkout?plan=professional'); await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 8000 }); await page.fill('#buyer-name', 'Test Buyer'); await page.fill('#buyer-email', 'buyer@example.com'); await page.locator('#payment-form').dispatchEvent('submit'); const msg = page.locator('#payment-message'); await expect(msg).toBeVisible({ timeout: 5000 }); const text = (await msg.textContent()) ?? ''; expect(text.toLowerCase()).toMatch(/declined|failed|error|card/); }); test('[professional] successful payment: submit-btn shows spinner then loading state', async ({ page }) => { await mockSupabaseConfig(page); await mockPaymentIntent(page); await injectMockStripe(page, { confirmResult: {} }); // no error = success → redirect await page.route('/api/link-customer', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: '{"linked":true}' }) ); // Intercept the redirect to /account await page.route('**/account**', (route) => route.fulfill({ status: 200, body: 'ok' })); await page.goto('/checkout?plan=professional'); await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 8000 }); await page.fill('#buyer-name', 'Test Buyer'); await page.fill('#buyer-email', 'buyer@example.com'); // Verify loading state is triggered on submit const submitBtn = page.locator('#submit-btn'); await page.locator('#payment-form').dispatchEvent('submit'); // setLoading(true) disables the button — verify it transitions await expect(submitBtn).toBeDisabled({ timeout: 2000 }).catch(() => { // May redirect before we can check — that's also success }); }); // ─── /api/payment-intent endpoint contracts ─────────────────────────────────── test('POST /api/payment-intent free plan returns setup_mode (age verification)', async ({ request }) => { const res = await request.post('/api/payment-intent', { data: JSON.stringify({ plan: 'free', email: 'test@example.com' }), headers: { 'Content-Type': 'application/json' }, }); // Free plan creates a SetupIntent for age verification — must not 500 expect(res.status()).toBeLessThan(500); if (res.status() === 200) { const body = await res.json(); expect('setup_mode' in body || 'client_secret' in body || 'error' in body).toBeTruthy(); expect(body.no_payment_required).toBeFalsy(); } }); test('POST /api/payment-intent professional returns client_secret or stripe error (not 500)', async ({ request }) => { const res = await request.post('/api/payment-intent', { data: JSON.stringify({ plan: 'professional', email: 'test@example.com', name: 'Test', timing: 'now' }), headers: { 'Content-Type': 'application/json' }, }); expect(res.status()).toBeLessThan(500); if (res.status() === 200) { const body = await res.json(); expect('client_secret' in body || 'error' in body || 'setup_mode' in body).toBeTruthy(); } }); test('POST /api/payment-intent professional timing=later returns setup_mode flag', async ({ request }) => { const res = await request.post('/api/payment-intent', { data: JSON.stringify({ plan: 'professional', email: 'test@example.com', name: 'Test', timing: 'later' }), headers: { 'Content-Type': 'application/json' }, }); expect(res.status()).toBeLessThan(500); if (res.status() === 200) { const body = await res.json(); if ('client_secret' in body) { // Stripe configured: setup_mode should be true for timing=later expect(body.setup_mode).toBeTruthy(); } } }); test('POST /api/payment-intent founding returns client_secret or error (not 500)', async ({ request }) => { const res = await request.post('/api/payment-intent', { data: JSON.stringify({ plan: 'founding', email: 'test@example.com', name: 'Founder' }), headers: { 'Content-Type': 'application/json' }, }); expect(res.status()).toBeLessThan(500); }); test('POST /api/payment-intent empty body returns 4xx not 500', async ({ request }) => { const res = await request.post('/api/payment-intent', { data: {} }); expect(res.status()).toBeLessThan(500); }); // ─── /api/link-customer endpoint ───────────────────────────────────────────── test('POST /api/link-customer exists and handles request (not 404/500)', async ({ request }) => { const res = await request.post('/api/link-customer', { data: JSON.stringify({ pi_id: 'pi_test_fake', email: 'test@example.com', name: 'Test User', plan: 'professional', timing: 'now', mode: 'payment', supabase_user_id: '', }), headers: { 'Content-Type': 'application/json' }, }); // Should exist and not 500 expect(res.status()).not.toBe(404); expect(res.status()).toBeLessThan(500); }); // ─── /api/attest endpoint (founding) ───────────────────────────────────────── test('POST /api/attest founding exists and handles request (not 500)', async ({ request }) => { const res = await request.post('/api/attest', { data: JSON.stringify({ plan: 'founding', name: 'Test Founder', email: 'founder@example.com', timestamp: new Date().toISOString(), attestation: 'I am joining as a genuine early user...', user_agent: 'Playwright/Test', }), headers: { 'Content-Type': 'application/json' }, }); expect(res.status()).toBeLessThan(500); }); // ─── /api/founding-count ────────────────────────────────────────────────────── test('GET /api/founding-count returns remaining + sold + total', async ({ request }) => { const res = await request.get('/api/founding-count'); expect(res.status()).toBe(200); const body = await res.json(); expect(typeof body.remaining === 'number' || 'remaining' in body).toBeTruthy(); }); test('GET /api/founding-count: remaining is <= 1000', async ({ request }) => { const res = await request.get('/api/founding-count'); if (res.status() === 200) { const body = await res.json(); if (typeof body.remaining === 'number') { expect(body.remaining).toBeLessThanOrEqual(1000); expect(body.remaining).toBeGreaterThanOrEqual(0); } } }); // ─── Sold-out guard ─────────────────────────────────────────────────────────── test('[founding] payment-intent sold_out disables submit with sold-out message', async ({ page }) => { await mockSupabaseConfig(page); await page.route('/api/payment-intent', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ error: 'sold_out' }), }) ); await injectMockStripe(page); await page.goto('/checkout?plan=founding'); // Wait for sold_out message to appear await page.waitForFunction( () => { const msg = document.getElementById('payment-message'); return msg && msg.style.display !== 'none' && msg.textContent && msg.textContent.includes('spot'); }, { timeout: 8000 } ); const msg = page.locator('#payment-message'); await expect(msg).toBeVisible(); const text = (await msg.textContent()) ?? ''; expect(text.toLowerCase()).toMatch(/sold out|spot|founding|professional/); // Submit button should be disabled const btn = page.locator('#submit-btn'); const isDisabled = await btn.getAttribute('disabled'); expect(isDisabled).not.toBeNull(); }); // ─── Live Stripe test-card tests (requires STRIPE_LIVE=1) ───────────────────── // These only run when the stage has a real pk_test_ key and Stripe is reachable. test.describe('Stripe live test-card flows', () => { test.skip(!STRIPE_LIVE, 'Set STRIPE_LIVE=1 to run these against a configured test-mode stage'); test('[professional] test card 4242 redirects to /account?welcome=1', async ({ page }) => { await page.goto('/checkout?plan=professional'); // Wait for Stripe payment element iframe to mount const stripeFrame = page.frameLocator('iframe[title*="Secure payment"]'); await expect(stripeFrame.locator('[placeholder*="1234"]')).toBeVisible({ timeout: 15000 }); await page.fill('#buyer-name', 'Playwright Tester'); await page.fill('#buyer-email', 'playwright@neurontest.invalid'); // Fill card details inside Stripe iframe await stripeFrame.locator('[placeholder*="1234"]').fill('4242424242424242'); await stripeFrame.locator('[placeholder="MM / YY"]').fill('12 / 30'); await stripeFrame.locator('[placeholder="CVC"]').fill('123'); await stripeFrame.locator('[placeholder="ZIP"]').fill('10001').catch(() => {}); // optional field await page.locator('#submit-btn').click(); await page.waitForURL('**/account**', { timeout: 30000 }); expect(page.url()).toContain('welcome=1'); }); test('[professional] test card 4000 0000 0000 0002 (decline) shows error', async ({ page }) => { await page.goto('/checkout?plan=professional'); const stripeFrame = page.frameLocator('iframe[title*="Secure payment"]'); await expect(stripeFrame.locator('[placeholder*="1234"]')).toBeVisible({ timeout: 15000 }); await page.fill('#buyer-name', 'Declined User'); await page.fill('#buyer-email', 'declined@neurontest.invalid'); await stripeFrame.locator('[placeholder*="1234"]').fill('4000000000000002'); await stripeFrame.locator('[placeholder="MM / YY"]').fill('12 / 30'); await stripeFrame.locator('[placeholder="CVC"]').fill('123'); await page.locator('#submit-btn').click(); const msg = page.locator('#payment-message'); await expect(msg).toBeVisible({ timeout: 15000 }); const text = (await msg.textContent()) ?? ''; expect(text.toLowerCase()).toMatch(/declined|failed|card/); }); test('[founding] test card 4242 + attestation → redirect to /account', async ({ page }) => { await page.goto('/checkout?plan=founding'); const stripeFrame = page.frameLocator('iframe[title*="Secure payment"]'); await expect(stripeFrame.locator('[placeholder*="1234"]')).toBeVisible({ timeout: 15000 }); await page.fill('#buyer-name', 'Founder Playwright'); await page.fill('#buyer-email', 'founder@neurontest.invalid'); await page.locator('#founding-attest-cb').check(); await stripeFrame.locator('[placeholder*="1234"]').fill('4242424242424242'); await stripeFrame.locator('[placeholder="MM / YY"]').fill('12 / 30'); await stripeFrame.locator('[placeholder="CVC"]').fill('123'); await page.locator('#submit-btn').click(); await page.waitForURL('**/account**', { timeout: 30000 }); expect(page.url()).toContain('welcome=1'); }); });