mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
ops(OPS46): e2e local coleta via hook ESM (next/cache + server-only) e allocator de município com registry anti-wrap (Closes #733)
Fase 1: playwright bare (--list, extensão VS Code, runs ad-hoc) quebrava na coleta — next@15 sem exports não resolve 'next/cache' em ESM nativo e server-only exige a condição react-server; o script test:e2e carregava ambas via NODE_OPTIONS (por isso o CI passava). Hook de resolução ESM vitest-free (e2eEsmResolve.mjs) registrada no main via module.register + propagada aos workers via NODE_OPTIONS quando invocada bare; path do script intocado. Fase 2: a sequência persistente campaign_fixture_municipality_alloc nunca é resetada e um ciclo completo claima ~505 do catálogo de 435 — o wrap em todo ciclo em DB de worktree fazia dois runs concorrentes receberem o mesmo índice e purgarem as linhas vivas um do outro. Registry de claims compartilhado vitest-free (campaignMunicipalityAllocator.ts): INSERT ON CONFLICT DO NOTHING com skip de slot vivo, steal de claim stale (TTL 2h), release no cleanup dos dois fixtures, erro claro de exaustão. Guard estático no testMunicipalityAllocatorConventions + spec int de stress.
This commit is contained in:
parent
1cc32a3d09
commit
89bf9d0854
11 changed files with 536 additions and 36 deletions
|
|
@ -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).
|
||||
|
|
|
|||
1
docs/changelog/2026-08-23-ops46.md
Normal file
1
docs/changelog/2026-08-23-ops46.md
Normal file
|
|
@ -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`.
|
||||
133
docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md
Normal file
133
docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md
Normal file
|
|
@ -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=<loader>` 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<number>()
|
||||
private readonly touchedMunicipalities = new Set<number>()
|
||||
private readonly owned = new Map<OwnedCollection, Set<number>>(
|
||||
deletionOrder.map((collection) => [collection, new Set<number>()]),
|
||||
|
|
@ -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<void> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<void> | undefined
|
||||
|
||||
const nextAllocatedMunicipalityIndex = async (
|
||||
payload: Payload,
|
||||
catalogSize: number,
|
||||
): Promise<number> => {
|
||||
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<number>()
|
||||
private readonly markers = new Set<string>()
|
||||
private readonly owned = emptyOwnedIDs()
|
||||
private readonly touchedMunicipalities = new Set<number>()
|
||||
|
|
@ -377,10 +358,12 @@ export class CampaignFixtures {
|
|||
async getMunicipality(slug?: string): Promise<Municipality> {
|
||||
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<void> {
|
||||
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 [
|
||||
|
|
|
|||
128
tests/helpers/campaignMunicipalityAllocator.ts
Normal file
128
tests/helpers/campaignMunicipalityAllocator.ts
Normal file
|
|
@ -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<void> | undefined
|
||||
|
||||
const ensureAllocatorSchema = (payload: Payload): Promise<void> => {
|
||||
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<number> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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<number> => {
|
||||
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<void> => {
|
||||
await payload.db.drizzle.execute(sql`
|
||||
DELETE FROM ${sql.raw(claimsTable)}
|
||||
WHERE "run_id" = ${runID}
|
||||
`)
|
||||
}
|
||||
9
tests/helpers/e2eEsmLoader.mjs
Normal file
9
tests/helpers/e2eEsmLoader.mjs
Normal file
|
|
@ -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=<absolute path>` 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))
|
||||
29
tests/helpers/e2eEsmResolve.mjs
Normal file
29
tests/helpers/e2eEsmResolve.mjs
Normal file
|
|
@ -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)
|
||||
}
|
||||
154
tests/int/campaignMunicipalityAllocator.int.spec.ts
Normal file
154
tests/int/campaignMunicipalityAllocator.int.spec.ts
Normal file
|
|
@ -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<number> => {
|
||||
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<number>()
|
||||
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<number>()
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
claimedB.add(await claimMunicipalityIndex(payload, catalogSize, runB))
|
||||
}
|
||||
expect(claimedB.size).toBe(5)
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue