Skip to content

Commit dcfbad1

Browse files
committed
Implement component consent preferences full stack E2E test case with container mocks for third party services
1 parent 1c906a3 commit dcfbad1

3 files changed

Lines changed: 114 additions & 39 deletions

File tree

‎_TODO.md‎

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,6 @@ For faster troubleshooting in that noisy log stream, filter just warnings/errors
2626

2727
E2E Starting Point
2828

29-
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.
30-
31-
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.
32-
33-
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.
34-
35-
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.
36-
37-
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.
38-
39-
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.
40-
41-
Implementation order
42-
43-
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.
44-
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.
45-
4629
04-components/consentPreferences.spec.ts
4730

4831
## Typing client-side API calls and SSR API endpoints

‎test/e2e/specs/03-forms/newsletter-double-optin.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { TEST_EMAILS } from '@test/e2e/fixtures/test-data'
88
test.describe('Newsletter Double Opt-In Flow', () => {
99
test.skip('@blocked complete double opt-in flow', async ({ page: playwrightPage }) => {
1010
const page = await BasePage.init(playwrightPage)
11-
// Blocked by: Need email testing service integration (e.g., Mailosaur)
11+
// Blocked by: Need email testing service integration (Resend mock)
1212
// Expected: Full flow from subscription to welcome email
1313
// Actual: Cannot test without email service
1414

‎test/e2e/specs/04-components/consentPreferences.spec.ts‎

Lines changed: 113 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,26 @@
44
*/
55

66
import type { Page } from '@playwright/test'
7-
import { BasePage, expect, test, mockFetchEndpointResponse, type FetchOverrideHandle } from '@test/e2e/helpers'
7+
import { env } from 'node:process'
8+
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
9+
import { BasePage, expect, mocksEnabled, test, mockFetchEndpointResponse, type FetchOverrideHandle } from '@test/e2e/helpers'
10+
import type { ConsentResponse } from '@pages/api/_contracts/gdpr.contracts'
811

912
const ALLOW_ALL_BUTTON = '#consent-allow-all'
1013
const SAVE_BUTTON = '#consent-save-preferences'
1114
const COMPONENT_SELECTOR = 'consent-preferences'
12-
const WIP_TAG = '@wip'
15+
const FULL_STACK_TAG = '@containers'
1316
const CONSENT_PAGE_PATH = '/consent'
1417

18+
const SUPABASE_URL = env['SUPABASE_URL']?.replace(/\/$/, '')
19+
const SUPABASE_SERVICE_ROLE_KEY = env['SUPABASE_SERVICE_ROLE_KEY']
20+
21+
const supabaseAdminClient: SupabaseClient | null = SUPABASE_URL && SUPABASE_SERVICE_ROLE_KEY
22+
? createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
23+
auth: { autoRefreshToken: false, persistSession: false },
24+
})
25+
: null
26+
1527
const toggleLabel = (checkboxId: string): string => `[data-consent-toggle="${checkboxId}"]`
1628

1729
const interceptConsentApi = async (page: Page): Promise<FetchOverrideHandle> => {
@@ -21,6 +33,55 @@ const interceptConsentApi = async (page: Page): Promise<FetchOverrideHandle> =>
2133
})
2234
}
2335

36+
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
37+
38+
type ConsentRecordRow = {
39+
id: string
40+
data_subject_id: string
41+
purposes: string[]
42+
source: string | null
43+
timestamp: string
44+
}
45+
46+
/**
47+
* Polls Supabase until the consent record for the provided subject includes the expected purposes.
48+
*/
49+
const waitForSupabaseConsentRecord = async (
50+
dataSubjectId: string,
51+
expectedPurposes: string[],
52+
timeoutMs = 7_000,
53+
): Promise<ConsentRecordRow> => {
54+
if (!supabaseAdminClient) {
55+
throw new Error('Supabase admin client unavailable')
56+
}
57+
58+
const deadline = Date.now() + timeoutMs
59+
let lastError: string | undefined
60+
61+
while (Date.now() <= deadline) {
62+
const { data, error } = await supabaseAdminClient
63+
.from('consent_records')
64+
.select('id, data_subject_id, purposes, source, timestamp')
65+
.eq('data_subject_id', dataSubjectId)
66+
.order('timestamp', { ascending: false })
67+
.limit(1)
68+
69+
if (error) {
70+
lastError = error.message
71+
} else if (data && data.length > 0) {
72+
const record = data[0]!
73+
const hasAllPurposes = expectedPurposes.every((purpose) => record.purposes?.includes(purpose))
74+
if (hasAllPurposes) {
75+
return record
76+
}
77+
}
78+
79+
await wait(250)
80+
}
81+
82+
throw new Error(lastError ?? 'Timed out waiting for consent record to persist in Supabase')
83+
}
84+
2485
async function removeViteErrorOverlay(page: BasePage): Promise<void> {
2586
await page.evaluate(() => {
2687
const styleId = 'disable-vite-overlay-style'
@@ -56,7 +117,7 @@ test.describe('Consent Preferences Component', () => {
56117

57118
test.beforeEach(async ({ page: playwrightPage, context }, testInfo) => {
58119
const page = await BasePage.init(playwrightPage)
59-
const shouldMockConsentApi = !testInfo.title.includes(WIP_TAG)
120+
const shouldMockConsentApi = !testInfo.title.includes(FULL_STACK_TAG)
60121

61122
if (shouldMockConsentApi) {
62123
consentApiOverride = await interceptConsentApi(playwrightPage)
@@ -78,30 +139,61 @@ test.describe('Consent Preferences Component', () => {
78139
}
79140
})
80141

81-
test.skip(
82-
'@wip full stack consent submission hits backend mocks',
83-
async ({ page: playwrightPage }) => {
84-
// This smoke test is intended to run against the local dev/mock Docker stack
85-
// (e.g., Supabase container) and therefore bypasses request interception.
86-
const page = await BasePage.init(playwrightPage)
87-
await page.goto(CONSENT_PAGE_PATH, { timeout: 15000 })
88-
await playwrightPage.waitForLoadState('networkidle')
89-
await waitForConsentPreferences(page)
142+
test('@containers full stack consent submission hits backend mocks', async ({ page: playwrightPage }) => {
143+
test.skip(!mocksEnabled, 'E2E_MOCKS=1 is required to run Supabase-backed consent tests')
144+
test.skip(!supabaseAdminClient, 'Supabase containers must be running for full stack consent coverage')
90145

91-
await page.locator(ALLOW_ALL_BUTTON).click()
146+
const page = await BasePage.init(playwrightPage)
147+
await waitForConsentPreferences(page)
92148

93-
const consentRequest = page.waitForResponse('**/api/gdpr/consent')
149+
const analyticsCheckbox = page.locator('#analytics-cookies')
150+
const functionalCheckbox = page.locator('#functional-cookies')
151+
const marketingCheckbox = page.locator('#marketing-cookies')
94152

95-
await page.locator(SAVE_BUTTON).click()
153+
await page.locator(ALLOW_ALL_BUTTON).click()
96154

97-
const response = await consentRequest
98-
expect(response.ok()).toBeTruthy()
155+
const consentResponsePromise = page.waitForResponse((response) => {
156+
return response.url().includes('/api/gdpr/consent') && response.request().method() === 'POST'
157+
})
99158

100-
await expect(page.locator('#analytics-cookies')).toBeChecked()
101-
await expect(page.locator('#functional-cookies')).toBeChecked()
102-
await expect(page.locator('#marketing-cookies')).toBeChecked()
159+
await page.locator(SAVE_BUTTON).click()
160+
161+
const consentResponse = await consentResponsePromise
162+
expect(consentResponse.ok()).toBeTruthy()
163+
164+
const responseBody = (await consentResponse.json()) as ConsentResponse
165+
expect(responseBody.success).toBeTruthy()
166+
167+
const dataSubjectId = responseBody.record?.DataSubjectId
168+
expect(dataSubjectId).toBeTruthy()
169+
if (!dataSubjectId) {
170+
throw new Error('Consent API did not return a DataSubjectId')
103171
}
104-
)
172+
173+
const expectedPurposes = ['analytics', 'functional', 'marketing']
174+
175+
let cleanupId: string | null = dataSubjectId
176+
try {
177+
const record = await waitForSupabaseConsentRecord(dataSubjectId, expectedPurposes)
178+
cleanupId = record.data_subject_id
179+
180+
const sortedRecordPurposes = [...record.purposes].sort()
181+
const sortedExpectedPurposes = [...expectedPurposes].sort()
182+
expect(sortedRecordPurposes).toEqual(sortedExpectedPurposes)
183+
expect(record.source).toBe('cookies_modal')
184+
185+
await expect(analyticsCheckbox).toBeChecked()
186+
await expect(functionalCheckbox).toBeChecked()
187+
await expect(marketingCheckbox).toBeChecked()
188+
} finally {
189+
if (cleanupId) {
190+
await supabaseAdminClient
191+
?.from('consent_records')
192+
.delete()
193+
.eq('data_subject_id', cleanupId)
194+
}
195+
}
196+
})
105197

106198
test('@ready component renders headings and CTAs', async ({ page: playwrightPage }) => {
107199
const page = await BasePage.init(playwrightPage)

0 commit comments

Comments
 (0)