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