chore(e2e-auth): 🔧 Update Playwright test configs & assertions for auth flows, merchant submissions, product flows, privacy reports, 404 pages, and cart-to-checkout processes

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-01-30 17:21:59 -08:00
parent 16eb8891b0
commit 5a357f0241
8 changed files with 457 additions and 132 deletions

View file

@ -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<AuthFixtures>({
// 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 });

View file

@ -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<void> {
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}`
);
}
}

View file

@ -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',

View file

@ -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)
}
})
})
})

View file

@ -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 () => {

View file

@ -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()
})
})

View file

@ -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()
})
})

View file

@ -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')
})
})