refactor(S10): simplify pass — Lead unit-testado, e2e hermético e corridas de login resolvidas #118

Merged
fsolla merged 1 commit from S10-pixel-do-meta-facebook-na-pagina-publica-de-campanha-cam into main 2026-08-20 00:14:02 +00:00
12 changed files with 236 additions and 72 deletions

View file

@ -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 }) => (
<section
id="novidades"
aria-labelledby="novidades-title"
@ -33,7 +33,7 @@ export const CampaignNewsletterSection = ({ pixelId }: { pixelId: string | null
</p>
</div>
<div className="mt-8">
<CampaignNewsletterCapture pixelId={pixelId ?? undefined} />
<CampaignNewsletterCapture pixelId={pixelId} />
</div>
</div>
</section>

View file

@ -235,7 +235,7 @@ export default async function HomePage() {
</section>
<CampaignStorySection />
<CampaignNewsletterSection pixelId={pixelId} />
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
</main>
<CampaignFooter />

View file

@ -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<typeof slug> = {
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,
},

View file

@ -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()

View file

@ -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,
},

View file

@ -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)

View file

@ -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<Record<string, string>> => {
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<string, string>,
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}`)

View file

@ -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<Record<string, string>> => {
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<string, string>,
@ -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 },

View file

@ -107,6 +107,14 @@ export const test = base.extend<E2EFailureGuardFixtures>({
// 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)

41
tests/helpers/adminApi.ts Normal file
View file

@ -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<Record<string, string>> => {
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()
}
}

View file

@ -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<void> {
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.
}
}
/**

View file

@ -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(<CampaignNewsletterCapture pixelId={PIXEL_ID} />)
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(<CampaignNewsletterCapture />)
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(<CampaignNewsletterCapture pixelId={PIXEL_ID} />)
await fillAndSubmit()
await waitFor(() => {
expect(screen.getByText('Falha ao enviar. Tente novamente.')).toBeTruthy()
})
expect(trackMetaLeadMock).not.toHaveBeenCalled()
})
})