fix(petition): Lead tracking não falha mais assinatura commitada — guarda interna try/catch + spec (Closes #766)

This commit is contained in:
Francisco Solla 2026-08-24 17:27:50 -03:00
parent b4484ec604
commit 19683a0efb
4 changed files with 203 additions and 1 deletions

View file

@ -0,0 +1 @@
**#766 — PetitionForm: Lead dentro do try não transforma mais assinatura commitada em erro (2026-08-24):** guarda interna try/catch em volta de `trackMetaLead` no `PetitionForm.onSubmit` (src/components/PetitionForm.tsx), espelhando o padrão S10 do `CampaignNewsletterForm` — falha de tracking (ex.: `crypto.randomUUID` em contexto não-secure) não vira mais "Falha ao enviar assinatura" com assinatura já commitada, eliminando retry duplicado e re-disparo de `Lead`; spec novo `tests/unit/petitionLead.unit.spec.tsx` (4 testes: Lead único em captura ok com pixel, sucesso preservado quando tracking lança, nenhum Lead sem pixel, nenhum Lead em falha de submit); plano `docs/plans/petition-form-lead-guard-impl.md`.

View file

@ -0,0 +1,57 @@
# Impl: PetitionForm — Lead dentro do try pode transformar assinatura commitada em erro (retry duplica)
Status: rascunho
Atualizado em: 2026-08-24
Issue: #766
Intenção: body da Issue #766 (sem plano de intenção separado — body é a spec)
Appetite restante: herdado (chore P3, ~4 linhas + teste)
## Leitura da intenção
- **Outcome:** falha de tracking (`crypto.randomUUID` em contexto não-secure, navegadores antigos) nunca mais vira erro de UI após assinatura commitada — eliminando retry duplicado e re-disparo de `Lead`.
- **O que NÃO negociar:** assinatura commitada deve sempre mostrar sucesso; tracking invisível ao visitante; no máximo um `Lead` por captura.
- **O que reavaliar:** nada — padrão já resolvido no S10 (`CampaignNewsletterForm`), replicar no `PetitionForm`.
## Abordagem recomendada
```mermaid
flowchart LR
A[submitPetitionSignature ok] --> B{facebookPixelId set?}
B -- sim --> C[try trackMetaLead]
C -- throw --> D[catch vazio — tracking invisível]
B -- não --> E[reset + sucesso]
C -- ok --> E
```
**Opções consideradas:** A) guarda interna try/catch no bloco de tracking; B) mover tracking para fora do try de submit.
**Recomendação:** A — espelha exatamente o padrão S10 já aplicado e revisado em `CampaignNewsletterForm.tsx:59-65`.
**Rejeitadas:** B — cria fluxo de sucesso fora do try com estado sutil; divergiria do precedente consolidado.
### Componentes / mudanças
- **`PetitionForm.onSubmit`** (`src/components/PetitionForm.tsx`): envolver `trackMetaLead(...)` em `try/catch` próprio com catch vazio.
- **Migration:** sem migration.
- **Access / Consent:** não se aplica.
- **UI:** nenhuma mudança de UI.
## Fases verificáveis
1. **Código** — guarda interna no `PetitionForm` (~4 linhas).
2. **Teste** — unit espelhando `campaignNewsletterLead.unit.spec.tsx`: Lead uma vez com pixel em captura ok; tracking que lança não vira erro de UI; nenhum Lead sem pixel.
3. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não mexer no `trackMetaLead` (já no-op seguro sem `fbq`).
- Não tocar no `CampaignNewsletterForm` (já corrigido).
- Sem e2e novo: superfície já coberta por e2e existente da petição; o teste unit cobre o contrato do Lead.
## Riscos e mitigação
- Teste unit do form é frágil (jsdom + Radix comboboxes): copiar scaffolding já provado do spec S10 (ResizeObserver stub, `fireEvent.input` nativo). Baixo.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio previstos (unit) onde write paths mudam

View file

@ -59,7 +59,13 @@ export const PetitionForm = ({ id, petition, consentHTML, facebookPixelId }: Pet
petitionId: petition.id,
})
if (facebookPixelId) {
trackMetaLead(facebookPixelId, petition.title, crypto.randomUUID())
// #766 — tracking must never fail an already-committed signature
// (same inner guard as CampaignNewsletterForm S10).
try {
trackMetaLead(facebookPixelId, petition.title, crypto.randomUUID())
} catch {
// tracking is invisible to the visitor
}
}
methods.reset()
setSignatureNumber(result.signatureNumber)

View file

@ -0,0 +1,138 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { PetitionForm } from '@/components/PetitionForm'
import type { Petition } from '@/payload-types'
/**
* #766 — the `Lead` wiring of the petition form: a successful signature with a
* configured pixel fires exactly one `trackMetaLead` (content_name = petition
* title, UUID eventID); without a pixel, or when the submission fails, nothing
* fires. A tracking hiccup (e.g. `crypto.randomUUID` on a non-secure context)
* must never turn an already-committed signature into the error UI — the
* success dialog still shows, preventing a retry that would duplicate the
* signature and re-fire the `Lead`.
*/
const submitPetitionSignatureMock = vi.fn()
const trackMetaLeadMock = vi.fn()
vi.mock('@/app/(frontend)/actions/submitPetitionSignature', () => ({
submitPetitionSignature: (...args: unknown[]) => submitPetitionSignatureMock(...args),
}))
vi.mock('@/lib/facebookPixel', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/facebookPixel')>()
return {
...actual,
trackMetaLead: (...args: unknown[]) => trackMetaLeadMock(...args),
}
})
const PIXEL_ID = '123456789012345'
const petition = {
id: 1,
title: 'Pela manutenção do Hospital do Subúrbio',
form: { title: 'Assine a petição', subtitle: '' },
} as unknown as Petition
beforeAll(() => {
// Radix/Base UI 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('Digite seu nome completo'), {
target: { value: 'Maria Teste' },
})
fireEvent.input(screen.getByPlaceholderText('Digite seu e-mail'), {
target: { value: 'maria@teste.com' },
})
fireEvent.input(screen.getByPlaceholderText('(71) 99999-9999'), {
target: { value: '71987654321' },
})
fireEvent.input(screen.getByPlaceholderText('00000-000'), {
target: { value: '41950000' },
})
fireEvent.input(screen.getByPlaceholderText('Selecione um estado'), {
target: { value: 'BA' },
})
fireEvent.click(await screen.findByRole('option', { name: 'Bahia' }))
fireEvent.input(screen.getByPlaceholderText('Selecione uma cidade'), {
target: { value: 'Salvador' },
})
fireEvent.click(await screen.findByRole('option', { name: 'Salvador' }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Assinar' }))
})
}
describe('Petition form Lead wiring', () => {
beforeEach(() => {
submitPetitionSignatureMock.mockReset()
trackMetaLeadMock.mockReset()
submitPetitionSignatureMock.mockResolvedValue({ ok: true, signatureNumber: 42 })
})
afterEach(() => {
cleanup()
})
it('fires exactly one Lead on a successful signature with a configured pixel', async () => {
render(<PetitionForm id="formulario" petition={petition} consentHTML="" facebookPixelId={PIXEL_ID} />)
await fillAndSubmit()
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Obrigado por assinar!' })).toBeTruthy()
})
expect(trackMetaLeadMock).toHaveBeenCalledTimes(1)
expect(trackMetaLeadMock).toHaveBeenCalledWith(
PIXEL_ID,
petition.title,
expect.any(String),
)
})
it('keeps the committed signature a success when tracking throws', async () => {
trackMetaLeadMock.mockImplementation(() => {
throw new Error('crypto.randomUUID is not available')
})
render(<PetitionForm id="formulario" petition={petition} consentHTML="" facebookPixelId={PIXEL_ID} />)
await fillAndSubmit()
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Obrigado por assinar!' })).toBeTruthy()
})
expect(submitPetitionSignatureMock).toHaveBeenCalledTimes(1)
expect(screen.queryByText('Falha ao enviar assinatura. Tente novamente.')).toBeNull()
})
it('never fires a Lead without a configured pixel', async () => {
render(<PetitionForm id="formulario" petition={petition} consentHTML="" />)
await fillAndSubmit()
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Obrigado por assinar!' })).toBeTruthy()
})
expect(trackMetaLeadMock).not.toHaveBeenCalled()
})
it('never fires a Lead when the submission fails', async () => {
submitPetitionSignatureMock.mockRejectedValue(new Error('boom'))
render(<PetitionForm id="formulario" petition={petition} consentHTML="" facebookPixelId={PIXEL_ID} />)
await fillAndSubmit()
await waitFor(() => {
expect(screen.getByText('Falha ao enviar assinatura. Tente novamente.')).toBeTruthy()
})
expect(trackMetaLeadMock).not.toHaveBeenCalled()
})
})