import type { APIRequestContext } from '@playwright/test' import { CAMPAIGN_NEWSLETTER_CONSENT_KEY } from '@/lib/campaignConsentKeys' import { adminHeaders } from '../helpers/adminApi' import { seedTestUser } from '../helpers/seedUser' import { expect, test } from './fixtures/e2eTest' /** * S9 — the campaign home "novidades" capture: the hero CTA anchors the * section, the form records the contact + subscription with the engagement * level, and the page confirms in place. Runs the write through the deployed * server (server action + Local API) so the fail-closed consent path and the * transaction are the production ones. */ test.describe('Campaign home novidades capture', () => { const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000' const runSuffix = Date.now() // Unique 11-digit mobile per run (719 + 8 digits) so a crashed previous run // can never poison the by-phone lookups. const phoneDigits = String(runSuffix % 100_000_000).padStart(8, '0') const successPhone = `719${phoneDigits}` const successName = `E2e Novidades ${runSuffix}` let consentID: number | undefined const createNewsletterConsent = async ( request: APIRequestContext, headers: Record, ) => { // Self-healing lifecycle (S1 precedent): an aborted run could leave the // unique-key row behind and poison the next beforeAll — purge first. The // consent cannot be deleted while a subscription references it // (`subscription.consent_id` NOT NULL + FK SET NULL), so purge those too. const existing = await request.get( `${baseURL}/api/consent?where[key][equals]=${CAMPAIGN_NEWSLETTER_CONSENT_KEY}&limit=10&depth=0`, { headers }, ) if (existing.ok()) { const { docs } = await existing.json() for (const doc of docs) { const subscriptions = await request.get( `${baseURL}/api/subscription?where[consent][equals]=${doc.id}&limit=50&depth=0`, { headers }, ) if (subscriptions.ok()) { const { docs: subscriptionDocs } = await subscriptions.json() for (const subscription of subscriptionDocs) { await request.delete(`${baseURL}/api/subscription/${subscription.id}`, { headers }) } } await request.delete(`${baseURL}/api/consent/${doc.id}`, { headers }) } } const response = await request.post(`${baseURL}/api/consent`, { headers, data: { key: CAMPAIGN_NEWSLETTER_CONSENT_KEY, text: { root: { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', text: 'Consentimento de novidades da campanha (e2e).', version: 1, }, ], direction: null, format: '', indent: 0, version: 1, }, ], direction: null, format: '', indent: 0, version: 1, }, }, }, }) expect(response.ok()).toBeTruthy() const { doc } = await response.json() consentID = doc.id return doc } const cleanup = async (request: APIRequestContext, headers: Record) => { for (const phone of [successPhone]) { const contacts = await request.get( `${baseURL}/api/contact?where[phones.value][equals]=${phone}&limit=1&depth=0`, { headers }, ) if (contacts.ok()) { const { docs } = await contacts.json() const contact = docs[0] if (contact) { const subscriptions = await request.get( `${baseURL}/api/subscription?where[contact][equals]=${contact.id}&limit=1&depth=0`, { headers }, ) if (subscriptions.ok()) { const { docs: subscriptionDocs } = await subscriptions.json() if (subscriptionDocs[0]) { await request.delete(`${baseURL}/api/subscription/${subscriptionDocs[0].id}`, { headers, }) } } await request.delete(`${baseURL}/api/contact/${contact.id}`, { headers }) } } } if (consentID !== undefined) { await request.delete(`${baseURL}/api/consent/${consentID}`, { headers }) } } test.beforeAll(async ({ request }) => { await seedTestUser() const headers = await adminHeaders(request, baseURL) await createNewsletterConsent(request, headers) }) test.afterAll(async ({ request }) => { const headers = await adminHeaders(request, baseURL).catch(() => undefined) if (headers) { await cleanup(request, headers) } }) test('captures the visitor and confirms in place', async ({ page, request }) => { // Fresh load (unique query param bypasses the ISR fetch cache, same trick // as the content-section specs); the app ignores unknown params. await page.goto(`/?e2e=${runSuffix}`) await expect(page).toHaveURL(/\/\?e2e=/) const section = page.locator('[data-home-section="newsletter"]') await expect( section.getByRole('heading', { name: 'Receba as novidades da campanha' }), ).toBeVisible() // The hero CTA is the shortcut into the section (smooth anchor). The URL // legitimately carries the `?e2e=…` cache-buster query (OPS83), so assert // only the hash — no trailing `$`. await page.locator('[data-cta="secondary"]').click() await expect(page).toHaveURL(/#novidades/) await expect(section).toBeInViewport() // The toggle is pre-selected: the default level is "time". const levelToggle = section.locator('#campaign-level') await expect(levelToggle).toBeChecked() await section.getByPlaceholder('Nome completo*').fill(successName) // Digits are formatted by the input itself; the schema normalizes back. await section.getByPlaceholder('WhatsApp (com DDD)*').fill(successPhone) await section.getByRole('button', { name: 'QUERO RECEBER NOVIDADES' }).click() await expect(section.getByRole('heading', { name: 'Inscrição confirmada' })).toBeVisible() // The success card keeps the bandeiras reachable (they sit above). const flagsLink = section.getByRole('link', { name: 'Ver bandeiras' }) await expect(flagsLink).toHaveAttribute('href', '#bandeiras') // The record is visible to the admin with the level choice. const headers = await adminHeaders(request, baseURL) const contacts = await request.get( `${baseURL}/api/contact?where[phones.value][equals]=${successPhone}&limit=1&depth=0`, { headers }, ) expect(contacts.ok()).toBeTruthy() const { docs } = await contacts.json() const contact = docs[0] expect(contact).toBeDefined() // NameInput sanitizes/formatts the name on the way in (digits stripped). expect(contact.name.toLowerCase()).toContain('novidades') const subscriptions = await request.get( `${baseURL}/api/subscription?where[contact][equals]=${contact.id}&limit=1&depth=0`, { headers }, ) expect(subscriptions.ok()).toBeTruthy() const { docs: subscriptionDocs } = await subscriptions.json() expect(subscriptionDocs[0]).toBeDefined() expect(subscriptionDocs[0].campaignLevel).toBe('time') expect( typeof subscriptionDocs[0].consent === 'object' ? subscriptionDocs[0].consent.id : subscriptionDocs[0].consent, ).toBe(consentID) }) // NOTE: the fail-closed refusal (consent missing → nothing written) is NOT // e2e-covered on purpose: the server action rejects with a 500, and the // suite's e2eFailureGuard treats same-origin 5xx as a failure (the WebAuthn // refusal precedent is a handled 4xx). The refusal path is pinned by the // int spec `submitCampaignNewsletter.int.spec.ts` (exact message + no rows). }) /** * S12 — the home owns its scroll container (body `overflow-hidden`), so the * native fragment navigation lands on the section only through * `CampaignHashScroll`. These tests pin the deep-link contract on mobile * (where the `h-dvh` reflow + eager hero images used to kick the page back to * the top) and the direct-load path on desktop (previously uncovered). */ test.describe('Campaign home novidades hash scroll', () => { const scrollSuffix = Date.now() test.use({ viewport: { width: 390, height: 844 } }) test('direct #novidades deep link lands on the section (mobile)', async ({ page }) => { await page.goto(`/?e2e=scroll-${scrollSuffix}#novidades`) const section = page.locator('[data-home-section="newsletter"]') await expect( section.getByRole('heading', { name: 'Receba as novidades da campanha' }), ).toBeVisible() await expect(section).toBeInViewport() }) test('hero CTA scrolls to the newsletter section (mobile)', async ({ page }) => { await page.goto(`/?e2e=scroll-${scrollSuffix}`) await page.locator('[data-cta="secondary"]').click() // The URL legitimately carries the `?e2e=scroll-…` cache-buster query, // so assert only the hash (no trailing `$`), which lands after the click. await expect(page).toHaveURL(/#novidades/) await expect(page.locator('[data-home-section="newsletter"]')).toBeInViewport() }) test('deep link respects prefers-reduced-motion (mobile)', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce' }) await page.goto(`/?e2e=scroll-${scrollSuffix}#novidades`) await expect(page.locator('[data-home-section="newsletter"]')).toBeInViewport() }) }) test.describe('Campaign home novidades hash scroll — desktop', () => { const desktopSuffix = Date.now() test('direct #novidades deep link lands on the section (desktop)', async ({ page }) => { await page.goto(`/?e2e=scroll-${desktopSuffix}#novidades`) await expect(page.locator('[data-home-section="newsletter"]')).toBeInViewport() }) })