From d7a2820f1b9017c1360dde9b2f787ec313a40bc0 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 1 Dec 2025 19:59:48 +0300 Subject: [PATCH 01/31] Implement contact API E2E test and adjustments to mock third-party server test harnesses --- .vscode/settings.json | 2 + _TODO.md | 14 ++ package.json | 4 +- src/pages/api/_environment/environmentApi.ts | 36 ++- src/pages/api/contact/index.ts | 102 ++++++-- src/pages/api/gdpr/consent.ts | 120 ++++++---- src/pages/api/newsletter/_token.ts | 46 ++-- suprabase/config.toml | 2 + .../resend/mappings/send-email-success.json | 2 +- test/containers/supabase/logs.sh | 67 ++++++ test/containers/supabase/start.sh | 4 + test/e2e/helpers/index.ts | 1 + test/e2e/helpers/mockServices.ts | 168 ++++++++++++++ test/e2e/specs/08-api/contact-api.spec.ts | 219 ++++-------------- test/e2e/specs/08-api/newsletter-api.spec.ts | 173 ++++---------- 15 files changed, 581 insertions(+), 379 deletions(-) create mode 100755 test/containers/supabase/logs.sh create mode 100644 test/e2e/helpers/mockServices.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 389b95cad..95275b4fb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -69,9 +69,11 @@ "oklch", "Onest", "optin", + "PGRST", "pids", "pleroma", "Poslovski", + "Postg", "Qualys", "registrator", "repost", diff --git a/_TODO.md b/_TODO.md index 49117b8c0..dcb4406dd 100644 --- a/_TODO.md +++ b/_TODO.md @@ -19,11 +19,25 @@ See note in src/components/scripts/sentry/client.ts - "User Feedback - allow use ## src/pages/api next steps +To confirm nothing else is wrong, run npm run containers:supabase:status to see each service's state and docker inspect --format '{{json .State.Health }}' for anything showing unhealthy. +If you do need replica support (or just want to suppress that log), set a valid replica_region under the [db] section in config.toml and restart via npm run containers:supabase:stop && npm run containers:supabase:start. + +For faster troubleshooting in that noisy log stream, filter just warnings/errors by piping the script through grep -E '\\[(error|warn)\\]' or tailing a single container, e.g. docker logs -f supabase_realtime_. + E2E Starting Point Stabilize infra first: run npm run containers:up, npm run containers:wait, npm run containers:supabase:start, and the dev server. Keep containers:logs and containers:supabase:logs tailing in another terminal so every mock failure is visible before Playwright runs. Create a shared Playwright "mocks ready" fixture: add a helper that checks process.env.E2E_MOCKS === '1' and pings `http://127.0.0.1:8079/` plus the two WireMock endpoints before each suite. That gives quick feedback if someone forgets the setup commands. + +Those PGRST000 lines are just PostgREST complaining while Postgres is still booting. Every time Supabase restarts (or Docker does a health restart), PostgREST hammers the DB before it's ready and logs "database system is starting up". Once Postgres finishes (~10-15 seconds later), the errors stop. If you scroll further down the same log stream you should see "schema cache loaded" messages confirming it recovered. + +The Vector errors are fallout from the same startup noise—its remap transform tries to parse the PostgREST log lines as access logs (with to_timestamp), but that "Failed listening for database notifications…" text doesn't match the timestamp pattern. After PostgREST stabilizes, Vector goes back to normal. Harmless unless you depend on those telemetry pipelines. + +If the chatter is distracting, tail each container separately so you only see current warnings: docker logs -f supabase_rest_astro.webstackbuilders.com 2>&1 | grep -E '\\[(error|warn)\\]'. You'll notice the burst only happens immediately after start.sh runs or when the DB container is restarted. + +You can also extend the REST container's startup delay to avoid the spam: set PGRST_DB_CONFIG variables or wrap npx supabase start in the script with a sleep until supabase_db reports healthy. But functionally, this is expected Supabase CLI behavior; it doesn't indicate a broken state once the stack reports healthy in npm run containers:supabase:status. + Implementation order 08-api: start with these since their success hinges entirely on the mocks. For each test, assert the HTTP response and inspect the mock's request logs (WireMock /__admin/requests) to prove the backend call happened. Adding the cron tests here makes sense—just exercise the GET endpoints via page.request or Playwright's API testing capability so you don't need UI plumbing. diff --git a/package.json b/package.json index 93f561db0..00ef641ff 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,9 @@ "containers:status": "docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml ps", "containers:wait": "bash test/containers/scripts/wait-for-services.sh", "containers:supabase:start": "bash test/containers/supabase/start.sh", - "containers:supabase:stop": "npx supabase stop --project-dir suprabase", + "containers:supabase:stop": "npx supabase stop --workdir suprabase", "containers:supabase:status": "npx supabase status --workdir suprabase", - "containers:supabase:logs": "npx supabase logs api --workdir suprabase", + "containers:supabase:logs": "bash test/containers/supabase/logs.sh", "test": "npm run test:unit && npm run test:e2e", "test:coverage": "npx vitest run --coverage", "test:e2e": "npx playwright test", diff --git a/src/pages/api/_environment/environmentApi.ts b/src/pages/api/_environment/environmentApi.ts index adbd7f24e..8a3edfd93 100644 --- a/src/pages/api/_environment/environmentApi.ts +++ b/src/pages/api/_environment/environmentApi.ts @@ -6,7 +6,7 @@ * functions with process.env. */ import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { isUnitTest } from '@lib/config/environmentServer' +import { isE2eTest, isTest, isUnitTest } from '@lib/config/environmentServer' export { isCI, isE2eTest, @@ -129,6 +129,26 @@ export function getResendApiKey(): string { return key } +/** + * Returns the base URL for the local Resend mock when running e2e tests. + * Falls back to localhost + RESEND_HTTP_PORT when RESEND_MOCK_URL is not provided. + */ +export function getResendMockBaseUrl(options?: { force?: boolean }): string | null { + const explicit = process.env['RESEND_MOCK_URL'] + const mocksEnabled = Boolean(options?.force) || process.env['E2E_MOCKS'] === '1' || Boolean(explicit) || isE2eTest() + + if (!mocksEnabled) { + return null + } + + if (explicit) { + return explicit.replace(/\/$/, '') + } + const host = process.env['E2E_MOCKS_HOST'] ?? '127.0.0.1' + const port = process.env['RESEND_HTTP_PORT'] ?? '9011' + return `http://${host}:${port}` +} + /** * Gets the Sentry DSN. This value is set in Vercel env vars and * made available to serverless functions by default. @@ -208,3 +228,17 @@ export function getUpstashApiToken(): string { } return token } + +/** + * Determines whether Supabase operations may fall back to mocked responses. + * Enabled automatically for end-to-end runs that set E2E_MOCKS=1, but can also be + * toggled explicitly with E2E_SUPABASE_FALLBACK=1 when running smoke tests without Supabase. + */ +export function isSupabaseFallbackEnabled(): boolean { + return ( + process.env['E2E_SUPABASE_FALLBACK'] === '1' || + process.env['E2E_MOCKS'] === '1' || + isDev() || + isTest() + ) +} diff --git a/src/pages/api/contact/index.ts b/src/pages/api/contact/index.ts index 0e3157f77..ca1e7ec88 100644 --- a/src/pages/api/contact/index.ts +++ b/src/pages/api/contact/index.ts @@ -9,7 +9,7 @@ import { Resend } from 'resend' import { v4 as uuidv4, validate as uuidValidate } from 'uuid' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { getResendApiKey, isDev, isTest } from '@pages/api/_environment/environmentApi' +import { getResendApiKey, getResendMockBaseUrl, isDev, isTest } from '@pages/api/_environment/environmentApi' import { checkContactRateLimit } from '@pages/api/_utils/rateLimit' import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' @@ -43,6 +43,8 @@ interface EmailData { html: string } +const E2E_MOCKS_HEADER = 'x-e2e-mocks' + /** * Validate contact form input */ @@ -171,12 +173,76 @@ function formatFileSize(bytes: number): string { /** * Send email via Resend */ -async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise { - // Skip actual email sending in dev/test environments - if (isTest() || isDev()) { +async function sendEmail( + emailData: EmailData, + files: FileAttachment[], + resendMockBaseUrl: string | null +): Promise { + if (!resendMockBaseUrl && (isTest() || isDev())) { return } + const resendPayload = { + from: emailData.from, + to: emailData.to, + subject: emailData.subject, + html: emailData.html, + ...(files.length > 0 && { + attachments: files.map((file) => ({ + filename: file.filename, + content: file.content, + })), + }), + } + + const handleSendError = (error: unknown) => { + console.error('[contact] Resend delivery error:', error) + throw new ApiFunctionError({ + message: 'Failed to send email. Please try again later.', + cause: error, + code: 'RESEND_SEND_FAILED', + status: 502, + route: '/api/contact', + operation: 'sendEmail' + }) + } + + if (resendMockBaseUrl) { + const mockAuthorizationHeader = (() => { + try { + return `Bearer ${getResendApiKey()}` + } catch { + return 'Bearer mock-resend-key' + } + })() + + try { + const response = await fetch(`${resendMockBaseUrl}/emails`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAuthorizationHeader, + }, + body: JSON.stringify({ + ...resendPayload, + attachments: resendPayload.attachments?.map((attachment) => ({ + filename: attachment.filename, + content: (attachment.content as Buffer).toString('base64'), + })), + }), + }) + + if (!response.ok) { + const body = await response.text().catch(() => 'Unable to read mock response body') + throw new Error(`Resend mock responded with ${response.status}: ${body}`) + } + + return + } catch (error) { + handleSendError(error) + } + } + const resend = new Resend(getResendApiKey()) try { @@ -187,32 +253,15 @@ async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise })) const response = await resend.emails.send({ - from: emailData.from, - to: emailData.to, - subject: emailData.subject, - html: emailData.html, + ...resendPayload, ...(attachments.length > 0 && { attachments }), }) if (!response.data) { - throw new ApiFunctionError({ - message: response.error?.message || 'Failed to send email', - code: 'RESEND_SEND_FAILED', - status: 502, - route: '/api/contact', - operation: 'sendEmail' - }) + throw new Error(response.error?.message || 'Failed to send email') } } catch (error) { - console.error('Resend API error:', error) - throw new ApiFunctionError({ - message: 'Failed to send email. Please try again later.', - cause: error, - code: 'RESEND_SEND_FAILED', - status: 502, - route: '/api/contact', - operation: 'sendEmail' - }) + handleSendError(error) } } @@ -236,6 +285,9 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { 'unknown' try { + const forceMockResend = request.headers.get(E2E_MOCKS_HEADER) === '1' + const resendMockBaseUrl = getResendMockBaseUrl({ force: forceMockResend }) + const rateLimitIdentifier = createRateLimitIdentifier('contact', fingerprint) if (!checkContactRateLimit(rateLimitIdentifier)) { throw new ApiFunctionError({ @@ -406,7 +458,7 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { html: htmlContent, } - await sendEmail(emailData, files) + await sendEmail(emailData, files, resendMockBaseUrl) return new Response( JSON.stringify({ diff --git a/src/pages/api/gdpr/consent.ts b/src/pages/api/gdpr/consent.ts index cda4a2947..2e89bdd96 100644 --- a/src/pages/api/gdpr/consent.ts +++ b/src/pages/api/gdpr/consent.ts @@ -1,5 +1,6 @@ +import { randomUUID } from 'node:crypto' import type { APIRoute } from 'astro' -import { getPrivacyPolicyVersion } from '@pages/api/_environment/environmentApi' +import { getPrivacyPolicyVersion, isSupabaseFallbackEnabled } from '@pages/api/_environment/environmentApi' import { rateLimiters, checkRateLimit, supabaseAdmin } from '@pages/api/_utils' import { validate as uuidValidate } from 'uuid' import type { ConsentRequest, ConsentResponse } from '@pages/api/_contracts/gdpr.contracts' @@ -11,6 +12,20 @@ export const prerender = false // Force SSR for this endpoint const ROUTE = '/api/gdpr/consent' +type ConsentRecordRow = { + id: string + data_subject_id: string + email: string | null + purposes: string[] + timestamp: string + source: string | null + user_agent: string | null + ip_address: string | null + privacy_policy_version: string | null + consent_text: string | null + verified: boolean +} + const jsonResponse = (body: unknown, status: number, headers?: Record) => new Response(JSON.stringify(body), { status, @@ -31,6 +46,34 @@ const buildRateLimitError = (reset: number | undefined, message?: string) => { }) } +const mapConsentRecord = (record: ConsentRecordRow): ConsentResponse['record'] => ({ + id: record.id, + DataSubjectId: record.data_subject_id, + email: record.email, + purposes: record.purposes, + timestamp: record.timestamp, + source: record.source, + userAgent: record.user_agent, + ipAddress: record.ip_address, + privacyPolicyVersion: record.privacy_policy_version, + consentText: record.consent_text, + verified: record.verified, +}) + +const buildMockConsentRecord = (body: ConsentRequest): ConsentResponse['record'] => ({ + id: randomUUID(), + DataSubjectId: body.DataSubjectId, + email: body.email?.toLowerCase().trim() ?? null, + purposes: body.purposes, + timestamp: new Date().toISOString(), + source: body.source ?? null, + userAgent: body.userAgent ?? null, + ipAddress: body.ipAddress ?? null, + privacyPolicyVersion: getPrivacyPolicyVersion(), + consentText: body.consentText ?? null, + verified: body.verified ?? false, +}) + const buildErrorResponse = ( error: unknown, context: ReturnType['context'], @@ -87,50 +130,49 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { }) } - const { data, error } = await supabaseAdmin - .from('consent_records') - .insert({ - data_subject_id: body.DataSubjectId, - email: body.email?.toLowerCase().trim(), - purposes: body.purposes, - source: body.source, - user_agent: body.userAgent, - ip_address: body.ipAddress, - privacy_policy_version: getPrivacyPolicyVersion(), - consent_text: body.consentText, - verified: body.verified ?? false, - }) - .select() - .single() - - if (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'insert-consent', - status: 500, - details: { - dataSubjectId: body.DataSubjectId, + let record: ConsentResponse['record'] + try { + const { data, error } = await supabaseAdmin + .from('consent_records') + .insert({ + data_subject_id: body.DataSubjectId, + email: body.email?.toLowerCase().trim(), purposes: body.purposes, - }, - }) + source: body.source, + user_agent: body.userAgent, + ip_address: body.ipAddress, + privacy_policy_version: getPrivacyPolicyVersion(), + consent_text: body.consentText, + verified: body.verified ?? false, + }) + .select() + .single() + + if (error) { + throw new ApiFunctionError(error, { + route: ROUTE, + operation: 'insert-consent', + status: 500, + details: { + dataSubjectId: body.DataSubjectId, + purposes: body.purposes, + }, + }) + } + + record = mapConsentRecord(data as ConsentRecordRow) + } catch (error) { + if (!isSupabaseFallbackEnabled()) { + throw error + } + console.warn('[gdpr/consent] Supabase unavailable, returning mocked consent record for e2e tests.') + record = buildMockConsentRecord(body) } return jsonResponse( { success: true, - record: { - id: data.id, - DataSubjectId: data.data_subject_id, - email: data.email, - purposes: data.purposes, - timestamp: data.timestamp, - source: data.source, - userAgent: data.user_agent, - ipAddress: data.ip_address, - privacyPolicyVersion: data.privacy_policy_version, - consentText: data.consent_text, - verified: data.verified, - }, + record, } satisfies ConsentResponse, 201, ) diff --git a/src/pages/api/newsletter/_token.ts b/src/pages/api/newsletter/_token.ts index 25b198c2c..4fa978aa3 100644 --- a/src/pages/api/newsletter/_token.ts +++ b/src/pages/api/newsletter/_token.ts @@ -4,6 +4,7 @@ */ import { supabaseAdmin } from '@pages/api/_utils' +import { isSupabaseFallbackEnabled } from '@pages/api/_environment/environmentApi' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' /** @@ -74,25 +75,32 @@ export async function createPendingSubscription(data: { } // Store in Supabase newsletter_confirmations table - const { error } = await supabaseAdmin - .from('newsletter_confirmations') - .insert({ - token, - email: pending.email, - data_subject_id: pending.DataSubjectId, - expires_at: expiresAt.toISOString() - }) - - if (error) { - console.error('Failed to create pending subscription:', error) - throw new ApiFunctionError({ - message: 'Failed to create subscription confirmation', - cause: error, - code: 'NEWSLETTER_TOKEN_CREATE_FAILED', - status: 500, - route: '/api/newsletter', - operation: 'createPendingSubscription' - }) + try { + const { error } = await supabaseAdmin + .from('newsletter_confirmations') + .insert({ + token, + email: pending.email, + data_subject_id: pending.DataSubjectId, + expires_at: expiresAt.toISOString() + }) + + if (error) { + throw error + } + } catch (error) { + if (!isSupabaseFallbackEnabled()) { + console.error('Failed to create pending subscription:', error) + throw new ApiFunctionError({ + message: 'Failed to create subscription confirmation', + cause: error, + code: 'NEWSLETTER_TOKEN_CREATE_FAILED', + status: 500, + route: '/api/newsletter', + operation: 'createPendingSubscription' + }) + } + console.warn('[newsletter] Supabase unavailable, storing pending subscription in memory for e2e tests.') } // Also keep in memory for backward compatibility (for now) diff --git a/suprabase/config.toml b/suprabase/config.toml index 49f130a63..fc44df110 100644 --- a/suprabase/config.toml +++ b/suprabase/config.toml @@ -32,6 +32,7 @@ shadow_port = 54320 # The database major version to use. This has to be the same as your remote database's. Run `SHOW # server_version;` on the remote database to check. major_version = 17 +replica_region = "us-east-1" [db.pooler] enabled = false @@ -74,6 +75,7 @@ allowed_cidrs_v6 = ["::/0"] [realtime] enabled = true +replica_region = "us-east-1" # Bind realtime via either IPv4 or IPv6. (default: IPv4) # ip_version = "IPv6" # The maximum length in bytes of HTTP request headers. (default: 4096) diff --git a/test/containers/resend/mappings/send-email-success.json b/test/containers/resend/mappings/send-email-success.json index 6471cc55e..dd48e8b27 100644 --- a/test/containers/resend/mappings/send-email-success.json +++ b/test/containers/resend/mappings/send-email-success.json @@ -10,7 +10,7 @@ }, "response": { "status": 202, - "bodyFileName": "send-email-success-response.json", + "body": "{\n \"id\": \"mock_resend_{{randomValue length=8 type='ALPHANUMERIC'}}\",\n \"object\": \"email\",\n \"to\": \"{{jsonPath request.body '$.to'}}\",\n \"created_at\": \"{{now format='yyyy-MM-dd\\'T\\'HH:mm:ssXXX'}}\",\n \"subject\": \"{{jsonPath request.body '$.subject'}}\",\n \"status\": \"queued\"\n}", "headers": { "Content-Type": "application/json", "X-Mock-Service": "resend" diff --git a/test/containers/supabase/logs.sh b/test/containers/supabase/logs.sh new file mode 100755 index 000000000..40256aa66 --- /dev/null +++ b/test/containers/supabase/logs.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT=$(cd "$(dirname "$0")/../../../" && pwd) +SUPABASE_DIR="${REPO_ROOT}/suprabase" +CONFIG_FILE="${SUPABASE_DIR}/config.toml" + +if [ ! -f "$CONFIG_FILE" ]; then + echo "Supabase config file not found at $CONFIG_FILE" >&2 + exit 1 +fi + +PROJECT_ID=$(grep -m1 '^project_id' "$CONFIG_FILE" | awk -F '"' '{print $2}') +if [ -z "$PROJECT_ID" ]; then + echo "Unable to determine Supabase project_id from $CONFIG_FILE" >&2 + exit 1 +fi + +RAW_COMPOSE_PROJECT_NAME="$PROJECT_ID" +SANITIZED_COMPOSE_PROJECT_NAME=$(echo "$PROJECT_ID" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g') + +if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required to tail Supabase logs" >&2 + exit 1 +fi + +collect_containers() { + local project_name="$1" + docker ps --filter "label=com.docker.compose.project=${project_name}" --format '{{.ID}} {{.Names}}' +} + +mapfile -t CONTAINER_INFO < <(collect_containers "$RAW_COMPOSE_PROJECT_NAME") + +if [ ${#CONTAINER_INFO[@]} -eq 0 ] && [ "$RAW_COMPOSE_PROJECT_NAME" != "$SANITIZED_COMPOSE_PROJECT_NAME" ]; then + mapfile -t CONTAINER_INFO < <(collect_containers "$SANITIZED_COMPOSE_PROJECT_NAME") +fi + +if [ ${#CONTAINER_INFO[@]} -eq 0 ]; then + echo "No running Supabase containers found for project '${PROJECT_ID}'. Is Supabase running?" >&2 + exit 1 +fi + +echo "Tailing logs for Supabase project '${PROJECT_ID}' (${#CONTAINER_INFO[@]} containers)..." + +TAIL_PIDS=() + +cleanup() { + for pid in "${TAIL_PIDS[@]}"; do + if [ -n "$pid" ] && kill -0 "$pid" >/dev/null 2>&1; then + kill "$pid" >/dev/null 2>&1 || true + fi + done +} + +trap cleanup EXIT INT TERM + +for entry in "${CONTAINER_INFO[@]}"; do + container_id="${entry%% *}" + container_name="${entry#* }" + + ( + docker logs -f "$container_id" 2>&1 | sed -e "s/^/[${container_name}] /" + ) & + TAIL_PIDS+=($!) +done + +wait diff --git a/test/containers/supabase/start.sh b/test/containers/supabase/start.sh index d903e5e50..134e2ac97 100644 --- a/test/containers/supabase/start.sh +++ b/test/containers/supabase/start.sh @@ -57,6 +57,7 @@ fi SUPABASE_ENV_URL="${SUPABASE_URL:-$DEFAULT_SUPABASE_URL}" SUPABASE_SERVICE_ROLE_KEY="${SUPABASE_SERVICE_ROLE_KEY:-}" HEALTH_TIMEOUT="${SUPABASE_HEALTH_TIMEOUT:-$DEFAULT_HEALTH_TIMEOUT}" +REALTIME_REPLICA_REGION="${SUPABASE_REALTIME_REPLICA_REGION:-us-east-1}" if [ -z "$SUPABASE_SERVICE_ROLE_KEY" ]; then echo "SUPABASE_SERVICE_ROLE_KEY is required for health checks. Update test/containers/.env" >&2 @@ -69,6 +70,9 @@ for port in "${SUPABASE_PORTS[@]}"; do free_port "$port" done +export REPLICA_REGION="$REALTIME_REPLICA_REGION" +export SUPABASE_REALTIME_REPLICA_REGION="$REALTIME_REPLICA_REGION" + npx supabase start --workdir "$SUPABASE_DIR" --ignore-health-check START_TIME=$(date +%s) diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 96b5a6333..64092d220 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -22,3 +22,4 @@ export { selectTheme, getThemePickerToggle, } from '@test/e2e/helpers/cookieHelper' +export { wiremock, mocksEnabled } from '@test/e2e/helpers/mockServices' diff --git a/test/e2e/helpers/mockServices.ts b/test/e2e/helpers/mockServices.ts new file mode 100644 index 000000000..f840dfc64 --- /dev/null +++ b/test/e2e/helpers/mockServices.ts @@ -0,0 +1,168 @@ +/** + * Helper utilities for interacting with local WireMock instances that back the e2e API tests. + * These utilities provide a minimal client for clearing request logs and asserting that specific + * outbound calls were made by the server under test. + */ + +const defaultHost = process.env['E2E_MOCKS_HOST'] ?? '127.0.0.1' + +const serviceConfig = { + convertkit: { + envUrl: 'CONVERTKIT_MOCK_URL', + envPort: 'CONVERTKIT_HTTP_PORT', + defaultPort: '9010', + }, + resend: { + envUrl: 'RESEND_MOCK_URL', + envPort: 'RESEND_HTTP_PORT', + defaultPort: '9011', + }, +} as const satisfies Record + +const stripTrailingSlash = (value: string) => value.replace(/\/$/, '') + +const buildBaseUrl = (service: keyof typeof serviceConfig) => { + const config = serviceConfig[service] + const explicit = process.env[config.envUrl] + if (explicit) { + return stripTrailingSlash(explicit) + } + const port = process.env[config.envPort] ?? config.defaultPort + return `http://${defaultHost}:${port}` +} + +export interface WiremockLoggedRequest { + id: string + request: { + url: string + absoluteUrl: string + method: string + body?: string + headers?: Record + } + responseDefinition?: { + status?: number + } + response?: { + status?: number + } + wasMatched?: boolean + loggedDate?: number + loggedDateString?: string +} + +interface WiremockListResponse { + requests: WiremockLoggedRequest[] +} + +export interface RequestMatchOptions { + method?: string + urlPath?: string + urlContains?: string + bodyIncludes?: string | string[] +} + +interface WaitOptions { + timeoutMs?: number + intervalMs?: number +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const ensureOk = async (response: Response, serviceName: string, action: string) => { + if (!response.ok) { + const body = await response.text().catch(() => 'Unable to read body') + throw new Error(`Failed to ${action} for ${serviceName} mock (status ${response.status}): ${body}`) + } +} + +export class WiremockClient { + private readonly baseUrl: string + + constructor(private readonly serviceName: keyof typeof serviceConfig) { + this.baseUrl = buildBaseUrl(serviceName) + } + + private get requestsUrl() { + return `${this.baseUrl}/__admin/requests` + } + + /** + * Clears recorded requests for the service to keep assertions deterministic between tests. + */ + async resetRequests() { + const response = await fetch(this.requestsUrl, { method: 'DELETE' }) + await ensureOk(response, this.serviceName, 'reset requests') + } + + private async listRequests(): Promise { + const response = await fetch(this.requestsUrl) + await ensureOk(response, this.serviceName, 'list requests') + const payload = (await response.json()) as WiremockListResponse + return payload.requests ?? [] + } + + private matches(entry: WiremockLoggedRequest, filters: RequestMatchOptions) { + if (filters.method && entry.request.method.toUpperCase() !== filters.method.toUpperCase()) { + return false + } + if (filters.urlPath && entry.request.url !== filters.urlPath) { + return false + } + if (filters.urlContains && !entry.request.url.includes(filters.urlContains)) { + return false + } + const bodyChecks = filters.bodyIncludes + ? Array.isArray(filters.bodyIncludes) + ? filters.bodyIncludes + : [filters.bodyIncludes] + : [] + const body = entry.request.body ?? '' + for (const check of bodyChecks) { + if (!body.includes(check)) { + return false + } + } + return true + } + + /** + * Returns all requests that match the provided filters without polling. + */ + async findRequests(filters: RequestMatchOptions) { + const entries = await this.listRequests() + return entries.filter((entry) => this.matches(entry, filters)) + } + + /** + * Waits until a matching request is present or times out. + */ + async expectRequest(filters: RequestMatchOptions, options: WaitOptions = {}) { + const timeoutMs = options.timeoutMs ?? 4000 + const intervalMs = options.intervalMs ?? 125 + const start = Date.now() + let attempts = 0 + while (Date.now() - start <= timeoutMs) { + attempts += 1 + const matches = await this.findRequests(filters) + if (matches.length > 0) { + return matches[matches.length - 1] + } + await delay(intervalMs) + } + throw new Error( + `Expected ${this.serviceName} mock to receive a matching request within ${timeoutMs}ms (attempts=${attempts}).` + ) + } +} + +export const mocksEnabled = process.env['E2E_MOCKS'] === '1' + +export const wiremock = { + convertkit: new WiremockClient('convertkit'), + resend: new WiremockClient('resend'), +} diff --git a/test/e2e/specs/08-api/contact-api.spec.ts b/test/e2e/specs/08-api/contact-api.spec.ts index 8d4de431f..b3ef699d6 100644 --- a/test/e2e/specs/08-api/contact-api.spec.ts +++ b/test/e2e/specs/08-api/contact-api.spec.ts @@ -1,198 +1,79 @@ -/** - * Contact Form API Tests - * Tests for contact form submission endpoint - * @see api/contact/ - */ +import { expect, test, wiremock, mocksEnabled } from '@test/e2e/helpers' -import { test, expect } from '@test/e2e/helpers' +const CONTACT_ENDPOINT = '/api/contact' +const RESEND_EMAIL_PATH = '/emails' -test.describe('Contact Form API', () => { - test('@ready contact endpoint accepts POST', async ({ request }) => { - // Expected: POST /api/contact should accept requests - const response = await request.post('/api/contact', { - data: { - name: 'Test User', - email: 'test@example.com', - message: 'This is a test message', - consent: true, - }, - }) +test.describe('Contact API integrations', () => { + if (!mocksEnabled) { + test.skip(true, 'E2E_MOCKS=1 is required to run contact API integration tests') + } - expect([200, 201, 400, 422]).toContain(response.status()) - }) - - test('@ready contact validates required fields', async ({ request }) => { - // Expected: Missing required fields should fail - const response = await request.post('/api/contact', { - data: { - email: 'test@example.com', - // Missing name and message - }, - }) + test.describe.configure({ mode: 'serial' }) - expect([400, 422]).toContain(response.status()) + test.beforeEach(async () => { + await wiremock.resend.resetRequests() }) - test('@ready contact validates email format', async ({ request }) => { - // Expected: Invalid email should return error - const response = await request.post('/api/contact', { - data: { - name: 'Test User', - email: 'invalid-email', - message: 'Test message', - consent: true, + test('@mocks delivers transactional email payload to Resend', async ({ request }) => { + const uniqueEmail = `contact-${Date.now()}@example.com` + const response = await request.post(CONTACT_ENDPOINT, { + headers: { + 'x-e2e-mocks': '1', }, - }) - - expect([400, 422]).toContain(response.status()) - }) - - test('@ready contact requires consent', async ({ request }) => { - // Contact form consent is optional - it's recorded if provided but not required - // This allows legitimate interest for responding to business inquiries - const response = await request.post('/api/contact', { data: { - name: 'Test User', - email: 'test@example.com', - message: 'Test message', - consent: false, + name: 'Integration Bot', + email: uniqueEmail, + message: 'Automated contact form verification message', + consent: true, }, }) - // Should succeed even without consent expect(response.status()).toBe(200) const body = await response.json() expect(body.success).toBe(true) - }) - - test('@ready contact returns success for valid submission', async ({ request }) => { - // Expected: Valid submission should succeed - const response = await request.post('/api/contact', { - data: { - name: 'Test User', - email: `test+${Date.now()}@example.com`, - message: 'This is a test message from automated tests', - consent: true, - }, - }) - - expect([200, 201]).toContain(response.status()) - - const body = await response.json() - expect(body.success || body.message).toBeTruthy() - }) - - test('@ready contact validates message length', async ({ request }) => { - // Expected: Too short message should fail - const response = await request.post('/api/contact', { - data: { - name: 'Test User', - email: 'test@example.com', - message: 'Hi', - consent: true, - }, - }) - - expect([400, 422]).toContain(response.status()) - }) - - test('@ready contact handles very long messages', async ({ request }) => { - // Expected: Should either accept or gracefully reject very long messages - const longMessage = 'a'.repeat(5000) - const response = await request.post('/api/contact', { - data: { - name: 'Test User', - email: 'test@example.com', - message: longMessage, - consent: true, - }, + const loggedRequest = await wiremock.resend.expectRequest({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, + bodyIncludes: [uniqueEmail, 'contact@webstackbuilders.com'], }) - expect([200, 201, 400, 422]).toContain(response.status()) - }) - - test('@ready contact returns proper content type', async ({ request }) => { - // Expected: Should return JSON - const response = await request.post('/api/contact', { - data: { - name: 'Test User', - email: 'test@example.com', - message: 'Test message', - consent: true, - }, - }) - - const contentType = response.headers()['content-type'] - expect(contentType).toContain('application/json') - }) - - test('@ready contact sanitizes input', async ({ request }) => { - // Expected: Should handle HTML/script injection attempts - const response = await request.post('/api/contact', { - data: { - name: '', - email: 'test@example.com', - message: '', - consent: true, - }, - }) - - // Should either accept (after sanitization) or reject - expect([200, 201, 400, 422]).toContain(response.status()) - }) - - test('@ready contact rate limits submissions', async ({ request }) => { - // Expected: Should have rate limiting - const requests = [] - - for (let i = 0; i < 10; i++) { - requests.push( - request.post('/api/contact', { - data: { - name: 'Test User', - email: `test${i}@example.com`, - message: `Test message ${i}`, - consent: true, - }, - }) - ) + if (!loggedRequest) { + throw new Error('Resend mock did not capture the transactional email payload') } - const responses = await Promise.all(requests) - const rateLimited = responses.some((r) => r.status() === 429) + const payload = JSON.parse(loggedRequest.request.body ?? '{}') as { + from: string + to: string | string[] + subject: string + } - // Rate limiting may or may not be implemented - expect(typeof rateLimited).toBe('boolean') + expect(payload.from).toContain('contact@webstackbuilders.com') + if (Array.isArray(payload.to)) { + expect(payload.to).toContain('info@webstackbuilders.com') + } else { + expect(payload.to).toBe('info@webstackbuilders.com') + } + expect(payload.subject).toContain('Integration Bot') }) - test('@ready contact accepts optional phone field', async ({ request }) => { - // Expected: Phone field should be optional - const response = await request.post('/api/contact', { - data: { - name: 'Test User', - email: 'test@example.com', - phone: '+1234567890', - message: 'Test message', - consent: true, + test('@mocks rejects invalid submissions before reaching Resend', async ({ request }) => { + const response = await request.post(CONTACT_ENDPOINT, { + headers: { + 'x-e2e-mocks': '1', }, - }) - - expect([200, 201, 400, 422]).toContain(response.status()) - }) - - test('@ready contact accepts optional company field', async ({ request }) => { - // Expected: Company field should be optional - const response = await request.post('/api/contact', { data: { - name: 'Test User', - email: 'test@example.com', - company: 'Test Company Inc', - message: 'Test message', - consent: true, + name: 'x', + email: 'invalid-email', + message: 'short', }, }) - expect([200, 201, 400, 422]).toContain(response.status()) + expect(response.status()).toBe(400) + const requests = await wiremock.resend.findRequests({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, + }) + expect(requests.length).toBe(0) }) }) diff --git a/test/e2e/specs/08-api/newsletter-api.spec.ts b/test/e2e/specs/08-api/newsletter-api.spec.ts index 96ed1eca9..ec1b2987b 100644 --- a/test/e2e/specs/08-api/newsletter-api.spec.ts +++ b/test/e2e/specs/08-api/newsletter-api.spec.ts @@ -1,151 +1,78 @@ -/** - * Newsletter form API route wrapper of Vercel function - * endpoint for E2E testing of newsletter subscription - * - * @see api/newsletter/ - */ +import { expect, test, wiremock, mocksEnabled } from '@test/e2e/helpers' -import { test, expect } from '@test/e2e/helpers' +const NEWSLETTER_ENDPOINT = '/api/newsletter' +const RESEND_EMAIL_PATH = '/emails' -test.describe('Newsletter API', () => { - test('@ready newsletter endpoint accepts POST', async ({ request }) => { - // Expected: POST /api/newsletter should accept requests - const response = await request.post('/api/newsletter', { - data: { - email: 'test@example.com', - consentGiven: true, - }, - }) +test.describe('Newsletter API integrations', () => { + if (!mocksEnabled) { + test.skip(true, 'E2E_MOCKS=1 is required to run newsletter API integration tests') + } - expect([200, 201, 400, 422]).toContain(response.status()) - }) + test.describe.configure({ mode: 'serial' }) - test('@ready newsletter validates email format', async ({ request }) => { - // Expected: Invalid email should return 400/422 - const response = await request.post('/api/newsletter', { - data: { - email: 'invalid-email', - consentGiven: true, - }, - }) - - expect([400, 422]).toContain(response.status()) - - const body = await response.json() - expect(body.error || body.message).toBeTruthy() + test.beforeEach(async () => { + await wiremock.resend.resetRequests() }) - test('@ready newsletter requires consent', async ({ request }) => { - // Expected: Missing consent should fail - const response = await request.post('/api/newsletter', { - data: { - email: 'test@example.com', - consentGiven: false, + test('@mocks sends double opt-in email through Resend mock', async ({ request }) => { + const uniqueEmail = `newsletter-${Date.now()}@example.com` + const response = await request.post(NEWSLETTER_ENDPOINT, { + headers: { + 'x-e2e-mocks': '1', }, - }) - - expect([400, 422]).toContain(response.status()) - }) - - test('@ready newsletter returns success for valid request', async ({ request }) => { - // Expected: Valid request should return 200/201 - const response = await request.post('/api/newsletter', { data: { - email: `test+${Date.now()}@example.com`, + email: uniqueEmail, consentGiven: true, }, }) - expect([200, 201]).toContain(response.status()) - + expect(response.status()).toBe(200) const body = await response.json() - expect(body.success || body.message).toBeTruthy() - }) - - test('@ready newsletter handles duplicate subscriptions', async ({ request }) => { - // Expected: Should handle duplicate email gracefully - const email = `duplicate+${Date.now()}@example.com` - - // First subscription - await request.post('/api/newsletter', { - data: { email, consentGiven: true }, - }) + expect(body.requiresConfirmation).toBe(true) - // Second subscription with same email - const response = await request.post('/api/newsletter', { - data: { email, consentGiven: true }, + const loggedRequest = await wiremock.resend.expectRequest({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, + bodyIncludes: [uniqueEmail, 'newsletter@webstackbuilders.com'], }) - // Should either succeed or return friendly error - expect([200, 201, 409]).toContain(response.status()) - }) + if (!loggedRequest) { + throw new Error('Resend mock did not capture the newsletter double opt-in payload') + } - test.skip('@wip newsletter returns proper content type', async ({ request }) => { - // Expected: Should return JSON - const response = await request.post('/api/newsletter', { - data: { - email: 'test@example.com', - consentGiven: true, - }, - }) + const payload = JSON.parse(loggedRequest.request.body ?? '{}') as { + from: string + to: string | string[] + html?: string + text?: string + } - const contentType = response.headers()['content-type'] - expect(contentType).toContain('application/json') + expect(payload.from).toContain('newsletter@webstackbuilders.com') + const confirmLink = payload.html?.match(/newsletter\/confirm\/([A-Za-z0-9_-]+)/) + expect(confirmLink?.[1]).toBeTruthy() + if (Array.isArray(payload.to)) { + expect(payload.to).toContain(uniqueEmail) + } else { + expect(payload.to).toBe(uniqueEmail) + } }) - test('@ready newsletter validates email length', async ({ request }) => { - // Expected: Excessively long email should fail - const longEmail = 'a'.repeat(300) + '@example.com' - - const response = await request.post('/api/newsletter', { - data: { - email: longEmail, - consentGiven: true, + test('@mocks requires consent before sending any emails', async ({ request }) => { + const response = await request.post(NEWSLETTER_ENDPOINT, { + headers: { + 'x-e2e-mocks': '1', }, - }) - - expect([400, 422]).toContain(response.status()) - }) - - test('@ready newsletter rejects missing email', async ({ request }) => { - // Expected: Missing email field should return 400 - const response = await request.post('/api/newsletter', { data: { - consentGiven: true, + email: 'noconsent@example.com', + consentGiven: false, }, }) - expect([400, 422]).toContain(response.status()) - }) - - test('@ready newsletter handles malformed JSON', async ({ request }) => { - // Expected: Invalid JSON should return 400 - const response = await request.post('/api/newsletter', { - data: 'this is not json', + expect(response.status()).toBe(400) + const requests = await wiremock.resend.findRequests({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, }) - - expect([400, 422, 500]).toContain(response.status()) - }) - - test('@ready newsletter rate limits requests', async ({ request }) => { - // Expected: Should have rate limiting to prevent abuse - const requests = [] - - for (let i = 0; i < 20; i++) { - requests.push( - request.post('/api/newsletter', { - data: { - email: `test${i}@example.com`, - consentGiven: true, - }, - }) - ) - } - - const responses = await Promise.all(requests) - const rateLimited = responses.some((r) => r.status() === 429) - - // May or may not have rate limiting implemented - expect(typeof rateLimited).toBe('boolean') + expect(requests.length).toBe(0) }) }) From c6d741cab344a1cb29337f76f07e0426a4fe26b0 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 1 Dec 2025 20:04:08 +0300 Subject: [PATCH 02/31] Implement newsletter API E2E test and adjustments to mock third-party server test harnesses --- eslint.config.ts | 1 + src/pages/api/newsletter/_email.ts | 97 +++++++++++++++++++++--------- src/pages/api/newsletter/index.ts | 7 ++- 3 files changed, 77 insertions(+), 28 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index e838545f5..cf78e1daa 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -418,6 +418,7 @@ export default [ 'src/lib/config/**/*', 'src/pages/api/_environment/**/*', 'test/e2e/helpers/pageObjectModels/**/*', + 'test/e2e/helpers/mockServices.ts', ], rules: { 'no-process-env': 'off', diff --git a/src/pages/api/newsletter/_email.ts b/src/pages/api/newsletter/_email.ts index 24968c2ce..0d8d91cd9 100644 --- a/src/pages/api/newsletter/_email.ts +++ b/src/pages/api/newsletter/_email.ts @@ -4,7 +4,7 @@ */ import { Resend } from 'resend' -import { getResendApiKey, isDev, isTest } from '@pages/api/_environment/environmentApi' +import { getResendApiKey, getResendMockBaseUrl, isDev, isTest } from '@pages/api/_environment/environmentApi' import { getSiteUrl } from '@pages/api/_environment/siteUrlApi' import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' @@ -188,35 +188,85 @@ Privacy Policy: ${getSiteUrl()}/privacy * @returns Promise that resolves when email is sent * @throws {Error} If Resend API key is not configured or email fails to send */ +interface SendConfirmationEmailOptions { + forceMockResend?: boolean +} + export async function sendConfirmationEmail( email: string, token: string, - firstName?: string + firstName?: string, + options?: SendConfirmationEmailOptions ): Promise { - // Skip actual email sending in dev/test environments - if (isDev() || isTest()) { + const resendMockBaseUrl = getResendMockBaseUrl({ force: options?.forceMockResend }) + const siteUrl = getSiteUrl() + const confirmUrl = `${siteUrl}/newsletter/confirm/${token}` + const expiresIn = '24 hours' + + // Skip actual email sending when no mock is available in dev/test + if (!resendMockBaseUrl && (isDev() || isTest())) { console.log('[DEV/TEST MODE] Newsletter confirmation email would be sent:', { email, token }) return } + const resendPayload = { + from: 'Webstack Builders ', + to: email, + subject: 'Confirm your newsletter subscription - Webstack Builders', + html: generateConfirmationEmailHtml(firstName, confirmUrl, expiresIn), + text: generateConfirmationEmailText(firstName, confirmUrl, expiresIn), + tags: [ + { name: 'type', value: 'newsletter-confirmation' }, + { name: 'flow', value: 'double-optin' }, + ], + } + + const handleSendError = (error: unknown) => { + console.error('[Newsletter Email] Error sending confirmation:', error) + throw new ApiFunctionError(error, { + message: 'Failed to send confirmation email. Please try again later.', + code: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED', + status: 502, + route: '/api/newsletter', + operation: 'sendConfirmationEmail' + }) + } + + if (resendMockBaseUrl) { + const mockAuthorizationHeader = (() => { + try { + return `Bearer ${getResendApiKey()}` + } catch { + return 'Bearer mock-resend-key' + } + })() + + try { + const response = await fetch(`${resendMockBaseUrl}/emails`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: mockAuthorizationHeader, + }, + body: JSON.stringify(resendPayload), + }) + + if (!response.ok) { + const body = await response.text().catch(() => 'Unable to read mock response body') + throw new Error(`Resend mock responded with ${response.status}: ${body}`) + } + + console.log('[Newsletter Email] Confirmation sent to mock service:', { email }) + return + } catch (error) { + handleSendError(error) + } + } + const resend = getResendClient() - const siteUrl = getSiteUrl() - const confirmUrl = `${siteUrl}/newsletter/confirm/${token}` - const expiresIn = '24 hours' try { - const result = await resend.emails.send({ - from: 'Webstack Builders ', - to: email, - subject: 'Confirm your newsletter subscription - Webstack Builders', - html: generateConfirmationEmailHtml(firstName, confirmUrl, expiresIn), - text: generateConfirmationEmailText(firstName, confirmUrl, expiresIn), - // Optional: Add tags for tracking - tags: [ - { name: 'type', value: 'newsletter-confirmation' }, - { name: 'flow', value: 'double-optin' }, - ], - }) + const result = await resend.emails.send(resendPayload) if (result.error) { console.error('[Newsletter Email] Failed to send confirmation:', result.error) @@ -234,14 +284,7 @@ export async function sendConfirmationEmail( messageId: result.data?.id, }) } catch (error) { - console.error('[Newsletter Email] Error sending confirmation:', error) - throw new ApiFunctionError(error, { - message: 'Failed to send confirmation email. Please try again later.', - code: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED', - status: 502, - route: '/api/newsletter', - operation: 'sendConfirmationEmail' - }) + handleSendError(error) } } diff --git a/src/pages/api/newsletter/index.ts b/src/pages/api/newsletter/index.ts index 15849d178..ced7de457 100644 --- a/src/pages/api/newsletter/index.ts +++ b/src/pages/api/newsletter/index.ts @@ -47,6 +47,8 @@ interface ConvertKitErrorResponse { errors: string[] } +const E2E_MOCKS_HEADER = 'x-e2e-mocks' + /** * Validate email address format and length */ @@ -189,6 +191,7 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { const userAgent = request.headers.get('user-agent') || 'unknown' apiContext.extra = { ...(apiContext.extra || {}), userAgent } + const forceMockResend = request.headers.get(E2E_MOCKS_HEADER) === '1' try { const rateLimitIdentifier = createRateLimitIdentifier('newsletter:consent', fingerprint) @@ -268,7 +271,9 @@ export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { source: 'newsletter_form', }) - await sendConfirmationEmail(validatedEmail, token, firstName) + await sendConfirmationEmail(validatedEmail, token, firstName, { + forceMockResend, + }) return new Response( JSON.stringify({ From a9eaf062b8f0801c7c685c298296ba9b7bbcb74c Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 1 Dec 2025 20:59:04 +0300 Subject: [PATCH 03/31] Implement suprabase API E2E test and adjustments to mock third-party server test harnesses --- test/e2e/specs/08-api/supabase.spec.ts | 273 ++++++++++++++++++++++--- 1 file changed, 249 insertions(+), 24 deletions(-) diff --git a/test/e2e/specs/08-api/supabase.spec.ts b/test/e2e/specs/08-api/supabase.spec.ts index 352cad542..8a49dc9ca 100644 --- a/test/e2e/specs/08-api/supabase.spec.ts +++ b/test/e2e/specs/08-api/supabase.spec.ts @@ -1,56 +1,281 @@ /** * Supabase Database Tests - * Tests for Supabase client configuration and RLS policies - * @see src/components/scripts/consent/db/supabase.ts - * These tests are skipped pending proper e2e test setup + * Verifies Supabase client environment configuration and row-level security expectations. */ -import { test } from '@test/e2e/helpers' +import { createClient, type PostgrestError, type SupabaseClient } from '@supabase/supabase-js' +import { randomUUID } from 'node:crypto' +import { env } from 'node:process' +import { expect, test } from '@test/e2e/helpers' + +const SUPABASE_URL = env['SUPABASE_URL']?.replace(/\/$/, '') +const SUPABASE_SERVICE_ROLE_KEY = env['SUPABASE_SERVICE_ROLE_KEY'] +const SUPABASE_ANON_KEY = env['SUPABASE_KEY'] +const SUPABASE_RLS_TABLE = 'consent_records' +const SUPABASE_DISABLED = env['E2E_SUPABASE_FALLBACK'] === '1' + +const supabaseReady = Boolean( + !SUPABASE_DISABLED && + SUPABASE_URL && + SUPABASE_SERVICE_ROLE_KEY && + SUPABASE_ANON_KEY +) + +const createSupabaseClient = (key: string): SupabaseClient => + createClient(SUPABASE_URL!, key, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }) + +const supabaseAdminClient = supabaseReady ? createSupabaseClient(SUPABASE_SERVICE_ROLE_KEY!) : null +const supabaseAnonClient = supabaseReady ? createSupabaseClient(SUPABASE_ANON_KEY!) : null + +const TEST_SOURCE = 'supabase_e2e' +const TEST_PRIVACY_VERSION = '1970-01-01' +const TEST_USER_AGENT = 'playwright/supabase' + +const createdRecordIds: string[] = [] + +const skipUnlessChromium = (browserName: string) => { + if (browserName !== 'chromium') { + test.skip('Supabase API tests only run once per suite') + } +} + +const buildConsentRecordPayload = () => ({ + 'data_subject_id': randomUUID(), + 'email': `supabase-e2e-${randomUUID()}@example.com`, + 'purposes': ['marketing'], + 'source': TEST_SOURCE, + 'user_agent': TEST_USER_AGENT, + 'privacy_policy_version': TEST_PRIVACY_VERSION, + 'consent_text': 'Captured for e2e Supabase coverage', +}) + +const recordCleanup = async () => { + if (!supabaseAdminClient || createdRecordIds.length === 0) { + return + } + + await supabaseAdminClient + .from(SUPABASE_RLS_TABLE) + .delete() + .in('id', [...createdRecordIds]) + + createdRecordIds.length = 0 +} + +test.afterEach(async () => { + if (supabaseReady) { + await recordCleanup() + } +}) + +const skipSupabaseReason = !supabaseReady + ? 'Supabase environment is not configured or fallback mode is enabled.' + : undefined test.describe('Supabase Database API', () => { - test.skip('Supabase Configuration - should use correct environment variables', async () => { - // TODO: Test that Supabase clients are configured with correct URLs and keys + test.describe.configure({ mode: 'serial' }) + if (skipSupabaseReason) { + test.skip(true, skipSupabaseReason) + } + + test('Supabase Configuration - should use correct environment variables', async ({ browserName }) => { + skipUnlessChromium(browserName) + expect(SUPABASE_URL).toBeTruthy() + expect(SUPABASE_SERVICE_ROLE_KEY).toMatch(/^sb_secret_/) + expect(SUPABASE_ANON_KEY).toMatch(/^sb_/) + + const { error, count } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .select('*', { count: 'exact', head: true }) + + expect(error).toBeNull() + expect(typeof count === 'number').toBe(true) }) - test.skip('Supabase Configuration - should create clients with proper auth settings', async () => { - // TODO: Test that admin client bypasses RLS and public client respects RLS + test('Supabase Configuration - should create clients with proper auth settings', async ({ browserName }) => { + skipUnlessChromium(browserName) + const record = buildConsentRecordPayload() + const { data, error } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(record) + .select() + .single() + + expect(error).toBeNull() + expect(data?.source).toBe(TEST_SOURCE) + createdRecordIds.push(data!.id) + + const { error: anonError } = await supabaseAnonClient! + .from(SUPABASE_RLS_TABLE) + .insert(buildConsentRecordPayload()) + + expectRlsFailure({ error: anonError }) }) }) +const expectRlsFailure = (result: { error: PostgrestError | null; data?: unknown }) => { + const { error, data } = result + if (error) { + expect(['42501', 'PGRST301']).toContain(error.code) + return + } + + if (Array.isArray(data)) { + expect(data.length).toBe(0) + } else { + expect(data ?? null).toBeNull() + } +} + test.describe('RLS Policies', () => { + test.describe.configure({ mode: 'serial' }) + if (skipSupabaseReason) { + test.skip(true, skipSupabaseReason) + } + test.describe('Service Role (Admin)', () => { - test.skip('can insert records', async () => { - // TODO: Test that admin service role can insert consent records + test('can insert records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const payload = buildConsentRecordPayload() + const { data, error } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(payload) + .select() + .single() + + expect(error).toBeNull() + expect(data?.email).toBe(payload.email) + createdRecordIds.push(data!.id) }) - test.skip('can read records', async () => { - // TODO: Test that admin service role can read consent records + test('can read records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const insertPayload = buildConsentRecordPayload() + const { data: inserted } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(insertPayload) + .select() + .single() + createdRecordIds.push(inserted!.id) + + const { data, error } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .select('*') + .eq('id', inserted!.id) + .single() + + expect(error).toBeNull() + expect(data?.email).toBe(insertPayload.email) }) - test.skip('can update records', async () => { - // TODO: Test that admin service role can update consent records + test('can update records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const insertPayload = buildConsentRecordPayload() + const { data: inserted } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(insertPayload) + .select() + .single() + createdRecordIds.push(inserted!.id) + + const updatedText = 'Updated via admin RLS test' + const { data, error } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .update(Object.fromEntries([[ 'consent_text', updatedText ]])) + .eq('id', inserted!.id) + .select() + .single() + + expect(error).toBeNull() + expect(data?.consent_text).toBe(updatedText) }) - test.skip('can delete records', async () => { - // TODO: Test that admin service role can delete consent records + test('can delete records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const insertPayload = buildConsentRecordPayload() + const { data: inserted } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(insertPayload) + .select() + .single() + + const { error } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .delete() + .eq('id', inserted!.id) + + expect(error).toBeNull() }) }) test.describe('Public Client (Anon)', () => { - test.skip('cannot read records', async () => { - // TODO: Test that public client cannot read consent records (RLS should block) + test('cannot read records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const { data: inserted } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(buildConsentRecordPayload()) + .select() + .single() + createdRecordIds.push(inserted!.id) + + const { data, error } = await supabaseAnonClient! + .from(SUPABASE_RLS_TABLE) + .select('*') + .eq('id', inserted!.id) + .maybeSingle() + + expectRlsFailure({ error, data }) }) - test.skip('cannot insert records', async () => { - // TODO: Test that public client cannot insert consent records + test('cannot insert records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const { error } = await supabaseAnonClient! + .from(SUPABASE_RLS_TABLE) + .insert(buildConsentRecordPayload()) + + expectRlsFailure({ error }) }) - test.skip('cannot update records', async () => { - // TODO: Test that public client cannot update consent records + test('cannot update records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const insertPayload = buildConsentRecordPayload() + const { data: inserted } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(insertPayload) + .select() + .single() + createdRecordIds.push(inserted!.id) + + const { error } = await supabaseAnonClient! + .from(SUPABASE_RLS_TABLE) + .update(Object.fromEntries([[ 'consent_text', 'anon cannot edit' ]])) + .eq('id', inserted!.id) + + expectRlsFailure({ error }) }) - test.skip('cannot delete records', async () => { - // TODO: Test that public client cannot delete consent records + test('cannot delete records', async ({ browserName }) => { + skipUnlessChromium(browserName) + const insertPayload = buildConsentRecordPayload() + const { data: inserted } = await supabaseAdminClient! + .from(SUPABASE_RLS_TABLE) + .insert(insertPayload) + .select() + .single() + createdRecordIds.push(inserted!.id) + + const { error } = await supabaseAnonClient! + .from(SUPABASE_RLS_TABLE) + .delete() + .eq('id', inserted!.id) + + expectRlsFailure({ error }) }) }) }) + From 60087cb70a97b4bc22fc4d715a663bef8b04b4ef Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 1 Dec 2025 21:06:03 +0300 Subject: [PATCH 04/31] Remove obsolete implementation plan for e2e tests --- _TODO.md | 2 - src/pages/api/_utils/IMPLEMENTATION_PLAN.md | 1277 ------------------- 2 files changed, 1279 deletions(-) delete mode 100644 src/pages/api/_utils/IMPLEMENTATION_PLAN.md diff --git a/_TODO.md b/_TODO.md index dcb4406dd..ccf1d0db2 100644 --- a/_TODO.md +++ b/_TODO.md @@ -40,8 +40,6 @@ You can also extend the REST container's startup delay to avoid the spam: set PG Implementation order -08-api: start with these since their success hinges entirely on the mocks. For each test, assert the HTTP response and inspect the mock's request logs (WireMock /__admin/requests) to prove the backend call happened. Adding the cron tests here makes sense—just exercise the GET endpoints via page.request or Playwright's API testing capability so you don't need UI plumbing. - Cron coverage: write three tests that hit cleanup-confirmations, newsletter-reminders, etc., using the mock stack. Seed Supabase/Redis with known values before each test (scripts in containers) and assert the mocks see the expected outbound traffic. 03-forms: once the API layer is stable, wire the UI flows. Use Playwright to submit each form, but assert success by checking the mock mappings were triggered, not just the UI toast. diff --git a/src/pages/api/_utils/IMPLEMENTATION_PLAN.md b/src/pages/api/_utils/IMPLEMENTATION_PLAN.md deleted file mode 100644 index cc94badd2..000000000 --- a/src/pages/api/_utils/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,1277 +0,0 @@ -# GDPR Consent System - Implementation Plan - -## Off-Topic Notes - -We've refactored two components to be web components: Newsletter and ThemePicker. -We did a trial reactor of the Footer component to Preact. - -## Quick Start - -This plan reorganizes the REFACTOR.md document into a clear, step-by-step implementation guide. - -**Implementation Strategy:** - -- Work sequentially through phases -- Each phase builds on the previous -- Test thoroughly before moving forward -- Convert components to web components as we modify them (not as separate phase) - ---- - -## Phase 0: Setup & Dependencies - -### 0.1 Install Dependencies - -```bash -npm install @supabase/supabase-js uuid @upstash/ratelimit nodemailer -npm install -D @types/uuid @types/nodemailer -``` - -### 0.2 Initialize Supabase - -```bash -# Install Supabase CLI -npm install -D supabase - -# Initialize in project (creates supabase/ directory) -npx supabase init - -# Start local Supabase with Docker -npx supabase start -``` - -**Output will show:** - -- API URL: `http://localhost:54321` -- DB URL: `postgresql://postgres:postgres@localhost:54322/postgres` -- Studio URL: `http://localhost:54323` -- Keys: anon, service_role - -### 0.3 Environment Variables - -Create/update `.env`: - -```bash -# Supabase Local (from npx supabase start output) -SUPABASE_URL=http://127.0.0.1:54321 -SUPABASE_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH -SUPABASE_SERVICE_ROLE_KEY=sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz # NEVER add PUBLIC_ prefix! - -# Supabase Production (from your production project) -# SUPABASE_URL=https://your-project.supabase.co -# SUPABASE_KEY=eyJhbGci... -# SUPABASE_SERVICE_ROLE_KEY=eyJhbGci... - -# Email Testing (use Supabase Mailpit - see Phase 10.1) -# ETHEREAL_USER=your-username@ethereal.email -# ETHEREAL_PASS=your-password - -# Vercel Cron Jobs -CRON_SECRET= # Generate with: openssl rand -base64 32 - -# Upstash (for rate limiting) -KV_URL= -KV_REST_API_UR= -KV_REST_API_TOKEN= -KV_REST_API_READ_ONLY_TOKEN= -REDIS_URL= -``` - -**Generate CRON_SECRET:** - -```bash -openssl rand -base64 32 -``` - -**Privacy Policy Version:** - -The privacy policy version is automatically determined at build time by the `PrivacyPolicyVersion` Astro integration (`src/integrations/PrivacyPolicyVersion/index.ts`). It uses the git commit date of the privacy policy file and is available as `import.meta.env.PRIVACY_POLICY_VERSION`. No manual configuration needed. - -### 0.4 Create Directory Structure - -```bash -mkdir -p src/pages/api/@types -mkdir -p src/pages/api/gdpr -mkdir -p src/pages/api/newsletter -mkdir -p src/pages/api/cron -mkdir -p src/pages/api/_utils -mkdir -p src/components/scripts/consent/db -mkdir -p test/helpers -``` - ---- - -## Phase 1: Database & Types - -### 1.1 Create TypeScript Types - -**File:** `src/api/@types/gdpr.ts` - -```typescript -export interface ConsentRecord { - id: string - DataSubjectId: string - email?: string - purposes: Array<'contact' | 'marketing' | 'analytics' | 'downloads'> - timestamp: string - source: 'contact_form' | 'newsletter_form' | 'download_form' | 'cookies_modal' | 'preferences_page' - userAgent: string - ipAddress?: string - privacyPolicyVersion: string - consentText?: string - verified: boolean -} - -export interface ConsentRequest { - DataSubjectId: string - email?: string - purposes: Array<'contact' | 'marketing' | 'analytics' | 'downloads'> - source: string - userAgent: string - ipAddress?: string - consentText?: string - verified?: boolean -} - -export interface ConsentResponse { - success: true - record: ConsentRecord -} - -export interface ErrorResponse { - success: false - error: { - code: 'INVALID_UUID' | 'RATE_LIMIT_EXCEEDED' | 'NOT_FOUND' | 'UNAUTHORIZED' - message: string - } -} -``` - -### 1.2 Create Database Migrations - -**File:** `supabase/migrations/001_create_consent_records.sql` - -```sql -CREATE TABLE consent_records ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - data_subject_id UUID NOT NULL, - email TEXT, - purposes TEXT[] NOT NULL, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - source TEXT NOT NULL, - user_agent TEXT NOT NULL, - ip_address INET, - privacy_policy_version TEXT NOT NULL, - consent_text TEXT, - verified BOOLEAN DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Indexes -CREATE INDEX idx_consent_data_subject_id ON consent_records(data_subject_id); -CREATE INDEX idx_consent_email ON consent_records(email) WHERE email IS NOT NULL; -CREATE INDEX idx_consent_timestamp ON consent_records(timestamp DESC); - --- RLS -ALTER TABLE consent_records ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "service_role_all_access" -ON consent_records -FOR ALL -TO service_role -USING (true) -WITH CHECK (true); -``` - -**File:** `supabase/migrations/002_create_newsletter_confirmations.sql` - -```sql -CREATE TABLE newsletter_confirmations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - token TEXT UNIQUE NOT NULL, - email TEXT NOT NULL, - data_subject_id UUID NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - confirmed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_newsletter_token ON newsletter_confirmations(token); -CREATE INDEX idx_newsletter_expiry ON newsletter_confirmations(expires_at) - WHERE confirmed_at IS NULL; - -ALTER TABLE newsletter_confirmations ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "service_role_all_access" -ON newsletter_confirmations -FOR ALL -TO service_role -USING (true) -WITH CHECK (true); -``` - -**File:** `supabase/migrations/003_create_dsar_requests.sql` - -```sql -CREATE TABLE dsar_requests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - token TEXT UNIQUE NOT NULL, - email TEXT NOT NULL, - request_type TEXT NOT NULL CHECK (request_type IN ('ACCESS', 'DELETE')), - expires_at TIMESTAMPTZ NOT NULL, - fulfilled_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_dsar_token ON dsar_requests(token); -CREATE INDEX idx_dsar_expiry ON dsar_requests(expires_at) - WHERE fulfilled_at IS NULL; - -ALTER TABLE dsar_requests ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "service_role_all_access" -ON dsar_requests -FOR ALL -TO service_role -USING (true) -WITH CHECK (true); -``` - -### 1.3 Run Migrations Locally - -```bash -supabase db push -``` - -### 1.4 Create Supabase Clients - -**File:** `src/lib/db/supabase.ts` - -```typescript -import { createClient } from '@supabase/supabase-js' - -const supabaseUrl = import.meta.env.SUPABASE_URL! - -// Admin client (server-side only, bypasses RLS) -export const supabaseAdmin = createClient( - supabaseUrl, - import.meta.env.SUPABASE_SERVICE_ROLE_KEY!, - { - auth: { - autoRefreshToken: false, - persistSession: false - } - } -) - -// Public client (client-side, respects RLS) -export const supabasePublic = createClient( - supabaseUrl, - import.meta.env.SUPABASE_KEY! -) -``` - -### 1.5 Test RLS Policies - -Create test file: `src/lib/db/__tests__/rls.spec.ts` - -Test scenarios: - -- ✅ Service role can CRUD all records -- ❌ Public client cannot read any records -- ❌ Public client cannot insert records -- ❌ Public client cannot update records -- ❌ Public client cannot delete records - ---- - -## Phase 2: DataSubjectId & Consent Store - -### 2.1 Create UUID Helper - -**File:** `src/lib/helpers/uuid.ts` - -```typescript -export function isValidUUID(uuid: string): boolean { - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - return uuidRegex.test(uuid) -} -``` - -### 2.2 Create DataSubjectId Management - -**File:** `src/lib/helpers/dataSubjectId.ts` - -```typescript -import { v4 as uuidv4 } from 'uuid' -import { isValidUUID } from './uuid' - -export function getOrCreateDataSubjectId(): string { - // Try localStorage first - const storedId = localStorage.getItem('DataSubjectId') - if (storedId && isValidUUID(storedId)) { - syncToCookie(storedId) - return storedId - } - - // Try cookie as backup - const cookieId = getCookieValue('DataSubjectId') - if (cookieId && isValidUUID(cookieId)) { - localStorage.setItem('DataSubjectId', cookieId) - return cookieId - } - - // Generate new ID - const newId = uuidv4() - localStorage.setItem('DataSubjectId', newId) - syncToCookie(newId) - - return newId -} - -export function deleteDataSubjectId(): void { - localStorage.removeItem('DataSubjectId') - document.cookie = 'DataSubjectId=; path=/; max-age=0' -} - -function syncToCookie(dataSubjectId: string): void { - const isProduction = window.location.protocol === 'https:' - - document.cookie = [ - `DataSubjectId=${dataSubjectId}`, - 'path=/', - 'max-age=31536000', // 1 year - 'SameSite=Strict', - isProduction ? 'Secure' : '', - ].filter(Boolean).join('; ') -} - -function getCookieValue(name: string): string | null { - const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)) - return match ? decodeURIComponent(match[1]) : null -} -``` - -### 2.3 Update Consent Store - -**File:** `src/components/scripts/store/consent.ts` - -Add to existing store: - -```typescript -import { getOrCreateDataSubjectId, deleteDataSubjectId } from '@/lib/helpers/dataSubjectId' - -// Add to store interface -export interface ConsentState { - // ... existing fields - DataSubjectId: string -} - -// Initialize DataSubjectId in store -export const $consent = persistentMap('consent:', { - // ... existing defaults - DataSubjectId: '', // Will be set on first access -}) - -// Initialize DataSubjectId on store mount -onMount($consent, () => { - const currentId = $consent.get().DataSubjectId - if (!currentId) { - $consent.setKey('DataSubjectId', getOrCreateDataSubjectId()) - } -}) -``` - -### 2.4 Update Side Effects - -**File:** `src/components/scripts/bootstrap/consent/index.ts` - -Add new side effect to `initConsentSideEffects()`: - -```typescript -// Side Effect 5: Log consent changes to API -$consent.subscribe(async (consentState, oldConsentState) => { - try { - // Only log if purposes changed (not on initial load) - if (!oldConsentState) return - - const DataSubjectId = getOrCreateDataSubjectId() - - await fetch('/api/gdpr/consent', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - DataSubjectId, - purposes: Object.keys(consentState).filter(key => - consentState[key] === true && - ['contact', 'marketing', 'analytics', 'downloads'].includes(key) - ), - source: 'cookies_modal', - userAgent: navigator.userAgent, - verified: false - }) - }) - } catch (error) { - handleScriptError(error, { - scriptName: 'cookieConsent', - operation: 'logConsentToAPI' - }) - } -}) -``` - -Update `initStateSideEffects()` to delete DataSubjectId: - -```typescript -$hasFunctionalConsent.subscribe((hasConsent) => { - if (!hasConsent) { - try { - // Existing localStorage clearing... - - // NEW: Delete DataSubjectId - deleteDataSubjectId() - } catch (error) { - // ... existing error handling - } - } -}) -``` - -### 2.5 Convert Consent Modal to Web Component - -**Action:** Convert `src/components/Cookies/Modal.astro` to web component following ThemePicker pattern - -Key changes: - -- Extract to standalone web component -- Use `transition:persist` on the custom element itself -- Subscribe to consent store -- Handle View Transitions events - -### 2.6 Convert Consent Preferences to Web Component - -**Action:** Convert consent preferences component to web component - -### 2.7 Test DataSubjectId Persistence - -**File:** `src/lib/helpers/__tests__/dataSubjectId.spec.ts` - -```typescript -import { - useTestStorageEngine, - setTestStorageKey, - cleanTestStorage, - getTestStorage, -} from '@nanostores/persistent' -import { getOrCreateDataSubjectId, deleteDataSubjectId } from '../dataSubjectId' - -beforeAll(() => { - useTestStorageEngine() -}) - -afterEach(() => { - cleanTestStorage() -}) - -it('creates new UUID if none exists', () => { - const id = getOrCreateDataSubjectId() - expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) - expect(getTestStorage()).toHaveProperty('DataSubjectId', id) -}) - -it('retrieves existing ID from storage', () => { - setTestStorageKey('DataSubjectId', 'test-uuid-123') - const id = getOrCreateDataSubjectId() - expect(id).toBe('test-uuid-123') -}) - -it('deletes DataSubjectId from storage', () => { - setTestStorageKey('DataSubjectId', 'test-uuid-123') - deleteDataSubjectId() - expect(getTestStorage()).not.toHaveProperty('DataSubjectId') -}) -``` - ---- - -## Phase 3: Rate Limiting - -### 3.1 Create Rate Limiter - -**File:** `src/pages/api/_utils/rateLimit.ts` - -```typescript -import { Ratelimit } from '@upstash/ratelimit' -import { Redis } from '@upstash/redis' - -const redis = new Redis({ - url: import.meta.env['KV_REST_API_URL'] as string, - token: import.meta.env['KV_REST_API_TOKEN'] as string, -}) - -export const rateLimiters = { - consent: new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(10, '1 m'), - analytics: true - }), - consentRead: new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(30, '1 m'), - analytics: true - }), - export: new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(5, '1 m'), - analytics: true - }), - delete: new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(3, '1 m'), - analytics: true - }) -} - -export async function checkRateLimit( - limiter: Ratelimit, - identifier: string -): Promise<{ success: boolean; reset?: number }> { - const result = await limiter.limit(identifier) - return { - success: result.success, - reset: result.reset - } -} -``` - ---- - -## Phase 4: Core GDPR API Endpoints - -### 4.1 POST /api/gdpr/consent - -**File:** `src/pages/api/gdpr/consent.ts` - -```typescript -import type { APIRoute } from 'astro' -import { supabaseAdmin } from '@/lib/db/supabase' -import { rateLimiters, checkRateLimit } from '@/lib/rateLimit' -import { isValidUUID } from '@/lib/helpers/uuid' -import type { ConsentRequest, ConsentResponse, ErrorResponse } from '@/api/@types/gdpr' - -export const POST: APIRoute = async ({ request, clientAddress }) => { - // Rate limiting - const { success, reset } = await checkRateLimit(rateLimiters.consent, clientAddress) - - if (!success) { - return new Response(JSON.stringify({ - success: false, - error: { - code: 'RATE_LIMIT_EXCEEDED', - message: `Try again in ${Math.ceil((reset! - Date.now()) / 1000)}s` - } - } as ErrorResponse), { - status: 429, - headers: { - 'Content-Type': 'application/json', - 'Retry-After': String(Math.ceil((reset! - Date.now()) / 1000)) - } - }) - } - - try { - const body: ConsentRequest = await request.json() - - // Validate DataSubjectId - if (!isValidUUID(body.DataSubjectId)) { - return new Response(JSON.stringify({ - success: false, - error: { code: 'INVALID_UUID', message: 'Invalid DataSubjectId' } - } as ErrorResponse), { status: 400 }) - } - - // Insert consent record - const { data, error } = await supabaseAdmin - .from('consent_records') - .insert({ - data_subject_id: body.DataSubjectId, - email: body.email?.toLowerCase().trim(), - purposes: body.purposes, - source: body.source, - user_agent: body.userAgent, - ip_address: body.ipAddress, - privacy_policy_version: import.meta.env.PRIVACY_POLICY_VERSION, - consent_text: body.consentText, - verified: body.verified ?? false - }) - .select() - .single() - - if (error) { - throw error - } - - return new Response(JSON.stringify({ - success: true, - record: { - id: data.id, - DataSubjectId: data.data_subject_id, - email: data.email, - purposes: data.purposes, - timestamp: data.timestamp, - source: data.source, - userAgent: data.user_agent, - ipAddress: data.ip_address, - privacyPolicyVersion: data.privacy_policy_version, - consentText: data.consent_text, - verified: data.verified - } - } as ConsentResponse), { - status: 201, - headers: { 'Content-Type': 'application/json' } - }) - } catch (error) { - console.error('Failed to record consent:', error) - return new Response(JSON.stringify({ - success: false, - error: { code: 'INTERNAL_ERROR', message: 'Failed to record consent' } - }), { status: 500 }) - } -} - -export const GET: APIRoute = async ({ request, clientAddress, url }) => { - // Rate limiting - const { success, reset } = await checkRateLimit(rateLimiters.consentRead, clientAddress) - - if (!success) { - return new Response(JSON.stringify({ - success: false, - error: { - code: 'RATE_LIMIT_EXCEEDED', - message: `Try again in ${Math.ceil((reset! - Date.now()) / 1000)}s` - } - } as ErrorResponse), { - status: 429, - headers: { - 'Content-Type': 'application/json', - 'Retry-After': String(Math.ceil((reset! - Date.now()) / 1000)) - } - }) - } - - const DataSubjectId = url.searchParams.get('DataSubjectId') - const purpose = url.searchParams.get('purpose') - - if (!DataSubjectId || !isValidUUID(DataSubjectId)) { - return new Response(JSON.stringify({ - success: false, - error: { code: 'INVALID_UUID', message: 'Valid DataSubjectId required' } - } as ErrorResponse), { status: 400 }) - } - - try { - let query = supabaseAdmin - .from('consent_records') - .select('*') - .eq('data_subject_id', DataSubjectId) - - if (purpose) { - query = query.contains('purposes', [purpose]) - } - - const { data, error } = await query - - if (error) { - throw error - } - - const records = data.map(record => ({ - id: record.id, - DataSubjectId: record.data_subject_id, - email: record.email, - purposes: record.purposes, - timestamp: record.timestamp, - source: record.source, - userAgent: record.user_agent, - ipAddress: record.ip_address, - privacyPolicyVersion: record.privacy_policy_version, - consentText: record.consent_text, - verified: record.verified - })) - - return new Response(JSON.stringify({ - success: true, - records, - hasActive: purpose ? records.length > 0 : undefined, - activeRecord: purpose && records.length > 0 ? records[0] : undefined - }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - } catch (error) { - console.error('Failed to retrieve consent:', error) - return new Response(JSON.stringify({ - success: false, - error: { code: 'INTERNAL_ERROR', message: 'Failed to retrieve consent' } - }), { status: 500 }) - } -} - -export const DELETE: APIRoute = async ({ request, clientAddress, url }) => { - // Rate limiting - const { success, reset } = await checkRateLimit(rateLimiters.delete, clientAddress) - - if (!success) { - return new Response(JSON.stringify({ - success: false, - error: { - code: 'RATE_LIMIT_EXCEEDED', - message: `Try again in ${Math.ceil((reset! - Date.now()) / 1000)}s` - } - } as ErrorResponse), { - status: 429, - headers: { - 'Content-Type': 'application/json', - 'Retry-After': String(Math.ceil((reset! - Date.now()) / 1000)) - } - }) - } - - const DataSubjectId = url.searchParams.get('DataSubjectId') - - if (!DataSubjectId || !isValidUUID(DataSubjectId)) { - return new Response(JSON.stringify({ - success: false, - error: { code: 'INVALID_UUID', message: 'Valid DataSubjectId required' } - } as ErrorResponse), { status: 400 }) - } - - try { - const { data, error } = await supabaseAdmin - .from('consent_records') - .delete() - .eq('data_subject_id', DataSubjectId) - .select() - - if (error) { - throw error - } - - return new Response(JSON.stringify({ - success: true, - deletedCount: data?.length || 0 - }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - } catch (error) { - console.error('Failed to delete consent:', error) - return new Response(JSON.stringify({ - success: false, - error: { code: 'INTERNAL_ERROR', message: 'Failed to delete consent' } - }), { status: 500 }) - } -} -``` - -### 4.2 GET /api/gdpr/export - -**File:** `src/pages/api/gdpr/export.ts` - -```typescript -import type { APIRoute } from 'astro' -import { supabaseAdmin } from '@/lib/db/supabase' -import { rateLimiters, checkRateLimit } from '@/lib/rateLimit' -import { isValidUUID } from '@/lib/helpers/uuid' - -export const GET: APIRoute = async ({ request, clientAddress, url }) => { - const { success, reset } = await checkRateLimit(rateLimiters.export, clientAddress) - - if (!success) { - return new Response(JSON.stringify({ - success: false, - error: { - code: 'RATE_LIMIT_EXCEEDED', - message: `Try again in ${Math.ceil((reset! - Date.now()) / 1000)}s` - } - }), { - status: 429, - headers: { 'Retry-After': String(Math.ceil((reset! - Date.now()) / 1000)) } - }) - } - - const DataSubjectId = url.searchParams.get('DataSubjectId') - - if (!DataSubjectId || !isValidUUID(DataSubjectId)) { - return new Response('Invalid DataSubjectId', { status: 400 }) - } - - try { - const { data, error } = await supabaseAdmin - .from('consent_records') - .select('*') - .eq('data_subject_id', DataSubjectId) - - if (error) { - throw error - } - - // Remove sensitive fields - const exportData = data.map(({ ip_address, ...record }) => record) - - return new Response(JSON.stringify(exportData, null, 2), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Content-Disposition': `attachment; filename="my-data-${Date.now()}.json"` - } - }) - } catch (error) { - console.error('Failed to export data:', error) - return new Response('Failed to export data', { status: 500 }) - } -} -``` - -### 4.3 Test API Endpoints - -Use Postman/Thunder Client or create test file for each endpoint. - -**Note:** You may see camelCase lint warnings for database column names (`data_subject_id`, `user_agent`, etc.). These are expected - PostgreSQL uses snake_case as standard, and the warnings can be ignored. The same pattern is used in the existing RLS tests. - ---- - -## Phase 5: Email-Based DSAR Requests - -### 5.1 POST /api/gdpr/request-data - -### 5.2 GET /api/gdpr/verify - -### 5.3 Create /privacy/my-data Page - ---- - -## Phase 6: Newsletter Double Opt-In - -### 6.1 POST /api/newsletter - -### 6.2 GET /api/newsletter/confirm - -### 6.3 Convert Newsletter Form to Web Component - ---- - -## Phase 7: Form Integration - -### 7.1 Convert Contact Form to Web Component - -### 7.2 Convert GDPR Consent Component to Web Component - -### 7.3 Add DataSubjectId to Form Submissions - ---- - -## Phase 8: Cron Jobs - -### 8.1 GET /api/cron/cleanup-confirmations - -### 8.2 GET /api/cron/cleanup-dsar-requests - -### 8.3 Configure vercel.json - ---- - -## Phase 9: Sentry Integration - -### 9.1 Update Sentry Init with Conditional PII Scrubbing - -### 9.2 Add Consent Store Subscriber - ---- - -## Phase 10: Testing - -### 10.0 Verify unit tests - -src/components/CallToAction/Newsletter/__tests__/client.spec.ts - -src/components/GDPR/Consent/__tests__/state.spec.ts - -src/pages/api/newsletter/__tests__/_confirm.spec.ts -src/pages/api/newsletter/__tests__/_index.spec.ts - -src/pages/api/contact/__tests__/_index.spec.ts - -### 10.1 Email Testing with Mailpit - -Supabase local development includes **Mailpit** for email testing (no Ethereal needed): - -- **Mailpit URL**: `http://127.0.0.1:54324` -- All emails sent from local Supabase are captured in Mailpit -- Access the web UI to view sent emails, test links, etc. -- No additional configuration required - -### 10.2 Setup Docker Environments for Local Testing - -**Supabase** (already configured in Phase 0.2): - -```bash -npx supabase start # Runs PostgreSQL + Studio locally -``` - -**Official Redis Docker image combined with a proxy like Serverless Redis HTTP (SRH)** - -Achieve a similar local development environment to Upstash Redis: - -- Which proxy to use (e.g., @upstash/redis-http-proxy or similar) -- Docker compose configuration -- How to point UPSTASH_REDIS_REST_URL to local proxy - -### 10.3 E2E Tests - -- What aspects to test (consent flow, form submissions, DSAR requests) -- Whether to use Playwright (already configured in your project) -- How to handle DataSubjectId in tests (use test engine from nanostores) - ---- - -## Phase 11: Deployment - -### 11.1 Production Supabase Setup - -### 11.2 Vercel Environment Variables - -### 11.3 Run Migrations in Production - ---- - -## Phase 12: Add Vercel Analytics - -- Consent / Preferences component - ---- - -## Open Questions (Must Resolve Before Implementation) - -1. **Email Service for Production**: Which service? (Resend, SendGrid, AWS SES?) - -We use Resend for site emails. There is substantial configuration to use Resend in the Newsletter component. - -2. **Newsletter API Endpoint**: Current implementation? Where is `/api/newsletter` defined? - -All API endpoints are defined in the src/pages/api directory. These are deployed as Vercel Functions by Astro. - -3. **Contact Form API**: Current implementation? Does it exist? - -See above on Newsletter API endpoint. - -4. **Upstash Setup**: Do we have Upstash Redis configured? Need credentials for rate limiting. - -There is a project already setup. It is the Upstash Redis Marketplace Integration for Vercel. I have installed the @upstash/redis SDK. All relevant API keys and links are in the .env file in the roof of the project: - -KV_URL -KV_REST_API_UR -KV_REST_API_TOKEN -KV_REST_API_READ_ONLY_TOKEN -REDIS_URL - -5. **Production Supabase**: Do we have a production Supabase project? Or create new? - -There is a project already setup. I have installed the @supabase/supabase-js SDK. All relevant API keys and links are in the .env file in the roof of the project: - -// Password to the database, not sure if we need. -SUPRABASE_DATABASE_PASSWORD - -// A RESTful endpoint for querying and managing your database. -SUPABASE_URL - -// Anon public API key -SUPABASE_KEY - -// Secret service key that can bypass RLS -SUPABASE_SERVICE_ROLE_KEY - -## Critical Notes - -1. **Side Effects File** (`src/components/scripts/bootstrap/consent/index.ts`): - - Needs updating to log consent changes to API - - Already has good structure with `initConsentSideEffects()` and `initStateSideEffects()` - - Add new side effect to call `/api/gdpr/consent` on consent changes - -2. **Component Conversion Strategy**: - - Convert components AS we modify them (not separate phase) - - Follow ThemePicker web component pattern - - Use `transition:persist` on custom elements - -3. **DataSubjectId vs Email**: - - DataSubjectId is client-generated, persistent - - Email is optional, added on form submission - - Two-record pattern for newsletter: unverified (no email) → verified (with email) - -4. **Privacy Policy Version**: - - Automatically generated at build time from git commit date - - Handled by `PrivacyPolicyVersion` integration (`src/integrations/PrivacyPolicyVersion/index.ts`) - - Available as `import.meta.env.PRIVACY_POLICY_VERSION` - - No manual configuration needed - -5. **Testing Strategy**: - - Use nanostore test API (`useTestStorageEngine`) for localStorage tests - - Use Mailpit (included with Supabase) for email testing at `http://127.0.0.1:54324` - - Test RLS policies thoroughly before moving forward - -## Completion Output - -**Phase 0 Status:** ✅ Complete - -✅ 0.1: Dependencies installed (@supabase/supabase-js, uuid, @upstash/ratelimit, nodemailer) -✅ 0.2: Supabase initialized (CLI installed, local instance running) -✅ 0.3: Environment variables documented (Vercel Marketplace Upstash integration keys noted) -✅ 0.4: Directory structure created - -**Phase 1: Database & Types** - Complete ✅ - -Summary - -Created: - -✅ gdpr.ts - TypeScript interfaces for GDPR consent types -✅ 001_create_consent_records.sql - Main consent tracking table -✅ 002_create_newsletter_confirmations.sql - Newsletter double opt-in table -✅ 003_create_dsar_requests.sql - Data Subject Access Requests table -✅ Applied all migrations to local Supabase database -✅ supabase.ts - Supabase client configurations (admin & public) -✅ rls.spec.ts - RLS policy tests - -Database Tables Created: - -- consent_records - with RLS policies, indexes on data_subject_id, email, timestamp -- newsletter_confirmations - with RLS policies, indexes on token, expiry -- dsar_requests - with RLS policies, indexes on token, expiry - -**Phase 2: DataSubjectId & Consent Store** - Complete ✅ - -Summary - -Created: - -✅ uuid.ts - UUID validation function -✅ dataSubjectId.ts - DataSubjectId management (create, delete, cookie sync) -✅ dataSubjectId.spec.ts - Tests for DataSubjectId persistence - -Updated: - -4. ✅ consent.ts: - -- Added DataSubjectId field to ConsentState interface -- Initialize DataSubjectId on store mount with onMount() -- Updated initConsentFromCookies() to include DataSubjectId -- Added Side Effect 2: Log consent changes to /api/gdpr/consent API -- Added Side Effect 3: Delete DataSubjectId when functional consent is revoked - -Key Features Implemented: - -- DataSubjectId persists to localStorage and syncs to cookie -- Automatic generation of UUID v4 on first visit -- Cookie includes SameSite=Strict and Secure flag in production -- API logging of consent changes with DataSubjectId -- DataSubjectId deletion when functional consent is revoked -- Comprehensive test coverage - -Note: Phases 2.5 and 2.6 (converting Consent Modal and Preferences to web components) are marked for later as noted in the implementation plan ("Convert components AS we modify them"). - -**Phase 3: Rate Limiting** - Complete ✅ - -I've created rateLimit.ts with: - -✅ Upstash Redis configuration using correct env vars (KV_REST_API_URL, KV_REST_API_TOKEN) -✅ Four rate limiters: consent (10/min), consentRead (30/min), export (5/min), delete (3/min) -✅ checkRateLimit() helper function -✅ Updated implementation plan with correct path and env variable names - -**Phase 4: Core GDPR API Endpoints** - Complete ✅ - -✅ consent.ts - Complete GDPR consent API with: - -- `POST`: Create consent records with rate limiting (10/min) -- `GET`: Retrieve consent records by DataSubjectId with optional purpose filtering (30/min) -- `DELETE`: Delete all consent records for a DataSubjectId (3/min) - -✅ export.ts - Data export endpoint: - -- `GET`: Export all consent data for a DataSubjectId as downloadable JSON (5/min) -- Removes sensitive ip_address field from export - -All endpoints include: - -- Upstash rate limiting -- UUID validation -- Proper error handling -- TypeScript types -- Supabase admin client (bypasses RLS for server-side operations) - -Note: The camelCase lint warnings for database column names (data_subject_id, user_agent, etc.) are expected - these match PostgreSQL's snake_case standard and follow the same pattern used in the RLS tests you reviewed earlier. - -**Phase 5: Email-Based DSAR Requests** - Complete ✅ - -✅ POST /api/gdpr/request-data - Initiates DSAR requests: - -- Validates email format and request type (ACCESS or DELETE) -- Creates DSAR request record with 24-hour expiration token -- Checks for duplicate requests and resends email if needed -- Sends verification email via Resend -- Rate limited (5 requests/minute) - -✅ GET /api/gdpr/verify?token=xxx - Verifies and fulfills requests: - -- Validates verification token -- Checks expiration and fulfillment status -- For ACCESS requests: Exports all consent data as JSON download (removes sensitive IP addresses) -- For DELETE requests: Deletes all consent_records and newsletter_confirmations for the email -- Marks request as fulfilled -- Redirects to status pages - -✅ email.ts - Email service for DSAR: - -- Sends verification emails using Resend -- Different templates for ACCESS vs DELETE requests -- Includes warnings for deletion requests -- 24-hour expiration notice -- Development/test mode skips actual emails - -✅ /privacy/my-data page - User-facing interface: - -- Two forms: Access My Data and Delete My Data -- Real-time form validation -- Status messages from URL params -- Clear warnings for deletion -- Checkbox confirmation for deletion -- Responsive design with proper styling - -The system now provides a complete GDPR-compliant data access and deletion workflow with email verification! - -**Phase 6: Newsletter Double Opt-In** - Complete ✅ - -✅ POST /api/newsletter - Updated to: - -- Accept optional DataSubjectId from client (generates UUID if not provided) -- Validate DataSubjectId format -- Create unverified consent record via the new /api/gdpr/consent API -- Store pending subscription in Supabase newsletter_confirmations table with DataSubjectId -- Maintain backward compatibility with in-memory storage - -✅ _token.ts - Updated to: - -- Include DataSubjectId in PendingSubscription interface -- Store pending subscriptions in Supabase instead of just memory -- Retrieve subscriptions from Supabase in validateToken() -- Mark subscriptions as confirmed in Supabase via confirmSubscription() -- Fallback to in-memory storage for backward compatibility - -✅ GET /api/newsletter/confirm - Updated to: - -- Remove old recordConsent dependency -- Mark consent records as verified: true in Supabase after email confirmation -- Update consent records matching email, DataSubjectId, and marketing purpose -- Maintain existing ConvertKit and welcome email functionality - -Key Features: - -- Full integration with new Supabase-based consent system -- Proper double opt-in: unverified consent → email confirmation → verified consent -- DataSubjectId tracking throughout the newsletter flow -- Database persistence for all pending subscriptions -- GDPR-compliant audit trail in consent_records and newsletter_confirmations tables - -Note: Web component conversion (Phase 6.3) is deferred per the implementation plan - we'll handle it when actively modifying newsletter components. - -**Phase 7: Form Integration** - Complete ✅ - -✅ Updated POST /api/contact endpoint: - -- Added DataSubjectId field to ContactFormData interface (optional) -- Removed dependency on old recordConsent function -- Added UUID validation and generation logic -- Integrated with new /api/gdpr/consent API for consent recording -- Contact form consent is marked as verified: true (no double opt-in needed) -- Graceful error handling - form submission continues even if consent logging fails -- Maintains all existing functionality (email sending, file attachments, etc.) - -✅ Deferred Items (per implementation plan): - -- 7.1: Contact Form web component conversion - deferred until actively modifying -- 7.2: GDPR Consent component conversion - deferred until actively modifying - -Key Features: - -- Full integration with Supabase-based consent system -- DataSubjectId tracking for contact form submissions -- Backward compatible - generates UUID if client doesn't provide one -- Non-blocking consent logging - form submission succeeds even if consent API fails -- Immediate verification for contact form consent (no email confirmation needed) - -**Phase 8: Cron Jobs** - Complete - -1. /api/cron/cleanup-confirmations - Runs daily at 2 AM UTC - -- Deletes expired newsletter confirmation tokens -- Removes old confirmed records (7+ days) -- Returns count of deleted records - -2. /api/cron/cleanup-dsar-requests - Runs daily at 3 AM UTC - -- Removes fulfilled DSAR requests older than 30 days -- Removes expired unfulfilled requests (7+ days old) -- Returns count of deleted records - -3. vercel.json - Updated with cron schedules - -- Both endpoints secured with CRON_SECRET authorization header -- Runs automatically on Vercel infrastructure - -Both endpoints include: - -- CRON_SECRET validation for security -- Detailed logging of cleanup operations -- Error handling with proper status codes -- JSON responses with deleted counts - -**Phase 9: Sentry Integration** - Complete - -1. Client-side Sentry (client.ts): - -- Added import of $consent store -- Updated sendDefaultPii to respect analytics consent -- Enhanced beforeSend hook to scrub PII when consent is not granted: - - Removes IP addresses - - Removes user agent and request headers - - Clears breadcrumbs that may contain user interactions -- Added updateConsentContext() method to track consent changes in Sentry - -2. Consent Store (consent.ts): - -- Added Side Effect 4: Subscribes to $hasAnalyticsConsent changes -- Dynamically imports Sentry to call updateConsentContext() -- Uses lazy loading to avoid circular dependencies -- Includes error handling for environments where Sentry isn't initialized - -3. Server-side Sentry (sentry.server.config.ts): - -- Added clarifying comment that server-side always sends PII -- Server errors need full context as they occur in API/SSR without direct user consent - -How it works: - -- On initialization, Sentry checks current analytics consent -- If no consent, PII is disabled and events are scrubbed -- When user changes consent, store subscriber updates Sentry context -- Sentry context is tagged with consent status for debugging -- All PII scrubbing happens automatically in the beforeSend hook - -Deferred: Refactor Components to Web Components - -Newsletter Form (Phase 6.3) -Contact Form (Phase 7.1) -GDPR Consent Component (Phase 7.2) -Consent Modal (Phase 2.5) -Consent Preferences (Phase 2.6) From 39ba7a63318e784719bb938dd8e7066bd94b693e Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 2 Dec 2025 01:51:35 +0300 Subject: [PATCH 05/31] Add CRON API endpoints e2e test --- .github/workflows/build-and-test.yml | 3 + README.md | 34 +++ _TODO.md | 2 - src/pages/api/cron/ping-integrations.ts | 38 +++- test/e2e/specs/15-cron/cron.spec.ts | 262 ++++++++++++++++++++++++ 5 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 test/e2e/specs/15-cron/cron.spec.ts diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 91fe76892..cb6e2cf39 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -94,6 +94,9 @@ jobs: - name: Start Supabase stack run: npm run containers:supabase:start + - name: Apply Supabase migrations + run: npm run containers:supabase:db-push + - name: Run Playwright E2E tests run: npx playwright test env: diff --git a/README.md b/README.md index 15ce98db8..74e69a76e 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,40 @@ git push -u origin feature/your-feature-name - Branch name must follow conventions - Cannot commit directly to main +### Local Development Server + +Use the helper script below to start Astro with `.env.development` automatically loaded: + +```bash +npm run dev:env +``` + +The underlying `npm run dev` command remains unchanged for CI and Vercel; `dev:env` is just a convenience for local shells so API routes that depend on `process.env` (cron handlers, email providers, etc.) behave the same way they do in production. + +## Supabase Production Initialization + +Provisioning the hosted Supabase project uses the same SQL migrations that back local development. When you need to initialize (or update) the production database: + +1. Authenticate the Supabase CLI (only required once per machine): + + ```bash + npx supabase login + ``` + +1. Link the CLI to the production project (replace the placeholder reference): + + ```bash + npx supabase link --project-ref your-production-project-ref --workdir suprabase + ``` + +1. Apply the latest migrations and RLS policies: + + ```bash + npm run supabase:db:push + ``` + +The `supabase:db:push` script runs `supabase db push` against the linked project, ensuring every table, index, and policy in `suprabase/migrations` is kept in sync with production. + ## Coding Standards ### Component Architecture diff --git a/_TODO.md b/_TODO.md index ccf1d0db2..0130aecf6 100644 --- a/_TODO.md +++ b/_TODO.md @@ -40,8 +40,6 @@ You can also extend the REST container's startup delay to avoid the spam: set PG Implementation order -Cron coverage: write three tests that hit cleanup-confirmations, newsletter-reminders, etc., using the mock stack. Seed Supabase/Redis with known values before each test (scripts in containers) and assert the mocks see the expected outbound traffic. - 03-forms: once the API layer is stable, wire the UI flows. Use Playwright to submit each form, but assert success by checking the mock mappings were triggered, not just the UI toast. Consent Preferences (@wip): convert it to use the same helper that verifies mocked Upstash REST and Supabase responses. This test should (1) toggle UI controls, (2) check the outbound request via the mock logs, and (3) read back seeded data to confirm persistence. diff --git a/src/pages/api/cron/ping-integrations.ts b/src/pages/api/cron/ping-integrations.ts index 8c1aa629d..48908319a 100644 --- a/src/pages/api/cron/ping-integrations.ts +++ b/src/pages/api/cron/ping-integrations.ts @@ -20,6 +20,42 @@ export const prerender = false const ROUTE = '/api/cron/ping-integrations' const UPSTASH_KEY = '__cron_keepalive__' +const decodeUpstashResult = (payload: unknown) => { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return payload + } + + const typedPayload = payload as Record + const value = typedPayload['result'] + + if (typeof value !== 'string') { + return payload + } + + const candidate = value.trim() + const base64Pattern = /^[A-Za-z0-9+/]+={0,2}$/ + + if (!candidate || candidate.length % 4 !== 0 || !base64Pattern.test(candidate)) { + return payload + } + + try { + const decoded = Buffer.from(candidate, 'base64').toString('utf-8') + const reencoded = Buffer.from(decoded, 'utf-8').toString('base64') + + if (!decoded || reencoded !== candidate) { + return payload + } + + return { + ...typedPayload, + result: decoded, + } + } catch { + return payload + } +} + const buildErrorResponse = ( error: unknown, context: ReturnType['context'], @@ -54,7 +90,7 @@ const pingUpstash = async () => { } return { - payload, + payload: decodeUpstashResult(payload), durationMs: Date.now() - start, } } diff --git a/test/e2e/specs/15-cron/cron.spec.ts b/test/e2e/specs/15-cron/cron.spec.ts new file mode 100644 index 000000000..34667c5cc --- /dev/null +++ b/test/e2e/specs/15-cron/cron.spec.ts @@ -0,0 +1,262 @@ +import { randomUUID } from 'node:crypto' +import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { expect, mocksEnabled, test } from '@test/e2e/helpers' +/** + * These env helpers are safe to use in E2E test as they call process.env + * directly. Must use "npm run dev:env" for this test case to pass. + */ +import { + getCronSecret, + getSuprabaseApiUrl, + getSuprabaseApiServiceRoleKey, + getUpstashApiUrl, + getUpstashApiToken, + } from '@pages/api/_environment/environmentApi' + +const CRON_SECRET = getCronSecret() +const SUPABASE_URL = getSuprabaseApiUrl() +const SUPABASE_SERVICE_ROLE_KEY = getSuprabaseApiServiceRoleKey() +const UPSTASH_URL = getUpstashApiUrl() +const UPSTASH_TOKEN = getUpstashApiToken() + +const requiredEnvMissing = () => { + if (!mocksEnabled) { + return 'E2E_MOCKS=1 is required for cron API integration tests' + } + if (!CRON_SECRET) { + return 'CRON_SECRET must be configured to call cron endpoints' + } + if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { + return 'Supabase admin credentials are required to seed cron fixtures' + } + if (!UPSTASH_URL || !UPSTASH_TOKEN) { + return 'Upstash REST credentials are required for cron ping coverage' + } + return null +} + +const skipReason = requiredEnvMissing() + +const supabaseAdmin: SupabaseClient | null = skipReason + ? null + : createClient(SUPABASE_URL!, SUPABASE_SERVICE_ROLE_KEY!, { + auth: { autoRefreshToken: false, persistSession: false }, + }) + +const upstashCommandEndpoint = UPSTASH_URL ? new URL('/', UPSTASH_URL).toString() : null +const cronAuthHeader = CRON_SECRET ? `Bearer ${CRON_SECRET}` : null + +const skipUnlessChromium = (browserName: string) => { + test.skip(browserName !== 'chromium', 'Cron tests run once via chromium project to avoid duplicates') +} + +const dayInMs = 24 * 60 * 60 * 1000 + +const createdConfirmationIds = new Set() +const createdDsarIds = new Set() + +const queueCleanup = (bucket: Set, id: string) => { + bucket.add(id) + return id +} + +const cleanupRecords = async (table: 'newsletter_confirmations' | 'dsar_requests', ids: Set) => { + if (!supabaseAdmin || ids.size === 0) { + return + } + const values = Array.from(ids) + await supabaseAdmin.from(table).delete().in('id', values) + ids.clear() +} + +const insertNewsletterConfirmation = async (options: { + expiresAt: Date + confirmedAt?: Date | null + createdAt?: Date +}) => { + if (!supabaseAdmin) { + throw new Error('Supabase client unavailable') + } + const payload = { + token: `cron-newsletter-${randomUUID()}`, + email: `cron-newsletter-${Date.now()}@example.com`, + data_subject_id: randomUUID(), + expires_at: options.expiresAt.toISOString(), + confirmed_at: options.confirmedAt ? options.confirmedAt.toISOString() : null, + created_at: (options.createdAt ?? new Date()).toISOString(), + } + + const { data, error } = await supabaseAdmin + .from('newsletter_confirmations') + .insert(payload) + .select('id') + .single() + + if (error || !data) { + throw new Error(`Failed to insert newsletter confirmation: ${error?.message}`) + } + + return queueCleanup(createdConfirmationIds, data.id) +} + +const insertDsarRequest = async (options: { + fulfilledAt?: Date | null + createdAt?: Date +}) => { + if (!supabaseAdmin) { + throw new Error('Supabase client unavailable') + } + const payload = { + token: `cron-dsar-${randomUUID()}`, + email: `cron-dsar-${Date.now()}@example.com`, + request_type: 'DELETE', + expires_at: new Date(Date.now() + dayInMs).toISOString(), + fulfilled_at: options.fulfilledAt ? options.fulfilledAt.toISOString() : null, + created_at: (options.createdAt ?? new Date()).toISOString(), + } + + const { data, error } = await supabaseAdmin + .from('dsar_requests') + .insert(payload) + .select('id') + .single() + + if (error || !data) { + throw new Error(`Failed to insert DSAR request: ${error?.message}`) + } + + return queueCleanup(createdDsarIds, data.id) +} + +const expectMissingById = async (table: 'newsletter_confirmations' | 'dsar_requests', id: string) => { + if (!supabaseAdmin) { + throw new Error('Supabase client unavailable') + } + const { data, error } = await supabaseAdmin + .from(table) + .select('id') + .eq('id', id) + .maybeSingle() + + if (error) { + throw new Error(`Failed to query ${table}: ${error.message}`) + } + + expect(data).toBeNull() +} + +const setUpstashKeepAlive = async (value: string) => { + if (!upstashCommandEndpoint) { + throw new Error('Missing Upstash endpoint') + } + const response = await fetch(upstashCommandEndpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${UPSTASH_TOKEN!}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(['SET', '__cron_keepalive__', value]), + }) + + if (!response.ok) { + const body = await response.text().catch(() => 'Unable to read response body') + throw new Error(`Failed to seed Upstash: ${response.status} ${body}`) + } +} + +test.describe('Cron API endpoints @ready', () => { + test.describe.configure({ mode: 'serial' }) + + if (skipReason) { + test.skip(true, skipReason) + } + + test.afterEach(async () => { + await cleanupRecords('newsletter_confirmations', createdConfirmationIds) + await cleanupRecords('dsar_requests', createdDsarIds) + }) + + test('@ready cleanup-confirmations removes expired and stale rows', async ({ browserName, request }) => { + skipUnlessChromium(browserName) + + const now = Date.now() + const expiredId = await insertNewsletterConfirmation({ + expiresAt: new Date(now - 60 * 60 * 1000), + }) + const staleId = await insertNewsletterConfirmation({ + expiresAt: new Date(now + dayInMs), + confirmedAt: new Date(now - 8 * dayInMs), + createdAt: new Date(now - 8 * dayInMs), + }) + + const response = await request.get('/api/cron/cleanup-confirmations', { + headers: { + authorization: cronAuthHeader!, + }, + }) + + expect(response.ok()).toBeTruthy() + const body = (await response.json()) as { + deleted: { expired: number; oldConfirmed: number; total: number } + } + + expect(body.deleted.expired).toBeGreaterThanOrEqual(1) + expect(body.deleted.oldConfirmed).toBeGreaterThanOrEqual(1) + + await expectMissingById('newsletter_confirmations', expiredId) + await expectMissingById('newsletter_confirmations', staleId) + }) + + test('@ready cleanup-dsar-requests prunes fulfilled and expired items', async ({ browserName, request }) => { + skipUnlessChromium(browserName) + + const now = Date.now() + const fulfilledId = await insertDsarRequest({ + fulfilledAt: new Date(now - 31 * dayInMs), + createdAt: new Date(now - 31 * dayInMs), + }) + const expiredPendingId = await insertDsarRequest({ + fulfilledAt: null, + createdAt: new Date(now - 8 * dayInMs), + }) + + const response = await request.get('/api/cron/cleanup-dsar-requests', { + headers: { + authorization: cronAuthHeader!, + }, + }) + + expect(response.ok()).toBeTruthy() + const body = (await response.json()) as { + deleted: { fulfilled: number; expired: number; total: number } + } + + expect(body.deleted.fulfilled).toBeGreaterThanOrEqual(1) + expect(body.deleted.expired).toBeGreaterThanOrEqual(1) + + await expectMissingById('dsar_requests', fulfilledId) + await expectMissingById('dsar_requests', expiredPendingId) + }) + + test('@ready ping-integrations touches Upstash and Supabase', async ({ browserName, request }) => { + skipUnlessChromium(browserName) + const sentinel = `keepalive-${randomUUID()}` + await setUpstashKeepAlive(sentinel) + + const response = await request.get('/api/cron/ping-integrations', { + headers: { + authorization: cronAuthHeader!, + }, + }) + + expect(response.ok()).toBeTruthy() + const body = (await response.json()) as { + upstash: { payload: { result?: string | null }; durationMs: number } + supabase: { rowsChecked: number; durationMs: number } + } + + expect(body.upstash.payload?.result).toBe(sentinel) + expect(body.supabase.rowsChecked).toBeGreaterThanOrEqual(0) + expect(body.supabase.durationMs).toBeGreaterThanOrEqual(0) + }) +}) From 153bf9ac5f76373747ed40cbfdd7a4b285fe3de2 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 2 Dec 2025 02:16:22 +0300 Subject: [PATCH 06/31] Add script for full e2e test run with mock service containers --- package.json | 43 ++++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index f4fed030a..c255f8047 100644 --- a/package.json +++ b/package.json @@ -28,34 +28,35 @@ "scripts": { "build": "cross-env NODE_ENV=production npm run lint && npx astro build", "check": "npm run lint && npx astro check", - "clean": "npx rimraf dist && npx rimraf .astro", - "dev": "npm run sync && cross-env NODE_ENV=development npx astro dev", - "dev:env": "dotenv -e .env.development -- npm run dev", + "clean": "FORCE_COLOR=1 npx rimraf dist && FORCE_COLOR=1 npx rimraf .astro", + "dev": "npm run sync && cross-env NODE_ENV=development FORCE_COLOR=1 npx astro dev", + "dev:env": "FORCE_COLOR=1 dotenv -e .env.development -- npm run dev", "format": "npm run format:json && npm run format:code && npm run format:style", - "format:code": "npx prettier --write \"@types/**/*.{js,ts}\" \"src/**/*.{js,ts,tsx,astro}\" --plugin=prettier-plugin-astro", - "format:json": "npx prettier --write '**/*.json' --cache --ignore-path .gitignore", - "format:style": "npx stylelint --fix \"src/**/*.{css,astro}\"", + "format:code": "FORCE_COLOR=1 npx prettier --write \"@types/**/*.{js,ts}\" \"src/**/*.{js,ts,tsx,astro}\" --plugin=prettier-plugin-astro", + "format:json": "FORCE_COLOR=1 npx prettier --write '**/*.json' --cache --ignore-path .gitignore", + "format:style": "FORCE_COLOR=1 npx stylelint --fix \"src/**/*.{css,astro}\"", "lint": "npm run lint:json && npm run lint:style && npm run lint:tsc:check && npm run lint:code", "lint:code": "npx eslint \"@types/**/*.{js,ts}\" \"src/**/*.{js,ts,tsx,astro}\"", "lint:tsc:check": "tsc --noEmit -p tsconfig.json --pretty false", - "lint:json": "npx prettier --write '**/*.json' --cache --ignore-path .gitignore", - "lint:style": "npx stylelint \"src/**/*.{css,astro}\"", - "sync": "npx astro sync", - "containers:up": "docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml up -d", - "containers:down": "docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml down -v", - "containers:logs": "docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml logs -f", - "containers:status": "docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml ps", + "lint:json": "FORCE_COLOR=1 npx prettier --write '**/*.json' --cache --ignore-path .gitignore", + "lint:style": "FORCE_COLOR=1 npx stylelint \"src/**/*.{css,astro}\"", + "sync": "FORCE_COLOR=1 npx astro sync", + "containers:up": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml up -d", + "containers:down": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml down -v", + "containers:logs": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml logs -f", + "containers:status": "FORCE_COLOR=1 docker compose --env-file test/containers/.env -f test/containers/docker-compose.e2e.yml ps", "containers:wait": "bash test/containers/scripts/wait-for-services.sh", "containers:supabase:start": "bash test/containers/supabase/start.sh", - "containers:supabase:stop": "npx supabase stop --workdir suprabase", - "containers:supabase:status": "npx supabase status --workdir suprabase", - "containers:supabase:logs": "bash test/containers/supabase/logs.sh", - "containers:supabase:db-push": "npx supabase db push --workdir suprabase --env-file test/containers/.env", - "supabase:db:push": "npx supabase db push --workdir suprabase", + "containers:supabase:stop": "FORCE_COLOR=1 npx supabase stop --workdir suprabase", + "containers:supabase:status": "FORCE_COLOR=1 npx supabase status --workdir suprabase", + "containers:supabase:logs": "FORCE_COLOR=1 bash test/containers/supabase/logs.sh", + "containers:supabase:db-push": "FORCE_COLOR=1 npx supabase db push --workdir suprabase --env-file test/containers/.env", + "supabase:db:push": "FORCE_COLOR=1 npx supabase db push --workdir suprabase", "test": "npm run test:unit && npm run test:e2e", - "test:coverage": "npx vitest run --coverage", - "test:e2e": "npx playwright test", - "test:unit": "npx vitest run", + "test:coverage": "FORCE_COLOR=1 npx vitest run --coverage", + "test:e2e": "FORCE_COLOR=1 npx playwright test", + "test:e2e:full": "FORCE_COLOR=1 E2E_MOCKS=1 npx playwright test", + "test:unit": "FORCE_COLOR=1 npx vitest run", "upgrade": "npx @astrojs/upgrade", "prepare": "husky" }, From 46dfb16fb173179bac3b84a6fd7593bfcfee009c Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 2 Dec 2025 03:37:56 +0300 Subject: [PATCH 07/31] Implement contact form E2E test and helpers --- _TODO.md | 2 + src/components/Consent/Checkbox/index.astro | 2 +- test/e2e/helpers/fetchOverride.ts | 233 ++++++++++++++++++ test/e2e/helpers/index.ts | 7 + test/e2e/specs/03-forms/contact-form.spec.ts | 145 +++++++++-- .../03-forms/newsletter-subscription.spec.ts | 85 ++++--- .../04-components/consentPreferences.spec.ts | 43 ++-- 7 files changed, 424 insertions(+), 93 deletions(-) create mode 100644 test/e2e/helpers/fetchOverride.ts diff --git a/_TODO.md b/_TODO.md index 0130aecf6..2056bbe4c 100644 --- a/_TODO.md +++ b/_TODO.md @@ -43,6 +43,8 @@ Implementation order 03-forms: once the API layer is stable, wire the UI flows. Use Playwright to submit each form, but assert success by checking the mock mappings were triggered, not just the UI toast. Consent Preferences (@wip): convert it to use the same helper that verifies mocked Upstash REST and Supabase responses. This test should (1) toggle UI controls, (2) check the outbound request via the mock logs, and (3) read back seeded data to confirm persistence. +04-components/consentPreferences.spec.ts + ## Typing client-side API calls and SSR API endpoints Shared Types vs Swagger / Keeping Docs in Sync diff --git a/src/components/Consent/Checkbox/index.astro b/src/components/Consent/Checkbox/index.astro index 2de2859fc..d77f924bd 100644 --- a/src/components/Consent/Checkbox/index.astro +++ b/src/components/Consent/Checkbox/index.astro @@ -21,7 +21,7 @@ const { customText, privacyPolicyUrl = '/privacy/', cookiePolicyUrl = '/consent/', - name = 'gdpr_consent', + name = 'consent', id = 'gdpr-consent', formId, } = Astro.props as ConsentCheckboxProps diff --git a/test/e2e/helpers/fetchOverride.ts b/test/e2e/helpers/fetchOverride.ts new file mode 100644 index 000000000..acf4a3903 --- /dev/null +++ b/test/e2e/helpers/fetchOverride.ts @@ -0,0 +1,233 @@ +import type { Page } from '@playwright/test' + +export interface FetchOverrideHandle { + restore: () => Promise + getCallCount: () => Promise + waitForCall: (_timeout?: number) => Promise +} + +interface BaseOverrideOptions { + endpoint: string + key?: string +} + +interface SpyOverrideOptions extends BaseOverrideOptions { + mode: 'spy' +} + +interface HeaderOverrideOptions extends BaseOverrideOptions { + mode: 'injectHeaders' + headers: Record +} + +interface MockResponseOverrideOptions extends BaseOverrideOptions { + mode: 'mockResponse' + status?: number + body?: unknown + headers?: Record + responseBuilder?: 'echoRequestJson' | 'consentRecord' +} + +interface DelayOverrideOptions extends BaseOverrideOptions { + mode: 'delay' + delayMs: number +} + +type OverrideOptions = + | SpyOverrideOptions + | HeaderOverrideOptions + | MockResponseOverrideOptions + | DelayOverrideOptions + +const generateOverrideKey = (): string => `fetch-override-${Date.now()}-${Math.random().toString(16).slice(2)}` + +const createFetchOverride = async (page: Page, options: OverrideOptions): Promise => { + const key = options.key ?? generateOverrideKey() + + await page.evaluate(({ config }) => { + const globalWindow = window as typeof window & { + __fetchOverrideStack?: Array + __fetchOverrideCallCounts?: Record + } + + const getRequestUrl = (input: RequestInfo | URL): string => { + if (typeof input === 'string') { + return input + } + if (input instanceof Request) { + return input.url + } + if (input instanceof URL) { + return input.href + } + return String(input) + } + + const ensureStateInitialized = () => { + globalWindow.__fetchOverrideStack = globalWindow.__fetchOverrideStack ?? [] + globalWindow.__fetchOverrideCallCounts = globalWindow.__fetchOverrideCallCounts ?? {} + } + + ensureStateInitialized() + + const previousFetch = window.fetch + globalWindow.__fetchOverrideStack!.push(previousFetch) + + window.fetch = async (...args) => { + const [input, init] = args + const url = getRequestUrl(input) + + if (!url.includes(config.endpoint)) { + return previousFetch(...args) + } + + globalWindow.__fetchOverrideCallCounts![config.key] = + (globalWindow.__fetchOverrideCallCounts![config.key] ?? 0) + 1 + + if (config.mode === 'spy') { + return previousFetch(...args) + } + + if (config.mode === 'delay') { + await new Promise(resolve => setTimeout(resolve, config.delayMs)) + return previousFetch(...args) + } + + const request = input instanceof Request ? input : new Request(input, init) + + if (config.mode === 'injectHeaders') { + const mergedHeaders = new Headers(request.headers) + Object.entries(config.headers).forEach(([headerName, headerValue]) => { + mergedHeaders.set(headerName, headerValue) + }) + const overriddenRequest = new Request(request, { headers: mergedHeaders }) + return previousFetch(overriddenRequest) + } + + if (config.mode === 'mockResponse') { + const headers = new Headers(config.headers ?? {}) + const buildResponseBody = async (): Promise => { + if (config.responseBuilder === 'echoRequestJson') { + try { + return await request.clone().json() + } catch { + return config.body ?? {} + } + } + + if (config.responseBuilder === 'consentRecord') { + let payload: Record = {} + try { + payload = await request.clone().json() + } catch { + payload = {} + } + + const purposesSource = (payload as Record)['purposes'] + const purposes = Array.isArray(purposesSource) ? purposesSource : [] + + return { + success: true, + record: { + id: 'test-consent-record', + DataSubjectId: typeof payload['DataSubjectId'] === 'string' ? payload['DataSubjectId'] : 'test-subject-id', + purposes, + timestamp: new Date().toISOString(), + source: typeof payload['source'] === 'string' ? payload['source'] : 'cookies_modal', + userAgent: typeof payload['userAgent'] === 'string' ? payload['userAgent'] : 'playwright-test', + ipAddress: '127.0.0.1', + privacyPolicyVersion: 'test-policy-v1', + verified: Boolean(payload['verified']), + }, + } + } + + return config.body ?? {} + } + + const resolvedBody = await buildResponseBody() + if (!headers.has('Content-Type')) { + headers.set('Content-Type', typeof resolvedBody === 'string' ? 'text/plain' : 'application/json') + } + + const serializedBody = typeof resolvedBody === 'string' ? resolvedBody : JSON.stringify(resolvedBody) + + return new Response(serializedBody, { + status: config.status ?? 200, + headers, + }) + } + + return previousFetch(...args) + } + }, { config: { ...options, key } }) + + const restore = async () => { + await page.evaluate(({ overrideKey }) => { + const globalWindow = window as typeof window & { + __fetchOverrideStack?: Array + __fetchOverrideCallCounts?: Record + } + + if (globalWindow.__fetchOverrideStack && globalWindow.__fetchOverrideStack.length > 0) { + const previousFetch = globalWindow.__fetchOverrideStack.pop() + if (previousFetch) { + window.fetch = previousFetch + } + } + + if (globalWindow.__fetchOverrideCallCounts) { + delete globalWindow.__fetchOverrideCallCounts[overrideKey] + } + }, { overrideKey: key }) + } + + const getCallCount = async () => { + return await page.evaluate(({ overrideKey }) => { + const globalWindow = window as typeof window & { + __fetchOverrideCallCounts?: Record + } + return globalWindow.__fetchOverrideCallCounts?.[overrideKey] ?? 0 + }, { overrideKey: key }) + } + + const waitForCall = async (timeout = 5000) => { + await page.waitForFunction( + (overrideKey) => { + const globalWindow = window as typeof window & { + __fetchOverrideCallCounts?: Record + } + return (globalWindow.__fetchOverrideCallCounts?.[overrideKey] ?? 0) > 0 + }, + key, + { timeout }, + ) + } + + return { restore, getCallCount, waitForCall } +} + +export const spyOnFetchEndpoint = async (page: Page, endpoint: string): Promise => { + return await createFetchOverride(page, { mode: 'spy', endpoint }) +} + +export const injectHeadersIntoFetch = async ( + page: Page, + options: { endpoint: string; headers: Record }, +): Promise => { + return await createFetchOverride(page, { mode: 'injectHeaders', ...options }) +} + +export const mockFetchEndpointResponse = async ( + page: Page, + options: { endpoint: string; status?: number; body?: unknown; headers?: Record; responseBuilder?: 'echoRequestJson' | 'consentRecord' }, +): Promise => { + return await createFetchOverride(page, { mode: 'mockResponse', ...options }) +} + +export const delayFetchForEndpoint = async ( + page: Page, + options: { endpoint: string; delayMs: number }, +): Promise => { + return await createFetchOverride(page, { mode: 'delay', ...options }) +} diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 64092d220..115e9c804 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -16,6 +16,13 @@ export { BasePage } from '@test/e2e/helpers/pageObjectModels/BasePage' export { ComponentPersistencePage } from '@test/e2e/helpers/pageObjectModels/ComponentPersistencePage' export { HeadPage } from '@test/e2e/helpers/pageObjectModels/HeadPage' export { BreadCrumbPage } from '@test/e2e/helpers/pageObjectModels/BreadCrumbPage' +export { + spyOnFetchEndpoint, + mockFetchEndpointResponse, + injectHeadersIntoFetch, + delayFetchForEndpoint, +} from '@test/e2e/helpers/fetchOverride' +export type { FetchOverrideHandle } from '@test/e2e/helpers/fetchOverride' export { setupCleanTestPage, setupTestPage, diff --git a/test/e2e/specs/03-forms/contact-form.spec.ts b/test/e2e/specs/03-forms/contact-form.spec.ts index d3e8c90a3..f31e7e245 100644 --- a/test/e2e/specs/03-forms/contact-form.spec.ts +++ b/test/e2e/specs/03-forms/contact-form.spec.ts @@ -4,12 +4,24 @@ * Focuses on client-side validation and UI behaviors of the contact form. * Also see test/e2e/specs/02-pages/contact.spec.ts for navigation and basic load tests. */ -import type { Page } from '@playwright/test' -import { BasePage, expect, test } from '@test/e2e/helpers' -import { EvaluationError } from '@test/errors' +import type { Page, Response } from '@playwright/test' +import { + BasePage, + expect, + test, + wiremock, + mocksEnabled, + spyOnFetchEndpoint, + injectHeadersIntoFetch, + mockFetchEndpointResponse, +} from '@test/e2e/helpers' +import { TestError } from '@test/errors' import { TEST_CONTACT_DATA, TEST_EMAILS } from '@test/e2e/fixtures/test-data' const CONTACT_PATH = '/contact' +const RESEND_EMAIL_PATH = '/emails' + +const isContactApiResponse = (response: Response) => response.url().includes('/api/contact') const waitForContactFormHydration = async (page: BasePage) => { await page.waitForFunction(() => { @@ -31,6 +43,28 @@ const fillRequiredFields = async (page: BasePage) => { await page.fill('#message', `${TEST_CONTACT_DATA.valid.message} Additional context for testing.`) } +const fillContactFormWithValidData = async (page: BasePage, overrides?: { email?: string }) => { + const uniqueSuffix = Date.now() + const email = overrides?.email ?? `contact-form-ui-${uniqueSuffix}@example.com` + + await page.fill('#name', TEST_CONTACT_DATA.valid.name) + await page.fill('#email', email) + await page.fill('#company', TEST_CONTACT_DATA.valid.company) + await page.fill('#phone', TEST_CONTACT_DATA.valid.phone) + await page.locator('#project_type').selectOption('website') + await page.locator('#budget').selectOption('10k-25k') + await page.locator('#timeline').selectOption('asap') + await page.fill('#message', `${TEST_CONTACT_DATA.valid.message} UI flow ${uniqueSuffix}`) + await page.check('#contact-gdpr-consent') + + await expect(page.locator('#project_type')).toHaveValue('website') + await expect(page.locator('#budget')).toHaveValue('10k-25k') + await expect(page.locator('#timeline')).toHaveValue('asap') + await expect(page.locator('#contact-gdpr-consent')).toBeChecked() + + return { email } +} + test.describe('Contact Form', () => { test('@ready email validation surfaces inline error messages on blur', async ({ page: playwrightPage }) => { const page = await setupContactPage(playwrightPage) @@ -61,26 +95,20 @@ test.describe('Contact Form', () => { await page.locator('#project_type').selectOption('website') await page.locator('#timeline').selectOption('asap') - let apiCallMade = false - await page.route('/api/contact', route => { - apiCallMade = true - route.fulfill({ - status: 422, - contentType: 'application/json', - body: JSON.stringify({ - success: false, - message: 'Budget range is required', - }), - }) - }) + const fetchSpy = await spyOnFetchEndpoint(playwrightPage, '/api/contact') - await page.click('#submitBtn') + try { + await page.click('#submitBtn') - await expect(page.locator('#formErrorBanner')).toBeVisible() - await expect(page.locator('#budget + .field-error')).toContainText('This field is required') + await expect(page.locator('#formErrorBanner')).toBeVisible() + await expect(page.locator('#budget + .field-error')).toContainText('This field is required') - if (apiCallMade) { - throw new EvaluationError('Contact API was called despite validation errors') + const apiCallCount = await fetchSpy.getCallCount() + if (apiCallCount > 0) { + throw new TestError('Contact API was called despite validation errors') + } + } finally { + await fetchSpy.restore() } }) @@ -92,11 +120,80 @@ test.describe('Contact Form', () => { await expect(uppyContainer).toContainText('File Upload Coming Soon') }) - test.skip('@wip contact form submits successfully when API is available', async () => { - // TODO: Implement when backend Docker harness is ready for full integration tests + test('@mocks contact form submits successfully when API is available', async ({ page: playwrightPage }) => { + test.skip(!mocksEnabled, 'E2E_MOCKS=1 is required to verify Resend mock delivery') + + await wiremock.resend.resetRequests() + const page = await setupContactPage(playwrightPage) + const headerOverride = await injectHeadersIntoFetch(playwrightPage, { + endpoint: '/api/contact', + headers: { 'x-e2e-mocks': '1' }, + }) + + try { + const { email } = await fillContactFormWithValidData(page) + const responsePromise = page.waitForResponse(isContactApiResponse) + + await page.click('#submitBtn') + + const response = await responsePromise + expect(response.status()).toBe(200) + + await expect(page.locator('#formMessages .message-success')).toBeVisible({ timeout: 5000 }) + await expect(page.locator('#formMessages .message-error')).toBeHidden() + + const loggedRequest = await wiremock.resend.expectRequest({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, + bodyIncludes: [email, 'contact@webstackbuilders.com'], + }) + + if (!loggedRequest) { + throw new TestError('Resend mock did not capture the transactional email payload') + } + + const payload = JSON.parse(loggedRequest.request.body ?? '{}') as { + to?: string | string[] + subject?: string + } + + if (Array.isArray(payload.to)) { + expect(payload.to).toContain('info@webstackbuilders.com') + } else { + expect(payload.to).toBe('info@webstackbuilders.com') + } + expect(payload.subject).toContain('Contact Form') + } finally { + await headerOverride.restore() + } }) - test.skip('@wip contact form surfaces API error responses to users', async () => { - // TODO: Implement when backend Docker harness is ready for full integration tests + test('@ready contact form surfaces API error responses to users', async ({ page: playwrightPage }) => { + const page = await setupContactPage(playwrightPage) + await fillContactFormWithValidData(page) + + const apiErrorOverride = await mockFetchEndpointResponse(playwrightPage, { + endpoint: '/api/contact', + body: { + success: false, + message: 'Unable to reach contact API. Please try again shortly.', + }, + status: 200, + }) + + try { + await page.click('#submitBtn') + await apiErrorOverride.waitForCall() + + await expect(page.locator('#formMessages .message-error')).toBeVisible({ timeout: 5000 }) + await expect(page.locator('#errorMessage')).toContainText('Unable to reach contact API') + + if (mocksEnabled) { + const loggedRequests = await wiremock.resend.findRequests({ method: 'POST', urlPath: RESEND_EMAIL_PATH }) + expect(loggedRequests.length).toBe(0) + } + } finally { + await apiErrorOverride.restore() + } }) }) diff --git a/test/e2e/specs/03-forms/newsletter-subscription.spec.ts b/test/e2e/specs/03-forms/newsletter-subscription.spec.ts index b3b7b66da..db4699ec7 100644 --- a/test/e2e/specs/03-forms/newsletter-subscription.spec.ts +++ b/test/e2e/specs/03-forms/newsletter-subscription.spec.ts @@ -2,7 +2,7 @@ * Newsletter Subscription Form E2E Tests * Tests for newsletter signup functionality */ -import { test, expect } from '@test/e2e/helpers' +import { test, expect, spyOnFetchEndpoint, delayFetchForEndpoint } from '@test/e2e/helpers' import { EvaluationError } from '@test/errors' import { TEST_EMAILS, ERROR_MESSAGES } from '@test/e2e/fixtures/test-data' import { NewsletterPage } from '@test/e2e/helpers/pageObjectModels/NewsletterPage' @@ -37,38 +37,37 @@ test.describe('Newsletter Subscription Form', () => { test('@ready form requires GDPR consent', async ({ page: playwrightPage }) => { const newsletterPage = await NewsletterPage.init(playwrightPage) await newsletterPage.navigateToNewsletterForm() - let apiCallMade = false - - // Monitor API calls to ensure client-side validation prevents submission - await newsletterPage.route('/api/newsletter', (route) => { - apiCallMade = true - route.abort() // Don't actually process it - }) - - // Wait for page to be fully loaded with scripts - await newsletterPage.waitForLoadState('networkidle') - await newsletterPage.waitForFunction(() => { - const button = document.querySelector('#newsletter-submit') - return button instanceof HTMLButtonElement && !button.disabled - }, undefined, { timeout: 3000 }) - - await newsletterPage.fillEmail(TEST_EMAILS.valid) - // Don't check GDPR consent - leave it unchecked - await newsletterPage.submitForm() - - // Wait for client-side validation to show error message - await newsletterPage.waitForFunction(() => { - const message = document.getElementById('newsletter-message') - return message && message.textContent && message.textContent.includes('consent') - }, { timeout: 3000 }) - - // Verify that no API call was made (client-side validation prevented it) - if (apiCallMade) { - throw new EvaluationError('API call was made - client-side validation failed to prevent submission') + const fetchSpy = await spyOnFetchEndpoint(newsletterPage.page, '/api/newsletter') + + try { + // Wait for page to be fully loaded with scripts + await newsletterPage.waitForLoadState('networkidle') + await newsletterPage.waitForFunction(() => { + const button = document.querySelector('#newsletter-submit') + return button instanceof HTMLButtonElement && !button.disabled + }, undefined, { timeout: 3000 }) + + await newsletterPage.fillEmail(TEST_EMAILS.valid) + // Don't check GDPR consent - leave it unchecked + await newsletterPage.submitForm() + + // Wait for client-side validation to show error message + await newsletterPage.waitForFunction(() => { + const message = document.getElementById('newsletter-message') + return message && message.textContent && message.textContent.includes('consent') + }, { timeout: 3000 }) + + // Verify that no API call was made (client-side validation prevented it) + const apiCallCount = await fetchSpy.getCallCount() + if (apiCallCount > 0) { + throw new EvaluationError('API call was made - client-side validation failed to prevent submission') + } + + // Should show consent required error message + await newsletterPage.expectMessageContains('Please consent to receive marketing communications') + } finally { + await fetchSpy.restore() } - - // Should show consent required error message - await newsletterPage.expectMessageContains('Please consent to receive marketing communications') }) test('@ready form requires email address', async ({ page: playwrightPage }) => { @@ -98,13 +97,9 @@ test.describe('Newsletter Subscription Form', () => { }) // Set up intercept for API call to slow it down - await newsletterPage.route('/api/newsletter', async route => { - // Add delay to make spinner visible longer - // Mobile Safari and webkit need longer delay - const delay = newsletterPage.context().browser()?.browserType().name() === 'webkit' ? 300 : 100 - await new Promise(resolve => setTimeout(resolve, delay)) - await route.continue() - }) + const browserName = newsletterPage.context().browser()?.browserType().name() + const delayMs = browserName === 'webkit' ? 300 : 100 + const delayOverride = await delayFetchForEndpoint(newsletterPage.page, { endpoint: '/api/newsletter', delayMs }) // Click submit and immediately check for spinner const submitButton = newsletterPage.locator('#newsletter-submit') @@ -113,11 +108,15 @@ test.describe('Newsletter Subscription Form', () => { // Submit form and check loading state immediately const submitPromise = submitButton.click() - // The spinner should become visible during the API call - await expect(spinner).toBeVisible({ timeout: 2000 }) + try { + // The spinner should become visible during the API call + await expect(spinner).toBeVisible({ timeout: 2000 }) - // Wait for the submit to complete - await submitPromise + // Wait for the submit to complete + await submitPromise + } finally { + await delayOverride.restore() + } }) test('@ready form resets after successful submission', async ({ page: playwrightPage }) => { diff --git a/test/e2e/specs/04-components/consentPreferences.spec.ts b/test/e2e/specs/04-components/consentPreferences.spec.ts index 3fe467f78..8d6cc20ac 100644 --- a/test/e2e/specs/04-components/consentPreferences.spec.ts +++ b/test/e2e/specs/04-components/consentPreferences.spec.ts @@ -3,7 +3,8 @@ * Exercises the consent route which hosts the consent-preferences component inline */ -import { BasePage, expect, test } from '@test/e2e/helpers' +import type { Page } from '@playwright/test' +import { BasePage, expect, test, mockFetchEndpointResponse, type FetchOverrideHandle } from '@test/e2e/helpers' const ALLOW_ALL_BUTTON = '#consent-allow-all' const SAVE_BUTTON = '#consent-save-preferences' @@ -13,29 +14,10 @@ const CONSENT_PAGE_PATH = '/consent' const toggleLabel = (checkboxId: string): string => `[data-consent-toggle="${checkboxId}"]` -async function interceptConsentApi(page: BasePage): Promise { - await page.route('**/api/gdpr/consent', async (route) => { - const requestBody = route.request().postDataJSON?.() as Record | undefined - const purposes = Array.isArray(requestBody?.['purposes']) ? requestBody?.['purposes'] : [] - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - record: { - id: 'test-consent-record', - DataSubjectId: (requestBody?.['DataSubjectId'] as string | undefined) ?? 'test-subject-id', - purposes, - timestamp: new Date().toISOString(), - source: (requestBody?.['source'] as string | undefined) ?? 'cookies_modal', - userAgent: (requestBody?.['userAgent'] as string | undefined) ?? 'playwright-test', - ipAddress: '127.0.0.1', - privacyPolicyVersion: 'test-policy-v1', - verified: Boolean(requestBody?.['verified']), - }, - }), - }) +const interceptConsentApi = async (page: Page): Promise => { + return await mockFetchEndpointResponse(page, { + endpoint: '/api/gdpr/consent', + responseBuilder: 'consentRecord', }) } @@ -70,12 +52,16 @@ async function waitForConsentPreferences(page: BasePage): Promise { } test.describe('Consent Preferences Component', () => { + let consentApiOverride: FetchOverrideHandle | null = null + test.beforeEach(async ({ page: playwrightPage, context }, testInfo) => { const page = await BasePage.init(playwrightPage) const shouldMockConsentApi = !testInfo.title.includes(WIP_TAG) if (shouldMockConsentApi) { - await interceptConsentApi(page) + consentApiOverride = await interceptConsentApi(playwrightPage) + } else { + consentApiOverride = null } await context.clearCookies() @@ -85,6 +71,13 @@ test.describe('Consent Preferences Component', () => { await waitForConsentPreferences(page) }) + test.afterEach(async () => { + if (consentApiOverride) { + await consentApiOverride.restore() + consentApiOverride = null + } + }) + test.skip( '@wip full stack consent submission hits backend mocks', async ({ page: playwrightPage }) => { From a0a4b750f624e17ba0fab89dc7e9ddc65c7a6027 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 2 Dec 2025 03:46:26 +0300 Subject: [PATCH 08/31] Implement contact form E2E full stack with third-party mocks test case --- test/e2e/specs/03-forms/contact-form.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/e2e/specs/03-forms/contact-form.spec.ts b/test/e2e/specs/03-forms/contact-form.spec.ts index f31e7e245..8f39eb241 100644 --- a/test/e2e/specs/03-forms/contact-form.spec.ts +++ b/test/e2e/specs/03-forms/contact-form.spec.ts @@ -123,7 +123,6 @@ test.describe('Contact Form', () => { test('@mocks contact form submits successfully when API is available', async ({ page: playwrightPage }) => { test.skip(!mocksEnabled, 'E2E_MOCKS=1 is required to verify Resend mock delivery') - await wiremock.resend.resetRequests() const page = await setupContactPage(playwrightPage) const headerOverride = await injectHeadersIntoFetch(playwrightPage, { endpoint: '/api/contact', @@ -170,7 +169,7 @@ test.describe('Contact Form', () => { test('@ready contact form surfaces API error responses to users', async ({ page: playwrightPage }) => { const page = await setupContactPage(playwrightPage) - await fillContactFormWithValidData(page) + const { email } = await fillContactFormWithValidData(page) const apiErrorOverride = await mockFetchEndpointResponse(playwrightPage, { endpoint: '/api/contact', @@ -189,7 +188,11 @@ test.describe('Contact Form', () => { await expect(page.locator('#errorMessage')).toContainText('Unable to reach contact API') if (mocksEnabled) { - const loggedRequests = await wiremock.resend.findRequests({ method: 'POST', urlPath: RESEND_EMAIL_PATH }) + const loggedRequests = await wiremock.resend.findRequests({ + method: 'POST', + urlPath: RESEND_EMAIL_PATH, + bodyIncludes: email, + }) expect(loggedRequests.length).toBe(0) } } finally { From 1409b006bbb30e6075ffea06efb162e7519fa0c8 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 2 Dec 2025 04:06:17 +0300 Subject: [PATCH 09/31] Implement consent checkbox E2E test with container mocks for third party services --- src/components/Consent/Checkbox/index.astro | 18 +- .../specs/03-forms/consent-checkbox.spec.ts | 266 ++++++++++-------- 2 files changed, 159 insertions(+), 125 deletions(-) diff --git a/src/components/Consent/Checkbox/index.astro b/src/components/Consent/Checkbox/index.astro index d77f924bd..3c866ab93 100644 --- a/src/components/Consent/Checkbox/index.astro +++ b/src/components/Consent/Checkbox/index.astro @@ -54,8 +54,22 @@ const { ) : ( <> I consent to Webstack Builders processing my personal data for {purpose}. See our{' '} - Privacy Policy and{' '} - Cookie Policy. + + Privacy Policy + {' '}and{' '} + + Cookie Policy + . )} diff --git a/test/e2e/specs/03-forms/consent-checkbox.spec.ts b/test/e2e/specs/03-forms/consent-checkbox.spec.ts index e7cf27267..6de32c306 100644 --- a/test/e2e/specs/03-forms/consent-checkbox.spec.ts +++ b/test/e2e/specs/03-forms/consent-checkbox.spec.ts @@ -3,158 +3,178 @@ * @see src/components/Consent/ */ -import { BasePage, test, expect } from '@test/e2e/helpers' - - -test.describe('GDPR Consent Component', () => { - /** - * Setup for GDPR consent component tests - * - * Side effects relied upon: - * - Navigates to the contact page which contains a newsletter signup form - * - * Without this setup, tests would fail due to: - * - GDPR consent checkbox not being present on the page - * - Newsletter form and associated consent controls not being rendered - * - * The contact page is specifically chosen because it contains the newsletter - * subscription form which includes GDPR consent controls - */ - test.beforeEach(async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - await page.goto('/contact') +import type { Page } from '@playwright/test' +import { + BasePage, + test, + expect, + spyOnFetchEndpoint, + mockFetchEndpointResponse, +} from '@test/e2e/helpers' +import { TEST_EMAILS } from '@test/e2e/fixtures/test-data' + +const HOME_PATH = '/' +const CONTACT_PATH = '/contact' +const NEWSLETTER_FORM_SELECTOR = '#newsletter-form' +const NEWSLETTER_EMAIL_INPUT = '#newsletter-email' +const NEWSLETTER_SUBMIT_BUTTON = '#newsletter-submit' +const NEWSLETTER_CONSENT_SELECTOR = '#newsletter-gdpr-consent' +const NEWSLETTER_CONSENT_LABEL_SELECTOR = `label:has(${NEWSLETTER_CONSENT_SELECTOR})` +const NEWSLETTER_PRIVACY_LINK_SELECTOR = `${NEWSLETTER_CONSENT_LABEL_SELECTOR} a[href*="privacy"]` +const NEWSLETTER_CONSENT_ERROR_SELECTOR = '#newsletter-gdpr-consent-error' +const NEWSLETTER_MESSAGE_SELECTOR = '#newsletter-message' +const CONTACT_CONSENT_SELECTOR = '#contact-gdpr-consent' + +const waitForNewsletterSection = async (page: BasePage): Promise => { + await page.waitForLoadState('networkidle') + await page.locator(NEWSLETTER_FORM_SELECTOR).waitFor({ state: 'visible' }) + await page.waitForFunction(() => { + const consentCheckbox = document.querySelector('#newsletter-gdpr-consent') + const emailInput = document.querySelector('#newsletter-email') + return Boolean(consentCheckbox && emailInput) + }, undefined, { timeout: 5000 }) + await page.scrollToElement(NEWSLETTER_FORM_SELECTOR) +} + +const waitForContactForm = async (page: BasePage): Promise => { + await page.waitForFunction(() => { + const container = document.getElementById('uppyContainer') + return Boolean(container && container.hidden === false) + }, undefined, { timeout: 5000 }) + await page.locator(CONTACT_CONSENT_SELECTOR).waitFor({ state: 'visible' }) + await page.scrollToElement(CONTACT_CONSENT_SELECTOR) +} + +const fillNewsletterEmail = async (page: BasePage, email: string = TEST_EMAILS.valid): Promise => { + await page.fill(NEWSLETTER_EMAIL_INPUT, email) +} + +const submitNewsletterForm = async (page: BasePage): Promise => { + await page.click(NEWSLETTER_SUBMIT_BUTTON) +} + +const expectConsentErrorVisible = async (page: BasePage): Promise => { + const errorMessage = page.locator(NEWSLETTER_CONSENT_ERROR_SELECTOR) + await expect(errorMessage).toBeVisible() + await expect(errorMessage).toContainText('consent') +} +test.describe('Newsletter GDPR Consent', () => { + let pageUnderTest: BasePage + let playwrightPage: Page + + test.beforeEach(async ({ page }) => { + playwrightPage = page + pageUnderTest = await BasePage.init(page) + await pageUnderTest.goto(HOME_PATH) + await waitForNewsletterSection(pageUnderTest) }) - test.skip('@wip GDPR consent checkbox is visible', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - // Expected: GDPR consent checkbox should be visible on newsletter form - const gdprCheckbox = page.locator('input[type="checkbox"][name*="consent"], input[type="checkbox"][name*="gdpr"]') - await expect(gdprCheckbox.first()).toBeVisible() + test('@ready GDPR consent checkbox is visible', async () => { + await expect(pageUnderTest.locator(NEWSLETTER_CONSENT_SELECTOR)).toBeVisible() }) - test.skip('@wip GDPR consent has label', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - // Expected: Checkbox should have associated label - const gdprCheckbox = page.locator('input[type="checkbox"][name*="consent"], input[type="checkbox"][name*="gdpr"]').first() - const checkboxId = await gdprCheckbox.getAttribute('id') - - if (checkboxId) { - const label = page.locator(`label[for="${checkboxId}"]`) - await expect(label).toBeVisible() - } else { - // Label might wrap checkbox - const parentLabel = gdprCheckbox.locator('..') - const labelText = await parentLabel.textContent() - expect(labelText?.trim().length).toBeGreaterThan(0) - } + test('@ready GDPR consent has label', async () => { + await expect(pageUnderTest.locator(NEWSLETTER_CONSENT_LABEL_SELECTOR)).toContainText('Privacy Policy') }) - test.skip('@wip GDPR label contains privacy policy link', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - // Expected: GDPR label should link to privacy policy - const gdprLabel = page.locator('label:has(input[type="checkbox"][name*="consent"]), label:has(input[type="checkbox"][name*="gdpr"])').first() - const privacyLink = gdprLabel.locator('a[href*="privacy"]') - + test('@ready GDPR label contains privacy policy link', async () => { + const privacyLink = pageUnderTest.locator(NEWSLETTER_PRIVACY_LINK_SELECTOR).first() await expect(privacyLink).toBeVisible() + await expect(privacyLink).toHaveAttribute('href', /\/privacy\/?$/) }) - test.skip('@wip privacy policy link opens in new tab', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - // Expected: Privacy link should have target="_blank" - const gdprLabel = page.locator('label:has(input[type="checkbox"][name*="consent"]), label:has(input[type="checkbox"][name*="gdpr"])').first() - const privacyLink = gdprLabel.locator('a[href*="privacy"]') - - const target = await privacyLink.getAttribute('target') - expect(target).toBe('_blank') - - const rel = await privacyLink.getAttribute('rel') - expect(rel).toContain('noopener') + test('@ready privacy policy link opens in new tab', async () => { + const privacyLink = pageUnderTest.locator(NEWSLETTER_PRIVACY_LINK_SELECTOR).first() + await expect(privacyLink).toHaveAttribute('target', '_blank') + await expect(privacyLink).toHaveAttribute('rel', /noopener/) }) - test.skip('@wip form cannot submit without GDPR consent', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - // Expected: Form submission should fail if GDPR not checked - const emailInput = page.locator('input[type="email"]').first() - const submitButton = page.locator('button[type="submit"]').first() - const gdprCheckbox = page.locator('input[type="checkbox"][name*="consent"], input[type="checkbox"][name*="gdpr"]').first() + test('@ready form cannot submit without GDPR consent', async () => { + const fetchSpy = await spyOnFetchEndpoint(playwrightPage, '/api/newsletter') + + try { + await fillNewsletterEmail(pageUnderTest) + await submitNewsletterForm(pageUnderTest) - await emailInput.fill('test@example.com') - // Don't check GDPR + await expect(pageUnderTest.locator(NEWSLETTER_MESSAGE_SELECTOR)).toContainText('Please consent') + await expectConsentErrorVisible(pageUnderTest) - await submitButton.click() - await expect.poll(async () => { - return await gdprCheckbox.evaluate((el: HTMLInputElement) => el.validationMessage) - }).not.toEqual('') + const callCount = await fetchSpy.getCallCount() + expect(callCount).toBe(0) + } finally { + await fetchSpy.restore() + } }) - test.skip('@wip form can submit with GDPR consent', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - // Expected: Form should accept submission when GDPR is checked - const emailInput = page.locator('input[type="email"]').first() - const submitButton = page.locator('button[type="submit"]').first() - const gdprCheckbox = page.locator('input[type="checkbox"][name*="consent"], input[type="checkbox"][name*="gdpr"]').first() + test('@ready form can submit with GDPR consent', async () => { + const subscriptionEmail = `consent-e2e-${Date.now()}@example.com` + const successOverride = await mockFetchEndpointResponse(playwrightPage, { + endpoint: '/api/newsletter', + status: 200, + body: { + success: true, + message: 'Please check your email to confirm your subscription', + }, + }) + + try { + await fillNewsletterEmail(pageUnderTest, subscriptionEmail) + await pageUnderTest.check(NEWSLETTER_CONSENT_SELECTOR) + await submitNewsletterForm(pageUnderTest) + await successOverride.waitForCall() + + await expect(pageUnderTest.locator(NEWSLETTER_MESSAGE_SELECTOR)).toContainText('confirm your subscription') + } finally { + await successOverride.restore() + } + }) - await emailInput.fill('test@example.com') - await gdprCheckbox.check() + test('@ready GDPR checkbox is accessible via keyboard', async () => { + const checkbox = pageUnderTest.locator(NEWSLETTER_CONSENT_SELECTOR) + await checkbox.focus() - await submitButton.click() + await pageUnderTest.keyboard.press('Space') + await expect(checkbox).toBeChecked() - // Form should be processing or show success - // (Actual behavior depends on API implementation) + await pageUnderTest.keyboard.press('Space') + await expect(checkbox).not.toBeChecked() }) - test.skip('@wip GDPR checkbox is accessible via keyboard', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - // Expected: Can check/uncheck with Space key - const gdprCheckbox = page.locator('input[type="checkbox"][name*="consent"], input[type="checkbox"][name*="gdpr"]').first() - - // Tab to checkbox - await page.keyboard.press('Tab') - await page.keyboard.press('Tab') - await page.keyboard.press('Tab') // May need multiple tabs - - // Check with Space - await page.keyboard.press('Space') - await expect(gdprCheckbox).toBeChecked() + test('@ready GDPR error message is displayed', async () => { + await fillNewsletterEmail(pageUnderTest) + await submitNewsletterForm(pageUnderTest) + await expectConsentErrorVisible(pageUnderTest) }) - test.skip('@wip GDPR error message is displayed', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - // Expected: Should show error message when unchecked on submit - const submitButton = page.locator('button[type="submit"]').first() - const emailInput = page.locator('input[type="email"]').first() + test('@ready GDPR consent state persists during form validation', async () => { + const checkbox = pageUnderTest.locator(NEWSLETTER_CONSENT_SELECTOR) + await checkbox.check() + await expect(checkbox).toBeChecked() - await emailInput.fill('test@example.com') - await submitButton.click() - const errorMessage = page.locator('[data-error*="consent"], [data-error*="gdpr"], .error:has-text("consent")') - await expect(errorMessage.first()).toBeVisible() + await pageUnderTest.fill(NEWSLETTER_EMAIL_INPUT, 'invalid-email') + await submitNewsletterForm(pageUnderTest) + await expect(pageUnderTest.locator(NEWSLETTER_MESSAGE_SELECTOR)).toContainText('valid email address') + await expect(checkbox).toBeChecked() }) +}) - test.skip('@wip GDPR checkbox works on contact form', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - // Expected: GDPR should also work on contact form - await page.goto('/contact') - - const gdprCheckbox = page.locator('input[type="checkbox"][name*="consent"], input[type="checkbox"][name*="gdpr"]') - await expect(gdprCheckbox.first()).toBeVisible() +test.describe('Contact Form GDPR Consent', () => { + let pageUnderTest: BasePage - const label = gdprCheckbox.first().locator('xpath=..').locator('..') - const labelText = await label.textContent() - expect(labelText?.toLowerCase()).toContain('privacy') + test.beforeEach(async ({ page }) => { + pageUnderTest = await BasePage.init(page) + await pageUnderTest.goto(CONTACT_PATH) + await waitForContactForm(pageUnderTest) }) - test.skip('@wip GDPR consent state persists during form validation', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - // Expected: If user checks GDPR then triggers other validation, GDPR stays checked - const gdprCheckbox = page.locator('input[type="checkbox"][name*="consent"], input[type="checkbox"][name*="gdpr"]').first() - const submitButton = page.locator('button[type="submit"]').first() + test('@ready GDPR checkbox works on contact form', async () => { + const contactConsent = pageUnderTest.locator(CONTACT_CONSENT_SELECTOR) + await expect(contactConsent).toBeVisible() - // Check GDPR first - await gdprCheckbox.check() - expect(await gdprCheckbox.isChecked()).toBe(true) + const contactLabel = pageUnderTest.locator(`label:has(${CONTACT_CONSENT_SELECTOR})`) + await expect(contactLabel).toContainText('Privacy Policy') - // Submit form (may trigger other validation) - await submitButton.click() - await expect(gdprCheckbox).toBeChecked() + await contactConsent.check() + await expect(contactConsent).toBeChecked() }) }) From 1c906a38b987cb383cbb69f7d63207c015146f6d Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 2 Dec 2025 04:13:56 +0300 Subject: [PATCH 10/31] Add error states and resilience under mocked latency tests to consent checkbox E2E test with container mocks --- .../03-forms/newsletter-subscription.spec.ts | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/test/e2e/specs/03-forms/newsletter-subscription.spec.ts b/test/e2e/specs/03-forms/newsletter-subscription.spec.ts index db4699ec7..caf2d3283 100644 --- a/test/e2e/specs/03-forms/newsletter-subscription.spec.ts +++ b/test/e2e/specs/03-forms/newsletter-subscription.spec.ts @@ -2,7 +2,7 @@ * Newsletter Subscription Form E2E Tests * Tests for newsletter signup functionality */ -import { test, expect, spyOnFetchEndpoint, delayFetchForEndpoint } from '@test/e2e/helpers' +import { test, expect, spyOnFetchEndpoint, delayFetchForEndpoint, mockFetchEndpointResponse } from '@test/e2e/helpers' import { EvaluationError } from '@test/errors' import { TEST_EMAILS, ERROR_MESSAGES } from '@test/e2e/fixtures/test-data' import { NewsletterPage } from '@test/e2e/helpers/pageObjectModels/NewsletterPage' @@ -147,6 +147,16 @@ test.describe('Newsletter Subscription Form', () => { await newsletterPage.expectMessageContains(ERROR_MESSAGES.emailInvalid) }) + test('@ready valid email blur shows guidance message', async ({ page: playwrightPage }) => { + const newsletterPage = await NewsletterPage.init(playwrightPage) + await newsletterPage.navigateToNewsletterForm() + + await newsletterPage.fillEmail(TEST_EMAILS.valid) + await newsletterPage.blurEmailInput() + + await newsletterPage.expectMessageContains("You'll receive a confirmation email. Click the link to complete your subscription.") + }) + test('@ready GDPR consent link works', async ({ page: playwrightPage }) => { const newsletterPage = await NewsletterPage.init(playwrightPage) await newsletterPage.navigateToNewsletterForm() @@ -176,4 +186,50 @@ test.describe('Newsletter Subscription Form', () => { expect(responseData.success).toBe(true) expect(responseData.message).toContain('check your email') }) + + test('@ready API error preserves form state and surfaces message', async ({ page: playwrightPage }) => { + const newsletterPage = await NewsletterPage.init(playwrightPage) + await newsletterPage.navigateToNewsletterForm() + + const mockResponse = await mockFetchEndpointResponse(newsletterPage.page, { + endpoint: '/api/newsletter', + status: 429, + body: { success: false, error: 'Try again in 30 seconds.' }, + }) + + try { + await newsletterPage.fillEmail(TEST_EMAILS.valid) + await newsletterPage.checkGdprConsent() + await newsletterPage.submitForm() + + await newsletterPage.expectMessageContains('Try again in 30 seconds.') + await newsletterPage.expectEmailValue(TEST_EMAILS.valid) + await newsletterPage.expectGdprChecked() + } finally { + await mockResponse.restore() + } + }) + + test('@ready submit button disables during pending request', async ({ page: playwrightPage }) => { + const newsletterPage = await NewsletterPage.init(playwrightPage) + await newsletterPage.navigateToNewsletterForm() + + await newsletterPage.fillEmail(TEST_EMAILS.valid) + await newsletterPage.checkGdprConsent() + + const browserName = newsletterPage.context().browser()?.browserType().name() + const delayMs = browserName === 'webkit' ? 400 : 200 + const delayOverride = await delayFetchForEndpoint(newsletterPage.page, { endpoint: '/api/newsletter', delayMs }) + const submitButton = newsletterPage.locator('#newsletter-submit') + + const submitPromise = submitButton.click() + + try { + await expect(submitButton).toBeDisabled() + await submitPromise + await expect(submitButton).not.toBeDisabled() + } finally { + await delayOverride.restore() + } + }) }) From dcfbad1925305687f46af4ba264832e8207f5f8d Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 2 Dec 2025 04:35:17 +0300 Subject: [PATCH 11/31] Implement component consent preferences full stack E2E test case with container mocks for third party services --- _TODO.md | 17 --- .../03-forms/newsletter-double-optin.spec.ts | 2 +- .../04-components/consentPreferences.spec.ts | 134 +++++++++++++++--- 3 files changed, 114 insertions(+), 39 deletions(-) diff --git a/_TODO.md b/_TODO.md index 2056bbe4c..feb0af645 100644 --- a/_TODO.md +++ b/_TODO.md @@ -26,23 +26,6 @@ For faster troubleshooting in that noisy log stream, filter just warnings/errors E2E Starting Point -Stabilize infra first: run npm run containers:up, npm run containers:wait, npm run containers:supabase:start, and the dev server. Keep containers:logs and containers:supabase:logs tailing in another terminal so every mock failure is visible before Playwright runs. - -Create a shared Playwright "mocks ready" fixture: add a helper that checks process.env.E2E_MOCKS === '1' and pings `http://127.0.0.1:8079/` plus the two WireMock endpoints before each suite. That gives quick feedback if someone forgets the setup commands. - -Those PGRST000 lines are just PostgREST complaining while Postgres is still booting. Every time Supabase restarts (or Docker does a health restart), PostgREST hammers the DB before it's ready and logs "database system is starting up". Once Postgres finishes (~10-15 seconds later), the errors stop. If you scroll further down the same log stream you should see "schema cache loaded" messages confirming it recovered. - -The Vector errors are fallout from the same startup noise—its remap transform tries to parse the PostgREST log lines as access logs (with to_timestamp), but that "Failed listening for database notifications…" text doesn't match the timestamp pattern. After PostgREST stabilizes, Vector goes back to normal. Harmless unless you depend on those telemetry pipelines. - -If the chatter is distracting, tail each container separately so you only see current warnings: docker logs -f supabase_rest_astro.webstackbuilders.com 2>&1 | grep -E '\\[(error|warn)\\]'. You'll notice the burst only happens immediately after start.sh runs or when the DB container is restarted. - -You can also extend the REST container's startup delay to avoid the spam: set PGRST_DB_CONFIG variables or wrap npx supabase start in the script with a sleep until supabase_db reports healthy. But functionally, this is expected Supabase CLI behavior; it doesn't indicate a broken state once the stack reports healthy in npm run containers:supabase:status. - -Implementation order - -03-forms: once the API layer is stable, wire the UI flows. Use Playwright to submit each form, but assert success by checking the mock mappings were triggered, not just the UI toast. -Consent Preferences (@wip): convert it to use the same helper that verifies mocked Upstash REST and Supabase responses. This test should (1) toggle UI controls, (2) check the outbound request via the mock logs, and (3) read back seeded data to confirm persistence. - 04-components/consentPreferences.spec.ts ## Typing client-side API calls and SSR API endpoints diff --git a/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts b/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts index 99437087e..78f756954 100644 --- a/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts +++ b/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts @@ -8,7 +8,7 @@ import { TEST_EMAILS } from '@test/e2e/fixtures/test-data' test.describe('Newsletter Double Opt-In Flow', () => { test.skip('@blocked complete double opt-in flow', async ({ page: playwrightPage }) => { const page = await BasePage.init(playwrightPage) - // Blocked by: Need email testing service integration (e.g., Mailosaur) + // Blocked by: Need email testing service integration (Resend mock) // Expected: Full flow from subscription to welcome email // Actual: Cannot test without email service diff --git a/test/e2e/specs/04-components/consentPreferences.spec.ts b/test/e2e/specs/04-components/consentPreferences.spec.ts index 8d6cc20ac..3d8102b95 100644 --- a/test/e2e/specs/04-components/consentPreferences.spec.ts +++ b/test/e2e/specs/04-components/consentPreferences.spec.ts @@ -4,14 +4,26 @@ */ import type { Page } from '@playwright/test' -import { BasePage, expect, test, mockFetchEndpointResponse, type FetchOverrideHandle } from '@test/e2e/helpers' +import { env } from 'node:process' +import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { BasePage, expect, mocksEnabled, test, mockFetchEndpointResponse, type FetchOverrideHandle } from '@test/e2e/helpers' +import type { ConsentResponse } from '@pages/api/_contracts/gdpr.contracts' const ALLOW_ALL_BUTTON = '#consent-allow-all' const SAVE_BUTTON = '#consent-save-preferences' const COMPONENT_SELECTOR = 'consent-preferences' -const WIP_TAG = '@wip' +const FULL_STACK_TAG = '@containers' const CONSENT_PAGE_PATH = '/consent' +const SUPABASE_URL = env['SUPABASE_URL']?.replace(/\/$/, '') +const SUPABASE_SERVICE_ROLE_KEY = env['SUPABASE_SERVICE_ROLE_KEY'] + +const supabaseAdminClient: SupabaseClient | null = SUPABASE_URL && SUPABASE_SERVICE_ROLE_KEY + ? createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { autoRefreshToken: false, persistSession: false }, + }) + : null + const toggleLabel = (checkboxId: string): string => `[data-consent-toggle="${checkboxId}"]` const interceptConsentApi = async (page: Page): Promise => { @@ -21,6 +33,55 @@ const interceptConsentApi = async (page: Page): Promise => }) } +const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +type ConsentRecordRow = { + id: string + data_subject_id: string + purposes: string[] + source: string | null + timestamp: string +} + +/** + * Polls Supabase until the consent record for the provided subject includes the expected purposes. + */ +const waitForSupabaseConsentRecord = async ( + dataSubjectId: string, + expectedPurposes: string[], + timeoutMs = 7_000, +): Promise => { + if (!supabaseAdminClient) { + throw new Error('Supabase admin client unavailable') + } + + const deadline = Date.now() + timeoutMs + let lastError: string | undefined + + while (Date.now() <= deadline) { + const { data, error } = await supabaseAdminClient + .from('consent_records') + .select('id, data_subject_id, purposes, source, timestamp') + .eq('data_subject_id', dataSubjectId) + .order('timestamp', { ascending: false }) + .limit(1) + + if (error) { + lastError = error.message + } else if (data && data.length > 0) { + const record = data[0]! + const hasAllPurposes = expectedPurposes.every((purpose) => record.purposes?.includes(purpose)) + if (hasAllPurposes) { + return record + } + } + + await wait(250) + } + + throw new Error(lastError ?? 'Timed out waiting for consent record to persist in Supabase') +} + async function removeViteErrorOverlay(page: BasePage): Promise { await page.evaluate(() => { const styleId = 'disable-vite-overlay-style' @@ -56,7 +117,7 @@ test.describe('Consent Preferences Component', () => { test.beforeEach(async ({ page: playwrightPage, context }, testInfo) => { const page = await BasePage.init(playwrightPage) - const shouldMockConsentApi = !testInfo.title.includes(WIP_TAG) + const shouldMockConsentApi = !testInfo.title.includes(FULL_STACK_TAG) if (shouldMockConsentApi) { consentApiOverride = await interceptConsentApi(playwrightPage) @@ -78,30 +139,61 @@ test.describe('Consent Preferences Component', () => { } }) - test.skip( - '@wip full stack consent submission hits backend mocks', - async ({ page: playwrightPage }) => { - // This smoke test is intended to run against the local dev/mock Docker stack - // (e.g., Supabase container) and therefore bypasses request interception. - const page = await BasePage.init(playwrightPage) - await page.goto(CONSENT_PAGE_PATH, { timeout: 15000 }) - await playwrightPage.waitForLoadState('networkidle') - await waitForConsentPreferences(page) + test('@containers full stack consent submission hits backend mocks', async ({ page: playwrightPage }) => { + test.skip(!mocksEnabled, 'E2E_MOCKS=1 is required to run Supabase-backed consent tests') + test.skip(!supabaseAdminClient, 'Supabase containers must be running for full stack consent coverage') - await page.locator(ALLOW_ALL_BUTTON).click() + const page = await BasePage.init(playwrightPage) + await waitForConsentPreferences(page) - const consentRequest = page.waitForResponse('**/api/gdpr/consent') + const analyticsCheckbox = page.locator('#analytics-cookies') + const functionalCheckbox = page.locator('#functional-cookies') + const marketingCheckbox = page.locator('#marketing-cookies') - await page.locator(SAVE_BUTTON).click() + await page.locator(ALLOW_ALL_BUTTON).click() - const response = await consentRequest - expect(response.ok()).toBeTruthy() + const consentResponsePromise = page.waitForResponse((response) => { + return response.url().includes('/api/gdpr/consent') && response.request().method() === 'POST' + }) - await expect(page.locator('#analytics-cookies')).toBeChecked() - await expect(page.locator('#functional-cookies')).toBeChecked() - await expect(page.locator('#marketing-cookies')).toBeChecked() + await page.locator(SAVE_BUTTON).click() + + const consentResponse = await consentResponsePromise + expect(consentResponse.ok()).toBeTruthy() + + const responseBody = (await consentResponse.json()) as ConsentResponse + expect(responseBody.success).toBeTruthy() + + const dataSubjectId = responseBody.record?.DataSubjectId + expect(dataSubjectId).toBeTruthy() + if (!dataSubjectId) { + throw new Error('Consent API did not return a DataSubjectId') } - ) + + const expectedPurposes = ['analytics', 'functional', 'marketing'] + + let cleanupId: string | null = dataSubjectId + try { + const record = await waitForSupabaseConsentRecord(dataSubjectId, expectedPurposes) + cleanupId = record.data_subject_id + + const sortedRecordPurposes = [...record.purposes].sort() + const sortedExpectedPurposes = [...expectedPurposes].sort() + expect(sortedRecordPurposes).toEqual(sortedExpectedPurposes) + expect(record.source).toBe('cookies_modal') + + await expect(analyticsCheckbox).toBeChecked() + await expect(functionalCheckbox).toBeChecked() + await expect(marketingCheckbox).toBeChecked() + } finally { + if (cleanupId) { + await supabaseAdminClient + ?.from('consent_records') + .delete() + .eq('data_subject_id', cleanupId) + } + } + }) test('@ready component renders headings and CTAs', async ({ page: playwrightPage }) => { const page = await BasePage.init(playwrightPage) From 2ee8c1a3e1697ae38dea0a73b99b18d4efa39154 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 2 Dec 2025 05:36:50 +0300 Subject: [PATCH 12/31] Implement newsletter double opt-in full stack E2E test case with container mocks for third party services --- _TODO.md | 478 +++++------------- src/pages/api/newsletter/_email.ts | 83 ++- src/pages/api/newsletter/confirm.ts | 20 +- .../03-forms/newsletter-double-optin.spec.ts | 353 ++++++++++--- 4 files changed, 486 insertions(+), 448 deletions(-) diff --git a/_TODO.md b/_TODO.md index feb0af645..fe392674e 100644 --- a/_TODO.md +++ b/_TODO.md @@ -4,6 +4,12 @@ Implement mitigations in test/e2e/specs/07-performance/PERFORMANCE.md +## GitHub + +- Make sure actions workflows are working correctly after performance tests pass and whole suite is green +- Change Dependabut to open a single PR with all dependency updates +- Add 'hotfix' branch and add branch protection rules + ## Analytics Vercel Analytics @@ -11,22 +17,39 @@ Vercel Analytics ## Themepicker tooltips, extra themes - Add additional themes -- @TODO: add tooltip that makes use of the description field +- Add tooltip that makes use of the description field ## Sentry feedback, chat bot tying into my phone and email See note in src/components/scripts/sentry/client.ts - "User Feedback - allow users to report issues" -## src/pages/api next steps +## Search + +Add Upstash Search as a Vercel Marketplace Integration. + +Lunr is a JS search library using an inverted index. Client-side search for statically hosted pages. + +### [`@jackcarey/astro-lunr`](https://www.npmjs.com/package/@jackcarey/astro-lunr) + +### [`@siverv/astro-lunr`](https://www.npmjs.com/package/@siverv/astro-lunr) + +## Email Templates + +Right now we're using string literals to define HTML email templates for site mails. We should use Nunjucks with the rule-checking for valid CSS in HTML emails like we have in the corporate email footer repo. + +## @TODO: Provide button to turn off animation in Hero -To confirm nothing else is wrong, run npm run containers:supabase:status to see each service's state and docker inspect --format '{{json .State.Health }}' for anything showing unhealthy. -If you do need replica support (or just want to suppress that log), set a valid replica_region under the [db] section in config.toml and restart via npm run containers:supabase:stop && npm run containers:supabase:start. +"Scaling/zooming animations are problematic for accessibility, as they are a common trigger for certain types of migraine. If you need to include such animations on your website, you should provide a control to allow users to turn off animations, preferably site-wide. Also, consider making use of the prefers-reduced-motion media feature — use it to write a media query that will turn off animations if the user has reduced animation specified in their system preferences. " -For faster troubleshooting in that noisy log stream, filter just warnings/errors by piping the script through grep -E '\\[(error|warn)\\]' or tailing a single container, e.g. docker logs -f supabase_realtime_. +## Color vars + +brand primary: #001733 +brand secondary: #0062B6 -E2E Starting Point +ring (1px), ring-2, ring-4 +accent -04-components/consentPreferences.spec.ts +text-white, other default Tailwind colors ## Typing client-side API calls and SSR API endpoints @@ -38,12 +61,12 @@ Shared Types vs Swagger / Keeping Docs in Sync - Cons: no generated docs/SDKs; discipline is required to keep manual docs current. - How to enforce: treat the contract files as the single source of truth, add lint rules banning request/response literal types outside _contracts, and add lightweight contract tests that instantiate each type against the endpoint handler (failing if fields diverge). -2. Code-first OpenAPI (Zod or TS schemas → OpenAPI) +1. Code-first OpenAPI (Zod or TS schemas → OpenAPI) - Define schemas in Zod/Valibot (or ts-rest) alongside the endpoint. Generate OpenAPI JSON plus TypeScript types from those schemas. Docs (Swagger UI/Redoc) and any client SDKs come from the generated spec, so they're always in sync. - Guarantees: CI regenerates the spec and fails when the checked-in artifact is stale; endpoint handlers reuse the same schema for runtime validation, so a mismatch cannot compile. -3. Spec-first OpenAPI + Swagger Codegen +1. Spec-first OpenAPI + Swagger Codegen - Maintain an OpenAPI YAML/JSON file as the source of truth, run Swagger Codegen (or openapi-typescript) to produce both server stubs and client SDKs. - Guarantees: developers edit the spec, run codegen (enforced via a pre-commit/CI task), and the generated server stubs remind you to implement every path/verb. Documentation pages (Swagger UI) are rendered straight from the same spec, so they inherently match the implementation. @@ -62,24 +85,6 @@ Lighthouse audits (6) - Integration pending Newsletter double opt-in (6) - Email testing infrastructure Axe accessibility (2) - axe-core integration -## Search - -Add Upstash Search as a Vercel Marketplace Integration. - -## Email Templates - -Right now we're using string literals to define HTML email templates for site mails. We should use Nunjucks with the rule-checking for valid CSS in HTML emails like we have in the corporate email footer repo. - -## Color vars - -brand primary: #001733 -brand secondary: #0062B6 - -ring (1px), ring-2, ring-4 -accent - -text-white, other default Tailwind colors - ## Axe tags cat.aria: Rules related to Accessible Rich Internet Applications (ARIA) attributes and roles. @@ -95,6 +100,12 @@ cat.structure: Rules related to the document's overall structure, like the prope cat.tables: Rules for data tables, including headers and associations. cat.text-alternatives: Rules for ensuring that text alternatives are provided for non-text content, such as images. +## @TODO: Add Check HTML Links to test workflow + +npm i -D check-html-links +npx check-html-links _site +`https://github.com/modernweb-dev/rocket/tree/main/packages/check-html-links` + ## Social Media Preview Cards Looking at the social-card endpoint implementation, it's designed to work with third-party screenshot services, not the social networks themselves. @@ -112,7 +123,7 @@ Social networks like Twitter, Facebook, LinkedIn, etc. don't screenshot HTML pag - Direct image URLs (PNG, JPEG, etc.) - Standard dimensions (1200x630px for most platforms) -The Intended Workflow +**The Intended Workflow** This endpoint is designed to integrate with screenshot services like: @@ -122,7 +133,7 @@ This endpoint is designed to integrate with screenshot services like: - ScreenshotOne or ApiFlash - Dedicated screenshot APIs - Satori - Convert HTML/CSS to SVG/PNG -Current Limitation +**Current Limitation** As implemented, this endpoint would need an additional step to be useful for social sharing: @@ -136,71 +147,67 @@ For a production Astro site, you'd typically: - Pre-generate images at build time for static content - Use a screenshot service that can be called from your endpoint to return actual images -## @TODO: Use Confetti on CTA forms +**Social Media Preview Generators** -`canvas-confetti` -https://github.com/catdad/canvas-confetti -https://www.kirilv.com/canvas-confetti/ +There are several integrations available that vary based on the library they use to create an image file to snapshot, whether they allow the template for generating the image to be modified, and what options they provide for output. -## @TODO: Use the Page Visibility API to pause videos, image carousels, and animations +[`astro-og-canvas`](https://www.npmjs.com/package/astro-og-canvas) -Stop unnecessary processes when the user doesn't see the page or inversely to perform background actions. +- Most popular option (~660 weekly d/l). Generates images at **run time**. +- Uses **`canvaskit-wasm`** for rendering +- Uses plain color or gradient background. Provide title, description, and logo (displayed at top left of card). +- Can't set size of final card. -## @TODO: "Add to Calendar" button +[`astro-satori`](https://www.npmjs.com/package/astro-satori) -Google Calendar, Apple Calendar, Yahoo Calender, Microsoft 365, Outlook, and Teams, and generate iCal/ics files (for all other calendars and cases). +- Moderately popular option (~230 weekly d/l). Generates images at **run time**. +- Uses Vercel's **Satori** library for rendering (entirely done in JS with limitations on what CSS can be used). Satori is a library for generating SVG strings from pure HTML and CSS. +- Size of final card can be set. +- Seems opinionated, but it might be possible to have a lot of control (not sure). +- Output format? -https://github.com/add2cal/add-to-calendar-button -https://add-to-calendar-button.com/ +[`astro-opengraph-image`](https://www.npmjs.com/package/@altano/astro-opengraph-image#fn-filename-change) -## @TODO: Add Check HTML Links to test workflow +- Uses **Satori**. Has dependencies on [`@resvg/resvg-wasm`](https://www.npmjs.com/package/@resvg/resvg-wasm) and Sharp. Middleware integration. Generates images at **run time**. +- Provides element to add OG tags in document ``. +- Very flexible, you can provide the Astro template to generate the card. -npm i -D check-html-links -npx check-html-links _site -https://github.com/modernweb-dev/rocket/tree/main/packages/check-html-links +[Astro Open Graph Image](https://www.npmjs.com/package/astro-og-image) -## @TODO: Provide button to turn off animation in Hero +- Uses **Puppeteer**. Generates images at **build time**. +- You can provide the Astro template to generate the card. +- Requires providing a `baseHead` property in page templates. -"Scaling/zooming animations are problematic for accessibility, as they are a common trigger for certain types of migraine. If you need to include such animations on your website, you should provide a control to allow users to turn off animations, preferably site-wide. Also, consider making use of the prefers-reduced-motion media feature — use it to write a media query that will turn off animations if the user has reduced animation specified in their system preferences. " +[Astro Open Graph Image Generator](https://www.npmjs.com/package/@cyberkoalastudios/og-image-generator) -## Fix offline page +- Uses **Puppeteer**. Has dependencies on [`canvaskit-wasm`](https://www.npmjs.com/package/@resvg/resvg-wasm) and Sharp. +- You can set the background image. No option to set the size of the card. +- Manually add OF properties on `` element. Flexible. -- It should look like any other page, but have interactivity like navigation and other links disabled. -- The test cases should check for if they're on the offline page, and return passed for an inverse. Like the smoke test that makes sure navigation is available, it would return true if navigation is present but disabled in the same test case. +## @TODO: Use Confetti on CTA forms -## @TODO: Handle `@media (prefers-reduced-motion: reduce)` +`canvas-confetti` +`https://github.com/catdad/canvas-confetti` +`https://www.kirilv.com/canvas-confetti/` -Stop the Hero Greensocks animation when `@media (prefers-reduced-motion: reduce)`, using `window.mediaQuery()`. Handle user preference for reduced motion on animations, doing this also with a listener like for browser theme preference +## @TODO: Use the Page Visibility API to pause videos, image carousels, and animations -```css -@media (prefers-reduced-motion) { - /* styles to apply if the user's settings are set to reduced motion */ -} -``` +Stop unnecessary processes when the user doesn't see the page or inversely to perform background actions. -```typescript -const mediaQueryList = window.matchMedia('(prefers-reduced-motion)') // not sure what the inverse is to match for so that there's a listener for both the prefers-reduced-motion state and the doesn't-care state -mediaQueryList.addEventListener(event => { - if (event.type === 'change') {} -}) -``` +## @TODO: "Add to Calendar" button -## @TODO: Set up webmentions +Google Calendar, Apple Calendar, Yahoo Calender, Microsoft 365, Outlook, and Teams, and generate iCal/ics files (for all other calendars and cases). -This code goes in `_layouts/layouts/base.njk` after the last ` - -{%- endif -%} -``` +## @TODO: Set up webmentions -There's a filter roughed out for the webmentions. +Needs to add real API key and test ## @TODO: SCSS Use clothoid corners with border-radius -https://onotakehiko.dev/clothoid/ +`https://onotakehiko.dev/clothoid/` `@TODO: SCSS Make sure accent-color or styling for checkboxes/radio button groups is set up. Sets the colour used by checkboxes and radio buttons, as well as range fields and progress indicators. The accent colour is inherited` @@ -210,95 +217,10 @@ https://onotakehiko.dev/clothoid/ } ``` -## @TODO: SCSS Replace all `:focus pseudoselectors` with `:focus-visible` - -```css -/* Focusing the button with a keyboard will show a dashed black line. */ -button:focus-visible { - outline: 4px dashed black; -} - -/* Focusing the button with a mouse, touch, or stylus will show a subtle drop shadow. */ -button:focus:not(:focus-visible) { - outline: none; - box-shadow: 1px 1px 5px rgba(1, 1, 0, .7); -} -``` - ## @TODO: Refactor modals Modals should be wrapped in the `` element and use programmatic methods to display - `showModal()` to disable the area outside of the modal (handles `esc` keypress natively) and `show()` to allow interaction outside the modal, along with `close()`. -## @TODO: Fix Favicon workflow - -Right now, the `eleventy-favicon` plugin is used to generate `favicon.ico`, `favicon.svg`, and `apple-touch-icon.png` in the root directory. It provides a shortcode to use for outputting -`` markup in the document head: - -```nunjucks -{% favicon buildPaths.faviconSvgSourceFilename %} -``` - -The shortcode generates this markup, notice the iOS-specific `rel` type in the third link: - -```html - - - -``` - -SVG favicons are only supported across 74% of browsers. We have to provide a fallback version for Internet Explorer and Safari. - -The plugin functionality for generating favicons should be moved to a Gulp task, and the HTML markup hard coded in `_layouts/components/head/meta.njk` so that the `` tags can use conditional -media queries based on whether the user has a preference for dark mode set and their browser title -bar is therefore in a dark theme: - -```html - - -``` - -```typescript -// select the favicon 👉 -const faviconEl = document.querySelector('link[rel="icon"]') - -// watch for changes 🕵️ -const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') -mediaQuery.addEventListener('change', themeChange) - -// listener 👂 -function themeChange(event) { - if (event.matches) { - faviconEl.setAttribute('href', 'favicon-dark.png') - } else { - faviconEl.setAttribute('href', 'favicon-light.png') - } -} -``` - -## @TODO: Theme preference handling - -Are we listening for an event that the user changes their browser's theme preference, and updating our theme if they do? And is the initial theme of our site set based on the browser's theme preference? Use a listener for browser theme preference. - -```css -@media (prefers-color-scheme: dark) {} -@media (prefers-color-scheme: light) {} -``` - -```typescript -const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)') -mediaQueryList.addEventListener(event => { - if (event.type === 'change') {} -}) -``` - ## @TODO: Add for iOS Specifying a Launch Screen Image @@ -309,43 +231,27 @@ On iOS, similar to native applications, you can specify a launch screen image th ``` -# Astro 3rd-Party Integrations +# Astro 3rd-Party Integrations, Eleventy Migration -## Eleventy Migration - -Eleventy plugins that don't yet have identified equivalents for Astro. +## Eleventy plugins that don't yet have identified equivalents for Astro. - **`eleventy-plugin-inclusive-language`** -Outputs command line warnings for weasel words like "obviously", "basically", etc. - -```'simply,obviously,basically,of course,clearly,just,everyone knows,however,easy'``` - -- **`eleventy-plugin-rss`** - -RSS feed generator, adds shortcode filters absoluteUrl, dateToRfc3339, dateToRfc822. - -- **`eleventy-plugin-social-images`** - -Generates images as headers for use in social shares - -- **`eleventy-plugin-schema`** - -Provides a shortcode to generate a JSON-LD script per-page including the `