teqo-backup/tests/unit/campaignHome.unit.spec.tsx
Francisco Solla cc4b344a3b feat(C242/C243): abre a busca por selfie a qualquer visitante (escopo B) + seção na homepage
O escopo A/C do gate (PR #1370) é superado por decisão do dono (2026-10-01, aval jurídico declarado): o índice vira anônimo (archivePhotoFace, foto + descritor 128-d, sem nome/vínculo), o enrollment por pessoa morre (faceSubject/faces:enroll removidos) e qualquer visitante encontra as próprias fotos aprovadas.

- faces:index persiste os descritores por foto (transação substitui as linhas + marker = modelo); desaprovar/remover/deletar foto apaga os descritores (hooks + FK cascade)
- POST /api/fotos/selfie casa o vetor da consulta contra o índice em memória (TTL 60 s) e devolve só a interseção com o read aprovado do C233, sem score/nome de terceiro; leave-index apaga os descritores que casarem
- Consentimentos seguem fail-closed: busca-selfie-fotos (consulta) e busca-selfie-indice (aviso público do índice), textos versionados em scripts/lib/faceConsentTexts.mjs e provisionados por pnpm seed:face-consents
- pnpm archive:publish aprova o acervo em lote (draft -> approved; removed nunca) com guardas e recibo
- C243: seção condicional SelfieSearchHomeSection (port do design hi-fi, cenas 390/1280) entre a Central e a newsletter; sem engine/captura na home e sem CTA morto quando fechada
- docs: planos C242/C243, AGENTS-public, runbook §C242, changelog

Provas: gate:fast 457 arquivos/5.055 testes; int full 109/1.075; e2e frontendFotosSelfie 9/9; knip verde.
2026-10-01 12:29:04 -03:00

189 lines
7.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import HomePage from '@/app/(frontend)/(home)/page'
import { toContentPiecePublicItem, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
const pieceItem = toContentPiecePublicItem({
id: 1,
slug: 'peca-da-home',
title: 'Peça da home',
type: 'foto',
status: 'publicado',
origin: 'arquivo',
media: { id: 2, filename: 'peca.png', mimeType: 'image/png' },
})
if (!pieceItem) throw new Error('fixture should be public')
type MockImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
fill?: boolean
priority?: boolean
}
vi.mock('next/image', () => ({
default: ({ alt, fill: _fill, priority: _priority, ...props }: MockImageProps) => (
// eslint-disable-next-line @next/next/no-img-element
<img alt={alt} {...props} />
),
}))
// The content section is an async server component that reads posts through
// the Payload DB; the unit env has no database and an async child suspends the
// whole tree under testing-library. Its empty/full behavior is covered by e2e
// (frontend.e2e.spec.ts), so the home skeleton tests render it away.
vi.mock('@/components/CampaignContentSection', () => ({
CampaignContentSection: () => null,
}))
// The S9 capture form is a client component (radix comboboxes/checkbox) whose
// interactions are e2e-covered; the unit skeleton only pins the section shell.
vi.mock('@/components/CampaignNewsletterForm', () => ({
CampaignNewsletterCapture: () => null,
}))
// S10 — the section reads the site-settings global (server-only, Payload DB);
// the unit env has no database, and the pixel behavior is e2e-covered.
vi.mock('@/utilities/campaignHomeTracking', () => ({
getCampaignHomeMetaPixelId: async () => null,
}))
// S21/S22 — the home reads the jingle listing through the Payload DB +
// unstable_cache for the sound section and the footer discovery flag; the unit
// env has neither, and the real listing/kill-switch behavior is e2e-covered
// (frontendJingles.e2e.spec.ts).
vi.mock('@/utilities/jingleReads', () => ({
getPublishedJingleItems: async () => [],
}))
// S27/S39 — the same for the Central de Conteúdos listing (cached read that
// feeds the discovery flag and the home sample); the real kill-switch behavior
// is e2e-covered (frontendConteudos.e2e.spec.ts).
const contentPieces = vi.hoisted(() => ({ items: [] as ContentPiecePublicItem[] }))
vi.mock('@/utilities/content/contentPieceReads', () => ({
getPublishedContentPieceItems: async () => contentPieces.items,
}))
// C233/C243 — the footer's "Fotos" discovery flag and the selfie-search section
// read the approved archive photos and the photoAlbum global through the
// Payload DB; the unit env has no database and the real gate is e2e-covered
// (frontendFotosSelfie.e2e.spec.ts).
const homePhotoAlbum = vi.hoisted(() => ({
hasPhotos: false,
selfieSearchEnabled: false,
published: true,
}))
vi.mock('@/utilities/archivePhotos/archivePhotoReads', () => ({
hasPublishedArchivePhotos: async () => homePhotoAlbum.hasPhotos,
}))
vi.mock('@/utilities/globalReads', () => ({
getCachedGlobal: () => async () => ({
published: homePhotoAlbum.published,
selfieSearchEnabled: homePhotoAlbum.selfieSearchEnabled,
}),
}))
// S14 — the card section renders the client studio island (next/font local
// face + matchMedia + canvas); its behavior is e2e-covered, so the unit
// skeleton mocks both the face module and the island.
vi.mock('@/app/(frontend)/fonts', () => ({
brexterBold: { variable: 'font-brexter-mock', style: { fontFamily: 'Brexter' } },
}))
vi.mock('@/components/cards/CardsStudio', () => ({
CardsStudio: () => null,
}))
afterEach(() => {
contentPieces.items = []
homePhotoAlbum.hasPhotos = false
homePhotoAlbum.selfieSearchEnabled = false
homePhotoAlbum.published = true
cleanup()
})
describe('Campaign home', () => {
it('monta as seis seções previstas e o rodapé eleitoral', async () => {
render(await HomePage())
expect(screen.getByRole('heading', { level: 1, name: 'MAIS SAÚDE MAIS FUTURO' })).toBeTruthy()
expect(screen.getByText('3.333')).toBeTruthy()
expect(screen.getByText('1.031')).toBeTruthy()
expect(screen.getByText('3º')).toBeTruthy()
expect(screen.getByRole('heading', { name: /Eleger deputado é coisa séria/i })).toBeTruthy()
expect(
screen.getByRole('heading', {
name: /Junto com o trabalhador e do lado de quem mais precisa, sempre/i,
}),
).toBeTruthy()
// S22 — with zero published jingles the sound section shows only the radio.
expect(screen.getByRole('heading', { name: 'Sintonize com a Rádio 1313' })).toBeTruthy()
expect(screen.getByRole('heading', { name: /Mostre que você está com Solla/i })).toBeTruthy()
expect(screen.getByRole('heading', { name: /Receba as novidades da campanha/i })).toBeTruthy()
expect(screen.getByText(/CNPJ: 68\.430\.467\/0001-05/)).toBeTruthy()
})
it('usa exatamente as seis bandeiras aprovadas', async () => {
render(await HomePage())
// S6: cada bandeira aparece em duas renderizações irmãs (grade desktop e
// carrossel mobile); o teste garante que as duas seguem a mesma copy.
const expectedTitles = [
'Defender o SUS e valorizar quem cuida',
'Fim da escala 6×1 e jornada de 40h',
'Educação em tempo integral e federais no interior',
'Recomprar a Refinaria de Mataripe',
'Salário mínimo forte, emprego e moradia',
'Defesa intransigente da democracia',
]
for (const title of expectedTitles) {
expect(screen.getAllByText(title)).toHaveLength(2)
}
})
it('mostra a seção da Central com peça publicada e a esconde sem nenhuma (fail-closed)', async () => {
contentPieces.items = [pieceItem]
render(await HomePage())
expect(screen.getByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeTruthy()
// The single-piece layout renders one CTA per viewport (split desktop +
// stacked mobile); both hand off to the Central.
const ctas = screen.getAllByRole('link', { name: /Ver todas as peças/ })
expect(ctas.length).toBeGreaterThan(0)
for (const cta of ctas) expect(cta.getAttribute('href')).toBe('/conteudos')
cleanup()
contentPieces.items = []
render(await HomePage())
expect(screen.queryByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeNull()
expect(screen.queryByRole('link', { name: /Ver todas as peças/ })).toBeNull()
})
it('mostra a seção da busca por selfie só quando a busca está aberta (C243, fail-closed)', async () => {
homePhotoAlbum.hasPhotos = true
homePhotoAlbum.selfieSearchEnabled = true
render(await HomePage())
expect(screen.getByRole('heading', { name: 'Encontre você nas fotos' })).toBeTruthy()
const ctas = screen.getAllByRole('link', { name: /Encontrar minhas fotos/ })
expect(ctas.length).toBeGreaterThan(0)
for (const cta of ctas) expect(cta.getAttribute('href')).toBe('/fotos/encontre')
cleanup()
homePhotoAlbum.selfieSearchEnabled = false
render(await HomePage())
expect(screen.queryByRole('heading', { name: 'Encontre você nas fotos' })).toBeNull()
cleanup()
// Flag on but no approved photo is still closed: no dead CTA.
homePhotoAlbum.selfieSearchEnabled = true
homePhotoAlbum.hasPhotos = false
render(await HomePage())
expect(screen.queryByRole('heading', { name: 'Encontre você nas fotos' })).toBeNull()
})
})