Guardas programaticas: fronteira lib/ (no-restricted-imports), ban de consent id hardcoded, codebaseConventions.unit.spec (server-only, copy Praca/Nucleo, listas legadas do eslint, wrappers de formActions), ciclo do payload.config quebrado (documents/globals divididos em tags+reads), timeout global do int suite, personName schema-valido, consent drafts Onda 0 p/ Municipio

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 18:02:02 -03:00
parent e6f7c2e70f
commit 4bbabb7d5e
21 changed files with 415 additions and 178 deletions

48
eslint-legacy-ignores.mjs Normal file
View file

@ -0,0 +1,48 @@
// Files that predate a naming/syntax convention and are deliberately exempt
// from it. Consumed by eslint.config.mjs; tests/unit/codebaseConventions
// asserts every entry still exists so renames/moves can't leave zombie
// entries behind (that is how the campaign-logo.tsx exemption silently broke
// when the file moved to components/campaign/shell/ during Pass 2 W2).
// Predate the PascalCase component filename convention.
export const legacyComponentFilenameIgnores = [
'src/components/campaign/shell/campaign-logo.tsx',
'src/components/socialIcons.tsx',
'src/components/socialLinks.tsx',
'src/components/ui/breadcrumb.tsx',
'src/components/ui/button.tsx',
'src/components/ui/card.tsx',
'src/components/ui/combobox.tsx',
'src/components/ui/dialog.tsx',
'src/components/ui/field.tsx',
'src/components/ui/input-group.tsx',
'src/components/ui/input.tsx',
'src/components/ui/label.tsx',
'src/components/ui/native-select.tsx',
'src/components/ui/separator.tsx',
'src/components/ui/skeleton.tsx',
'src/components/ui/textarea.tsx',
'src/components/ui/tooltip.tsx',
]
// Predate the inline-named-function default-export convention for framework
// files. Entries are ESLint glob patterns, so `[` / `]` are escaped.
export const legacyFrameworkExportIgnores = [
'src/app/(payload)/admin/\\[\\[...segments\\]\\]/not-found.tsx',
'src/app/(payload)/admin/\\[\\[...segments\\]\\]/page.tsx',
'src/app/(payload)/layout.tsx',
]
// Predate the arrow-function component convention.
export const legacyComponentSyntaxIgnores = [
'src/components/StateSelect.tsx',
'src/components/ThemeProvider.tsx',
]
// Predate the camelCase filename convention for non-component modules.
export const legacyCamelCaseFilenameIgnores = [
'src/lib/schemas/campaign-login.ts',
'src/lib/schemas/petition-form.ts',
'src/lib/schemas/whatsapp-form.ts',
'tests/int/petition-page-layout.int.spec.ts',
]

View file

@ -4,6 +4,12 @@ import checkFile from 'eslint-plugin-check-file'
import { globalIgnores } from 'eslint/config'
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import {
legacyCamelCaseFilenameIgnores,
legacyComponentFilenameIgnores,
legacyComponentSyntaxIgnores,
legacyFrameworkExportIgnores,
} from './eslint-legacy-ignores.mjs'
import { localRules } from './eslint-local-rules.mjs'
const __filename = fileURLToPath(import.meta.url)
@ -17,46 +23,25 @@ const frameworkComponentFiles = [
'src/app/**/{page,layout,loading,error,not-found,template,default,global-error}.tsx',
]
// These predate the naming convention. Keep them linted by Next's rules while
// requiring new component files to use PascalCase.
const legacyComponentFilenameIgnores = [
'src/components/campaign/shell/campaign-logo.tsx',
'src/components/socialIcons.tsx',
'src/components/socialLinks.tsx',
'src/components/ui/breadcrumb.tsx',
'src/components/ui/button.tsx',
'src/components/ui/card.tsx',
'src/components/ui/combobox.tsx',
'src/components/ui/dialog.tsx',
'src/components/ui/field.tsx',
'src/components/ui/input-group.tsx',
'src/components/ui/input.tsx',
'src/components/ui/label.tsx',
'src/components/ui/native-select.tsx',
'src/components/ui/select.tsx',
'src/components/ui/separator.tsx',
'src/components/ui/skeleton.tsx',
'src/components/ui/textarea.tsx',
'src/components/ui/tooltip.tsx',
]
// `as never` silences every type check on the expression (worse than `any`).
// Fix the types instead; a justified eslint-disable comment is the only
// sanctioned escape hatch.
const noAsNeverCast = {
selector: 'TSAsExpression > TSNeverKeyword',
message:
'Do not cast with `as never` — it disables type checking entirely. Fix the types or use a narrowly-typed helper.',
}
const legacyFrameworkExportIgnores = [
'src/app/(payload)/admin/\\[\\[...segments\\]\\]/not-found.tsx',
'src/app/(payload)/admin/\\[\\[...segments\\]\\]/page.tsx',
'src/app/(payload)/layout.tsx',
]
const legacyComponentSyntaxIgnores = [
'src/components/StateSelect.tsx',
'src/components/ThemeProvider.tsx',
]
const legacyCamelCaseFilenameIgnores = [
'src/lib/schemas/campaign-login.ts',
'src/lib/schemas/petition-form.ts',
'src/lib/schemas/whatsapp-form.ts',
'tests/int/petition-page-layout.int.spec.ts',
]
// Consent documents are resolved by stable key, never by hardcoded id (ids
// differ per environment and the lookup must fail closed). History:
// `consent: 2` shipped in submitWhatsapp and needed a data migration
// (20260725_170000) to untangle. Source-only: tests legitimately forge
// numeric consent ids to prove schemas strip mass-assigned fields.
const noHardcodedConsentId = {
selector: "Property[key.name='consent'][value.type='Literal'][value.raw=/^[0-9]+$/]",
message:
'Never hardcode a Consent document id — resolve it by stable key via requireConsentByKey (keys in src/lib/campaignConsentKeys.ts).',
}
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
@ -132,13 +117,30 @@ const eslintConfig = [
},
},
{
// Catch Next.js invalid-use-server-value before `next build` (Vercel).
files: ['src/**/*.{ts,tsx}'],
plugins: {
local: localRules,
},
// src/lib is the pure, client-safe layer (docs/ARCHITECTURE.md): it must
// never depend on Payload/Next server code, on utilities/, or on higher
// layers. Sharing types across the boundary is always fine.
files: ['src/lib/**/*.{ts,tsx}'],
rules: {
'local/use-server-async-exports': 'error',
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/utilities/**', '@/components/**', '@/app/**'],
message:
'src/lib is the pure layer — it must not depend on utilities/components/app. Move the pure logic into lib/ or this module into utilities/.',
allowTypeImports: true,
},
{
group: ['payload', 'payload/**', '@payload-config', 'next', 'next/**'],
message:
'src/lib must stay Payload/Next-free. Server-coupled code lives in src/utilities (marked server-only).',
allowTypeImports: true,
},
],
},
],
},
},
{
@ -155,17 +157,21 @@ const eslintConfig = [
varsIgnorePattern: '^_',
},
],
// `as never` silences every type check on the expression (worse than
// `any`). Fix the types instead; a justified eslint-disable comment is
// the only sanctioned escape hatch.
'no-restricted-syntax': [
'error',
{
selector: 'TSAsExpression > TSNeverKeyword',
message:
'Do not cast with `as never` — it disables type checking entirely. Fix the types or use a narrowly-typed helper.',
},
],
'no-restricted-syntax': ['error', noAsNeverCast],
},
},
{
// Catch Next.js invalid-use-server-value before `next build` (Vercel).
// MUST come after the global no-restricted-syntax block: for the same
// rule, the last matching config object wins wholesale, so this src-only
// widening (as-never + consent-id ban) would otherwise be overridden.
files: ['src/**/*.{ts,tsx}'],
plugins: {
local: localRules,
},
rules: {
'local/use-server-async-exports': 'error',
'no-restricted-syntax': ['error', noAsNeverCast, noHardcodedConsentId],
},
},
{

View file

@ -1,7 +1,7 @@
import { PostCard } from '@/components/PostCard'
import { Media } from '@/payload-types'
import { getCachedDocumentById } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globals'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { getTypePath, getVisiblePosts } from '@/utilities/posts'
import Image from 'next/image'
import Link from 'next/link'

View file

@ -2,9 +2,9 @@ import type { Crumb } from '@/components/SiteHeader'
import { SiteHeader } from '@/components/SiteHeader'
import { Badge } from '@/components/ui/Badge'
import type { Media, Post } from '@/payload-types'
import { getCachedDocumentById } from '@/utilities/documents'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical'
import { getCachedGlobal } from '@/utilities/globals'
import { getCachedGlobal } from '@/utilities/globalReads'
import {
POST_TYPE_LABELS,
formatPostDate,

View file

@ -2,7 +2,7 @@ import { PostCard } from '@/components/PostCard'
import { SiteHeader } from '@/components/SiteHeader'
import { Badge } from '@/components/ui/Badge'
import { Separator } from '@/components/ui/separator'
import { getCachedGlobal } from '@/utilities/globals'
import { getCachedGlobal } from '@/utilities/globalReads'
import {
POST_TYPE_LABELS,
getCategoryName,

View file

@ -2,7 +2,7 @@ import { PostCard } from '@/components/PostCard'
import { SiteHeader } from '@/components/SiteHeader'
import { Badge } from '@/components/ui/Badge'
import { Separator } from '@/components/ui/separator'
import { getCachedGlobal } from '@/utilities/globals'
import { getCachedGlobal } from '@/utilities/globalReads'
import { POST_TYPE_LABELS, getVisiblePosts, isPostType } from '@/utilities/posts'
import { stripTrailingSlash } from '@/utilities/seo'
import type { Metadata } from 'next'

View file

@ -4,9 +4,9 @@ import { PetitionForm } from '@/components/PetitionForm'
import { SignatureCounter } from '@/components/SignatureCounter'
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
import type { Media } from '@/payload-types'
import { getCachedDocumentById, getPetitionIds } from '@/utilities/documents'
import { getCachedDocumentById, getPetitionIds } from '@/utilities/documentReads'
import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical'
import { getCachedGlobal } from '@/utilities/globals'
import { getCachedGlobal } from '@/utilities/globalReads'
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
import type { Metadata } from 'next'
import Link from 'next/link'

View file

@ -1,7 +1,7 @@
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave'
import { ThemeProvider } from '@/components/ThemeProvider'
import { getCachedDocumentById } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globals'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { Analytics } from '@vercel/analytics/next'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Metadata } from 'next'

View file

@ -1,4 +1,4 @@
import { getCachedGlobal } from '@/utilities/globals'
import { getCachedGlobal } from '@/utilities/globalReads'
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'

View file

@ -6,7 +6,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { getCachedGlobal } from '@/utilities/globals'
import { getCachedGlobal } from '@/utilities/globalReads'
import Link from 'next/link'
import { Fragment } from 'react'
import { SocialLinks } from './socialLinks'

View file

@ -1,5 +1,5 @@
import { cn } from '@/lib/utils'
import { getCachedGlobal } from '@/utilities/globals'
import { getCachedGlobal } from '@/utilities/globalReads'
import Link from 'next/link'
import { FacebookIcon, GlobeIcon, InstagramIcon, WhatsAppIcon, YoutubeIcon } from './socialIcons'

View file

@ -54,8 +54,8 @@ const consentFooter = `Mais informações: ${contactChannel} Consulte também a
const consentDefinitions: Record<Onda0ConsentKey, string[]> = {
[ONDA0_CONSENT_KEYS.leadershipAutofill]: [
ONDA0_PROVISIONAL_BANNER,
'Finalidade: registrar e atualizar seus dados de contato (nome, telefone, e-mail, setor) para participação como liderança de campanha vinculada às Praças em que atua, incluindo acesso à área interna /campanha quando aplicável.',
'Dados tratados: nome, telefone, e-mail, setor e vínculo com as Praças e organizações em que atua. Não coletamos dados sensíveis neste fluxo.',
'Finalidade: registrar e atualizar seus dados de contato (nome, telefone, e-mail, setor) para participação como liderança de campanha vinculada aos Municípios em que atua, incluindo acesso à área interna /campanha quando aplicável.',
'Dados tratados: nome, telefone, e-mail, setor e vínculo com os Municípios e organizações em que atua. Não coletamos dados sensíveis neste fluxo.',
'Base legal: consentimento do titular (art. 7º, I, e art. 8º da Lei nº 13.709/2018 — LGPD).',
'Compartilhamento: equipe autorizada da campanha e prestadores de hospedagem/processamento necessários à operação da plataforma, sem comercialização dos dados.',
'Prazo de retenção: enquanto durar o vínculo de liderança e pelos prazos legais aplicáveis após o encerramento.',
@ -63,7 +63,7 @@ const consentDefinitions: Record<Onda0ConsentKey, string[]> = {
],
[ONDA0_CONSENT_KEYS.supporterRegistration]: [
ONDA0_PROVISIONAL_BANNER,
'Finalidade: registrar seu apoio declarado à campanha de Jorge Solla, permitindo contato organizado pela equipe de campanha e eventual vinculação a uma Praça.',
'Finalidade: registrar seu apoio declarado à campanha de Jorge Solla, permitindo contato organizado pela equipe de campanha e eventual vinculação a um Município.',
'Dados tratados: nome, telefone, e-mail (quando informado), município/território e observações operacionais inseridas pela equipe.',
'Base legal: consentimento do titular (art. 7º, I, e art. 8º da LGPD).',
'Compartilhamento: equipe autorizada da campanha; não há venda ou cessão a terceiros para fins comerciais.',
@ -99,7 +99,7 @@ export const ONDA0_PRIVACY_POLICY_BODY: LexicalRoot = buildLexicalFromParagraphs
ONDA0_PROVISIONAL_BANNER,
'Esta Política de Privacidade descreve como a campanha de Jorge Solla (controlador) trata dados pessoais por meio do site público e da ferramenta interna /campanha (plataforma Teqo).',
'Dados que podemos tratar: identificação e contato (nome, telefone, e-mail), dados territoriais de apoio, registros de consentimento, dados operacionais de campanha e, quando separadamente consentido, intenção de voto (dado sensível — art. 11 da LGPD).',
'Finalidades: organização das Praças da campanha, cadastro de apoiadores, comunicação com lideranças e equipe, operação de agenda e inteligência eleitoral interna, sempre conforme a finalidade informada no momento da coleta.',
'Finalidades: organização dos Municípios da campanha, cadastro de apoiadores, comunicação com lideranças e equipe, operação de agenda e inteligência eleitoral interna, sempre conforme a finalidade informada no momento da coleta.',
'Bases legais: consentimento (arts. 7º e 8º da LGPD) e, para dado sensível de intenção de voto, consentimento específico e destacado (art. 11, I).',
'Compartilhamento: não vendemos dados. Prestadores de tecnologia (hospedagem, banco de dados) atuam como operadores sob nossas instruções.',
'Direitos do titular (art. 18 LGPD): confirmação de tratamento, acesso, correção, anonimização, portabilidade, eliminação e revogação de consentimento, mediante solicitação.',

View file

@ -0,0 +1,49 @@
import 'server-only'
import type { Config } from '@/payload-types'
import { getDocumentCacheTag } from '@/utilities/documents'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
// Config-dependent cached document reads, kept separate from `documents.ts`
// (the tag/revalidation contract collections import) so the module graph
// stays acyclic — see the note there.
type Collection = keyof Config['collections']
/** Bare petition ids (no relationship population) — for `generateStaticParams`. */
export const getPetitionIds = async (): Promise<string[]> => {
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'petition',
depth: 0,
limit: 0,
pagination: false,
select: {},
})
return result.docs.map((doc) => String(doc.id))
}
const getDocumentById = <Slug extends Collection>(
collection: Slug,
id: string | number,
depth?: number,
) =>
getPayload({ config: configPromise }).then((payload) =>
payload.findByID({ collection, id, depth }),
)
export const getCachedDocumentById = <Slug extends Collection>(
collection: Slug,
id: string | number,
depth?: number,
) =>
unstable_cache(
() => getDocumentById(collection, id, depth),
[getDocumentCacheTag(collection, id)],
{
tags: [getDocumentCacheTag(collection, id)],
},
)

View file

@ -2,50 +2,25 @@ import 'server-only'
import type { Config } from '@/payload-types'
import configPromise from '@payload-config'
import { revalidateTag, unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
import { revalidateTag } from 'next/cache'
// Tag vocabulary + revalidation helpers only. This module is imported by
// collections (afterChange hooks), which payload.config.ts aggregates — so it
// must NOT import `@payload-config`, or the module graph becomes circular
// (payload.config → collection → here → payload.config). Config-dependent
// cached reads live in `documentReads.ts`.
type Collection = keyof Config['collections']
/** Bare petition ids (no relationship population) — for `generateStaticParams`. */
export const getPetitionIds = async (): Promise<string[]> => {
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'petition',
depth: 0,
limit: 0,
pagination: false,
select: {},
})
return result.docs.map((doc) => String(doc.id))
}
const getDocumentById = <Slug extends Collection>(
export const getDocumentCacheTag = <Slug extends Collection>(
collection: Slug,
id: string | number,
depth?: number,
) =>
getPayload({ config: configPromise }).then((payload) =>
payload.findByID({ collection, id, depth }),
)
const getTag = <Slug extends Collection>(collection: Slug, id: string | number) =>
`document_${collection}:${id}`
export const getCachedDocumentById = <Slug extends Collection>(
collection: Slug,
id: string | number,
depth?: number,
) =>
unstable_cache(() => getDocumentById(collection, id, depth), [getTag(collection, id)], {
tags: [getTag(collection, id)],
})
) => `document_${collection}:${id}`
export const revalidateDocumentById = <Slug extends Collection>(
collection: Slug,
id: string | number,
) => revalidateTag(getTag(collection, id))
) => revalidateTag(getDocumentCacheTag(collection, id))
const getListingTag = <Slug extends Collection>(collection: Slug) => `${collection}s`

View file

@ -0,0 +1,42 @@
import 'server-only'
import type { Config } from '@/payload-types'
import { getGlobalCacheTag } from '@/utilities/globals'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
// Config-dependent cached global reads, kept separate from `globals.ts` (the
// tag/revalidation contract global hooks import) so the module graph stays
// acyclic — see the note in `documents.ts`.
type Global = keyof Config['globals']
async function getGlobal<Slug extends Global>(slug: Slug, depth?: number) {
const payload = await getPayload({ config: configPromise })
const global = await payload.findGlobal({
slug,
depth,
})
return global
}
type CachedGlobalFactory<Slug extends Global = Global> = () => Promise<Config['globals'][Slug]>
const cachedGlobalFactories = new Map<string, CachedGlobalFactory>()
export const getCachedGlobal = <Slug extends Global>(slug: Slug, depth?: number) => {
const tag = getGlobalCacheTag(slug)
const cacheKey = `${tag}:${depth ?? 0}`
const existing = cachedGlobalFactories.get(cacheKey) as CachedGlobalFactory<Slug> | undefined
if (existing) return existing
const factory = unstable_cache(async () => getGlobal(slug, depth), [cacheKey], {
tags: [tag],
}) as CachedGlobalFactory<Slug>
cachedGlobalFactories.set(cacheKey, factory as CachedGlobalFactory)
return factory
}

View file

@ -2,41 +2,16 @@ import 'server-only'
import type { Config } from '@/payload-types'
import configPromise from '@payload-config'
import { revalidateTag, unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
import { revalidateTag } from 'next/cache'
// Tag vocabulary + revalidation helper only. Imported by globals'
// afterChange hooks (aggregated by payload.config.ts), so it must NOT import
// `@payload-config` — see the cycle note in `documents.ts`. Config-dependent
// cached reads live in `globalReads.ts`.
type Global = keyof Config['globals']
async function getGlobal<Slug extends Global>(slug: Slug, depth?: number) {
const payload = await getPayload({ config: configPromise })
const global = await payload.findGlobal({
slug,
depth,
})
return global
}
export const getGlobalCacheTag = (slug: string) => `global_${slug}`
type CachedGlobalFactory<Slug extends Global = Global> = () => Promise<Config['globals'][Slug]>
const cachedGlobalFactories = new Map<string, CachedGlobalFactory>()
export const getCachedGlobal = <Slug extends Global>(slug: Slug, depth?: number) => {
const tag = getGlobalCacheTag(slug)
const cacheKey = `${tag}:${depth ?? 0}`
const existing = cachedGlobalFactories.get(cacheKey) as CachedGlobalFactory<Slug> | undefined
if (existing) return existing
const factory = unstable_cache(async () => getGlobal(slug, depth), [cacheKey], {
tags: [tag],
}) as CachedGlobalFactory<Slug>
cachedGlobalFactories.set(cacheKey, factory as CachedGlobalFactory)
return factory
}
export const revalidateGlobal = <Slug extends Global>(slug: Slug) =>
revalidateTag(getGlobalCacheTag(slug))

View file

@ -378,6 +378,24 @@ export class CampaignFixtures {
return value
}
/**
* Schema-valid unique person name. The contact/leadership name schemas
* accept letters only (with single spaces/hyphens between words), so the
* regular `value()` marker — uuid digits plus hyphens glued to them —
* fails zod validation when a test feeds it through a real action. Digits
* are transliterated to letters to keep per-run uniqueness, and the name
* registers as a marker so cleanup sweeps still recognize the row.
*/
personName(prefix: string): string {
this.counter += 1
const letterized = `${this.runID}${this.counter}`
.replaceAll('-', '')
.replaceAll(/[0-9]/g, (digit) => 'abcdefghij'[Number(digit)]!)
const name = `${prefix} ${letterized}`
this.markers.add(name)
return name
}
phone(): string {
const digest = createHash('sha256').update(this.value('phone')).digest('hex')
const subscriber = (BigInt(`0x${digest.slice(0, 12)}`) % 90_000_000n) + 10_000_000n

View file

@ -328,45 +328,37 @@ describe('loadMunicipalityListPageBundle', () => {
expect(conflicting.filterFacets.slugs).toContain(outsideRegion.slug)
})
// Two full-scope bundles back to back; since B16+ the facet reads join the
// page's main Promise.all, so under the 47-way parallel suite this test
// contends for the pool harder than any other — give it headroom beyond the
// 5s default (same convention as the ledgered e2e latency budgets).
it(
'keeps the options of filters that share a popover with the applied one',
{ timeout: 15_000 },
async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const priorityMunicipality = await fixtures.getMunicipality()
await payload.update({
collection: 'municipality',
id: priorityMunicipality.id,
data: { priority: 'alta' },
overrideAccess: true,
})
const covered = await fixtures.getMunicipality(
municipalityCatalog.find((entry) => entry.slug !== priorityMunicipality.slug)!.slug,
)
await fixtures.assignMunicipalityAdvisors(covered.id, [advisor.id])
it('keeps the options of filters that share a popover with the applied one', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const priorityMunicipality = await fixtures.getMunicipality()
await payload.update({
collection: 'municipality',
id: priorityMunicipality.id,
data: { priority: 'alta' },
overrideAccess: true,
})
const covered = await fixtures.getMunicipality(
municipalityCatalog.find((entry) => entry.slug !== priorityMunicipality.slug)!.slug,
)
await fixtures.assignMunicipalityAdvisors(covered.id, [advisor.id])
// "Prioritária" lives in the Município popover, so it must not hide the
// municipalities that popover is there to offer.
const byPriority = await loadMunicipalityListPageBundle(payload, coordinator, {
priority: 'alta',
})
expect(byPriority.municipalities.every((row) => row.priority === 'alta')).toBe(true)
expect(byPriority.filterFacets.slugs).toContain(covered.slug)
// "Prioritária" lives in the Município popover, so it must not hide the
// municipalities that popover is there to offer.
const byPriority = await loadMunicipalityListPageBundle(payload, coordinator, {
priority: 'alta',
})
expect(byPriority.municipalities.every((row) => row.priority === 'alta')).toBe(true)
expect(byPriority.filterFacets.slugs).toContain(covered.slug)
// Same for the "Sem assessor" toggle inside the Assessores popover: it would
// otherwise leave that popover with nobody to pick.
const withoutAdvisor = await loadMunicipalityListPageBundle(payload, coordinator, {
coverage: 'sem_assessor',
})
expect(withoutAdvisor.filterFacets.advisorIDs).toContain(advisor.id)
},
)
// Same for the "Sem assessor" toggle inside the Assessores popover: it would
// otherwise leave that popover with nobody to pick.
const withoutAdvisor = await loadMunicipalityListPageBundle(payload, coordinator, {
coverage: 'sem_assessor',
})
expect(withoutAdvisor.filterFacets.advisorIDs).toContain(advisor.id)
})
it('returns an empty bundle for leaders (municipality list lockdown)', async () => {
const fixtures = campaignFixtures()

View file

@ -44,7 +44,7 @@ describe('submitWhatsapp (consent by stable key)', () => {
await expect(
submitWhatsapp({
name: 'Contato Whatsapp',
name: campaignFixtures().personName('Contato'),
email: `${campaignFixtures().value('whats')}@example.com`,
phone: campaignFixtures().phone(),
state: 'BA',
@ -59,7 +59,7 @@ describe('submitWhatsapp (consent by stable key)', () => {
const phone = fixtures.phone()
const result = await submitWhatsapp({
name: 'Contato Whatsapp',
name: fixtures.personName('Contato'),
email: `${fixtures.value('whats')}@example.com`,
phone,
state: 'BA',

View file

@ -0,0 +1,125 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
legacyCamelCaseFilenameIgnores,
legacyComponentFilenameIgnores,
legacyComponentSyntaxIgnores,
legacyFrameworkExportIgnores,
} from '../../eslint-legacy-ignores.mjs'
// Programmatic guards for conventions that used to be enforced only by prose
// (codebase-map.mdc / docs/ARCHITECTURE.md) and drifted during Pass 2.
const repoRoot = process.cwd()
const walkSourceFiles = (root: string, extensions: readonly string[]): string[] =>
readdirSync(root, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && extensions.some((ext) => entry.name.endsWith(ext)))
.map((entry) => resolve(entry.parentPath, entry.name))
const repoPath = (absolute: string) => relative(repoRoot, absolute)
describe('server-only boundary in src/utilities', () => {
// Files that value-import Payload run server code; if they ever land in a
// client bundle the failure must be a build error, not a runtime surprise.
// W2 marked 21 loaders by hand — this pins the rule for every new module.
// Deliberate exceptions go here, each with a justification:
const allowlist = new Set<string>([])
const isTypeOnlyClause = (clause: string): boolean => {
if (/^type\b/.test(clause.trim())) return true
const inner = clause.trim().match(/^\{([\s\S]*)\}$/)?.[1]
if (inner === undefined) return false
return inner
.split(',')
.map((specifier) => specifier.trim())
.filter(Boolean)
.every((specifier) => specifier.startsWith('type '))
}
it('marks every Payload-coupled utilities module with import server-only', () => {
const offenders: string[] = []
for (const file of walkSourceFiles(resolve(repoRoot, 'src/utilities'), ['.ts', '.tsx'])) {
const source = readFileSync(file, 'utf8')
// Side-effect imports (`import 'x'`) never match: the clause group
// cannot contain quotes, so it cannot swallow a bare specifier.
const importsPayloadValues = [
...source.matchAll(/^import\s+([^'"]+?)\s+from\s+['"](payload|@payload-config)['"]/gm),
].some(([, clause]) => !isTypeOnlyClause(clause!))
if (!importsPayloadValues) continue
if (/^import 'server-only'/m.test(source)) continue
if (allowlist.has(repoPath(file))) continue
offenders.push(repoPath(file))
}
expect(offenders, "add `import 'server-only'` (or a justified allowlist entry)").toEqual([])
})
})
describe('banned campaign terminology', () => {
// "Praça"/"Núcleo" were replaced by "Município" (2026-07-23 remodel; W4f
// swept the copy). The manual sweep still missed 4 consent drafts — this
// keeps the invariant self-enforcing. Migrations are frozen history and
// stay out of scope. Allowlisted files carry legitimate data, not copy:
const allowlist = new Set<string>([
'src/lib/cities.ts', // real locality names (e.g. "Núcleo Bandeirante" — DF)
])
it('keeps Praça/Núcleo out of src (data catalogs allowlisted)', () => {
const offenders: string[] = []
for (const file of walkSourceFiles(resolve(repoRoot, 'src'), ['.ts', '.tsx'])) {
const path = repoPath(file)
if (path.startsWith('src/migrations/')) continue
if (allowlist.has(path)) continue
const lines = readFileSync(file, 'utf8').split('\n')
for (const [index, line] of lines.entries()) {
if (/praç|núcleo/i.test(line)) offenders.push(`${path}:${index + 1}`)
}
}
expect(offenders, 'use "Município" (or allowlist genuine data)').toEqual([])
})
})
describe('eslint legacy ignore lists', () => {
// ESLint never validates ignore paths, so a file move silently orphans its
// exemption (campaign-logo.tsx broke this way during Pass 2 W2).
const unescapeGlob = (pattern: string) => pattern.replaceAll('\\[', '[').replaceAll('\\]', ']')
it.each([
['legacyComponentFilenameIgnores', legacyComponentFilenameIgnores],
['legacyFrameworkExportIgnores', legacyFrameworkExportIgnores],
['legacyComponentSyntaxIgnores', legacyComponentSyntaxIgnores],
['legacyCamelCaseFilenameIgnores', legacyCamelCaseFilenameIgnores],
])('every %s entry still exists on disk', (_name, entries) => {
const missing = entries.filter((entry) => !existsSync(resolve(repoRoot, unescapeGlob(entry))))
expect(missing, 'file moved or deleted — update eslint-legacy-ignores.mjs').toEqual([])
})
})
describe('campaign formActions convention', () => {
// Every route-level formActions.ts goes through the shared wrappers
// (runCampaignFormAction / runCampaignRedirectFormAction) so error mapping
// and revalidation cannot drift per route (Pass 2 W4d). A file that truly
// cannot use them gets an allowlist entry documenting why:
const allowlist = new Set<string>([])
it('routes every formActions.ts through a shared wrapper', () => {
const offenders = walkSourceFiles(resolve(repoRoot, 'src/app/(campaign)'), ['.ts'])
.filter((file) => file.endsWith('/formActions.ts'))
.map(repoPath)
.filter((path) => !allowlist.has(path))
.filter(
(path) =>
!/runCampaign(Redirect)?FormAction/.test(readFileSync(resolve(repoRoot, path), 'utf8')),
)
expect(offenders, 'wrap with runCampaignFormAction/runCampaignRedirectFormAction').toEqual([])
})
})

View file

@ -15,5 +15,12 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['tests/int/**/*.int.spec.ts'],
// Int specs hit a real Postgres under a ~48-way parallel suite, so wall
// time is dominated by pool contention, not by the code under test. The
// 5s default made legitimately heavy RSC-composition tests flake as the
// suite grew (municipalityPageData was the first); one global budget
// beats per-test overrides. Genuinely hung tests still fail — just 10s
// later. The unit suite keeps the 5s default.
testTimeout: 15_000,
},
})