Merge pull request 'dev → stage: comprehensive checkout + Stripe tests' (#76) from dev into stage
Merge: dev into stage — comprehensive checkout + Stripe tests
This commit was merged in pull request #76.
This commit is contained in:
@@ -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 <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] 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);
|
||||
});
|
||||
@@ -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 =
|
||||
'<div id="stripe-mock-mounted" style="padding:1rem;border:1px solid #ccc;font-size:.875rem">Mock payment element</div>';
|
||||
}
|
||||
// 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<string, unknown> = {}) {
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user