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:
parent
e6f7c2e70f
commit
4bbabb7d5e
21 changed files with 415 additions and 178 deletions
48
eslint-legacy-ignores.mjs
Normal file
48
eslint-legacy-ignores.mjs
Normal 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',
|
||||
]
|
||||
|
|
@ -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],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
49
src/utilities/documentReads.ts
Normal file
49
src/utilities/documentReads.ts
Normal 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)],
|
||||
},
|
||||
)
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
42
src/utilities/globalReads.ts
Normal file
42
src/utilities/globalReads.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
125
tests/unit/codebaseConventions.unit.spec.ts
Normal file
125
tests/unit/codebaseConventions.unit.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue