teqo/tests/e2e/campaignNewsletter.e2e.spec.ts
Francisco Solla c723d6accf
merge(OPS75): convergência Forgejo→GitHub — GitHub como fonte única
* deploy: limpeza pós-deploy do build cache e tags locais antigas (INF3/F2)

O deploy compila no homeserver e acumulava build cache + tags locais
antigas no disco raiz (incidente 19/08: 45G de cache). Após o smoke:
docker builder prune -f + remoção das tags locais teqo-1313 exceto o
SHA em uso (best-effort, registry preserva o rollback).

* feat(S9): captura de novidades da campanha na home pública (form + CTA do hero)

Seção 'Receba as novidades da campanha' acima do rodapé: nome + WhatsApp
obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado
'Quero fazer parte do time' gravado como subscription.campaignLevel
(time|esporadico). CTA secundário do hero vira o atalho (#novidades, scroll
suave). Contact + Subscription em withPayloadTransaction com consentimento
novo fail-closed por chave estável 'campanha-novidades' — nada é gravado sem o
texto aprovado no admin. Migration 20260819_192605 (state de contact nullable
+ coluna campaign_level). Impl plan, changelog, int + e2e.

* plans(C141,C143,C142): permissões granulares por assessor + responsáveis de demanda + UI por perfil

* plans(C141,C143,C142): permissões granulares por assessor + responsáveis de demanda + UI por perfil

* feat(S8): seção 'Nossa história' na home — vídeo da trajetória embutido do YouTube (Closes #91)

* docs(S8): resolução do conflito com S9 (história → newsletter) + nota do guard e2e dev-only

* feat(S10): pixel do Meta na página pública de campanha (campo admin em SiteSettings + PageView na home + Lead no sucesso da captura)

Campo tracking.facebookPixelId no global SiteSettings (mesma validação/normalização do precedente Petition, regra centralizada em facebookPixel.ts); home (grupo (home)) injeta o base code + PageView quando o ID está configurado (fail-closed) e o CampaignNewsletterForm dispara exatamente um Lead no sucesso do S9. Migration 20260819_213947_add_site_settings_facebook_pixel_id. Closes #94

* 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.

* feat(C141): perfis de permissão por assessor — Visão × Edição configuráveis, enforcement fail-closed (Closes #105)

* chore(C141): regenera changelog agregado após rebase com S10

* plans(C144): options write-scoped para formulários de escrita pós-C141

---------

Co-authored-by: Francisco Solla <admin@solla.dev>
2026-08-21 01:41:18 -03:00

199 lines
7.7 KiB
TypeScript

import type { APIRequestContext } from '@playwright/test'
import { CAMPAIGN_NEWSLETTER_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import { adminHeaders } from '../helpers/adminApi'
import { seedTestUser } from '../helpers/seedUser'
import { expect, test } from './fixtures/e2eTest'
/**
* S9 — the campaign home "novidades" capture: the hero CTA anchors the
* section, the form records the contact + subscription with the engagement
* level, and the page confirms in place. Runs the write through the deployed
* server (server action + Local API) so the fail-closed consent path and the
* transaction are the production ones.
*/
test.describe('Campaign home novidades capture', () => {
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const runSuffix = Date.now()
// Unique 11-digit mobile per run (719 + 8 digits) so a crashed previous run
// can never poison the by-phone lookups.
const phoneDigits = String(runSuffix % 100_000_000).padStart(8, '0')
const successPhone = `719${phoneDigits}`
const successName = `E2e Novidades ${runSuffix}`
let consentID: number | undefined
const createNewsletterConsent = async (
request: APIRequestContext,
headers: Record<string, string>,
) => {
// Self-healing lifecycle (S1 precedent): an aborted run could leave the
// unique-key row behind and poison the next beforeAll — purge first. The
// consent cannot be deleted while a subscription references it
// (`subscription.consent_id` NOT NULL + FK SET NULL), so purge those too.
const existing = await request.get(
`${baseURL}/api/consent?where[key][equals]=${CAMPAIGN_NEWSLETTER_CONSENT_KEY}&limit=10&depth=0`,
{ headers },
)
if (existing.ok()) {
const { docs } = await existing.json()
for (const doc of docs) {
const subscriptions = await request.get(
`${baseURL}/api/subscription?where[consent][equals]=${doc.id}&limit=50&depth=0`,
{ headers },
)
if (subscriptions.ok()) {
const { docs: subscriptionDocs } = await subscriptions.json()
for (const subscription of subscriptionDocs) {
await request.delete(`${baseURL}/api/subscription/${subscription.id}`, { headers })
}
}
await request.delete(`${baseURL}/api/consent/${doc.id}`, { headers })
}
}
const response = await request.post(`${baseURL}/api/consent`, {
headers,
data: {
key: CAMPAIGN_NEWSLETTER_CONSENT_KEY,
text: {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'Consentimento de novidades da campanha (e2e).',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
},
},
})
expect(response.ok()).toBeTruthy()
const { doc } = await response.json()
consentID = doc.id
return doc
}
const cleanup = async (request: APIRequestContext, headers: Record<string, string>) => {
for (const phone of [successPhone]) {
const contacts = await request.get(
`${baseURL}/api/contact?where[phones.value][equals]=${phone}&limit=1&depth=0`,
{ headers },
)
if (contacts.ok()) {
const { docs } = await contacts.json()
const contact = docs[0]
if (contact) {
const subscriptions = await request.get(
`${baseURL}/api/subscription?where[contact][equals]=${contact.id}&limit=1&depth=0`,
{ headers },
)
if (subscriptions.ok()) {
const { docs: subscriptionDocs } = await subscriptions.json()
if (subscriptionDocs[0]) {
await request.delete(`${baseURL}/api/subscription/${subscriptionDocs[0].id}`, {
headers,
})
}
}
await request.delete(`${baseURL}/api/contact/${contact.id}`, { headers })
}
}
}
if (consentID !== undefined) {
await request.delete(`${baseURL}/api/consent/${consentID}`, { headers })
}
}
test.beforeAll(async ({ request }) => {
await seedTestUser()
const headers = await adminHeaders(request, baseURL)
await createNewsletterConsent(request, headers)
})
test.afterAll(async ({ request }) => {
const headers = await adminHeaders(request, baseURL).catch(() => undefined)
if (headers) {
await cleanup(request, headers)
}
})
test('captures the visitor and confirms in place', async ({ page, request }) => {
// Fresh load (unique query param bypasses the ISR fetch cache, same trick
// as the content-section specs); the app ignores unknown params.
await page.goto(`/?e2e=${runSuffix}`)
await expect(page).toHaveURL(/\/\?e2e=/)
const section = page.locator('[data-home-section="newsletter"]')
await expect(
section.getByRole('heading', { name: 'Receba as novidades da campanha' }),
).toBeVisible()
// The hero CTA is the shortcut into the section (smooth anchor).
await page.locator('[data-cta="secondary"]').click()
await expect(page).toHaveURL(/#novidades$/)
await expect(section).toBeInViewport()
// The toggle is pre-selected: the default level is "time".
const levelToggle = section.locator('#campaign-level')
await expect(levelToggle).toBeChecked()
await section.getByPlaceholder('Nome completo*').fill(successName)
// Digits are formatted by the input itself; the schema normalizes back.
await section.getByPlaceholder('WhatsApp (com DDD)*').fill(successPhone)
await section.getByRole('button', { name: 'QUERO RECEBER NOVIDADES' }).click()
await expect(section.getByRole('heading', { name: 'Inscrição confirmada' })).toBeVisible()
// The success card keeps the bandeiras reachable (they sit above).
const flagsLink = section.getByRole('link', { name: 'Ver bandeiras' })
await expect(flagsLink).toHaveAttribute('href', '#bandeiras')
// The record is visible to the admin with the level choice.
const headers = await adminHeaders(request, baseURL)
const contacts = await request.get(
`${baseURL}/api/contact?where[phones.value][equals]=${successPhone}&limit=1&depth=0`,
{ headers },
)
expect(contacts.ok()).toBeTruthy()
const { docs } = await contacts.json()
const contact = docs[0]
expect(contact).toBeDefined()
// NameInput sanitizes/formatts the name on the way in (digits stripped).
expect(contact.name.toLowerCase()).toContain('novidades')
const subscriptions = await request.get(
`${baseURL}/api/subscription?where[contact][equals]=${contact.id}&limit=1&depth=0`,
{ headers },
)
expect(subscriptions.ok()).toBeTruthy()
const { docs: subscriptionDocs } = await subscriptions.json()
expect(subscriptionDocs[0]).toBeDefined()
expect(subscriptionDocs[0].campaignLevel).toBe('time')
expect(
typeof subscriptionDocs[0].consent === 'object'
? subscriptionDocs[0].consent.id
: subscriptionDocs[0].consent,
).toBe(consentID)
})
// NOTE: the fail-closed refusal (consent missing → nothing written) is NOT
// e2e-covered on purpose: the server action rejects with a 500, and the
// suite's e2eFailureGuard treats same-origin 5xx as a failure (the WebAuthn
// refusal precedent is a handled 4xx). The refusal path is pinned by the
// int spec `submitCampaignNewsletter.int.spec.ts` (exact message + no rows).
})