mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 10:43:07 +00:00
* 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>
199 lines
7.7 KiB
TypeScript
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).
|
|
})
|