mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
fix(petition): Lead tracking não falha mais assinatura commitada — guarda interna try/catch + spec (Closes #766)
This commit is contained in:
parent
b4484ec604
commit
19683a0efb
4 changed files with 203 additions and 1 deletions
1
docs/changelog/2026-08-24-766.md
Normal file
1
docs/changelog/2026-08-24-766.md
Normal 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`.
|
||||
57
docs/plans/petition-form-lead-guard-impl.md
Normal file
57
docs/plans/petition-form-lead-guard-impl.md
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
138
tests/unit/petitionLead.unit.spec.tsx
Normal file
138
tests/unit/petitionLead.unit.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue