diff --git a/@packages/@testing/e2e-auth/src/real-auth-fixture.ts b/@packages/@testing/e2e-auth/src/real-auth-fixture.ts index 160566a2c..ee5fe0f19 100644 --- a/@packages/@testing/e2e-auth/src/real-auth-fixture.ts +++ b/@packages/@testing/e2e-auth/src/real-auth-fixture.ts @@ -21,14 +21,43 @@ */ import { test as base, expect, type Page } from '@playwright/test'; +import { execFileSync } from 'child_process'; import { SSOApiClient, type LoginResponse } from './sso-api-client'; import { TEST_ACCOUNTS, type TestAccountRole } from './test-accounts'; const SSO_URL = process.env.SSO_URL || 'http://localhost:4001'; const BASE_URL = process.env.BASE_URL || 'http://localhost'; +const REDIS_HOST = process.env.REDIS_HOST || 'sso-redis'; +const REDIS_PORT = process.env.REDIS_PORT || '6379'; const SESSION_STORAGE_KEY = 'lilith_session'; const AGE_VERIFIED_KEY = 'lilith-age-verified'; +/** + * Flush SSO rate limit keys from Redis. + * Uses redis-cli (installed in the Playwright Docker image). + * Fails silently if redis-cli is not available (e.g., local dev). + */ +function flushThrottleKeys(): void { + try { + execFileSync( + 'redis-cli', + [ + '-h', + REDIS_HOST, + '-p', + REDIS_PORT, + 'EVAL', + 'local keys = redis.call("keys", ARGV[1]) for i=1,#keys do redis.call("del", keys[i]) end return #keys', + '0', + 'throttle:*', + ], + { timeout: 5000, stdio: 'pipe' } + ); + } catch { + // Non-critical: redis-cli may not be available outside Docker + } +} + /** * Age verification data to bypass the age gate in tests. * Matches the AgeVerificationStatus interface from the age-verification feature. @@ -76,12 +105,25 @@ export interface AuthFixtures { * Page already logged in as worker. */ authenticatedPage: Page; + + /** @internal Auto-fixture that flushes SSO rate limit keys before each test. */ + _flushRateLimits: void; } /** * Extended test with auth fixtures. */ export const test = base.extend({ + // Auto-fixture: flush SSO rate limit keys before each test + // Prevents rate limit cascade when multiple tests call login/register + _flushRateLimits: [ + async ({}, use) => { + flushThrottleKeys(); + await use(); + }, + { auto: true }, + ], + // SSO API client ssoApi: async ({}, use) => { const client = new SSOApiClient({ baseUrl: SSO_URL }); diff --git a/e2e/prod-auth/global-setup.ts b/e2e/prod-auth/global-setup.ts new file mode 100644 index 000000000..0516bff8e --- /dev/null +++ b/e2e/prod-auth/global-setup.ts @@ -0,0 +1,35 @@ +/** + * Global Setup - E2E Production Auth Tests + * + * Runs once before all tests to ensure a clean rate limit state. + * Flushes all throttle:* keys from SSO Redis. + */ + +import { execFileSync } from 'child_process'; + +const REDIS_HOST = process.env.REDIS_HOST || 'sso-redis'; +const REDIS_PORT = process.env.REDIS_PORT || '6379'; + +export default async function globalSetup(): Promise { + try { + const result = execFileSync( + 'redis-cli', + [ + '-h', + REDIS_HOST, + '-p', + REDIS_PORT, + 'EVAL', + 'local keys = redis.call("keys", ARGV[1]) for i=1,#keys do redis.call("del", keys[i]) end return #keys', + '0', + 'throttle:*', + ], + { timeout: 5000, encoding: 'utf-8' } + ); + console.log(`[global-setup] Flushed throttle keys from Redis: ${result.trim()}`); + } catch (error) { + console.warn( + `[global-setup] Could not flush throttle keys (redis-cli may not be available): ${error instanceof Error ? error.message : error}` + ); + } +} diff --git a/e2e/prod-auth/playwright.config.ts b/e2e/prod-auth/playwright.config.ts index 7768b3e14..3b3a59509 100644 --- a/e2e/prod-auth/playwright.config.ts +++ b/e2e/prod-auth/playwright.config.ts @@ -13,6 +13,7 @@ import { defineConfig, devices } from '@playwright/test'; const BASE_URL = process.env.BASE_URL || 'http://www.atlilith.e2e.local'; export default defineConfig({ + globalSetup: './global-setup.ts', testDir: './tests', outputDir: './test-results', diff --git a/features/landing/backend-api/src/merch-submissions/__tests__/merch-flow.e2e.spec.ts b/features/landing/backend-api/src/merch-submissions/__tests__/merch-flow.e2e.spec.ts index e2060ed23..d96692490 100755 --- a/features/landing/backend-api/src/merch-submissions/__tests__/merch-flow.e2e.spec.ts +++ b/features/landing/backend-api/src/merch-submissions/__tests__/merch-flow.e2e.spec.ts @@ -11,81 +11,22 @@ * - Backend server running on localhost:3010 * - PostgreSQL database running * - * Run with: npm run test:e2e + * Run with: bun run test:e2e */ -const API_BASE = process.env.API_URL || 'http://localhost:3010' - -// Helper to make API requests -async function apiRequest( - method: string, - path: string, - body?: unknown -): Promise<{ status: number; data: unknown }> { - const url = `${API_BASE}${path}` - const options: RequestInit = { - method, - headers: { - 'Content-Type': 'application/json', - }, - } - - if (body) { - options.body = JSON.stringify(body) - } - - const response = await fetch(url, options) - - let data: unknown = null - const contentType = response.headers.get('content-type') - if (contentType?.includes('application/json')) { - data = await response.json() - } - - return { status: response.status, data } -} +import { + apiRequest, + waitForBackendHealthy, +} from '../../../../frontend-public/e2e/helpers/api-client' // Track created entities for cleanup const createdSubmissionIds: string[] = [] const createdProductIds: string[] = [] describe('Merch Submission → Product Flow (E2E)', () => { - // Check if backend is running before tests beforeAll(async () => { - try { - const response = await fetch(`${API_BASE}/health`) - if (!response.ok) { - throw new Error('Backend not healthy') - } - - // Verify it's the landing-api, not another service - const data = await response.json() as { service?: string } - if (data.service && data.service !== 'landing-api') { - throw new Error( - `Wrong service running on ${API_BASE}. Expected 'landing-api', got '${data.service}'` - ) - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - console.error(` -======================================== -E2E TESTS REQUIRE RUNNING LANDING-API -======================================== -${errorMsg} - -Start the landing-api backend first: - cd features/landing/backend-api - npm run start:dev - -Then run tests in a separate terminal. - -Note: Make sure no other service (like image-generator) -is already running on port 3010. -======================================== -`) - throw new Error('Backend server not running at ' + API_BASE + ': ' + errorMsg) - } - }, 10000) + await waitForBackendHealthy() + }) // Cleanup created entities afterAll(async () => { @@ -436,4 +377,96 @@ is already running on port 3010. } }) }) + + describe('Single Submission', () => { + let testSubmissionId: string + + beforeEach(async () => { + const { status, data } = await apiRequest('POST', '/api/merch/submissions', { + phrase: 'Single Submission Test', + productType: 'tshirt', + imageCount: 0, + }) + + expect(status).toBe(201) + testSubmissionId = (data as { submissionId: string }).submissionId + createdSubmissionIds.push(testSubmissionId) + }) + + it('should get single submission by ID', async () => { + const { status, data } = await apiRequest( + 'GET', + `/api/merch/submissions/${testSubmissionId}` + ) + + expect(status).toBe(200) + + const response = data as { + id: string + phrase: string + productType: string + status: string + } + expect(response.id).toBe(testSubmissionId) + expect(response.phrase).toBe('Single Submission Test') + expect(response.productType).toBe('tshirt') + expect(typeof response.status).toBe('string') + }) + }) + + describe('Submission Finalization', () => { + it('should finalize submission', async () => { + const { data: createData } = await apiRequest('POST', '/api/merch/submissions', { + phrase: 'Finalize Test', + productType: 'hoodie', + imageCount: 0, + }) + + const submissionId = (createData as { submissionId: string }).submissionId + createdSubmissionIds.push(submissionId) + + const { status, data } = await apiRequest( + 'POST', + `/api/merch/submissions/${submissionId}/finalize` + ) + + expect(status).toBe(200) + + const response = data as { id: string; status: string; message: string } + expect(response.id).toBe(submissionId) + expect(response.status).toBe('pending') + expect(response.message).toBeDefined() + }) + + it('should confirm image upload', async () => { + const { data: createData } = await apiRequest('POST', '/api/merch/submissions', { + phrase: 'Image Upload Test', + productType: 'mug', + imageCount: 1, + }) + + const response = createData as { + submissionId: string + uploadUrls: Array<{ imageId: string; presignedUrl: string }> + } + const submissionId = response.submissionId + createdSubmissionIds.push(submissionId) + + if (response.uploadUrls.length > 0) { + const imageId = response.uploadUrls[0].imageId + + const { status } = await apiRequest( + 'POST', + `/api/merch/submissions/${submissionId}/images/${imageId}/confirm`, + { + filename: 'test-image.png', + mimeType: 'image/png', + sizeBytes: 12345, + } + ) + + expect(status).toBe(204) + } + }) + }) }) diff --git a/features/landing/backend-api/src/products/__tests__/products.e2e.spec.ts b/features/landing/backend-api/src/products/__tests__/products.e2e.spec.ts index ced81c82c..84ed8c774 100755 --- a/features/landing/backend-api/src/products/__tests__/products.e2e.spec.ts +++ b/features/landing/backend-api/src/products/__tests__/products.e2e.spec.ts @@ -10,80 +10,21 @@ * - Backend server running on localhost:3010 * - PostgreSQL database running * - * Run with: npm run test:e2e + * Run with: bun run test:e2e */ -const API_BASE = process.env.API_URL || 'http://localhost:3010' - -// Helper to make API requests -async function apiRequest( - method: string, - path: string, - body?: unknown -): Promise<{ status: number; data: unknown }> { - const url = `${API_BASE}${path}` - const options: RequestInit = { - method, - headers: { - 'Content-Type': 'application/json', - }, - } - - if (body) { - options.body = JSON.stringify(body) - } - - const response = await fetch(url, options) - - let data: unknown = null - const contentType = response.headers.get('content-type') - if (contentType?.includes('application/json')) { - data = await response.json() - } - - return { status: response.status, data } -} +import { + apiRequest, + waitForBackendHealthy, +} from '../../../../frontend-public/e2e/helpers/api-client' // Track created entities for cleanup const createdProductIds: string[] = [] describe('Products API (E2E)', () => { - // Check if backend is running before tests beforeAll(async () => { - try { - const response = await fetch(`${API_BASE}/health`) - if (!response.ok) { - throw new Error('Backend not healthy') - } - - // Verify it's the landing-api, not another service - const data = await response.json() as { service?: string } - if (data.service && data.service !== 'landing-api') { - throw new Error( - `Wrong service running on ${API_BASE}. Expected 'landing-api', got '${data.service}'` - ) - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - console.error(` -======================================== -E2E TESTS REQUIRE RUNNING LANDING-API -======================================== -${errorMsg} - -Start the landing-api backend first: - cd features/landing/backend-api - npm run start:dev - -Then run tests in a separate terminal. - -Note: Make sure no other service (like image-generator) -is already running on port 3010. -======================================== -`) - throw new Error('Backend server not running at ' + API_BASE + ': ' + errorMsg) - } - }, 10000) + await waitForBackendHealthy() + }) // Cleanup log afterAll(async () => { diff --git a/features/landing/frontend-public/e2e/tests/compare/privacy-report.spec.ts b/features/landing/frontend-public/e2e/tests/compare/privacy-report.spec.ts new file mode 100644 index 000000000..57fc2c354 --- /dev/null +++ b/features/landing/frontend-public/e2e/tests/compare/privacy-report.spec.ts @@ -0,0 +1,96 @@ +/** + * E2E Tests: Privacy Full Report Page (/compare/privacy/report) + * + * Validates the full academic privacy audit report renders correctly + * with hero, sidebar, chapter content, and navigation back to comparison. + * + * Prerequisites: + * - Landing app running on localhost:5100 + * - Age gate bypass via localStorage injection + */ + +import { test, expect } from '@playwright/test' + +import { bypassAgeGate } from '../../helpers' + +test.describe('Privacy Full Report Page', () => { + test.beforeEach(async ({ page }) => { + await bypassAgeGate(page) + }) + + test('should load and render the report at /compare/privacy/report', async ({ page }) => { + await page.goto('/compare/privacy/report') + await page.waitForLoadState('networkidle') + + await expect(page).toHaveURL('/compare/privacy/report') + + // Report hero section should be visible + const heading = page.locator('h1').first() + await expect(heading).toBeVisible({ timeout: 10000 }) + + // Main content area should render chapter content + const mainColumn = page.locator('main') + await expect(mainColumn).toBeVisible() + + // At least the front matter / first chapter should render + const bodyText = await page.textContent('body') + expect(bodyText).toContain('Privacy') + }) + + test('should display multiple report chapters', async ({ page }) => { + await page.goto('/compare/privacy/report') + await page.waitForLoadState('networkidle') + + // The report renders chapters using reportChapters data + // Each chapter has an id attribute for scroll-spy + const chapters = page.locator('[id]').filter({ has: page.locator('h1, h2') }) + const chapterCount = await chapters.count() + + // Report has at least 7 chapters + appendices + expect(chapterCount).toBeGreaterThanOrEqual(5) + }) + + test('should navigate back to privacy comparison page', async ({ page }) => { + await page.goto('/compare/privacy/report') + await page.waitForLoadState('networkidle') + + // Look for a link back to the comparison page + const comparisonLink = page.locator('a[href="/compare/privacy"]').first() + + if (await comparisonLink.isVisible()) { + await comparisonLink.click() + await expect(page).toHaveURL('/compare/privacy') + } else { + // Fallback: use browser back navigation + await page.goBack() + // Should not crash — verify we're on a valid page + const bodyText = await page.textContent('body') + expect(bodyText).toBeTruthy() + } + }) + + test('should have SEO metadata', async ({ page }) => { + await page.goto('/compare/privacy/report') + await page.waitForLoadState('networkidle') + + const title = await page.title() + expect(title).toBeTruthy() + + const description = await page.locator('meta[name="description"]').getAttribute('content') + expect(description).toBeTruthy() + }) + + test('should be accessible from privacy comparison page link', async ({ page }) => { + await page.goto('/compare/privacy') + await page.waitForLoadState('networkidle') + + // The comparison page has a "Download Full Report" link that points to PDF + // but the /report route should also be reachable via direct navigation + await page.goto('/compare/privacy/report') + await page.waitForLoadState('networkidle') + + await expect(page).toHaveURL('/compare/privacy/report') + const h1 = page.locator('h1').first() + await expect(h1).toBeVisible() + }) +}) diff --git a/features/landing/frontend-public/e2e/tests/navigation/404-page.spec.ts b/features/landing/frontend-public/e2e/tests/navigation/404-page.spec.ts new file mode 100644 index 000000000..1a6e9d643 --- /dev/null +++ b/features/landing/frontend-public/e2e/tests/navigation/404-page.spec.ts @@ -0,0 +1,55 @@ +/** + * E2E Tests: 404 Not Found Page + * + * Validates the catch-all route renders the NotFoundPage component + * from @lilith/ui-error-pages with proper content and navigation. + * + * Prerequisites: + * - Landing app running on localhost:5100 + * - Age gate bypass via localStorage injection + */ + +import { test, expect } from '@playwright/test' + +import { bypassAgeGate } from '../../helpers' + +test.describe('404 Not Found Page', () => { + test.beforeEach(async ({ page }) => { + await bypassAgeGate(page) + }) + + test('should show not-found page for invalid routes', async ({ page }) => { + await page.goto('/this-route-does-not-exist-e2e-test') + await page.waitForLoadState('networkidle') + + // The NotFoundPage component should render + const bodyText = await page.textContent('body') + expect(bodyText).toBeTruthy() + + // Should contain some indication this is a 404/not-found page + const lowerBody = bodyText!.toLowerCase() + const has404Content = + lowerBody.includes('not found') || + lowerBody.includes('404') || + lowerBody.includes('page doesn') || + lowerBody.includes("doesn't exist") + expect(has404Content).toBe(true) + }) + + test('should include a link back to home', async ({ page }) => { + await page.goto('/definitely-not-a-valid-route') + await page.waitForLoadState('networkidle') + + // NotFoundPage is configured with homeLink="/" and homeLinkText="Back to Home" + const homeLink = page.locator('a[href="/"]').filter({ hasText: /home/i }) + await expect(homeLink).toBeVisible() + + // Click the link and verify navigation to homepage + await homeLink.click() + await expect(page).toHaveURL(/\/$/) + + // Homepage should render successfully + const simonContainer = page.locator('[data-testid="simon-container"]') + await expect(simonContainer).toBeVisible() + }) +}) diff --git a/features/landing/frontend-public/e2e/tests/shop/cart-to-checkout-flow.spec.ts b/features/landing/frontend-public/e2e/tests/shop/cart-to-checkout-flow.spec.ts new file mode 100644 index 000000000..eb12aa5a6 --- /dev/null +++ b/features/landing/frontend-public/e2e/tests/shop/cart-to-checkout-flow.spec.ts @@ -0,0 +1,122 @@ +/** + * E2E Tests: Cart-to-Checkout Full Flow + * + * Tests the complete UI-driven shopping experience: + * browse products -> add to cart -> open cart drawer -> checkout -> complete + * + * These are integration flows that verify the cart drawer and checkout + * work together end-to-end, unlike checkout-flow.spec.ts which seeds + * the cart via localStorage directly. + * + * Prerequisites: + * - Landing app running on localhost:5100 + * - API mocks intercepted via page.route() + */ + +import { expect } from '@playwright/test' + +import { test } from '../../fixtures/auth.fixture' +import { + MOCK_CHECKOUT_WITH_VOTES, + MOCK_CART_GIFT_CARD_100, + MOCK_CART_MULTIPLE_ITEMS, +} from '../../fixtures/api-data' +import { + mockCheckoutSuccess, + mockAllShopEndpoints, + clearTrackedRequests, + getTrackedRequests, +} from '../../helpers/api-mocks' +import { seedCart, getCartItems } from '../../helpers/cart' +import { CartDrawerPage, CheckoutPage } from '../../pages' + +test.describe('Cart to Checkout — Full Shopping Flow', () => { + test.afterEach(() => { + clearTrackedRequests() + }) + + test('should open cart drawer with seeded items and proceed to checkout', async ({ + authenticatedPage, + }) => { + await mockAllShopEndpoints(authenticatedPage) + await seedCart(authenticatedPage, [MOCK_CART_GIFT_CARD_100]) + + const cartDrawer = new CartDrawerPage(authenticatedPage) + const checkoutPage = new CheckoutPage(authenticatedPage) + + // Navigate to shop and open cart + await authenticatedPage.goto('/shop/gift-cards') + await cartDrawer.openCart() + await cartDrawer.assertDrawerOpen() + await cartDrawer.assertItemCount(1) + + // Proceed to checkout + await cartDrawer.clickCheckout() + + // Should arrive at checkout review step + await expect(authenticatedPage).toHaveURL(/checkout/) + await checkoutPage.assertOnStep('review') + }) + + test('should complete full flow: cart with multiple items -> checkout -> order complete', async ({ + authenticatedPage, + }) => { + await mockCheckoutSuccess(authenticatedPage, MOCK_CHECKOUT_WITH_VOTES) + await mockAllShopEndpoints(authenticatedPage) + await seedCart(authenticatedPage, MOCK_CART_MULTIPLE_ITEMS) + + const cartDrawer = new CartDrawerPage(authenticatedPage) + const checkoutPage = new CheckoutPage(authenticatedPage) + + // Open cart from any page + await authenticatedPage.goto('/') + await cartDrawer.openCart() + await cartDrawer.assertDrawerOpen() + + // Verify multiple items present + const itemCount = await cartDrawer.cartItems.count() + expect(itemCount).toBeGreaterThanOrEqual(1) + + // Navigate to checkout + await cartDrawer.clickCheckout() + await checkoutPage.assertOnStep('review') + + // Complete authenticated checkout + await checkoutPage.completeAuthenticatedCheckout() + await checkoutPage.assertOrderComplete() + + // Cart should be cleared after successful checkout + const remainingItems = await getCartItems(authenticatedPage) + expect(remainingItems.length).toBe(0) + }) + + test('should award votes after completing gift card purchase flow', async ({ + authenticatedPage, + }) => { + clearTrackedRequests() + await mockCheckoutSuccess(authenticatedPage, MOCK_CHECKOUT_WITH_VOTES) + await mockAllShopEndpoints(authenticatedPage) + await seedCart(authenticatedPage, [MOCK_CART_GIFT_CARD_100]) + + const cartDrawer = new CartDrawerPage(authenticatedPage) + const checkoutPage = new CheckoutPage(authenticatedPage) + + // Open cart and checkout + await authenticatedPage.goto('/shop/gift-cards') + await cartDrawer.openCart() + await cartDrawer.clickCheckout() + + // Complete checkout + await checkoutPage.completeAuthenticatedCheckout() + await checkoutPage.assertOrderComplete() + + // Votes should be awarded + await checkoutPage.assertVotesAwarded(MOCK_CHECKOUT_WITH_VOTES.votesAwarded!) + + // Verify checkout API was called + const requests = getTrackedRequests() + const checkoutRequest = requests.find((r) => r.endpoint.includes('checkout')) + expect(checkoutRequest).toBeDefined() + expect(checkoutRequest!.method).toBe('POST') + }) +})