Migrate billing from Paddle to Lemon Squeezy
This commit is contained in:
433
tests/ui/purchase/lemonsqueezy-sandbox-full.test.ts
Normal file
433
tests/ui/purchase/lemonsqueezy-sandbox-full.test.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
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_LEMONSQUEEZY_SANDBOX === '1' || 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_LEMONSQUEEZY_CARD_NUMBER ?? process.env.E2E_PADDLE_CARD_NUMBER ?? '4242 4242 4242 4242',
|
||||
expiry: process.env.E2E_LEMONSQUEEZY_CARD_EXPIRY ?? process.env.E2E_PADDLE_CARD_EXPIRY ?? '12/34',
|
||||
cvc: process.env.E2E_LEMONSQUEEZY_CARD_CVC ?? process.env.E2E_PADDLE_CARD_CVC ?? '123',
|
||||
name: process.env.E2E_LEMONSQUEEZY_CARD_NAME ?? process.env.E2E_PADDLE_CARD_NAME ?? 'Playwright Tester',
|
||||
postal: process.env.E2E_LEMONSQUEEZY_CARD_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('Lemon Squeezy sandbox full flow (staging)', () => {
|
||||
test.skip(!shouldRun, 'Set E2E_LEMONSQUEEZY_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 Lemon Squeezy sandbox, and login to event admin', async ({ page, request }, testInfo) => {
|
||||
const lemonsqueezyNetworkLog: string[] = [];
|
||||
|
||||
page.on('response', async (response) => {
|
||||
const url = response.url();
|
||||
if (!/lemonsqueezy|lemon/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');
|
||||
lemonsqueezyNetworkLog.push(entry);
|
||||
if (lemonsqueezyNetworkLog.length > 40) {
|
||||
lemonsqueezyNetworkLog.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 Lemon Squeezy|Continue with Lemon Squeezy/i }).first();
|
||||
await expect(checkoutCta).toBeVisible({ timeout: 20000 });
|
||||
|
||||
const [apiResponse] = await Promise.all([
|
||||
page.waitForResponse((resp) => resp.url().includes('/lemonsqueezy/create-checkout') && resp.status() < 500),
|
||||
checkoutCta.click(),
|
||||
]);
|
||||
|
||||
const rawBody = await apiResponse.text();
|
||||
let checkoutPayload: Record<string, unknown> | 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('lemonsqueezy');
|
||||
}
|
||||
|
||||
// Navigate to Lemon Squeezy hosted checkout and complete payment.
|
||||
if (inlineMode) {
|
||||
await expect(
|
||||
page.getByText(/Lemon Squeezy|Checkout geöffnet|Checkout opened/i).first()
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await waitForLemonCardInputs(page, ['input[autocomplete="cc-number"]', 'input[name="cardnumber"]', 'input[name="card_number"]']);
|
||||
} else if (checkoutUrl) {
|
||||
await page.goto(checkoutUrl);
|
||||
await expect(page).toHaveURL(/lemonsqueezy/);
|
||||
} else {
|
||||
throw new Error(`Missing Lemon Squeezy checkout URL. Response: ${rawBody}`);
|
||||
}
|
||||
|
||||
await completeHostedLemonSqueezyCheckout(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 (lemonsqueezyNetworkLog.length > 0) {
|
||||
const logPath = testInfo.outputPath('lemonsqueezy-network-log.txt');
|
||||
await fs.writeFile(logPath, lemonsqueezyNetworkLog.join('\n\n'), 'utf8');
|
||||
await testInfo.attach('lemonsqueezy-network-log', {
|
||||
path: logPath,
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function completeRegistrationOrLogin(page: Page, credentials: { email: string; password: string }): Promise<void> {
|
||||
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<void> {
|
||||
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 completeHostedLemonSqueezyCheckout(
|
||||
page: Page,
|
||||
card: { number: string; expiry: string; cvc: string; name: string; postal: string }
|
||||
): Promise<void> {
|
||||
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 waitForLemonCardInputs(page: Page, selectors: string[], timeoutMs = 30_000): Promise<void> {
|
||||
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('Lemon Squeezy inline checkout returned an error in the iframe.');
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
throw new Error('Lemon Squeezy card inputs did not appear in time.');
|
||||
}
|
||||
|
||||
async function hasAnySelector(page: Page, selectors: string[]): Promise<boolean> {
|
||||
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<boolean> {
|
||||
for (const selector of selectors) {
|
||||
if ((await scope.locator(selector).count()) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function hasAnyText(page: Page, matcher: RegExp): Promise<boolean> {
|
||||
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<string, unknown> | 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_LEMONSQUEEZY_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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user