diff --git a/src/app/(frontend)/(home)/CampaignNewsletterSection.tsx b/src/app/(frontend)/(home)/CampaignNewsletterSection.tsx index 30a86e94..ccb8889d 100644 --- a/src/app/(frontend)/(home)/CampaignNewsletterSection.tsx +++ b/src/app/(frontend)/(home)/CampaignNewsletterSection.tsx @@ -9,7 +9,7 @@ import { CampaignNewsletterCapture } from '@/components/CampaignNewsletterForm' * S10 — passes the site-level Meta pixel to the form so a successful capture * fires exactly one `Lead` (the seam lives in the client form). */ -export const CampaignNewsletterSection = ({ pixelId }: { pixelId: string | null }) => ( +export const CampaignNewsletterSection = ({ pixelId }: { pixelId?: string }) => (
- +
diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx index 9017ce4f..9527a720 100644 --- a/src/app/(frontend)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -235,7 +235,7 @@ export default async function HomePage() { - + diff --git a/src/collections/Petition.ts b/src/collections/Petition.ts index 86c2faab..287f8208 100644 --- a/src/collections/Petition.ts +++ b/src/collections/Petition.ts @@ -1,4 +1,8 @@ -import { normalizeFacebookPixelId, validateFacebookPixelId } from '@/lib/facebookPixel' +import { + FACEBOOK_PIXEL_ID_DESCRIPTION, + normalizeFacebookPixelId, + validateFacebookPixelId, +} from '@/lib/facebookPixel' import { payloadAdminOnly } from '@/utilities/campaignAccess' import { revalidateDocumentById } from '@/utilities/documents' import type { CollectionConfig } from 'payload' @@ -116,8 +120,7 @@ export const Petition: CollectionConfig = { type: 'text', label: 'ID do Pixel do Meta (Facebook)', admin: { - description: - 'Cole somente o ID numérico do Events Manager (ex.: 123456789012345), não o snippet HTML completo.', + description: FACEBOOK_PIXEL_ID_DESCRIPTION, }, validate: validateFacebookPixelId, }, diff --git a/src/components/CampaignNewsletterForm.tsx b/src/components/CampaignNewsletterForm.tsx index 512b3e40..b6fd5f37 100644 --- a/src/components/CampaignNewsletterForm.tsx +++ b/src/components/CampaignNewsletterForm.tsx @@ -52,9 +52,16 @@ const CampaignNewsletterForm = ({ pixelId, onSubmit }: CampaignNewsletterFormPro try { await submitCampaignNewsletter(input) // S10 — exactly one `Lead` per successful capture, never on page load - // (petition precedent). `trackMetaLead` no-ops safely without fbq. + // (petition precedent). The inner guard keeps a tracking hiccup (e.g. + // `crypto.randomUUID` on a non-secure context) from turning an + // already-committed capture into the error UI; `trackMetaLead` + // no-ops safely without fbq. if (pixelId) { - trackMetaLead(pixelId, NEWSLETTER_LEAD_CONTENT_NAME, crypto.randomUUID()) + try { + trackMetaLead(pixelId, NEWSLETTER_LEAD_CONTENT_NAME, crypto.randomUUID()) + } catch { + // tracking is invisible to the visitor + } } methods.reset() onSubmit() diff --git a/src/globals/SiteSettings.ts b/src/globals/SiteSettings.ts index 804b46b9..f1ff2082 100644 --- a/src/globals/SiteSettings.ts +++ b/src/globals/SiteSettings.ts @@ -1,4 +1,8 @@ -import { normalizeFacebookPixelId, validateFacebookPixelId } from '@/lib/facebookPixel' +import { + FACEBOOK_PIXEL_ID_DESCRIPTION, + normalizeFacebookPixelId, + validateFacebookPixelId, +} from '@/lib/facebookPixel' import { payloadAdminOnly } from '@/utilities/campaignAccess' import { revalidateGlobal } from '@/utilities/globals' import type { GlobalConfig } from 'payload' @@ -101,8 +105,7 @@ export const SiteSettings: GlobalConfig = { type: 'text', label: 'ID do Pixel do Meta (Facebook)', admin: { - description: - 'Cole somente o ID numérico do Events Manager (ex.: 123456789012345), não o snippet HTML completo.', + description: FACEBOOK_PIXEL_ID_DESCRIPTION, }, validate: validateFacebookPixelId, }, diff --git a/src/lib/facebookPixel.ts b/src/lib/facebookPixel.ts index f6a1b037..d29685dc 100644 --- a/src/lib/facebookPixel.ts +++ b/src/lib/facebookPixel.ts @@ -1,5 +1,10 @@ const FACEBOOK_PIXEL_ID_PATTERN = /^\d{5,20}$/ +// Shared admin copy: every surface that takes a Pixel ID must say the same +// thing — only the numeric ID, never the full snippet (drift would invite XSS). +export const FACEBOOK_PIXEL_ID_DESCRIPTION = + 'Cole somente o ID numérico do Events Manager (ex.: 123456789012345), não o snippet HTML completo.' + export function isValidFacebookPixelId(value: string | null | undefined): boolean { if (value == null || value === '') return false return FACEBOOK_PIXEL_ID_PATTERN.test(value) diff --git a/tests/e2e/campaignHomePixel.e2e.spec.ts b/tests/e2e/campaignHomePixel.e2e.spec.ts index 598bc5e1..da768970 100644 --- a/tests/e2e/campaignHomePixel.e2e.spec.ts +++ b/tests/e2e/campaignHomePixel.e2e.spec.ts @@ -1,16 +1,22 @@ import type { APIRequestContext } from '@playwright/test' -import { seedTestUser, testUser } from '../helpers/seedUser' +import { adminHeaders } from '../helpers/adminApi' +import { seedTestUser } from '../helpers/seedUser' import { expect, test } from './fixtures/e2eTest' /** * S10 — the Meta pixel on the public campaign page: configured through * `SiteSettings.tracking.facebookPixelId` (admin REST), the home renders the * base code (inline `next/script` + `fbq` init) and nothing at all without a - * configured ID (fail-closed). The external `fbevents.js` fetch is stubbed so - * the spec never depends on the Meta CDN; the base code itself is inline. - * Serial mode (precedent `campaignNearestMunicipality`): the two tests share - * one worker — a parallel split would race two `seedTestUser` beforeAlls. + * configured ID (fail-closed). The external `fbevents.js` fetch is fulfilled + * locally by the shared e2e guard (fixtures/e2eTest.ts), so no spec ever + * depends on the Meta CDN; the base code itself is inline. The `Lead` wiring + * (successful S9 capture → exactly one queued `fbq` Lead) is pinned by the + * unit spec `campaignNewsletterLead.unit.spec.tsx` — a real submission here + * would share the `campanha-novidades` consent key with the parallel + * `campaignNewsletter` spec's purge/create lifecycle. Serial mode (precedent + * `campaignNearestMunicipality`): the tests share one worker — a parallel + * split would race two `seedTestUser` beforeAlls. */ test.describe.configure({ mode: 'serial' }) @@ -20,15 +26,6 @@ test.describe('Campaign home Meta pixel', () => { // Valid 15-digit numeric ID (same class the admin validation accepts). const PIXEL_ID = '123456789012345' - const adminHeaders = async (request: APIRequestContext): Promise> => { - const login = await request.post(`${baseURL}/api/users/login`, { - data: { email: testUser.email, password: testUser.password }, - }) - expect(login.ok()).toBeTruthy() - const { token } = await login.json() - return { cookie: `payload-token=${token}` } - } - // `_status: 'published'` publishes through the global's draft pipeline, same // pattern the frontend spec uses for versioned documents. const setSitePixel = async ( @@ -36,47 +33,56 @@ test.describe('Campaign home Meta pixel', () => { headers: Record, pixelId: string | null, ) => { - const response = await request.post(`${baseURL}/api/globals/site-settings`, { - headers, - data: { tracking: { facebookPixelId: pixelId }, _status: 'published' }, - }) - expect(response.ok()).toBeTruthy() + // Insurance against the same-user concurrent-login session race with + // specs that do NOT route their login through the advisory-locked + // `adminHeaders` helper (e.g. the frontend spec's inline logins): a 403 + // means the session row was lost to another worker — re-login mints a + // fresh one. The locked helper already serializes the specs that use it. + for (let attempt = 0; attempt < 2; attempt += 1) { + const response = await request.post(`${baseURL}/api/globals/site-settings`, { + headers, + data: { tracking: { facebookPixelId: pixelId }, _status: 'published' }, + }) + if (response.ok()) return + if (response.status() === 403 && attempt === 0) { + headers = await adminHeaders(request, baseURL) + continue + } + throw new Error(`setSitePixel(${pixelId}) failed with ${response.status()}`) + } } test.beforeAll(async ({ request }) => { await seedTestUser() // Self-healing lifecycle (S9 precedent): an aborted run could leave the // pixel behind and poison the fail-closed assertion — purge first. - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) await setSitePixel(request, headers, null) }) test.afterAll(async ({ request }) => { - const headers = await adminHeaders(request).catch(() => undefined) + const headers = await adminHeaders(request, baseURL).catch(() => undefined) if (headers) { await setSitePixel(request, headers, null) } }) test('renders nothing without a configured pixel ID', async ({ page }) => { - // Fresh load (unique query param bypasses the ISR fetch cache, same trick - // as the content-section specs); the app ignores unknown params. + // Fresh load (unique query param misses every HTTP/router cache entry — + // the content-section trick); the app ignores unknown params. The + // fail-closed read comes from the global, which beforeAll just reset. await page.goto(`/?e2e=${runSuffix}-none`) await expect(page.locator('script[id^="meta-pixel-"]')).toHaveCount(0) }) - test('renders the Meta base code when the site settings carry a valid ID', async ({ + test('renders the base code when the site settings carry a valid ID', async ({ page, request, }) => { - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) + // The REST update busts `global_site-settings` via afterChange BEFORE the + // goto, so the fresh render carries the pixel (deterministic, no cache race). await setSitePixel(request, headers, PIXEL_ID) - - // The inline base code always runs; only the external fbevents.js fetch is - // stubbed, so the assertion never depends on the Meta CDN. - await page.route('https://connect.facebook.net/**', (route) => - route.fulfill({ body: '', contentType: 'application/javascript' }), - ) await page.goto(`/?e2e=${runSuffix}-set`) const pixelScript = page.locator(`script#meta-pixel-${PIXEL_ID}`) diff --git a/tests/e2e/campaignNewsletter.e2e.spec.ts b/tests/e2e/campaignNewsletter.e2e.spec.ts index c34ac08e..e3079f08 100644 --- a/tests/e2e/campaignNewsletter.e2e.spec.ts +++ b/tests/e2e/campaignNewsletter.e2e.spec.ts @@ -1,7 +1,8 @@ import type { APIRequestContext } from '@playwright/test' import { CAMPAIGN_NEWSLETTER_CONSENT_KEY } from '@/lib/campaignConsentKeys' -import { seedTestUser, testUser } from '../helpers/seedUser' +import { adminHeaders } from '../helpers/adminApi' +import { seedTestUser } from '../helpers/seedUser' import { expect, test } from './fixtures/e2eTest' /** @@ -22,15 +23,6 @@ test.describe('Campaign home novidades capture', () => { let consentID: number | undefined - const adminHeaders = async (request: APIRequestContext): Promise> => { - const login = await request.post(`${baseURL}/api/users/login`, { - data: { email: testUser.email, password: testUser.password }, - }) - expect(login.ok()).toBeTruthy() - const { token } = await login.json() - return { cookie: `payload-token=${token}` } - } - const createNewsletterConsent = async ( request: APIRequestContext, headers: Record, @@ -130,12 +122,12 @@ test.describe('Campaign home novidades capture', () => { test.beforeAll(async ({ request }) => { await seedTestUser() - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) await createNewsletterConsent(request, headers) }) test.afterAll(async ({ request }) => { - const headers = await adminHeaders(request).catch(() => undefined) + const headers = await adminHeaders(request, baseURL).catch(() => undefined) if (headers) { await cleanup(request, headers) } @@ -173,7 +165,7 @@ test.describe('Campaign home novidades capture', () => { await expect(flagsLink).toHaveAttribute('href', '#bandeiras') // The record is visible to the admin with the level choice. - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) const contacts = await request.get( `${baseURL}/api/contact?where[phones.value][equals]=${successPhone}&limit=1&depth=0`, { headers }, diff --git a/tests/e2e/fixtures/e2eTest.ts b/tests/e2e/fixtures/e2eTest.ts index 27e57f07..a600f86c 100644 --- a/tests/e2e/fixtures/e2eTest.ts +++ b/tests/e2e/fixtures/e2eTest.ts @@ -107,6 +107,14 @@ export const test = base.extend({ // player loading — same spirit as the S2/S3 feed stubs). await context.route('https://www.youtube-nocookie.com/**', (route) => route.abort()) + // S10 — the campaign-home pixel spec publishes a Meta Pixel ID into the + // shared test DB, so concurrent specs may render the home with the base + // code. Fulfill the external script locally (never a real request to + // Meta): a blocked network would trip the console-error guard below. + await context.route('https://connect.facebook.net/**', (route) => + route.fulfill({ body: '', contentType: 'application/javascript' }), + ) + await use() page.off('console', onConsole) diff --git a/tests/helpers/adminApi.ts b/tests/helpers/adminApi.ts new file mode 100644 index 00000000..311c21c1 --- /dev/null +++ b/tests/helpers/adminApi.ts @@ -0,0 +1,41 @@ +import type { APIRequestContext } from '@playwright/test' +import { Client } from 'pg' + +import { testUser } from './seedUser' + +// Arbitrary fixed key — every spec logs the same test user in through this +// helper, so one serialization point covers the whole suite. +const ADMIN_LOGIN_LOCK_KEY = 727_001 + +/** + * Logs the shared test user in and returns the `payload-token` cookie. + * + * Serialized by a Postgres advisory lock: Payload keeps auth sessions as a + * read-modify-write array on the user document (the campaign WebAuthn note + * documents the exact class), so two parallel workers logging the same + * `dev@payloadcms.com` in concurrently can lose one session row — the loser's + * token then resolves to nobody and every admin request answers 403. The + * lock makes the read-modify-write exclusive; a waiting worker logs in after + * the winner, keeping every session. + */ +export const adminHeaders = async ( + request: APIRequestContext, + baseURL: string, +): Promise> => { + const client = new Client({ connectionString: process.env.DATABASE_URL }) + await client.connect() + try { + await client.query('SELECT pg_advisory_lock($1)', [ADMIN_LOGIN_LOCK_KEY]) + const login = await request.post(`${baseURL}/api/users/login`, { + data: { email: testUser.email, password: testUser.password }, + }) + if (!login.ok()) { + throw new Error(`admin login failed with ${login.status()}`) + } + const { token } = await login.json() + return { cookie: `payload-token=${token}` } + } finally { + await client.query('SELECT pg_advisory_unlock($1)', [ADMIN_LOGIN_LOCK_KEY]).catch(() => {}) + await client.end() + } +} diff --git a/tests/helpers/seedUser.ts b/tests/helpers/seedUser.ts index 4a468255..e251aedd 100644 --- a/tests/helpers/seedUser.ts +++ b/tests/helpers/seedUser.ts @@ -10,29 +10,30 @@ export const testUser = { /** * Seeds a test user for e2e admin tests. + * + * Create-if-missing on purpose: specs run fully parallel across workers and + * share this one user, so a delete-then-create left a window where (a) a + * concurrent `create` hit the unique email, and (b) the user — and every + * already-minted admin token — briefly vanished. The credentials are constant + * (testUser), so a row created by any worker is identical to the one another + * would create: the unique-conflict loser simply proceeds, and no password + * reset ever invalidates a live session. */ export async function seedTestUser(): Promise { assertTestDatabase(process.env.DATABASE_URL) const payload = await getPayload({ config }) - // Delete existing test user if any - await payload.delete({ - collection: 'users', - where: { - email: { - equals: testUser.email, + try { + await payload.create({ + collection: 'users', + data: { + ...testUser, + roles: ['admin'], }, - }, - }) - - // Create fresh test user - await payload.create({ - collection: 'users', - data: { - ...testUser, - roles: ['admin'], - }, - }) + }) + } catch { + // A parallel worker already seeded the shared user; nothing to reset. + } } /** diff --git a/tests/unit/campaignNewsletterLead.unit.spec.tsx b/tests/unit/campaignNewsletterLead.unit.spec.tsx new file mode 100644 index 00000000..318180de --- /dev/null +++ b/tests/unit/campaignNewsletterLead.unit.spec.tsx @@ -0,0 +1,98 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { CampaignNewsletterCapture } from '@/components/CampaignNewsletterForm' + +/** + * S10 — the `Lead` wiring of the S9 capture form: a successful submission + * with a configured pixel fires exactly one `trackMetaLead` (content_name + * `novidades-da-campanha`, UUID eventID); without a pixel, or when the + * submission fails, nothing fires. The e2e covers the base-code render; the + * real submission stays out of e2e on purpose (the `campanha-novidades` + * consent key is shared with the parallel `campaignNewsletter` spec). + */ +const submitCampaignNewsletterMock = vi.fn() +const trackMetaLeadMock = vi.fn() + +vi.mock('@/app/(frontend)/actions/submitCampaignNewsletter', () => ({ + submitCampaignNewsletter: (...args: unknown[]) => submitCampaignNewsletterMock(...args), +})) + +vi.mock('@/lib/facebookPixel', () => ({ + trackMetaLead: (...args: unknown[]) => trackMetaLeadMock(...args), +})) + +const PIXEL_ID = '123456789012345' + +beforeAll(() => { + // Radix comboboxes (StateSelect/CitySelect) need ResizeObserver in jsdom. + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) +}) + +const fillAndSubmit = async () => { + // FormInput propagates to react-hook-form through the native `input` event. + fireEvent.input(screen.getByPlaceholderText('Nome completo*'), { + target: { value: 'Maria Teste' }, + }) + fireEvent.input(screen.getByPlaceholderText('WhatsApp (com DDD)*'), { + target: { value: '71987654321' }, + }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'QUERO RECEBER NOVIDADES' })) + }) +} + +describe('Campaign newsletter Lead wiring', () => { + beforeEach(() => { + submitCampaignNewsletterMock.mockReset() + trackMetaLeadMock.mockReset() + submitCampaignNewsletterMock.mockResolvedValue(undefined) + }) + + afterEach(() => { + cleanup() + }) + + it('fires exactly one Lead on a successful capture with a configured pixel', async () => { + render() + await fillAndSubmit() + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Inscrição confirmada' })).toBeTruthy() + }) + expect(trackMetaLeadMock).toHaveBeenCalledTimes(1) + expect(trackMetaLeadMock).toHaveBeenCalledWith( + PIXEL_ID, + 'novidades-da-campanha', + expect.any(String), + ) + }) + + it('never fires a Lead without a configured pixel', async () => { + render() + await fillAndSubmit() + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Inscrição confirmada' })).toBeTruthy() + }) + expect(trackMetaLeadMock).not.toHaveBeenCalled() + }) + + it('never fires a Lead when the capture fails', async () => { + submitCampaignNewsletterMock.mockRejectedValue(new Error('boom')) + render() + await fillAndSubmit() + + await waitFor(() => { + expect(screen.getByText('Falha ao enviar. Tente novamente.')).toBeTruthy() + }) + expect(trackMetaLeadMock).not.toHaveBeenCalled() + }) +})