diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index d2091251..956ecce0 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **E1 — extração do hook compartilhado de busca assíncrona com debounce (2026-08-23):** a 3ª cópia do efeito de busca async (requestId + `setTimeout(250)` + loading/failed) — introduzida pela C143 em `DemandResponsibleMultiSelect` (demand), e presente em `ResponsibleMultiSelect` (C90) e `AsyncSearchCombobox` — vira o hook `useAsyncSearchOptions({ open, query, search, isQueryReady? }) → { options, loading, failed }` (`src/hooks/useAsyncSearchOptions.ts`, novo; constante `ASYNC_SEARCH_DEBOUNCE_MS` exportada). Os 3 componentes consomem o hook e mantêm só o que é seu (grupos por typeLabel, avatares, headings de sugestões do município, `pinnedOptions`, filtro de selecionados, mensagens de vazio); semântica de query vazia preservada por consumidor (Demand busca sempre → sugestões; ResponsibleMultiSelect reseta via `isContactSearchQueryReady`; combobox com gate configurável — o efeito sync `selected←value` fica no componente). Fix pontual: server actions intocadas (continuam props — hook client-safe por injeção), sem schema/migration/access/Consent. Testes: spec unit novo `tests/unit/useAsyncSearchOptions.unit.spec.ts` (renderHook + fake timers, 9 casos: debounce/trim, coalesce, gate reset, closed, failed + retry, in-flight loading, out-of-order, quirk gate→not-ready com resposta stale pinado — comportamento herdado dos 3 originais, registrado na decisão D3 do impl plan). Impl plan `docs/plans/extrair-hook-busca-assincrona-debounce-impl.md`. Verificação: unit 2428 + int 815 verdes, e2e activity/demand (campaignActivity, campaignRegisterDemand, campaignDemandVisibility) verdes em re-run (1 flake de cold-compile pré-existente isolado), tsc/lint/format/cycles/build verdes; knip com o erro de load pré-existente (exit 0); fora de escopo: `WizardMunicipalitySearchStep` (4ª variante, AbortController — defer com gatilho no plano). +**OPS46 — infra de teste e2e: coleta local consertada (next/cache) + allocator de município sem colisão no wrap (2026-08-23):** entrega de dois débitos do OPS45 (capture-review-debts). **Fase 1 — e2e local não coleta:** `pnpm exec playwright test --list` falhava em todo spec com `Cannot find module 'node_modules/next/cache'` (e o diagnóstico correto do CI-vs-máquina: não há delta — o CI só roda pelo script `pnpm test:e2e`, que carrega `--conditions=react-server --import=tsx/esm` no `NODE_OPTIONS`; comando bare não tinha nada). Causa: `next@15` não declara `exports` → ESM nativo não resolve `next/cache` (o arquivo é `cache.js`), e `server-only` precisa da condição `react-server`. Fix no runner, zero mudança em produção: hook de resolução ESM vitest-free `tests/helpers/e2eEsmResolve.mjs` (mapeia `next/cache`→`next/cache.js` e injeta `react-server` nas conditions) registrada no processo main via `module.register` e propagada aos workers via `NODE_OPTIONS=--import=e2eEsmLoader.mjs` no `playwright.config.ts` — só quando os flags do script já não estão presentes. Aceite: bare `playwright test --list` coleta 198 testes em 41 arquivos (antes: 0); runs reais bare admin 5/5 e campaign 18/18; `pnpm test:e2e` intocado. **Fase 2 — allocator de município com wrap destrutivo:** a sequência persistente `campaign_fixture_municipality_alloc` nunca é resetada e um ciclo completo de suíte claima ~505 do catálogo de 435 (medido: 432 no int + 73 no e2e) → o wrap acontece em todo ciclo em DB persistente de worktree, e dois runs concorrentes podiam receber o mesmo índice e purgar as linhas vivas um do outro (purge-on-claim). Fix: registry de claims em módulo compartilhado vitest-free `tests/helpers/campaignMunicipalityAllocator.ts` — tabela `campaign_fixture_municipality_claims` (`index` PK, `run_id`, `claimed_at`), claim via `INSERT ON CONFLICT DO NOTHING RETURNING` (conflito = slot vivo → pula; claim stale >2h de run crashado → rouba; tudo ocupado → erro claro de exaustão), release no `cleanup()` dos dois fixtures (int + e2e, só quando claimaram). Guard estático novo no `testMunicipalityAllocatorConventions` (proíbe `nextval`/sequence fora do módulo) + spec int de stress `campaignMunicipalityAllocator.int.spec.ts` (exclusividade viva por 3 wraps, exaustão+recuperação, steal, release). Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente de não carregar payload.config, igual no HEAD), unit 2420 + int 819 (92 arquivos) verdes com tabela de claims zerada ao final, e2e local afetado (admin 5/5 + campaign 18/18 bare) verdes; importMap.js restaurado com envs `S3_*` dummy após o `--list` bare regenerá-lo sem elas (classe OPS69 — o guard do #87 cobre no CI). Impl plan: `docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md`. + **C142 — interface do /campanha respeita o perfil de permissão do assessor (2026-08-21):** a entrega fechada parte do C141 (que definiu os eixos `visibility`/`editing` no `campaignUser` e os gates server-side). Com o C142, a UI de todos os rascunhos staff do `/campanha` obedece de ponta a ponta o perfil de edição do assessor: com `editing = somente_leitura` não há nenhum controle de escrita em lugar nenhum (listas, detalhes, tabs, wizards); com `editing = tudo` o assessor enxerga/edita tudo o que vê; com `carteira` mantém o comportamento por carteira. Discriminação das entregas: (1) núcleo puro `src/lib/campaignAdvisorProfile.ts` com `advisorEditingScope()`/`rowEditingAllowed()` (retorna `none` para combinação incoerente `editing=tudo` + `visibility≠tudo`) e `gate: 'writable'` em `requireCampaignPageActor` (redireciona para `/campanha`); (2) todas as células compartilhadas (CampaignInlineEditableCell, MunicipalityRelationEditor, PhonesFieldEditor, DemandDescriptionEditor/Card/WorkflowCard, MunicipalityV2\*Cells, MunicipalityGoalAccountCard, ActivityTaskChecklist, SuggestionCard, MunicipalityPledgesPanel, etc.) ganharam o prop `readOnly`; (3) páginas staff — municípios (lista desktop+mobile, detalhe com tabs Overview/Updates/Leaderships/Suggestions, edição), pessoas (invite e criação gated), lideranças, dobradinhas, demandas, atividades, apoiadores, organizações — escondem os botões de criação (`Nova demanda`, `Nova atividade`, `Planejar giro`, `Novo`/`Importar CSV`, `Nova organização`, `Nova liderança`, `Nova dobradinha`) com `editingScope !== 'none'`; (4) home: `homeActionsForRole(role, editingScope)` filtra os 4 wizards para `somente_leitura` (sobra só o atalho de navegação); (5) `shouldMountQuickActionsFab` não monta o FAB para `somente_leitura`; (6) registry de quick actions (`resolveQuickActionsForPath`) recebe `editingScope` e filtra; (7) e2e novo `tests/e2e/campaignPermissionProfile.e2e.spec.ts` cobre os 3 perfis (somente_leitura sem controles, carteira com carteira, tudo com tudo) usando `createCampaignUser` com `visibility`/`editing`. Verificação: tsc/lint/format/knip/cycles/units (2419) + `gate:fast` verdes; PR em main com auto-merge (rebase); nada muda para coordinator/candidate/leader. **S10 — Pixel do Meta (Facebook) na página pública de campanha (2026-08-19):** o time de campanha paga pode configurar o ID numérico do Pixel (Dataset do Events Manager) no admin — global `SiteSettings` → group "Rastreamento / Ads" → "ID do Pixel do Meta (Facebook)", mesma validação/normalização do precedente dos abaixo-assinados (5–20 dígitos, sem HTML/JS; regras agora vivem num único dono em `facebookPixel.ts`, com o `Petition` refatorado para o helper compartilhado). Com o ID configurado, a home pública de campanha (`/`) carrega o base code + `PageView` do Meta (injeção no layout do grupo `(home)` via `next/script`, escopada só à página de campanha — nada de PageView vazando para notícias/etc.); sem ID, nada é carregado (fail-closed). A conclusão da captura de novidades (S9) dispara exatamente um `Lead` (`content_name` "novidades-da-campanha", `eventID` UUID) no padrão do `PetitionForm` — nunca no page load. Leitura via `getCachedGlobal('site-settings')` (ISR sob a tag `global_site-settings`; a edição do admin já revalida a home). Migração `20260819_213947_add_site_settings_facebook_pixel_id` (colunas `tracking_facebook_pixel_id` + versão, sem backfill). Fora da fatia (gatilhos herdados do plano da petição): CAPI/Advanced Matching, dedup `eventID`, fila/retry de `Lead` e cookie banner/CMP. E2e novo `campaignHomePixel` (script `#meta-pixel-*` presente só com ID configurado, CDN stubada; `mode: serial` por precedente). diff --git a/docs/changelog/2026-08-23-ops46.md b/docs/changelog/2026-08-23-ops46.md new file mode 100644 index 00000000..66c39256 --- /dev/null +++ b/docs/changelog/2026-08-23-ops46.md @@ -0,0 +1 @@ +**OPS46 — infra de teste e2e: coleta local consertada (next/cache) + allocator de município sem colisão no wrap (2026-08-23):** entrega de dois débitos do OPS45 (capture-review-debts). **Fase 1 — e2e local não coleta:** `pnpm exec playwright test --list` falhava em todo spec com `Cannot find module 'node_modules/next/cache'` (e o diagnóstico correto do CI-vs-máquina: não há delta — o CI só roda pelo script `pnpm test:e2e`, que carrega `--conditions=react-server --import=tsx/esm` no `NODE_OPTIONS`; comando bare não tinha nada). Causa: `next@15` não declara `exports` → ESM nativo não resolve `next/cache` (o arquivo é `cache.js`), e `server-only` precisa da condição `react-server`. Fix no runner, zero mudança em produção: hook de resolução ESM vitest-free `tests/helpers/e2eEsmResolve.mjs` (mapeia `next/cache`→`next/cache.js` e injeta `react-server` nas conditions) registrada no processo main via `module.register` e propagada aos workers via `NODE_OPTIONS=--import=e2eEsmLoader.mjs` no `playwright.config.ts` — só quando os flags do script já não estão presentes. Aceite: bare `playwright test --list` coleta 198 testes em 41 arquivos (antes: 0); runs reais bare admin 5/5 e campaign 18/18; `pnpm test:e2e` intocado. **Fase 2 — allocator de município com wrap destrutivo:** a sequência persistente `campaign_fixture_municipality_alloc` nunca é resetada e um ciclo completo de suíte claima ~505 do catálogo de 435 (medido: 432 no int + 73 no e2e) → o wrap acontece em todo ciclo em DB persistente de worktree, e dois runs concorrentes podiam receber o mesmo índice e purgar as linhas vivas um do outro (purge-on-claim). Fix: registry de claims em módulo compartilhado vitest-free `tests/helpers/campaignMunicipalityAllocator.ts` — tabela `campaign_fixture_municipality_claims` (`index` PK, `run_id`, `claimed_at`), claim via `INSERT ON CONFLICT DO NOTHING RETURNING` (conflito = slot vivo → pula; claim stale >2h de run crashado → rouba; tudo ocupado → erro claro de exaustão), release no `cleanup()` dos dois fixtures (int + e2e, só quando claimaram). Guard estático novo no `testMunicipalityAllocatorConventions` (proíbe `nextval`/sequence fora do módulo) + spec int de stress `campaignMunicipalityAllocator.int.spec.ts` (exclusividade viva por 3 wraps, exaustão+recuperação, steal, release). Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente de não carregar payload.config, igual no HEAD), unit 2420 + int 819 (92 arquivos) verdes com tabela de claims zerada ao final, e2e local afetado (admin 5/5 + campaign 18/18 bare) verdes; importMap.js restaurado com envs `S3_*` dummy após o `--list` bare regenerá-lo sem elas (classe OPS69 — o guard do #87 cobre no CI). Impl plan: `docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md`. diff --git a/docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md b/docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md new file mode 100644 index 00000000..b67ab9bb --- /dev/null +++ b/docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md @@ -0,0 +1,133 @@ +# OPS46 — Infra de teste e2e: e2e local não coleta (next/cache) + allocator de município com wrap + +Status: implementado +Atualizado em: 2026-08-23 +Issue: #733 +Impl plan do plano de intenção: `docs/plans/ops46-infra-teste-e2e-local-allocator.md` + +## Fase 1 — e2e local não coleta (`Cannot find module 'next/cache'`) + +### Causa raiz (medida) + +- `next@15.4.11` não declara `exports` no package.json → ESM nativo do Node + **nunca** resolve o subpath `next/cache` (o arquivo existe como `cache.js`, + mas ESM não tenta extensões — `ERR_MODULE_NOT_FOUND` com "Did you mean to + import next/cache.js?"). +- `server-only` (0.0.1) resolve `index.js` (que lança) sem a condição de + export `react-server` (`--conditions=react-server`). +- `pnpm test:e2e` carrega ambas as correções via + `NODE_OPTIONS="--no-deprecation --conditions=react-server --import=tsx/esm"` + — por isso o CI passa: o CI nunca roda `playwright` bare, sempre via script. + Qualquer invocação bare (extensão VS Code, `pnpm exec playwright test + --list`, runs ad-hoc) quebra na coleta. +- CI vs máquina: sem delta — o CI usa o mesmo comando com os mesmos flags; + a hipótese do plano ("investigar o que difere no ambiente CI") se resolveu + em "nada difere — o CI só roda pelo script". + +### Abordagem implementada (aprovada no GATE) + +Fix no nível do runner, zero mudança em código de produção: + +1. `tests/helpers/e2eEsmResolve.mjs` — hook de resolução ESM com 2 rewrites: + - `next/cache` → `next/cache.js` (entry oficial, TS mapeia para `cache.d.ts`); + - injeta `react-server` nas `conditions` de resolução (equivalente exato do + flag `--conditions=react-server`, aplicado em runtime via `nextResolve`). +2. `tests/helpers/e2eEsmLoader.mjs` — entry `--import` que registra a hook + (o padrão do tsx: `--import` só funciona se o módulo chamar `module.register`). +3. `playwright.config.ts` — quando `NODE_OPTIONS` **não** tem + `--conditions=react-server` (invocação bare): `register()` a hook no + processo main (a coleta do `--list` acontece aqui, pós-load do config) e + propaga `--import=` via `NODE_OPTIONS` para os workers (herdam o + env no spawn). Com os flags presentes (script), nada muda. + +### Opções rejeitadas + +- `next/cache` → `next/cache.js` na fonte (~40 arquivos): toca código de + produção para um problema de infra de teste e não resolve `server-only` + (o comando bare continuaria quebrando); não idiomático. +- Patch do `next` via pnpm `patchedDependencies` adicionando `exports`: + exports parcial quebra qualquer subpath não listado; frágil por versão. +- `--import`/flags no script apenas + documentar: não satisfaz o aceite + (`pnpm exec playwright test --list` coleta na máquina do humano). + +### Verificação Fase 1 + +- `pnpm exec playwright test --list` (bare, sem flags): **198 testes em 41 + arquivos** (antes: `Total: 0 tests in 0 files`). +- Run real bare `--project=admin`: **5 passed** (workers com a hook). +- Run real bare `--project=campaign` `campaignHomeActions`: **18 passed**. +- `pnpm test:e2e --list` (script, flags): inalterado, 198. + +## Fase 2 — allocator de município com wrap destrutivo + +### Medição (plano: "medir primeiro") + +- Claims por ciclo completo num DB persistente: **~505** (432 + `getMunicipality()` no int + 73 `claimMunicipality()` no e2e) > 435 do + catálogo → o wrap acontece em **todo** ciclo de suíte completa. Bug agudo + (não teórico): após o wrap, dois runs concorrentes podem receber o mesmo + índice e o purge-on-claim de cada um apaga as linhas vivas do outro. + +### Abordagem implementada + +Registry de claims em módulo compartilhado vitest-free +`tests/helpers/campaignMunicipalityAllocator.ts` (mesma convenção do +`campaignResidue.ts` — importável pelo processo Playwright do e2e): + +- Tabela `campaign_fixture_municipality_claims` (`index` PK, `run_id` text, + `claimed_at` timestamptz default now()) criada lazy com a sequence + (`CREATE IF NOT EXISTS`, corrida 23505 tolerada). +- `claimMunicipalityIndex(payload, catalogSize, runID)`: `nextval` → índice → + `INSERT ON CONFLICT ("index") DO NOTHING RETURNING`; conflito = run vivo + segura o slot → pula; claim **stale** (TTL 2h, run crashado) → rouba e + retenta; tudo ocupado por claims vivos → erro claro ("exhausted") em vez + de colisão silenciosa. +- `releaseMunicipalityClaims(payload, runID)`: `DELETE WHERE run_id` — chamado + no `cleanup()` de ambos os fixtures (int `CampaignFixtures.cleanup`, + e2e `CampaignE2EOwnership.cleanup`), só quando o fixture claimou + (Set `claimedMunicipalityIndexes` — cleanup sem claims não toca a tabela). +- O fixture int perdeu o allocator inline (`nextAllocatedMunicipalityIndex` + + a sequence própria); o e2e idem (CREATE SEQUENCE + nextval inline). + +### Guards e testes + +- Guard estático novo no `testMunicipalityAllocatorConventions.unit.spec.ts`: + proíbe `nextval(`/`campaign_fixture_municipality_alloc` em `tests/int`, + `tests/e2e` e `tests/helpers` fora do módulo do allocator — todo claim passa + pelo registry. +- Spec int comportamental `tests/int/campaignMunicipalityAllocator.int.spec.ts` + (catalogSize sintético, robusto a concorrência da suíte paralela): + 1. exclusividade viva através de múltiplos wraps (âncora segura 1 slot por + 120 claims de runs efêmeros > 3 wraps do espaço de 40); + 2. exaustão (erro claro) + recuperação após release; + 3. steal de claim stale (envelhecida 3h); + 4. release libera slots para reuso. + +### Verificação Fase 2 + +- Spec do allocator: 4/4 verdes, estável em re-runs. +- Suíte int completa: **92 arquivos / 819 testes, todos verdes** (o fixture + refatorado claima ~432 municípios sob 8 workers paralelos). +- Tabela de claims **zerada** ao final da suíte (release funcionando). +- e2e campaign (`campaignHomeActions`, wizard com claims reais): 18/18, claims + zeradas após o run. + +## Riscos e notas + +- A hook depende de `module.register` (estável desde Node 20.6); só ativa em + invocações bare — o path do script (`pnpm test:e2e`) fica intocado e é o + fallback se algo mudar. +- TTL 2h > duração de qualquer run vivo; claim crashado bloqueia slots até o + TTL (erro de exaustão menciona o prazo). +- Um run vivo não pode claimar mais de `catalogSize` slots (a própria + exclusividade exaure) — correto por design; fixtures reais liberam por + teste. +- Durante a entrega, o `--list` bare regenerou o `importMap.js` sem envs + `S3_*` (classe OPS69/OPS70 #87, já documentada) — restaurado com + `generate:importmap` + envs dummy. + +## Aceite + +- [x] Fase 1: `pnpm exec playwright test --list` coleta na máquina do humano +- [x] Fase 2: sem colisão de claim após N wraps (stress test com wraps reais) +- [x] Guards: `testMunicipalityAllocatorConventions` verde após a mudança diff --git a/playwright.config.ts b/playwright.config.ts index ec39b176..911b0bc6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,35 @@ +import { register } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + import { defineConfig, devices } from '@playwright/test' import { config as loadEnv } from 'dotenv' import { assertTestDatabase } from './tests/helpers/assertTestDatabase' + +/* + * OPS46 — a bare `playwright test` (VS Code extension, `pnpm exec playwright + * test --list`, ad-hoc runs) must collect like `pnpm test:e2e` does. That + * script carries the needed flags in NODE_OPTIONS (`--conditions=react-server + * --import=tsx/esm`); this block re-applies the same behavior when they are + * absent: + * - register() installs the resolution hook (tests/helpers/e2eEsmResolve.mjs) + * in THIS process — test collection happens here after config load, which + * is why a bare `--list` needs it in-process; + * - the NODE_OPTIONS mutation hands the same hook to the worker processes + * Playwright spawns for actual runs (they inherit the env at spawn). + * When the flags are already present (the scripted path) nothing changes. + */ +const bareInvocation = !process.env.NODE_OPTIONS?.includes('--conditions=react-server') +if (bareInvocation) { + register(new URL('./tests/helpers/e2eEsmResolve.mjs', import.meta.url)) + const configDir = path.dirname(fileURLToPath(import.meta.url)) + const loaderEntry = path.join(configDir, 'tests', 'helpers', 'e2eEsmLoader.mjs') + process.env.NODE_OPTIONS = [process.env.NODE_OPTIONS, `--import=${loaderEntry}`] + .filter(Boolean) + .join(' ') +} + import { GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME, GOOGLE_CALENDAR_TEST_KEY, diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index 10dae38a..eeb4b4be 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -9,6 +9,10 @@ import type { CampaignUser } from '../../../src/payload-types.js' import config from '../../../src/payload.config.js' import { withPayloadTransaction } from '../../../src/utilities/payloadTransaction.js' import { assertTestDatabase } from '../../helpers/assertTestDatabase.js' +import { + claimMunicipalityIndex, + releaseMunicipalityClaims, +} from '../../helpers/campaignMunicipalityAllocator.js' import { purgeMunicipalityResidue } from '../../helpers/campaignResidue.js' import { withInviteConsent } from '../../helpers/testDatabaseLease.js' import { test as base, expect } from './e2eTest.js' @@ -75,6 +79,7 @@ export class CampaignE2EOwnership { readonly payload: Payload readonly runID = randomUUID() private counter = 0 + private readonly claimedMunicipalityIndexes = new Set() private readonly touchedMunicipalities = new Set() private readonly owned = new Map>( deletionOrder.map((collection) => [collection, new Set()]), @@ -145,17 +150,12 @@ export class CampaignE2EOwnership { } async claimMunicipality(): Promise<{ id: number; name: string; slug: string }> { - await this.rootPayload.db.drizzle - .execute(sql.raw(`CREATE SEQUENCE IF NOT EXISTS "campaign_fixture_municipality_alloc"`)) - .catch((error: unknown) => { - if ((error as { code?: string }).code === '23505') return - throw error - }) - const result = await this.rootPayload.db.drizzle.execute( - sql.raw(`SELECT nextval('"campaign_fixture_municipality_alloc"') AS "value"`), + const index = await claimMunicipalityIndex( + this.rootPayload, + municipalityCatalog.length, + this.runID, ) - const index = - Number((result.rows[0] as { value: string | number }).value) % municipalityCatalog.length + this.claimedMunicipalityIndexes.add(index) const slug = municipalityCatalog[index]!.slug const found = await this.rootPayload.find({ collection: 'municipality', @@ -423,6 +423,9 @@ export class CampaignE2EOwnership { } async cleanup(): Promise { + if (this.claimedMunicipalityIndexes.size > 0) { + await releaseMunicipalityClaims(this.rootPayload, this.runID) + } await this.discoverOwnedRows() await withPayloadTransaction(this.rootPayload, async ({ req, transactionID }) => { const transaction = this.rootPayload.db.sessions?.[String(transactionID)]?.db as diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 7c2cf6c9..920e028b 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -21,6 +21,7 @@ import type { VotePledge, } from '@/payload-types' import { withPayloadTransaction } from '@/utilities/payloadTransaction' +import { claimMunicipalityIndex, releaseMunicipalityClaims } from './campaignMunicipalityAllocator' import { purgeMunicipalityResidue, relationId, relationIds } from './campaignResidue' /** @@ -202,32 +203,11 @@ const combineErrors = (primary: unknown, cleanup: unknown): AggregateError => /** * Cross-process municipality allocator: seeded municipalities are shared reference rows, so - * concurrently running spec files must never operate on the same municipality. A - * Postgres sequence hands out globally unique catalog indexes. + * concurrently running spec files must never operate on the same municipality. The allocator + * (`campaignMunicipalityAllocator.ts`) hands out a catalog index per claim — unique across + * live runs even after the Postgres sequence wraps (see its module docs for the wrap + * mechanism). Claims are released by `cleanup()` so later runs reuse the slots. */ -const MUNICIPALITY_ALLOCATION_SEQUENCE = 'campaign_fixture_municipality_alloc' -let allocationSequenceReady: Promise | undefined - -const nextAllocatedMunicipalityIndex = async ( - payload: Payload, - catalogSize: number, -): Promise => { - allocationSequenceReady ??= payload.db.drizzle - .execute(sql.raw(`CREATE SEQUENCE IF NOT EXISTS "${MUNICIPALITY_ALLOCATION_SEQUENCE}"`)) - .then(() => undefined) - .catch((error: unknown) => { - // IF NOT EXISTS still races across parallel workers (pg_class unique - // violation, SQLSTATE 23505) — the sequence exists, which is all we need. - if ((error as { code?: string }).code === '23505') return undefined - throw error - }) - await allocationSequenceReady - const result = await payload.db.drizzle.execute( - sql.raw(`SELECT nextval('"${MUNICIPALITY_ALLOCATION_SEQUENCE}"') AS "value"`), - ) - const value = Number((result.rows[0] as { value: string | number }).value) - return value % catalogSize -} export class CampaignFixtures { readonly payload: Payload @@ -236,6 +216,7 @@ export class CampaignFixtures { private cleaned = false private counter = 0 private municipalityCursor = 0 + private readonly claimedMunicipalityIndexes = new Set() private readonly markers = new Set() private readonly owned = emptyOwnedIDs() private readonly touchedMunicipalities = new Set() @@ -377,10 +358,12 @@ export class CampaignFixtures { async getMunicipality(slug?: string): Promise { let requestedSlug = slug if (!requestedSlug) { - const index = await nextAllocatedMunicipalityIndex( + const index = await claimMunicipalityIndex( this.rootPayload, municipalityCatalog.length, + this.runID, ) + this.claimedMunicipalityIndexes.add(index) requestedSlug = municipalityCatalog[index]!.slug this.municipalityCursor += 1 } @@ -1033,6 +1016,9 @@ export class CampaignFixtures { async cleanup(): Promise { if (this.cleaned) return + if (this.claimedMunicipalityIndexes.size > 0) { + await releaseMunicipalityClaims(this.rootPayload, this.runID) + } await this.discoverDependents() await withPayloadTransaction(this.rootPayload, async ({ req }) => { for (const collection of [ diff --git a/tests/helpers/campaignMunicipalityAllocator.ts b/tests/helpers/campaignMunicipalityAllocator.ts new file mode 100644 index 00000000..f3768991 --- /dev/null +++ b/tests/helpers/campaignMunicipalityAllocator.ts @@ -0,0 +1,128 @@ +import { sql } from '@payloadcms/db-postgres' +import type { Payload } from 'payload' + +/** + * Cross-process municipality allocator — shared by the int + * (`campaignFixtures.ts`) and e2e (`campaignE2EFixtures.ts`) suites. + * + * Deliberately VITEST-FREE: the e2e fixture imports this module inside the + * Playwright process, and a vitest import there crashes at load time (same + * rule as `campaignResidue.ts` — OPS45). + * + * The Postgres sequence hands out globally unique catalog indexes across + * parallel workers, but it is NEVER reset: worktree test databases are + * persistent, and a full int + e2e cycle claims ~505 of the 435 catalog + * slots, so the residue space wraps on every cycle. Once it wraps, two + * concurrent runs can receive the SAME index — and the purge-on-claim + * contract turns that collision into corruption (each run deletes the other + * run's live rows). The claims registry below keeps the sequence as the + * cursor but makes a claim exclusive per LIVE run: + * + * - claim: nextval → index → INSERT (index, run_id) ON CONFLICT DO NOTHING; + * a conflict means a live run holds the slot, so skip to the next value; + * - stale steal: a claim older than STALE_CLAIM_WINDOW belongs to a + * CRASHED run (its cleanup never ran), so it is deleted and the slot is + * reclaimed — without this, one aborted run would block slots for hours; + * - release: the fixture deletes its own rows at cleanup, so the slots + * become claimable again for later runs; + * - exhaustion: all slots held by fresh claims → clear error instead of a + * silent collision. + */ +const ALLOCATION_SEQUENCE = 'campaign_fixture_municipality_alloc' +const ALLOCATION_CLAIMS = 'campaign_fixture_municipality_claims' +const STALE_CLAIM_WINDOW = "interval '2 hours'" + +// Identifiers in the `sql` templates below are INLINE LITERALS on purpose: +// `${}` in a drizzle template is a bound parameter, never an identifier. +const claimsTable = `"${ALLOCATION_CLAIMS}"` + +let allocatorReady: Promise | undefined + +const ensureAllocatorSchema = (payload: Payload): Promise => { + allocatorReady ??= Promise.all([ + payload.db.drizzle.execute(sql.raw(`CREATE SEQUENCE IF NOT EXISTS "${ALLOCATION_SEQUENCE}"`)), + payload.db.drizzle.execute( + sql.raw(` + CREATE TABLE IF NOT EXISTS ${claimsTable} ( + "index" integer PRIMARY KEY, + "run_id" text NOT NULL, + "claimed_at" timestamptz NOT NULL DEFAULT now() + ) + `), + ), + ]) + .then(() => undefined) + .catch((error: unknown) => { + // IF NOT EXISTS still races across parallel workers (pg_class unique + // violation, SQLSTATE 23505) — the objects exist, which is all we need. + if ((error as { code?: string }).code === '23505') return undefined + throw error + }) + return allocatorReady +} + +const nextAllocationValue = async (payload: Payload): Promise => { + const result = await payload.db.drizzle.execute( + sql.raw(`SELECT nextval('"${ALLOCATION_SEQUENCE}"') AS "value"`), + ) + return Number((result.rows[0] as { value: string | number }).value) +} + +const tryClaimIndex = async (payload: Payload, index: number, runID: string): Promise => { + const result = await payload.db.drizzle.execute(sql` + INSERT INTO ${sql.raw(claimsTable)} ("index", "run_id") + VALUES (${index}, ${runID}) + ON CONFLICT ("index") DO NOTHING + RETURNING "index" + `) + return result.rows.length > 0 +} + +const tryStealStaleClaim = async (payload: Payload, index: number): Promise => { + const result = await payload.db.drizzle.execute(sql` + DELETE FROM ${sql.raw(claimsTable)} + WHERE "index" = ${index} AND "claimed_at" < now() - ${sql.raw(STALE_CLAIM_WINDOW)} + RETURNING "index" + `) + return result.rows.length > 0 +} + +/** + * A catalog index that no LIVE run holds. Never returns an index that a + * concurrently running fixture still owns, even after the sequence wraps — + * the claim only lands when the INSERT wins. One run may hold at most + * `catalogSize` slots at once (its own claims are live); claiming past that + * with the same runID exhausts, which is correct — real fixtures release at + * each test's cleanup. + */ +export const claimMunicipalityIndex = async ( + payload: Payload, + catalogSize: number, + runID: string, +): Promise => { + await ensureAllocatorSchema(payload) + for (let attempt = 0; attempt < catalogSize; attempt += 1) { + const index = (await nextAllocationValue(payload)) % catalogSize + if (await tryClaimIndex(payload, index, runID)) return index + if (await tryStealStaleClaim(payload, index)) { + if (await tryClaimIndex(payload, index, runID)) return index + } + } + throw new Error( + `Municipality allocator exhausted: all ${catalogSize} catalog slots are held by ` + + `live test runs. Stale claims from crashed runs expire after 2 hours.`, + ) +} + +/** + * Frees every slot a run claimed (runID is per fixture instance — one test). + * Called from the fixture cleanup; the claim is held from claim until the + * test's rows are purged, so releasing here cannot collide with the live + * owner (there is none by definition). + */ +export const releaseMunicipalityClaims = async (payload: Payload, runID: string): Promise => { + await payload.db.drizzle.execute(sql` + DELETE FROM ${sql.raw(claimsTable)} + WHERE "run_id" = ${runID} + `) +} diff --git a/tests/helpers/e2eEsmLoader.mjs b/tests/helpers/e2eEsmLoader.mjs new file mode 100644 index 00000000..e0abb5d3 --- /dev/null +++ b/tests/helpers/e2eEsmLoader.mjs @@ -0,0 +1,9 @@ +/** + * OPS46 — entry point that registers the Playwright ESM resolution hook + * (see `e2eEsmResolve.mjs`). Passed to worker processes as + * `NODE_OPTIONS=--import=` so bare `playwright test` runs + * resolve `next/cache` and `server-only` exactly like `pnpm test:e2e` does. + */ +import { register } from 'node:module' + +register(new URL('./e2eEsmResolve.mjs', import.meta.url)) diff --git a/tests/helpers/e2eEsmResolve.mjs b/tests/helpers/e2eEsmResolve.mjs new file mode 100644 index 00000000..625bf3f8 --- /dev/null +++ b/tests/helpers/e2eEsmResolve.mjs @@ -0,0 +1,29 @@ +/** + * OPS46 — ESM resolution hook for the Playwright runner process. + * + * next@15 ships NO `exports` map in its package.json, so native Node ESM + * cannot resolve the bare `next/cache` subpath (the file exists as + * `cache.js`, but ESM never tries extensions); `server-only` needs the + * `react-server` export condition. `pnpm test:e2e` papers over both with + * NODE_OPTIONS flags (`--conditions=react-server --import=tsx/esm`), but a + * bare `playwright test` (VS Code extension, `--list`, ad-hoc runs) has + * neither. This hook applies the same two resolutions in-process: + * - `next/cache` → `next/cache.js` (the shipped, TS-mapped entry) + * - inject `react-server` into the resolution conditions (same effect as + * the `--conditions=react-server` flag) + * + * Registered in the main process by `playwright.config.ts` and passed to the + * worker processes via `NODE_OPTIONS=--import=...` (see `e2eEsmLoader.mjs`). + */ +export async function resolve(specifier, context, nextResolve) { + if (specifier === 'next/cache') { + return nextResolve('next/cache.js', context) + } + if (!context.conditions.includes('react-server')) { + return nextResolve(specifier, { + ...context, + conditions: ['react-server', ...context.conditions], + }) + } + return nextResolve(specifier, context) +} diff --git a/tests/int/campaignMunicipalityAllocator.int.spec.ts b/tests/int/campaignMunicipalityAllocator.int.spec.ts new file mode 100644 index 00000000..b778b4c1 --- /dev/null +++ b/tests/int/campaignMunicipalityAllocator.int.spec.ts @@ -0,0 +1,154 @@ +// @vitest-environment node + +import { randomUUID } from 'node:crypto' + +import { sql } from '@payloadcms/db-postgres' +import { getPayload, type Payload } from 'payload' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import config from '@/payload.config' +import { + claimMunicipalityIndex, + releaseMunicipalityClaims, +} from '../helpers/campaignMunicipalityAllocator' + +/** + * Behavioral stress of the allocator's claims registry (OPS46). The real + * catalog has 435 slots and a full int + e2e cycle claims ~505 of them, so + * the sequence wraps every cycle on persistent worktree databases; these + * tests exercise the wrap with a small synthetic `catalogSize` so the + * properties are observable in one run. They run under the PARALLEL int + * suite, so assertions never depend on the exact index a claim lands on — + * only on the registry invariants (live exclusivity through the wrap, + * exhaustion, release, stale steal) that hold regardless of what other + * specs claim concurrently. + */ + +let payload: Payload +const releasedRunIDs: string[] = [] + +const freshRunID = (): string => { + const runID = randomUUID() + releasedRunIDs.push(runID) + return runID +} + +const claimCount = async (runID: string): Promise => { + const result = await payload.db.drizzle.execute(sql` + SELECT count(*)::int AS "count" + FROM "campaign_fixture_municipality_claims" + WHERE "run_id" = ${runID} + `) + return Number((result.rows[0] as { count: number }).count) +} + +describe('campaign municipality allocator registry', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + afterAll(async () => { + await Promise.all(releasedRunIDs.map((runID) => releaseMunicipalityClaims(payload, runID))) + }) + + it('never hands a live run an index another live run holds, across many sequence wraps', async () => { + const catalogSize = 40 + // The anchor run keeps ONE slot held for the whole test; the residue + // space (40 slots) wraps ~3 times over the 120 claims below, and the + // registry must skip the held slot every time the cursor lands on it. + const anchor = freshRunID() + const anchorIndex = await claimMunicipalityIndex(payload, catalogSize, anchor) + + for (let cycle = 0; cycle < 60; cycle += 1) { + const runID = freshRunID() + const first = await claimMunicipalityIndex(payload, catalogSize, runID) + const second = await claimMunicipalityIndex(payload, catalogSize, runID) + expect(second).not.toBe(first) + expect([first, second]).not.toContain(anchorIndex) + await releaseMunicipalityClaims(payload, runID) + } + expect(await claimCount(anchor)).toBe(1) + }) + + it('fails loudly with an exhaustion error instead of colliding, then recovers on release', async () => { + const catalogSize = 8 + const holder = freshRunID() + + let exhausted = false + for (let attempt = 0; attempt < 100 && !exhausted; attempt += 1) { + try { + await claimMunicipalityIndex(payload, catalogSize, holder) + } catch (error) { + expect((error as Error).message).toMatch(/exhausted/i) + exhausted = true + } + } + expect(exhausted, 'all slots held must surface as an exhaustion error').toBe(true) + + await releaseMunicipalityClaims(payload, holder) + expect(await claimCount(holder)).toBe(0) + + const recovered = freshRunID() + let claimed = false + for (let attempt = 0; attempt < 20 && !claimed; attempt += 1) { + try { + await claimMunicipalityIndex(payload, catalogSize, recovered) + claimed = true + } catch (error) { + expect((error as Error).message).toMatch(/exhausted/i) + } + } + expect(claimed, 'released slots must be claimable again').toBe(true) + }) + + it('steals claims of a crashed run once they age past the stale window', async () => { + const catalogSize = 40 + const crashed = freshRunID() + const survivor = freshRunID() + + const crashedIndex = await claimMunicipalityIndex(payload, catalogSize, crashed) + // Simulate a run killed before cleanup: age its claim past the 2h window. + await payload.db.drizzle.execute(sql` + UPDATE "campaign_fixture_municipality_claims" + SET "claimed_at" = now() - interval '3 hours' + WHERE "run_id" = ${crashed} + `) + + // The stale slot is reclaimable: keep claiming until the survivor lands + // on the crashed run's index (the sequence advances one slot per claim, + // so a bounded loop covers every slot of the residue space). + let reclaimed = false + for (let attempt = 0; attempt < catalogSize * 3 && !reclaimed; attempt += 1) { + if ((await claimMunicipalityIndex(payload, catalogSize, survivor)) === crashedIndex) { + reclaimed = true + } + } + expect(reclaimed, 'stale claim must be stealable').toBe(true) + expect(await claimCount(crashed)).toBe(0) + // The survivor may hold up to catalogSize slots at this point; release + // inline so the later tests keep free space (the afterAll is the net). + await releaseMunicipalityClaims(payload, survivor) + }) + + it('releases every slot of a run on release and lets later runs reuse the space', async () => { + const catalogSize = 40 + const runA = freshRunID() + const runB = freshRunID() + + const claimedA = new Set() + for (let i = 0; i < 5; i += 1) { + claimedA.add(await claimMunicipalityIndex(payload, catalogSize, runA)) + } + expect(claimedA.size).toBe(5) + expect(await claimCount(runA)).toBe(5) + + await releaseMunicipalityClaims(payload, runA) + expect(await claimCount(runA)).toBe(0) + + const claimedB = new Set() + for (let i = 0; i < 5; i += 1) { + claimedB.add(await claimMunicipalityIndex(payload, catalogSize, runB)) + } + expect(claimedB.size).toBe(5) + }) +}) diff --git a/tests/unit/testMunicipalityAllocatorConventions.unit.spec.ts b/tests/unit/testMunicipalityAllocatorConventions.unit.spec.ts index e267f3e7..8d281445 100644 --- a/tests/unit/testMunicipalityAllocatorConventions.unit.spec.ts +++ b/tests/unit/testMunicipalityAllocatorConventions.unit.spec.ts @@ -95,4 +95,31 @@ describe('municipality allocator discipline (miss #73)', () => { 'anchor with ^ (or locate by href) — catalog names are not prefix-unique', ).toEqual([]) }) + + it('routes every claim through the shared allocator module (OPS46)', () => { + // The claims registry (campaignMunicipalityAllocator.ts) is what makes the + // purge-on-claim contract safe after the sequence wraps; a direct + // nextval/sequence reference anywhere else reintroduces the collision it + // exists to prevent. + const offenders: string[] = [] + const allocatorModule = repoPath( + resolve(repoRoot, 'tests/helpers/campaignMunicipalityAllocator.ts'), + ) + const scanned = [ + ...specFiles, + ...walkFiles(resolve(repoRoot, 'tests/helpers'), ['.ts']), + ].filter((file) => repoPath(file) !== allocatorModule) + + for (const file of scanned) { + const source = readFileSync(file, 'utf8') + if (/nextval\(|campaign_fixture_municipality_alloc/.test(source)) { + offenders.push(repoPath(file)) + } + } + + expect( + offenders, + 'claim through claimMunicipalityIndex — direct sequence access bypasses the claims registry', + ).toEqual([]) + }) })