diff --git a/tests/e2e/checkout-flows.spec.ts b/tests/e2e/checkout-flows.spec.ts new file mode 100644 index 0000000..277dafa --- /dev/null +++ b/tests/e2e/checkout-flows.spec.ts @@ -0,0 +1,580 @@ +/** + * checkout-flows.spec.ts — Comprehensive checkout + auth flow tests. + * + * Covers: + * - All three plan variants (free, professional, founding) + * - Page structure, pricing, features list, noindex, canonical + * - Auth section / payment section initial visibility per plan + * - Form validation (empty fields, short password) + * - Sign in / sign up toggle + * - Mocked auth flows: sign-up success, email-confirm-required, + * existing session, sign-in error + * - DOM transitions: auth-section hidden → payment/free-success shown + * - Auth badge rendered with user name after auth + * - buyer-email pre-filled from Supabase user object + * - /api/checkout endpoint response shapes + * - /api/supabase-config CORS enforcement + * - Edge cases: unknown plan, no plan param + * + * Network mocking strategy: Playwright route() intercepts + * - GET /api/supabase-config → returns fake Supabase URL + anon key + * - GET /auth/v1/user → no session or mock user + * - POST /auth/v1/signup → success or email-confirm + * - POST /auth/v1/token → sign-in success or error + * This lets us test full JS-driven DOM transitions without real credentials. + */ + +import { test, expect, type Page } from '@playwright/test'; + +// ─── Mock helpers ──────────────────────────────────────────────────────────── + +const FAKE_SUPA_URL = 'https://xyzfaketest.supabase.co'; +const FAKE_ANON_KEY = 'fake-anon-key-for-playwright-testing'; + +const MOCK_USER = { + id: 'test-uid-playwright-001', + email: 'playwright@example.com', + user_metadata: { full_name: 'Playwright Tester' }, +}; + +const MOCK_SESSION = { + access_token: 'fake-access-token-playwright', + refresh_token: 'fake-refresh-token-playwright', + token_type: 'bearer', + expires_in: 3600, + user: MOCK_USER, +}; + +async function mockSupabaseConfig(page: Page) { + await page.route('/api/supabase-config', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ url: FAKE_SUPA_URL, anon_key: FAKE_ANON_KEY }), + }) + ); +} + +async function mockNoSession(page: Page) { + await page.route(`${FAKE_SUPA_URL}/auth/v1/user`, (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: 'not_authenticated', message: 'JWT expired' }), + }) + ); +} + +async function mockExistingSession(page: Page) { + // Pre-seed localStorage with a fake Supabase session so getUser() fires + // the /auth/v1/user HTTP request (Supabase v2 only calls the endpoint when + // a stored token exists). Key format: sb-{projectRef}-auth-token. + await page.addInitScript(([supaUrl, mockUser, mockSession]: [string, typeof MOCK_USER, typeof MOCK_SESSION]) => { + const ref = new URL(supaUrl).hostname.split('.')[0]; // "xyzfaketest" + const stored = { + access_token: mockSession.access_token, + token_type: 'bearer', + expires_in: 3600, + expires_at: Math.floor(Date.now() / 1000) + 3600, + refresh_token: mockSession.refresh_token, + user: mockUser, + }; + localStorage.setItem(`sb-${ref}-auth-token`, JSON.stringify(stored)); + }, [FAKE_SUPA_URL, MOCK_USER, MOCK_SESSION] as [string, typeof MOCK_USER, typeof MOCK_SESSION]); + + // Mock the /auth/v1/user endpoint that Supabase calls to validate the token + await page.route(`${FAKE_SUPA_URL}/auth/v1/user`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_USER), + }) + ); +} + +async function mockSignUpSuccess(page: Page) { + await page.route(`${FAKE_SUPA_URL}/auth/v1/signup`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { session: MOCK_SESSION, user: MOCK_USER }, + error: null, + ...MOCK_SESSION, + }), + }) + ); +} + +async function mockSignUpEmailConfirmRequired(page: Page) { + await page.route(`${FAKE_SUPA_URL}/auth/v1/signup`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { session: null, user: MOCK_USER }, + error: null, + }), + }) + ); +} + +async function mockSignInSuccess(page: Page) { + await page.route(`${FAKE_SUPA_URL}/auth/v1/token*`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_SESSION), + }) + ); +} + +async function mockSignInError(page: Page, message = 'Invalid login credentials') { + await page.route(`${FAKE_SUPA_URL}/auth/v1/token**`, (route) => + route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ error: 'invalid_grant', error_description: message }), + }) + ); +} + +// ─── Per-plan structure ─────────────────────────────────────────────────────── + +for (const plan of ['free', 'professional', 'founding'] as const) { + test(`[${plan}] page loads 200 with content`, async ({ page }) => { + const res = await page.goto(`/checkout?plan=${plan}`); + expect(res?.status()).toBe(200); + await expect(page.locator('body')).not.toBeEmpty(); + }); + + test(`[${plan}] page has non-empty title`, async ({ page }) => { + await page.goto(`/checkout?plan=${plan}`); + expect((await page.title()).trim().length).toBeGreaterThan(0); + }); + + test(`[${plan}] nav back link to /`, async ({ page }) => { + await page.goto(`/checkout?plan=${plan}`); + await expect(page.locator('nav a[href="/"]').first()).toBeAttached(); + }); + + test(`[${plan}] canonical is production URL — not stage/run.app`, async ({ page }) => { + await page.goto(`/checkout?plan=${plan}`); + const canonical = await page.locator('link[rel="canonical"]').getAttribute('href'); + expect(canonical).toContain('neurontechnologies.ai'); + expect(canonical).not.toMatch(/run\.app|stage/); + }); + + test(`[${plan}] noindex meta tag present`, async ({ page }) => { + await page.goto(`/checkout?plan=${plan}`); + const robots = await page.locator('meta[name="robots"]').getAttribute('content'); + expect(robots).toContain('noindex'); + }); + + test(`[${plan}] Google + GitHub social buttons present`, async ({ page }) => { + await page.goto(`/checkout?plan=${plan}`); + await expect(page.locator('#btn-google')).toBeAttached(); + await expect(page.locator('#btn-github')).toBeAttached(); + }); + + test(`[${plan}] email + password inputs present`, async ({ page }) => { + await page.goto(`/checkout?plan=${plan}`); + await expect(page.locator('#auth-email')).toBeAttached(); + await expect(page.locator('#auth-password')).toBeAttached(); + }); + + test(`[${plan}] auth message div present`, async ({ page }) => { + await page.goto(`/checkout?plan=${plan}`); + await expect(page.locator('#auth-message')).toBeAttached(); + }); + + test(`[${plan}] auth badge container in DOM`, async ({ page }) => { + await page.goto(`/checkout?plan=${plan}`); + await expect(page.locator('#auth-badge')).toBeAttached(); + }); +} + +// ─── Plan-specific content ──────────────────────────────────────────────────── + +test('[professional] shows $19 / month pricing', async ({ page }) => { + await page.goto('/checkout?plan=professional'); + const body = (await page.locator('body').textContent()) ?? ''; + expect(body).toContain('$19'); + expect(body.toLowerCase()).toContain('month'); +}); + +test('[professional] features include persistent memory + API keys', async ({ page }) => { + await page.goto('/checkout?plan=professional'); + const body = (await page.locator('body').textContent()) ?? ''; + expect(body).toContain('Persistent memory'); + expect(body).toContain('Bring your own API keys'); +}); + +test('[founding] shows $199 one-time pricing', async ({ page }) => { + await page.goto('/checkout?plan=founding'); + const body = (await page.locator('body').textContent()) ?? ''; + expect(body).toContain('$199'); + expect(body.toLowerCase()).toContain('one-time'); +}); + +test('[founding] features include founding badge + lifetime', async ({ page }) => { + await page.goto('/checkout?plan=founding'); + const body = (await page.locator('body').textContent()) ?? ''; + expect(body).toContain('Founding member badge'); + expect(body.toLowerCase()).toContain('lifetime'); +}); + +test('[free] shows free / no card pricing', async ({ page }) => { + await page.goto('/checkout?plan=free'); + const body = (await page.locator('body').textContent()) ?? ''; + expect(body.toLowerCase()).toMatch(/\$0|free|no card/); +}); + +test('[free] features include persistent memory + BYOAPI', async ({ page }) => { + await page.goto('/checkout?plan=free'); + const body = (await page.locator('body').textContent()) ?? ''; + expect(body).toContain('Persistent memory'); +}); + +// ─── Initial visibility per plan ───────────────────────────────────────────── + +test('[free] auth-section visible on load (account creation flow)', async ({ page }) => { + await page.goto('/checkout?plan=free'); + await expect(page.locator('#auth-section')).toBeVisible(); +}); + +test('[free] free-success panel hidden on load', async ({ page }) => { + await page.goto('/checkout?plan=free'); + await expect(page.locator('#free-success')).toBeHidden(); +}); + +test('[free] no payment-section or it is hidden', async ({ page }) => { + await page.goto('/checkout?plan=free'); + const ps = page.locator('#payment-section'); + if (await ps.count() > 0) { + await expect(ps).toBeHidden(); + } +}); + +test('[professional] payment-section visible on load', async ({ page }) => { + await page.goto('/checkout?plan=professional'); + await expect(page.locator('#payment-section')).toBeVisible(); +}); + +test('[professional] auth-section hidden on load (optional for paid)', async ({ page }) => { + await page.goto('/checkout?plan=professional'); + await expect(page.locator('#auth-section')).toBeHidden(); +}); + +test('[founding] payment-section visible on load', async ({ page }) => { + await page.goto('/checkout?plan=founding'); + await expect(page.locator('#payment-section')).toBeVisible(); +}); + +test('[founding] auth-section hidden on load (optional for paid)', async ({ page }) => { + await page.goto('/checkout?plan=founding'); + await expect(page.locator('#auth-section')).toBeHidden(); +}); + +// ─── Payment form elements (paid plans) ────────────────────────────────────── + +for (const plan of ['professional', 'founding'] as const) { + test(`[${plan}] payment-element container present (Stripe mounts here)`, async ({ page }) => { + await page.goto(`/checkout?plan=${plan}`); + await expect(page.locator('#payment-element')).toBeAttached(); + }); + + test(`[${plan}] buyer-email input present`, async ({ page }) => { + await page.goto(`/checkout?plan=${plan}`); + await expect(page.locator('#buyer-email')).toBeAttached(); + }); + + test(`[${plan}] submit/pay button present`, async ({ page }) => { + await page.goto(`/checkout?plan=${plan}`); + const submitBtn = page.locator('#submit-btn, .checkout-submit, button[type="submit"]').first(); + await expect(submitBtn).toBeAttached(); + }); +} + +// ─── Form validation ────────────────────────────────────────────────────────── + +test('[free] submit with empty email shows auth error', async ({ page }) => { + await mockSupabaseConfig(page); + await page.goto('/checkout?plan=free'); + await page.locator('.checkout-email-btn').click(); + const msg = page.locator('#auth-message'); + await expect(msg).toBeVisible({ timeout: 4000 }); + const text = (await msg.textContent()) ?? ''; + expect(text.toLowerCase()).toMatch(/email|password|enter|required/); +}); + +test('[free] submit with password < 8 chars shows length error', async ({ page }) => { + await mockSupabaseConfig(page); + await page.goto('/checkout?plan=free'); + await page.fill('#auth-email', 'test@example.com'); + await page.fill('#auth-password', 'short'); + await page.locator('.checkout-email-btn').click(); + const msg = page.locator('#auth-message'); + await expect(msg).toBeVisible({ timeout: 4000 }); + const text = (await msg.textContent()) ?? ''; + expect(text).toContain('8'); +}); + +test('[free] submit with email only (no password) shows error', async ({ page }) => { + await mockSupabaseConfig(page); + await page.goto('/checkout?plan=free'); + await page.fill('#auth-email', 'test@example.com'); + // leave password empty + await page.locator('.checkout-email-btn').click(); + const msg = page.locator('#auth-message'); + await expect(msg).toBeVisible({ timeout: 4000 }); +}); + +// ─── Sign in / sign up toggle ───────────────────────────────────────────────── + +test('[free] initial button says "Create account"', async ({ page }) => { + await page.goto('/checkout?plan=free'); + await expect(page.locator('.checkout-email-btn')).toContainText('Create account'); +}); + +test('[free] clicking "Sign in" link changes button text to "Sign in"', async ({ page }) => { + await page.goto('/checkout?plan=free'); + await page.click('a[onclick*="showSignIn"]'); + await expect(page.locator('.checkout-email-btn')).toContainText('Sign in'); +}); + +test('[free] divider label changes for email mode', async ({ page }) => { + await page.goto('/checkout?plan=free'); + await expect(page.locator('#auth-divider-label')).toContainText(/email|account/i); +}); + +// ─── Mocked free-plan auth flows ────────────────────────────────────────────── + +test('[free] successful sign-up → free-success shown, auth-section hidden', async ({ page }) => { + await mockSupabaseConfig(page); + await mockSignUpSuccess(page); + await page.goto('/checkout?plan=free'); + + await page.fill('#auth-email', 'newuser@example.com'); + await page.fill('#auth-password', 'password123'); + await page.locator('.checkout-email-btn').click(); + + await expect(page.locator('#free-success')).toBeVisible({ timeout: 6000 }); + await expect(page.locator('#auth-section')).toBeHidden(); +}); + +test('[free] sign-up email-confirm-required → shows check-email message', async ({ page }) => { + await mockSupabaseConfig(page); + await mockSignUpEmailConfirmRequired(page); + await page.goto('/checkout?plan=free'); + + await page.fill('#auth-email', 'confirm@example.com'); + await page.fill('#auth-password', 'password123'); + await page.locator('.checkout-email-btn').click(); + + const msg = page.locator('#auth-message'); + await expect(msg).toBeVisible({ timeout: 6000 }); + const text = (await msg.textContent()) ?? ''; + expect(text.toLowerCase()).toMatch(/email|confirm|check/); +}); + +test('[free] sign-in success (via toggle) → free-success shown', async ({ page }) => { + await mockSupabaseConfig(page); + await mockSignInSuccess(page); + await page.goto('/checkout?plan=free'); + + await page.click('a[onclick*="showSignIn"]'); + await page.fill('#auth-email', 'existing@example.com'); + await page.fill('#auth-password', 'password123'); + await page.locator('.checkout-email-btn').click(); + + await expect(page.locator('#free-success')).toBeVisible({ timeout: 6000 }); +}); + +test('[free] sign-in error → shows error message, form stays visible', async ({ page }) => { + await mockSupabaseConfig(page); + await mockSignInError(page, 'Invalid login credentials'); + await page.goto('/checkout?plan=free'); + + await page.click('a[onclick*="showSignIn"]'); + await page.fill('#auth-email', 'wrong@example.com'); + await page.fill('#auth-password', 'wrongpassword'); + await page.locator('.checkout-email-btn').click(); + + const msg = page.locator('#auth-message'); + await expect(msg).toBeVisible({ timeout: 6000 }); + const text = (await msg.textContent()) ?? ''; + expect(text.toLowerCase()).toMatch(/invalid|credential|incorrect|error/); +}); + +// ─── Mocked paid-plan auth flows ───────────────────────────────────────────── + +for (const plan of ['professional', 'founding'] as const) { + test(`[${plan}] existing session → auth badge visible with user info`, async ({ page }) => { + await mockSupabaseConfig(page); + await mockExistingSession(page); + await page.goto(`/checkout?plan=${plan}`); + + const badge = page.locator('#auth-badge'); + await expect(badge).toBeVisible({ timeout: 6000 }); + const text = (await badge.textContent()) ?? ''; + expect(text).toMatch(/Playwright Tester|playwright@example\.com/); + }); + + test(`[${plan}] existing session → buyer-email pre-filled`, async ({ page }) => { + await mockSupabaseConfig(page); + await mockExistingSession(page); + await page.goto(`/checkout?plan=${plan}`); + + await page.waitForFunction( + () => { + const el = document.getElementById('buyer-email') as HTMLInputElement | null; + return el !== null && el.value.includes('@'); + }, + { timeout: 6000 } + ); + const val = await page.locator('#buyer-email').inputValue(); + expect(val).toBe('playwright@example.com'); + }); + + test(`[${plan}] existing session → auth-section hidden`, async ({ page }) => { + await mockSupabaseConfig(page); + await mockExistingSession(page); + await page.goto(`/checkout?plan=${plan}`); + + // After session is detected auth-section stays/becomes hidden + await page.waitForTimeout(2000); // let JS run + await expect(page.locator('#auth-section')).toBeHidden(); + }); + + test(`[${plan}] existing session → payment-section remains visible`, async ({ page }) => { + await mockSupabaseConfig(page); + await mockExistingSession(page); + await page.goto(`/checkout?plan=${plan}`); + + await expect(page.locator('#payment-section')).toBeVisible({ timeout: 6000 }); + }); + + test(`[${plan}] no session → payment form immediately visible`, async ({ page }) => { + await mockSupabaseConfig(page); + await mockNoSession(page); + await page.goto(`/checkout?plan=${plan}`); + + await expect(page.locator('#payment-section')).toBeVisible({ timeout: 4000 }); + await expect(page.locator('#payment-element')).toBeAttached(); + }); +} + +// ─── /api/checkout endpoint ─────────────────────────────────────────────────── + +test('POST /api/checkout free plan returns no_payment_required', async ({ request }) => { + const res = await request.post('/api/checkout', { + data: JSON.stringify({ plan: 'free', email: 'test@example.com' }), + headers: { 'Content-Type': 'application/json' }, + }); + // Free plan never calls Stripe — must be fast and return the flag + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.no_payment_required ?? body.free).toBeTruthy(); +}); + +test('POST /api/checkout professional returns client_secret or config error (not 500)', async ({ request }) => { + const res = await request.post('/api/checkout', { + data: JSON.stringify({ plan: 'professional', email: 'test@example.com', name: 'Test User' }), + 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/checkout founding returns client_secret or config error (not 500)', async ({ request }) => { + const res = await request.post('/api/checkout', { + data: JSON.stringify({ plan: 'founding', email: 'test@example.com', name: 'Test User' }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(res.status()).toBeLessThan(500); +}); + +test('POST /api/checkout empty body returns 4xx not 500', async ({ request }) => { + const res = await request.post('/api/checkout', { data: {} }); + expect(res.status()).toBeLessThan(500); +}); + +// ─── /api/supabase-config CORS ──────────────────────────────────────────────── + +test('GET /api/supabase-config with allowed origin returns url + anon_key', async ({ request }) => { + const res = await request.get('/api/supabase-config', { + headers: { Origin: 'https://neurontechnologies.ai' }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('url'); + expect(body).toHaveProperty('anon_key'); + expect(body.url).toMatch(/supabase/); +}); + +test('GET /api/supabase-config with disallowed origin returns 403', async ({ request }) => { + const res = await request.get('/api/supabase-config', { + headers: { Origin: 'https://evil-attacker.com' }, + }); + expect(res.status()).toBe(403); +}); + +// ─── Edge cases ─────────────────────────────────────────────────────────────── + +test('[unknown plan] defaults gracefully — 200 and non-empty body', async ({ page }) => { + const res = await page.goto('/checkout?plan=unknown'); + expect(res?.status()).toBe(200); + const body = (await page.locator('body').textContent()) ?? ''; + expect(body.trim().length).toBeGreaterThan(100); +}); + +test('[no plan param] checkout loads without error', async ({ page }) => { + const res = await page.goto('/checkout'); + expect(res?.status()).toBe(200); + await expect(page.locator('body')).not.toBeEmpty(); +}); + +test('[checkout] page has no JS console errors on load (professional)', async ({ page }) => { + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + page.on('pageerror', (err) => errors.push(err.message)); + await page.goto('/checkout?plan=professional'); + await page.waitForTimeout(2000); + // Filter out known third-party noise (Stripe, Supabase unreachable in test env) + const criticalErrors = errors.filter( + (e) => + !e.includes('stripe') && + !e.includes('Stripe') && + !e.includes('supabase') && + !e.includes('Failed to fetch') && + !e.includes('net::ERR') && + !e.includes('Content Security Policy') + ); + expect(criticalErrors).toHaveLength(0); +}); + +test('[checkout] page has no JS console errors on load (free)', async ({ page }) => { + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + page.on('pageerror', (err) => errors.push(err.message)); + await page.goto('/checkout?plan=free'); + await page.waitForTimeout(2000); + const criticalErrors = errors.filter( + (e) => + !e.includes('stripe') && + !e.includes('Stripe') && + !e.includes('supabase') && + !e.includes('Failed to fetch') && + !e.includes('net::ERR') && + !e.includes('Content Security Policy') + ); + expect(criticalErrors).toHaveLength(0); +}); diff --git a/tests/e2e/checkout-stripe.spec.ts b/tests/e2e/checkout-stripe.spec.ts new file mode 100644 index 0000000..917d7cc --- /dev/null +++ b/tests/e2e/checkout-stripe.spec.ts @@ -0,0 +1,626 @@ +/** + * 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; +} = {}) { + 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 still loaded (though not used)', async ({ page }) => { + // Free plan still includes Stripe.js for forward compat + 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 no_payment_required', async ({ request }) => { + const res = await request.post('/api/payment-intent', { + data: JSON.stringify({ plan: 'free', email: 'test@example.com' }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.no_payment_required ?? body.free).toBeTruthy(); +}); + +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'); + }); +});