diff --git a/docs/testing/e2e-manual-test-plan.md b/docs/testing/e2e-manual-test-plan.md new file mode 100644 index 00000000..004c7e68 --- /dev/null +++ b/docs/testing/e2e-manual-test-plan.md @@ -0,0 +1,270 @@ +# LFX Crowdfunding — E2E Manual Test Plan + +**Environment:** https://crowdfunding.dev.lfx.dev +**Self Serve:** https://app.dev.lfx.dev/crowdfunding +**Test initiative:** https://crowdfunding.dev.lfx.dev/initiatives/test-html-text + +> **For automated payment tests**, see `frontend/e2e/tests/donate.spec.ts` — run with: +> ``` +> E2E_BASE_URL=https://crowdfunding.dev.lfx.dev pnpm test:e2e --grep "@dev" +> ``` +> This file covers exploratory flows, initiative creation, and corporate donations not yet in the Playwright suite. + +--- + +## How to use this file with an AI + +Point your AI assistant at this file and say: + +> "Follow the test plan in docs/testing/e2e-manual-test-plan.md. Use Playwright MCP to drive the browser. Sign in with my DEV credentials before starting. Report pass/fail and findings for each section." + +The AI will need: +- Your DEV login credentials (provide at runtime — do not store here) +- Playwright MCP enabled in your Claude Code session +- Access to the DB tunnel if running DB verification steps + +--- + +## Prerequisites + +1. Sign in at https://crowdfunding.dev.lfx.dev using your DEV LFID credentials. +2. The test initiative `test-html-text` must exist and be **published** with `accept_funding = true`. +3. For DB verification: a tunnel to the DEV RDS instance must be up (see team runbook for tunnel setup). + +### Stripe test cards + +| Scenario | Card number | Expiry | CVC | +|---|---|---|---| +| Valid (succeeds) | `4242 4242 4242 4242` | Any future | Any | +| Declined | `4000 0000 0000 0002` | Any future | Any | +| Expired | `4000 0000 0000 0069` | Any future | Any | +| Wrong ZIP (accepted) | `4000 0000 0000 0010` | Any future | Any | +| 3DS — requires auth | `4000 0000 0000 3220` | Any future | Any | + +--- + +## Section 1 — Individual Donation Flows + +Navigate to https://crowdfunding.dev.lfx.dev/initiatives/test-html-text and click **Donate**. + +### 1.1 Valid card (one-time) + +1. Select **$25**, click Continue. +2. Select **Individual**. Fill Full name and Email (use a personal email to verify receipt). +3. Click Continue to Payment. +4. Click **Use a different card**. Enter the valid card (`4242...`). +5. Click Donate. +6. **Assert:** "Thank you for your donation! Your $25 donation to Test HTML Text has been processed." + +### 1.2 Expired card + +1. Donate $10 as Individual. +2. At payment step, enter expired card (`...0069`). +3. **Assert:** Inline error "Your card is expired. Try a different card." — donation does NOT succeed. + +### 1.3 Declined card + +1. Donate $10 as Individual. +2. Enter declined card (`...0002`). +3. **Assert:** Error "Your card has been declined." — donation does NOT succeed. + +### 1.4 3DS — approve + +1. Donate $5 as Individual. +2. Enter 3DS card (`...3220`). +3. When Stripe 3DS test page appears, click **COMPLETE**. +4. **Assert:** Thank-you screen. Donation processed. + +### 1.5 3DS — fail + +1. Donate $5 as Individual. +2. Enter 3DS card (`...3220`). +3. When Stripe 3DS test page appears, click **FAIL**. +4. **Assert:** Error "We are unable to authenticate your payment method. Please choose a different payment method and try again." + +--- + +## Section 2 — Corporate Donation Flows + +Same initiative. Click Donate. + +### 2.1 Valid card — company + +1. Select **$25**, Continue. +2. Select **Company**. Fill: Company name, Contact name, Email. +3. Continue to Payment → Use a different card → enter valid card (`4242...`). +4. **Assert:** Thank-you screen. $25 processed. + +### 2.2 Declined card — company + +1. Donate $25 as Company (same contact details). +2. Enter declined card (`...0002`). +3. **Assert:** "Your card has been declined." shown. No charge. + +--- + +## Section 3 — Recurring (Monthly) Donation + +### 3.1 Subscribe with valid card + +1. Click Donate → select **Monthly** frequency. +2. Select **$10/mo**, Continue → fill Individual contact → Continue to Payment. +3. Enter valid card or use saved card. +4. **Assert:** Thank-you screen. Check https://app.dev.lfx.dev/crowdfunding/donations — recurring subscription appears under "Recurring Donations" with status Active, $10/mo. + +### 3.2 Cancel subscription + +1. Go to https://app.dev.lfx.dev/crowdfunding/donations. +2. Find the active subscription, click the **…** menu → Cancel. +3. **Assert:** Subscription status changes to Canceled. + +--- + +## Section 4 — Initiative Creation (Fundraise flow) + +Navigate to https://crowdfunding.dev.lfx.dev and click **Start Fundraise**. + +> After each submission, verify in Self Serve at https://app.dev.lfx.dev/crowdfunding/initiatives (Pending tab). + +### 4.1 Project type + +1. Select **Project** → Continue. +2. Choose **Git URL** → enter any public GitHub repo URL → Continue. +3. Fill: Name, Elevator Pitch, select at least one Topic. +4. Upload a logo (min 600×600px). +5. Set Annual Funding Goal. Enable at least one Fund Distribution category and set it to 100%. +6. Continue → check both compliance boxes → Submit. +7. **Assert:** "Initiative submitted with success!" shown. + +### 4.2 OSTIF Security Audit type + +1. Select **OSTIF Security Audit** → Continue. +2. Fill: Name, Elevator Pitch, Topic, Repository URL. +3. Upload logo. Fill Primary Contact (first name, last name, email). +4. Continue → check compliance → Submit. +5. **Assert:** Success screen. +6. **Extra:** Check Primary Contact fields mapped correctly (first name ≠ last name field). + +### 4.3 General Fund type + +1. Select **General Fund** → Continue. +2. Fill: Name, Elevator Pitch, Topic, Funding Goal, enable one fund distribution category at 100%. +3. Upload logo → Continue → compliance → Submit. +4. **Assert:** Success screen. + +### 4.4 Event / Meetup type + +1. Select **Event / Meetup** → Continue. +2. Fill: Event name, Summary, Topic, Registration URL (required), Start date, End date. +3. Upload logo. Set Sponsorship Goal, enable one Budget Distribution category at 100%. +4. Continue → compliance → Submit. +5. **Assert:** Success screen. + +--- + +## Section 5 — Self Serve Validation + +After completing Sections 1–4: + +### 5.1 My Donations + +1. Go to https://app.dev.lfx.dev/crowdfunding/donations. +2. **Assert:** Donation History shows all successful donations (correct amounts, initiative names, dates). +3. **Assert:** Failed/declined donations do NOT appear in history. +4. **Assert:** Invoice links render for each donation. + +### 5.2 My Initiatives + +1. Go to https://app.dev.lfx.dev/crowdfunding/initiatives → Pending tab. +2. **Assert:** All submitted initiatives appear with status "Submitted / Under review by our team". + +--- + +## Section 6 — DB Verification (optional, requires DB tunnel) + +Connect to the DEV database (`crowdfunding` schema) and verify: + +### 6.1 Donations table + +```sql +SELECT d.id, d.status, d.current_amount_in_cents, d.payment_method, + d.organization_id, d.stripe_payment_intent_id, d.stripe_charge_id, + i.name AS initiative +FROM crowdfunding.donations d +JOIN crowdfunding.users u ON d.user_id = u.id +LEFT JOIN crowdfunding.initiatives i ON d.initiative_id = i.id +WHERE u.username = '' +ORDER BY d.created_on DESC; +``` + +**Assert:** +- All successful donations have `status = 'succeeded'` +- `stripe_payment_intent_id` (`pi_...`) is populated on every row +- `stripe_charge_id` (`ch_...`) is populated on succeeded rows +- Declined/failed donations: either absent (rejected client-side) or `status = 'failed'` +- Corporate donations: check whether `organization_id` is set (known gap — currently NULL) + +### 6.2 Initiatives table + +```sql +SELECT i.name, i.slug, i.initiative_type, i.status, i.accept_funding, + i.stripe_product_id, i.industry, i.event_start_date, i.event_end_date +FROM crowdfunding.initiatives i +JOIN crowdfunding.users u ON i.owner_id = u.id +WHERE u.username = '' +ORDER BY i.created_on DESC; +``` + +**Assert:** +- All 4 types present: `project`, `security_audit`, `general_fund`, `event` +- All have `status = 'submitted'`, `accept_funding = true` +- All have a non-empty `stripe_product_id` (`prod_...`) +- Event has `event_start_date` and `event_end_date` populated + +### 6.3 Initiative goals + +```sql +SELECT i.name AS initiative, g.name AS goal, g.amount_in_cents, g.allocation +FROM crowdfunding.initiative_goals g +JOIN crowdfunding.initiatives i ON g.initiative_id = i.id +JOIN crowdfunding.users u ON i.owner_id = u.id +WHERE u.username = '' +ORDER BY i.created_on, g.sort_order; +``` + +**Assert:** Goals exist for Project (Annual + Development), General Fund (Annual), Event (Sponsorship Goal). + +### 6.4 OSTIF contacts + +```sql +SELECT i.name, c.contact_type, c.first_name, c.last_name, c.email +FROM crowdfunding.initiative_contacts c +JOIN crowdfunding.initiatives i ON c.initiative_id = i.id +JOIN crowdfunding.users u ON i.owner_id = u.id +WHERE u.username = ''; +``` + +**Assert:** Primary contact has correct first_name, last_name, email in the right columns. + +### 6.5 Ledger stats + +```sql +SELECT i.name, ls.total_raised_cents, ls.supporters, ls.updated_on +FROM crowdfunding.initiative_ledger_stats ls +JOIN crowdfunding.initiatives i ON ls.initiative_id = i.id +WHERE i.slug = 'test-html-text'; +``` + +**Assert:** `total_raised_cents` matches sum of all succeeded donations. `updated_on` is within the last hour (cron runs hourly). + +--- + +## Known gaps / open issues + +| # | Area | Issue | Severity | +|---|---|---|---| +| 1 | Corporate donation | `organization_id` is NULL — company name not persisted to `organizations` table | Medium | +| 2 | Donations | `payment_method` column is NULL on all rows — not set by service | Low | +| 3 | OSTIF detail | No `initiative_ostif_detail` row created when optional fields left blank | Low (expected) | +| 4 | Balance widget | Does not refresh after donation without page reload (by design — cron-driven) | Info | +| 5 | Self Serve | My Initiatives page was initially empty — needed fresh page load to show Pending count | Low | diff --git a/frontend/e2e/fixtures/seed.ts b/frontend/e2e/fixtures/seed.ts index 2504b7da..381e2514 100644 --- a/frontend/e2e/fixtures/seed.ts +++ b/frontend/e2e/fixtures/seed.ts @@ -5,3 +5,10 @@ // These are inserted before the e2e suite runs (CI: "Seed e2e data" step; // local: run `psql $DATABASE_URL -f backend/db/e2e-seed.sql` once). export const E2E_INITIATIVE_SLUG = 'e2e-test-initiative'; + +// Dedicated DEV initiative for payment E2E tests. +// This initiative has a real Stripe test-mode product ID so full card flows +// (charge, 3DS, declines) can be exercised against the DEV environment. +// Run payment tests against DEV with: +// E2E_BASE_URL=https://crowdfunding.dev.lfx.dev pnpm test:e2e --grep "@dev" +export const DEV_PAYMENT_INITIATIVE_SLUG = 'test-html-text'; diff --git a/frontend/e2e/fixtures/stripe.ts b/frontend/e2e/fixtures/stripe.ts new file mode 100644 index 00000000..4ee284dc --- /dev/null +++ b/frontend/e2e/fixtures/stripe.ts @@ -0,0 +1,58 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import type { Page } from '@playwright/test'; + +export interface StripeCard { + number: string; + expiry: string; // MM/YYYY + cvc: string; +} + +// Stripe test cards used in E2E suites. +// Full list: https://docs.stripe.com/testing#cards +export const STRIPE_CARDS = { + // Always succeeds + valid: { number: '4242424242424242', expiry: '12/2030', cvc: '123' }, + // Card is expired + expired: { number: '4000000000000069', expiry: '12/2030', cvc: '123' }, + // Zip check fails but charge still succeeds (AVS mismatch accepted by default) + incorrectZip: { number: '4000000000000010', expiry: '12/2030', cvc: '123' }, + // Requires 3D Secure 2 authentication + threeDSecure: { number: '4000000000003220', expiry: '12/2030', cvc: '123' }, + // Generic decline + declined: { number: '4000000000000002', expiry: '12/2030', cvc: '123' }, +} satisfies Record; + +/** + * Fill the Stripe card element iframes on the donate payment step. + * + * All three fields (card number, expiry, CVC) live inside a single cross-origin + * Stripe iframe titled "Secure card number input frame". They are accessed via + * Playwright's frameLocator and filled with their internal `name` attributes. + */ +export async function fillStripeCard(page: Page, card: StripeCard): Promise { + const [month, year] = card.expiry.split('/'); + const frame = page.frameLocator('iframe[title="Secure card number input frame"]'); + await frame.locator('[name="cardnumber"]').fill(card.number); + await frame.locator('[name="cc-exp-month"]').fill(month); + await frame.locator('[name="cc-exp-year"]').fill(year); + await frame.locator('[name="cc-csc"]').fill(card.cvc); +} + +/** + * Complete a Stripe 3DS test challenge. + * The Stripe test ACS page has COMPLETE and FAIL buttons inside a nested iframe. + */ +export async function complete3DS(page: Page, action: 'complete' | 'fail'): Promise { + const label = action === 'complete' ? 'COMPLETE' : 'FAIL'; + // The 3DS challenge loads in an iframe on the page; search all frames + for (const frame of page.frames()) { + const btn = frame.locator(`button:has-text("${label}")`); + if ((await btn.count()) > 0) { + await btn.click(); + return; + } + } + throw new Error(`3DS ${label} button not found in any frame`); +} diff --git a/frontend/e2e/tests/donate.spec.ts b/frontend/e2e/tests/donate.spec.ts index e9bd4b51..b066d936 100644 --- a/frontend/e2e/tests/donate.spec.ts +++ b/frontend/e2e/tests/donate.spec.ts @@ -2,10 +2,116 @@ // SPDX-License-Identifier: MIT import { test, expect } from '../fixtures/auth'; -import { E2E_INITIATIVE_SLUG } from '../fixtures/seed'; +import { fillStripeCard, complete3DS, STRIPE_CARDS } from '../fixtures/stripe'; +import { E2E_INITIATIVE_SLUG, DEV_PAYMENT_INITIATIVE_SLUG } from '../fixtures/seed'; -test.describe('One-time donation flow (authenticated)', () => { - test('donate button visible on published initiative with accept_funding=true', async ({ +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const isDevRun = process.env.E2E_BASE_URL?.includes('dev.lfx.dev'); + +/** + * Open the donate drawer for the given slug, pick $10 one-time, fill contact, + * and land on the payment step. Leaves the page ready for card entry. + */ +async function openDonateToPayment( + page: Parameters[0], + initiativeSlug: string, + opts: { email?: string } = {}, +) { + await page.goto(`/initiatives/${initiativeSlug}`); + await page.waitForLoadState('networkidle'); + + // The Donate button has a child that intercepts pointer events, + // so use a JS dispatch to open the drawer reliably. + await page.evaluate(() => { + const btns = document.querySelectorAll('button'); + for (const b of btns) { + if (b.textContent?.trim() === 'Donate') { + b.click(); + return; + } + } + }); + + // Step 1 — amount + await page.getByRole('button', { name: '$10', exact: true }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Step 2 — contact + await page.locator('input[type="text"]').fill('E2E Test User'); + await page.locator('input[type="email"]').fill(opts.email ?? 'e2e@example.com'); + await page.getByRole('button', { name: 'Continue to Payment' }).click(); +} + +/** Click the primary Donate submit button inside the drawer. */ +async function clickDrawerDonate(page: Parameters[0]) { + await page.evaluate(() => { + const donateBtn = Array.from(document.querySelectorAll('button')).find( + (b) => + b.textContent?.trim() === 'Donate' && + b.className.includes('p-button-primary') && + !b.className.includes('pill'), + ); + donateBtn?.click(); + }); +} + +// --------------------------------------------------------------------------- +// CI suite — runs in CI against the local stack (placeholder Stripe keys). +// Tests the drawer UI: visibility, step validation, Cancel. +// No real Stripe charges are made. +// --------------------------------------------------------------------------- + +test.describe('Donate — UI flows (CI)', () => { + test('Donate button is visible on a published initiative', async ({ authenticatedPage }) => { + await authenticatedPage.goto(`/initiatives/${E2E_INITIATIVE_SLUG}`); + await authenticatedPage.waitForLoadState('networkidle'); + + await expect(authenticatedPage.getByRole('button', { name: /donate/i }).first()).toBeVisible(); + }); + + test('Donation drawer opens when Donate is clicked', async ({ authenticatedPage }) => { + await authenticatedPage.goto(`/initiatives/${E2E_INITIATIVE_SLUG}`); + await authenticatedPage.waitForLoadState('networkidle'); + + await authenticatedPage.evaluate(() => { + const btns = document.querySelectorAll('button'); + for (const b of btns) { + if (b.textContent?.trim() === 'Donate') { + b.click(); + return; + } + } + }); + + await expect(authenticatedPage.getByText('Enter a custom amount')).toBeVisible({ + timeout: 8000, + }); + }); + + test('Continue is disabled until an amount is selected', async ({ authenticatedPage }) => { + await authenticatedPage.goto(`/initiatives/${E2E_INITIATIVE_SLUG}`); + await authenticatedPage.waitForLoadState('networkidle'); + + await authenticatedPage.evaluate(() => { + const btns = document.querySelectorAll('button'); + for (const b of btns) { + if (b.textContent?.trim() === 'Donate') { + b.click(); + return; + } + } + }); + + const continueBtn = authenticatedPage.getByRole('button', { name: 'Continue' }); + await expect(continueBtn).toBeDisabled(); + await authenticatedPage.getByRole('button', { name: '$10', exact: true }).click(); + await expect(continueBtn).toBeEnabled(); + }); + + test('Continue to Payment is disabled until name and email are filled', async ({ authenticatedPage, }) => { await authenticatedPage.goto(`/initiatives/${E2E_INITIATIVE_SLUG}`, { @@ -13,31 +119,144 @@ test.describe('One-time donation flow (authenticated)', () => { }); await authenticatedPage.waitForLoadState('networkidle'); - const donateBtn = authenticatedPage - .getByRole('button', { name: /donate/i }) - .or(authenticatedPage.getByRole('link', { name: /donate/i })); + await authenticatedPage.evaluate(() => { + const btns = document.querySelectorAll('button'); + for (const b of btns) { + if (b.textContent?.trim() === 'Donate') { + b.click(); + return; + } + } + }); - await expect(donateBtn.first()).toBeVisible(); - }); + await authenticatedPage.getByRole('button', { name: '$10', exact: true }).click(); + await authenticatedPage.getByRole('button', { name: 'Continue' }).click(); - test('donation form opens when donate button clicked', async ({ authenticatedPage }) => { - await authenticatedPage.goto(`/initiatives/${E2E_INITIATIVE_SLUG}`, { - waitUntil: 'domcontentloaded', + const continueToPayment = authenticatedPage.getByRole('button', { + name: 'Continue to Payment', }); + await expect(continueToPayment).toBeDisabled(); + + await authenticatedPage.locator('input[type="text"]').fill('Test User'); + await expect(continueToPayment).toBeDisabled(); // still needs email + + await authenticatedPage.locator('input[type="email"]').fill('test@example.com'); + await expect(continueToPayment).toBeEnabled(); + }); + + test('Cancel closes the drawer', async ({ authenticatedPage }) => { + await authenticatedPage.goto(`/initiatives/${E2E_INITIATIVE_SLUG}`); await authenticatedPage.waitForLoadState('networkidle'); - // Use the same locator as the visibility test — the button may be inside a - // tooltip wrapper that Playwright considers non-visible but is still clickable. - const donateBtn = authenticatedPage - .getByRole('button', { name: /donate/i }) - .or(authenticatedPage.getByRole('link', { name: /donate/i })) - .first(); - await donateBtn.click({ force: true }); - - const amountInput = authenticatedPage - .getByRole('spinbutton') - .or(authenticatedPage.locator('input[type="number"]')) - .first(); - await expect(amountInput).toBeVisible({ timeout: 10000 }); + await authenticatedPage.evaluate(() => { + const btns = document.querySelectorAll('button'); + for (const b of btns) { + if (b.textContent?.trim() === 'Donate') { + b.click(); + return; + } + } + }); + + await expect(authenticatedPage.getByText('Enter a custom amount')).toBeVisible({ + timeout: 8000, + }); + await authenticatedPage.getByRole('button', { name: 'Cancel' }).click(); + await expect(authenticatedPage.getByText('Enter a custom amount')).not.toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// DEV payment suite — tagged @dev, skipped in CI. +// Runs against crowdfunding.dev.lfx.dev with real Stripe test-mode keys. +// The DEV frontend must have NUXT_E2E_TEST_MODE=true for the e2e-auth +// endpoint to be available (so the authenticatedPage fixture works). +// +// Run locally: +// E2E_BASE_URL=https://crowdfunding.dev.lfx.dev pnpm test:e2e --grep "@dev" +// --------------------------------------------------------------------------- + +test.describe('Donate — payment flows @dev', () => { + test.skip(!isDevRun, 'Payment tests only run against the DEV environment (@dev)'); + + test('valid card completes donation and shows thank-you screen @dev', async ({ + authenticatedPage, + }) => { + await openDonateToPayment(authenticatedPage, DEV_PAYMENT_INITIATIVE_SLUG); + + await authenticatedPage.getByText('Use a different card').click(); + await fillStripeCard(authenticatedPage, STRIPE_CARDS.valid); + await clickDrawerDonate(authenticatedPage); + + await expect(authenticatedPage.getByText('Thank you for your donation!')).toBeVisible({ + timeout: 15000, + }); + }); + + test('expired card shows "Your card is expired" error @dev', async ({ authenticatedPage }) => { + await openDonateToPayment(authenticatedPage, DEV_PAYMENT_INITIATIVE_SLUG); + + await authenticatedPage.getByText('Use a different card').click(); + await fillStripeCard(authenticatedPage, STRIPE_CARDS.expired); + await clickDrawerDonate(authenticatedPage); + + await expect(authenticatedPage.getByText(/your card is expired/i)).toBeVisible({ + timeout: 10000, + }); + }); + + test('declined card shows error message @dev', async ({ authenticatedPage }) => { + await openDonateToPayment(authenticatedPage, DEV_PAYMENT_INITIATIVE_SLUG); + + await authenticatedPage.getByText('Use a different card').click(); + await fillStripeCard(authenticatedPage, STRIPE_CARDS.declined); + await clickDrawerDonate(authenticatedPage); + + await expect(authenticatedPage.getByText(/card.*declined|declined|do not honor/i)).toBeVisible({ + timeout: 10000, + }); + }); + + test('3DS card — approve — completes donation @dev', async ({ authenticatedPage }) => { + await openDonateToPayment(authenticatedPage, DEV_PAYMENT_INITIATIVE_SLUG); + + await authenticatedPage.getByText('Use a different card').click(); + await fillStripeCard(authenticatedPage, STRIPE_CARDS.threeDSecure); + await clickDrawerDonate(authenticatedPage); + + // Stripe test ACS may show the challenge twice + for (let i = 0; i < 2; i++) { + await authenticatedPage.waitForTimeout(2000); + const frames = authenticatedPage.frames(); + let found = false; + for (const frame of frames) { + const btn = frame.locator('button:has-text("COMPLETE")'); + if ((await btn.count()) > 0) { + await btn.click(); + found = true; + break; + } + } + if (!found) break; + } + + await expect(authenticatedPage.getByText('Thank you for your donation!')).toBeVisible({ + timeout: 15000, + }); + }); + + test('3DS card — fail — shows authentication error @dev', async ({ authenticatedPage }) => { + await openDonateToPayment(authenticatedPage, DEV_PAYMENT_INITIATIVE_SLUG); + + await authenticatedPage.getByText('Use a different card').click(); + await fillStripeCard(authenticatedPage, STRIPE_CARDS.threeDSecure); + await clickDrawerDonate(authenticatedPage); + + await authenticatedPage.waitForTimeout(2000); + await complete3DS(authenticatedPage, 'fail'); + + await expect( + authenticatedPage.getByText(/unable to authenticate|authentication.*failed/i), + ).toBeVisible({ timeout: 10000 }); }); }); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 01476fca..567db47b 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -8,6 +8,10 @@ import { defineConfig, devices } from '@playwright/test'; // NUXT_E2E_TEST_USERNAME= — username injected into the mock session // DISABLED_MOCK_LOCAL_PRINCIPAL= — backend bypasses JWT for this username // ALLOW_MOCK_LOCAL_PRINCIPAL_BYPASS=true — required by backend config validation +// +// Running payment tests against DEV (real Stripe test-mode charges): +// E2E_BASE_URL=https://crowdfunding.dev.lfx.dev pnpm test:e2e --grep "@dev" +// The DEV deployment must have NUXT_E2E_TEST_MODE=true set for e2e-auth to work. export default defineConfig({ testDir: './e2e/tests',