mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
refactor(S10): simplify pass — Lead wiring unit-tested e raças de estado compartilhado do e2e resolvidas
e2e: stub hermético de connect.facebook.net no guard compartilhado (specs paralelos renderizam a home com o pixel); advisory lock no Postgres serializando o login do usuário de teste (Payload perde sessão em login concorrente read-modify-write — classe documentada no webauthn); seedTestUser vira create-if-missing (sem janela de usuário ausente nem reset de senha); Lead do form protegido do catch de erro (tracking nunca falha uma captura commitada); descrição do campo compartilhada; unidade nova campaignNewsletterLead cobre exatamente um Lead no sucesso.
This commit is contained in:
parent
e339ceec9f
commit
504f6c371d
12 changed files with 236 additions and 72 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ export default async function HomePage() {
|
|||
</section>
|
||||
|
||||
<CampaignStorySection />
|
||||
<CampaignNewsletterSection pixelId={pixelId} />
|
||||
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
|
||||
</main>
|
||||
|
||||
<CampaignFooter />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
41
tests/helpers/adminApi.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
98
tests/unit/campaignNewsletterLead.unit.spec.tsx
Normal file
98
tests/unit/campaignNewsletterLead.unit.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue