import type { Page } from '@playwright/test'; import fs from 'node:fs/promises'; import { dismissConsentBanner, expectFixture as expect, test } from '../helpers/test-fixtures'; const shouldRun = process.env.E2E_PADDLE_SANDBOX === '1'; const baseUrl = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app'; const locale = process.env.E2E_LOCALE ?? 'de'; const checkoutSlug = locale === 'en' ? 'checkout' : 'bestellen'; const tenantEmail = buildTenantEmail(); const tenantPassword = process.env.E2E_TENANT_PASSWORD ?? null; const sandboxCard = { number: process.env.E2E_PADDLE_CARD_NUMBER ?? '4242 4242 4242 4242', expiry: process.env.E2E_PADDLE_CARD_EXPIRY ?? '12/34', cvc: process.env.E2E_PADDLE_CARD_CVC ?? '123', name: process.env.E2E_PADDLE_CARD_NAME ?? 'Playwright Tester', postal: process.env.E2E_PADDLE_CARD_POSTAL ?? '10115', }; test.use({ channel: process.env.E2E_BROWSER_CHANNEL ?? 'chrome', userAgent: process.env.E2E_USER_AGENT ?? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', launchOptions: { args: ['--disable-blink-features=AutomationControlled'], }, }); test.describe('Paddle sandbox full flow (staging)', () => { test.skip(!shouldRun, 'Set E2E_PADDLE_SANDBOX=1 to run live sandbox checkout on staging.'); test.skip(!tenantEmail || !tenantPassword, 'Set E2E_TENANT_EMAIL and E2E_TENANT_PASSWORD for sandbox flow.'); test('register, pay via Paddle sandbox, and login to event admin', async ({ page, request }, testInfo) => { const paddleNetworkLog: string[] = []; page.on('response', async (response) => { const url = response.url(); if (!/paddle/i.test(url)) { return; } const status = response.status(); let bodySnippet = ''; if (status >= 400 || /checkout|error/i.test(url)) { try { const text = await response.text(); bodySnippet = text.trim().slice(0, 2000); } catch { bodySnippet = ''; } } const entry = [`[${status}] ${url}`, bodySnippet].filter(Boolean).join('\n'); paddleNetworkLog.push(entry); if (paddleNetworkLog.length > 40) { paddleNetworkLog.shift(); } }); await page.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined, }); }); try { // Jump directly into wizard for Classic package (2) await page.goto(`${baseUrl}/${locale}/${checkoutSlug}/2`); await dismissConsentBanner(page); await proceedToAccountStep(page); await completeRegistrationOrLogin(page, { email: tenantEmail!, password: tenantPassword!, }); const termsCheckbox = page.locator('#checkout-terms-hero'); await expect(termsCheckbox).toBeVisible(); await termsCheckbox.click(); const checkoutCta = page.getByRole('button', { name: /Weiter mit Paddle|Continue with Paddle/i }).first(); await expect(checkoutCta).toBeVisible({ timeout: 20000 }); const [apiResponse] = await Promise.all([ page.waitForResponse((resp) => resp.url().includes('/paddle/create-checkout') && resp.status() < 500), checkoutCta.click(), ]); const rawBody = await apiResponse.text(); let checkoutPayload: Record | null = null; try { checkoutPayload = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null; } catch { checkoutPayload = null; } const inlineMode = checkoutPayload?.mode === 'inline' || checkoutPayload?.inline === true; const checkoutUrl = extractCheckoutUrl(checkoutPayload, rawBody); if (!inlineMode) { expect(checkoutUrl).toContain('paddle'); } // Navigate to Paddle hosted checkout and complete payment. if (inlineMode) { await expect( page.getByText(/Checkout geöffnet|Checkout opened|Paddle-Checkout/i).first() ).toBeVisible({ timeout: 20_000 }); await waitForPaddleCardInputs(page, ['input[autocomplete="cc-number"]', 'input[name="cardnumber"]', 'input[name="card_number"]']); } else if (checkoutUrl) { await page.goto(checkoutUrl); await expect(page).toHaveURL(/paddle/); } else { throw new Error(`Missing Paddle checkout URL. Response: ${rawBody}`); } await completeHostedPaddleCheckout(page, sandboxCard); await expect .poll( async () => { const latestCompleted = await request.get('/api/_testing/checkout/sessions/latest', { params: { status: 'completed', email: tenantEmail }, }); if (!latestCompleted.ok()) { return null; } const json = await latestCompleted.json(); return json?.data?.status ?? null; }, { timeout: 120_000 } ) .toBe('completed'); await page.goto(`${baseUrl}/event-admin/login`); await dismissConsentBanner(page); await page.locator('input[name="identifier"]').fill(tenantEmail!); await page.locator('input[name="password"]').fill(tenantPassword!); await page.getByRole('button', { name: /Anmelden|Login/i }).click(); await expect(page).toHaveURL(/\/event-admin\/mobile\/(dashboard|welcome)/i, { timeout: 30_000 }); await expect(page.getByText(/Dashboard|Willkommen/i)).toBeVisible(); } finally { if (paddleNetworkLog.length > 0) { const logPath = testInfo.outputPath('paddle-network-log.txt'); await fs.writeFile(logPath, paddleNetworkLog.join('\n\n'), 'utf8'); await testInfo.attach('paddle-network-log', { path: logPath, contentType: 'text/plain', }); } } }); }); async function completeRegistrationOrLogin(page: Page, credentials: { email: string; password: string }): Promise { const identifierInput = page.locator('input[name="identifier"]'); if (await identifierInput.isVisible()) { await identifierInput.fill(credentials.email); await page.locator('input[name="password"]').fill(credentials.password); await page.getByRole('button', { name: /^Anmelden$|Login/i }).last().click(); await expect(page.getByPlaceholder(/Gutscheincode|Coupon/i)).toBeVisible({ timeout: 20_000 }); return; } await page.fill('input[name="first_name"]', 'Play'); await page.fill('input[name="last_name"]', 'Wright'); await page.fill('input[name="email"]', credentials.email); const addressInput = page.locator('input[name="address"]'); if (await addressInput.isVisible()) { await addressInput.fill('Teststrasse 1, 12345 Berlin'); } const phoneInput = page.locator('input[name="phone"]'); if (await phoneInput.isVisible()) { await phoneInput.fill('+49123456789'); } const usernameInput = page.locator('input[name="username"]'); if (await usernameInput.isVisible()) { await usernameInput.fill(credentials.email); } await page.fill('input[name="password"]', credentials.password); await page.fill('input[name="password_confirmation"]', credentials.password); await page.check('input[name="privacy_consent"]'); await page.getByRole('button', { name: /Registrieren|Register/i }).last().click(); try { await expect(page.getByPlaceholder(/Gutscheincode|Coupon/i)).toBeVisible({ timeout: 20_000 }); return; } catch { const loginButton = page.getByRole('button', { name: /^Anmelden$|Login/i }).first(); if (await loginButton.isVisible()) { await loginButton.click(); } } await expect(page.locator('input[name="identifier"]')).toBeVisible({ timeout: 10_000 }); await page.locator('input[name="identifier"]').fill(credentials.email); await page.locator('input[name="password"]').fill(credentials.password); await page.getByRole('button', { name: /^Anmelden$|Login/i }).last().click(); await expect(page.getByPlaceholder(/Gutscheincode|Coupon/i)).toBeVisible({ timeout: 20_000 }); } async function proceedToAccountStep(page: Page, timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const accountForm = page.locator('input[name="first_name"], input[name="identifier"]'); if (await accountForm.isVisible()) { return; } await dismissConsentBanner(page); const continueButton = page.getByRole('button', { name: /Weiter|Continue/i }).first(); if (await continueButton.isVisible()) { if (await continueButton.isEnabled()) { await continueButton.click(); } } await page.waitForTimeout(500); } throw new Error('Account step did not load in time.'); } async function completeHostedPaddleCheckout( page: Page, card: { number: string; expiry: string; cvc: string; name: string; postal: string } ): Promise { const cardNumberSelectors = [ 'input[autocomplete="cc-number"]', 'input[name="cardnumber"]', 'input[name="card_number"]', ]; const expirySelectors = [ 'input[autocomplete="cc-exp"]', 'input[name="exp-date"]', 'input[name="exp_date"]', 'input[name="expiry"]', ]; const cvcSelectors = [ 'input[autocomplete="cc-csc"]', 'input[name="cvc"]', 'input[name="security-code"]', 'input[name="cvv"]', ]; const nameSelectors = [ 'input[autocomplete="cc-name"]', 'input[name="cardholder"]', 'input[name="cardholder_name"]', 'input[name="cardholder-name"]', ]; const postalSelectors = [ 'input[autocomplete="postal-code"]', 'input[name="postal"]', 'input[name="postal_code"]', 'input[name="zip"]', ]; await maybeFillInAnyFrame(page, nameSelectors, card.name); await fillInAnyFrame(page, cardNumberSelectors, card.number); await fillInAnyFrame(page, expirySelectors, card.expiry); await fillInAnyFrame(page, cvcSelectors, card.cvc); await maybeFillInAnyFrame(page, postalSelectors, card.postal); const payButton = page.getByRole('button', { name: /Jetzt bezahlen|Pay now|Complete order|Order now|Kaufen|Bezahlen|Zahlung abschließen/i, }).first(); await expect(payButton).toBeVisible({ timeout: 20_000 }); await payButton.click(); } async function waitForPaddleCardInputs(page: Page, selectors: string[], timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await hasAnySelector(page, selectors)) { return; } if (await hasAnyText(page, /Something went wrong|try again later/i)) { throw new Error('Paddle inline checkout returned an error in the iframe.'); } await page.waitForTimeout(500); } throw new Error('Paddle card inputs did not appear in time.'); } async function hasAnySelector(page: Page, selectors: string[]): Promise { if (await hasSelectorInFrame(page, selectors)) { return true; } for (const frame of page.frames()) { if (await hasSelectorInFrame(frame, selectors)) { return true; } } return false; } async function hasSelectorInFrame(scope: Page | import('@playwright/test').Frame, selectors: string[]): Promise { for (const selector of selectors) { if ((await scope.locator(selector).count()) > 0) { return true; } } return false; } async function hasAnyText(page: Page, matcher: RegExp): Promise { if ((await page.getByText(matcher).count()) > 0) { return true; } for (const frame of page.frames()) { if ((await frame.getByText(matcher).count()) > 0) { return true; } } return false; } function extractCheckoutUrl(payload: Record | null, rawBody: string): string | null { if (payload) { const directUrl = payload.checkout_url ?? payload.url ?? payload.checkoutUrl; if (typeof directUrl === 'string') { return directUrl; } } const trimmed = rawBody.trim(); if (/^https?:\/\//i.test(trimmed)) { return trimmed; } if (trimmed.startsWith('<')) { const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:/?#@!$&'()*+,;=%-]+/); if (match) { return match[0]; } } return null; } function buildTenantEmail(): string | null { const rawEmail = process.env.E2E_TENANT_EMAIL ?? process.env.E2E_PADDLE_EMAIL ?? null; if (!rawEmail) { return null; } if (!rawEmail.includes('{timestamp}')) { return rawEmail; } const timestamp = Math.floor(Date.now() / 1000).toString(); return rawEmail.replaceAll('{timestamp}', timestamp); } async function fillInAnyFrame(page: Page, selectors: string[], value: string): Promise { const filled = await attemptFill(page, selectors, value); if (filled) { return; } const frames = page.frames(); for (const frame of frames) { const frameFilled = await attemptFill(frame, selectors, value); if (frameFilled) { return; } } throw new Error(`Unable to find input for selectors: ${selectors.join(', ')}`); } async function maybeFillInAnyFrame(page: Page, selectors: string[], value: string): Promise { const filled = await attemptFill(page, selectors, value); if (filled) { return; } const frames = page.frames(); for (const frame of frames) { const frameFilled = await attemptFill(frame, selectors, value); if (frameFilled) { return; } } } async function attemptFill( scope: Page | import('@playwright/test').Frame, selectors: string[], value: string ): Promise { for (const selector of selectors) { const locator = scope.locator(selector).first(); if ((await locator.count()) === 0) { continue; } try { await locator.fill(value); return true; } catch { try { await locator.click(); await locator.type(value, { delay: 25 }); return true; } catch { continue; } } } return false; }