From 19683a0efb76f95e1619ce1e791596c4d323d05d Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 24 Aug 2026 17:27:50 -0300 Subject: [PATCH] =?UTF-8?q?fix(petition):=20Lead=20tracking=20n=C3=A3o=20f?= =?UTF-8?q?alha=20mais=20assinatura=20commitada=20=E2=80=94=20guarda=20int?= =?UTF-8?q?erna=20try/catch=20+=20spec=20(Closes=20#766)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-08-24-766.md | 1 + docs/plans/petition-form-lead-guard-impl.md | 57 ++++++++ src/components/PetitionForm.tsx | 8 +- tests/unit/petitionLead.unit.spec.tsx | 138 ++++++++++++++++++++ 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 docs/changelog/2026-08-24-766.md create mode 100644 docs/plans/petition-form-lead-guard-impl.md create mode 100644 tests/unit/petitionLead.unit.spec.tsx diff --git a/docs/changelog/2026-08-24-766.md b/docs/changelog/2026-08-24-766.md new file mode 100644 index 00000000..f274ac2a --- /dev/null +++ b/docs/changelog/2026-08-24-766.md @@ -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`. diff --git a/docs/plans/petition-form-lead-guard-impl.md b/docs/plans/petition-form-lead-guard-impl.md new file mode 100644 index 00000000..675ec3c8 --- /dev/null +++ b/docs/plans/petition-form-lead-guard-impl.md @@ -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 diff --git a/src/components/PetitionForm.tsx b/src/components/PetitionForm.tsx index 0eac50ff..6d746ef2 100644 --- a/src/components/PetitionForm.tsx +++ b/src/components/PetitionForm.tsx @@ -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) diff --git a/tests/unit/petitionLead.unit.spec.tsx b/tests/unit/petitionLead.unit.spec.tsx new file mode 100644 index 00000000..6d85c3f4 --- /dev/null +++ b/tests/unit/petitionLead.unit.spec.tsx @@ -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() + 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() + 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() + 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() + 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() + await fillAndSubmit() + + await waitFor(() => { + expect(screen.getByText('Falha ao enviar assinatura. Tente novamente.')).toBeTruthy() + }) + expect(trackMetaLeadMock).not.toHaveBeenCalled() + }) +})