diff --git a/eslint-legacy-ignores.mjs b/eslint-legacy-ignores.mjs new file mode 100644 index 00000000..8646416e --- /dev/null +++ b/eslint-legacy-ignores.mjs @@ -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', +] diff --git a/eslint.config.mjs b/eslint.config.mjs index 865cb819..d392d5da 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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], }, }, { diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx index 4a58c7b3..205a212d 100644 --- a/src/app/(frontend)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -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' diff --git a/src/app/(frontend)/[type]/[category]/[slug]/page.tsx b/src/app/(frontend)/[type]/[category]/[slug]/page.tsx index da2f3fc9..6affbb18 100644 --- a/src/app/(frontend)/[type]/[category]/[slug]/page.tsx +++ b/src/app/(frontend)/[type]/[category]/[slug]/page.tsx @@ -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, diff --git a/src/app/(frontend)/[type]/[category]/page.tsx b/src/app/(frontend)/[type]/[category]/page.tsx index ed08ba9a..b577e879 100644 --- a/src/app/(frontend)/[type]/[category]/page.tsx +++ b/src/app/(frontend)/[type]/[category]/page.tsx @@ -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, diff --git a/src/app/(frontend)/[type]/page.tsx b/src/app/(frontend)/[type]/page.tsx index 2f1fc353..909fd6c7 100644 --- a/src/app/(frontend)/[type]/page.tsx +++ b/src/app/(frontend)/[type]/page.tsx @@ -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' diff --git a/src/app/(frontend)/abaixo-assinado/[id]/page.tsx b/src/app/(frontend)/abaixo-assinado/[id]/page.tsx index 77d47e4c..1d94b6bc 100644 --- a/src/app/(frontend)/abaixo-assinado/[id]/page.tsx +++ b/src/app/(frontend)/abaixo-assinado/[id]/page.tsx @@ -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' diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index 5326abb9..ab743110 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -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' diff --git a/src/app/(frontend)/privacidade/page.tsx b/src/app/(frontend)/privacidade/page.tsx index 6aed56fa..b19e5670 100644 --- a/src/app/(frontend)/privacidade/page.tsx +++ b/src/app/(frontend)/privacidade/page.tsx @@ -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' diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index 6926dd70..c63500fb 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -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' diff --git a/src/components/socialLinks.tsx b/src/components/socialLinks.tsx index 64accc5c..671ab5b3 100644 --- a/src/components/socialLinks.tsx +++ b/src/components/socialLinks.tsx @@ -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' diff --git a/src/lib/onda0ConsentTexts.ts b/src/lib/onda0ConsentTexts.ts index d7abe46b..db7a89a4 100644 --- a/src/lib/onda0ConsentTexts.ts +++ b/src/lib/onda0ConsentTexts.ts @@ -54,8 +54,8 @@ const consentFooter = `Mais informações: ${contactChannel} Consulte também a const consentDefinitions: Record = { [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 = { ], [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.', diff --git a/src/utilities/documentReads.ts b/src/utilities/documentReads.ts new file mode 100644 index 00000000..7f6084d0 --- /dev/null +++ b/src/utilities/documentReads.ts @@ -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 => { + 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 = ( + collection: Slug, + id: string | number, + depth?: number, +) => + getPayload({ config: configPromise }).then((payload) => + payload.findByID({ collection, id, depth }), + ) + +export const getCachedDocumentById = ( + collection: Slug, + id: string | number, + depth?: number, +) => + unstable_cache( + () => getDocumentById(collection, id, depth), + [getDocumentCacheTag(collection, id)], + { + tags: [getDocumentCacheTag(collection, id)], + }, + ) diff --git a/src/utilities/documents.ts b/src/utilities/documents.ts index 1c7e8da4..4e4b4922 100644 --- a/src/utilities/documents.ts +++ b/src/utilities/documents.ts @@ -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 => { - 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 = ( +export const getDocumentCacheTag = ( collection: Slug, id: string | number, - depth?: number, -) => - getPayload({ config: configPromise }).then((payload) => - payload.findByID({ collection, id, depth }), - ) - -const getTag = (collection: Slug, id: string | number) => - `document_${collection}:${id}` - -export const getCachedDocumentById = ( - 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 = ( collection: Slug, id: string | number, -) => revalidateTag(getTag(collection, id)) +) => revalidateTag(getDocumentCacheTag(collection, id)) const getListingTag = (collection: Slug) => `${collection}s` diff --git a/src/utilities/globalReads.ts b/src/utilities/globalReads.ts new file mode 100644 index 00000000..d669785d --- /dev/null +++ b/src/utilities/globalReads.ts @@ -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: Slug, depth?: number) { + const payload = await getPayload({ config: configPromise }) + + const global = await payload.findGlobal({ + slug, + depth, + }) + + return global +} + +type CachedGlobalFactory = () => Promise + +const cachedGlobalFactories = new Map() + +export const getCachedGlobal = (slug: Slug, depth?: number) => { + const tag = getGlobalCacheTag(slug) + const cacheKey = `${tag}:${depth ?? 0}` + const existing = cachedGlobalFactories.get(cacheKey) as CachedGlobalFactory | undefined + if (existing) return existing + + const factory = unstable_cache(async () => getGlobal(slug, depth), [cacheKey], { + tags: [tag], + }) as CachedGlobalFactory + cachedGlobalFactories.set(cacheKey, factory as CachedGlobalFactory) + return factory +} diff --git a/src/utilities/globals.ts b/src/utilities/globals.ts index c10baf63..7db9868b 100644 --- a/src/utilities/globals.ts +++ b/src/utilities/globals.ts @@ -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: 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 = () => Promise - -const cachedGlobalFactories = new Map() - -export const getCachedGlobal = (slug: Slug, depth?: number) => { - const tag = getGlobalCacheTag(slug) - const cacheKey = `${tag}:${depth ?? 0}` - const existing = cachedGlobalFactories.get(cacheKey) as CachedGlobalFactory | undefined - if (existing) return existing - - const factory = unstable_cache(async () => getGlobal(slug, depth), [cacheKey], { - tags: [tag], - }) as CachedGlobalFactory - cachedGlobalFactories.set(cacheKey, factory as CachedGlobalFactory) - return factory -} - export const revalidateGlobal = (slug: Slug) => revalidateTag(getGlobalCacheTag(slug)) diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 6bc3834d..15abcae8 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -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 diff --git a/tests/int/municipalityPageData.int.spec.ts b/tests/int/municipalityPageData.int.spec.ts index 0824b152..8f4f1141 100644 --- a/tests/int/municipalityPageData.int.spec.ts +++ b/tests/int/municipalityPageData.int.spec.ts @@ -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() diff --git a/tests/int/submitWhatsapp.int.spec.ts b/tests/int/submitWhatsapp.int.spec.ts index ecab370a..d28a2368 100644 --- a/tests/int/submitWhatsapp.int.spec.ts +++ b/tests/int/submitWhatsapp.int.spec.ts @@ -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', diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts new file mode 100644 index 00000000..df8a6230 --- /dev/null +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -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([]) + + 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([ + '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([]) + + 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([]) + }) +}) diff --git a/vitest.config.mts b/vitest.config.mts index c1161888..2915ee18 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -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, }, })