Files
neuron-web/tests/e2e/checkout-flows.spec.ts
will.anderson c6fd06b3de Fix Stripe CDN mock override and free-plan sync guards in E2E tests
- Block real Stripe CDN (js.stripe.com) in injectMockStripe() so the
  addInitScript mock is never overwritten by the async-loaded SDK
- Replace waitForFunction(signUpWithEmail) with waitForLoadState in
  all 8 free-plan auth tests; defer scripts run before DOMContentLoaded
  so the function is guaranteed present without polling for it
2026-05-11 09:54:55 -05:00

594 lines
24 KiB
TypeScript

/**
* 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 <fake-supabase>/auth/v1/user → no session or mock user
* - POST <fake-supabase>/auth/v1/signup → success or email-confirm
* - POST <fake-supabase>/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] payment-section hidden on load (shown after auth)', async ({ page }) => {
await page.goto('/checkout?plan=free');
const ps = page.locator('#payment-section');
if (await ps.count() > 0) {
await expect(ps).toBeHidden();
}
});
test('[free] payment-element container present (Stripe mounts here)', async ({ page }) => {
await page.goto('/checkout?plan=free');
await expect(page.locator('#payment-element')).toBeAttached();
});
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.waitForLoadState('domcontentloaded');
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.waitForLoadState('domcontentloaded');
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.waitForLoadState('domcontentloaded');
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.waitForLoadState('domcontentloaded');
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 → payment-section shown, auth-section hidden', async ({ page }) => {
await mockSupabaseConfig(page);
await mockSignUpSuccess(page);
await page.goto('/checkout?plan=free');
await page.waitForLoadState('domcontentloaded');
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('#payment-section')).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.waitForLoadState('domcontentloaded');
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) → payment-section shown', async ({ page }) => {
await mockSupabaseConfig(page);
await mockSignInSuccess(page);
await page.goto('/checkout?plan=free');
await page.waitForLoadState('domcontentloaded');
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('#payment-section')).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.waitForLoadState('domcontentloaded');
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/payment-intent endpoint ────────────────────────────────────────────
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();
// Either setup_mode (success) or an error from Stripe (unconfigured env) — both valid
expect('setup_mode' in body || 'client_secret' in body || 'error' in body).toBeTruthy();
// Must NOT return the old no_payment_required flag
expect(body.no_payment_required).toBeFalsy();
}
});
test('POST /api/payment-intent professional returns client_secret or config error (not 500)', async ({ request }) => {
const res = await request.post('/api/payment-intent', {
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/payment-intent founding returns client_secret or config error (not 500)', async ({ request }) => {
const res = await request.post('/api/payment-intent', {
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/payment-intent empty body returns 4xx or config error (not 500)', async ({ request }) => {
const res = await request.post('/api/payment-intent', { 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);
});