fix(int): escopo do espelho Google Calendar por id, não por título — destrava deploy verify

O verify do deploy manual (run 34763253924, main 7ca708a3) falhou em
tests/int/googleCalendarSync.int.spec.ts ("a cancelled event...") nas duas
tentativas do retry: o spec isolava o espelho e a limpeza com
`{ title: { like: 'C114%' } }`, mas no Payload 3.82 `like`/`contains` compilam
para ILIKE '%…%' (contains, sem ancoragem) — um UUID de fixture de outro spec
embutindo o hex `c114` entrava no escopo e podia ser deletado pela limpeza.

Escopa passes e cleanup pelos ids criados (ownedActivityIds), com
`{ track: false }` para linhas deliberadamente alheias; teste de regressão do
cenário exato (falha sem o fix); pin de convenção em codebaseConventions
banindo marcador de título hex em tests/int; post-mortem e changelog.
This commit is contained in:
Francisco Solla 2026-09-13 12:11:54 -03:00
parent 7ca708a3cb
commit bebbb5104d
4 changed files with 185 additions and 25 deletions

View file

@ -0,0 +1 @@
**Deploy verify destravado — escopo do espelho Google Calendar por id, não por título (2026-09-13):** o verify do deploy manual (run 34763253924, main `7ca708a3`) falhou no passo "Integration tests (full suite)" com 1 failed | 82 passed — `tests/int/googleCalendarSync.int.spec.ts` > "a cancelled event in Google cancels the confirmado activity, then the trash is cleaned" caiu nas duas tentativas do `retry: 1` (1ª `AssertionError: expected 2 to be 1` no `cleanup.deleted`; 2ª `store` ainda com 1 evento `{ id: 'teqo11' }`) e os jobs `deploy-staging`/`deploy-production` ficaram skipped, sem outage (produção permaneceu no build anterior). Causa: o escopo/limpeza do spec usava marcador de título (`{ title: { like: 'C114%' } }`, introduzido em `c69990a3`, 2026-08-11), mas no Payload 3.82 + `@payloadcms/drizzle` o `like` compila para `ILIKE '%<word>%'` por palavra (split por espaço) e o `contains` para `ILIKE '%<value>%'` (`node_modules/@payloadcms/drizzle/dist/queries/parseParams.js:183-184`, `sanitizeQueryValue.js:202-212`) — ambos CONTAINS, nunca prefixo: um UUID de fixture de outro spec embutindo o hex `c114` (case-insensitive) entrou no espelho e podia ser DELETADO pela limpeza `afterEach` do próprio spec; a colisão é rara (~29 posições hex por UUID; ~4% por arquivo/run) e ficou latente desde agosto, com a C153 adicionando 2 arquivos int (speechCatalog/speechImport) que cruzaram a probabilidade e o verify rodando a suíte int completa a cada commit. Fix (sem migration, sem código de produção): `tests/int/googleCalendarSync.int.spec.ts` passa a rastrear `ownedActivityIds: Set<number>`, `createActivity(..., { track: false })` para linhas deliberadamente alheias, `afterEach` deleta por `id: { in: [...] }`, `runSync` usa `activityWhere: { id: { in: [...ownedActivityIds] } }` e um teste de regressão novo ("a foreign title CONTAINING c114 stays out of the scope (id-scoped mirror)"), com o comentário de convenção do header corrigido; `tests/unit/codebaseConventions.unit.spec.ts` ganha o describe "int fixture scoping avoids hex title markers", que varre `tests/int` e bane `title: { like|contains: '<hex-only 3+>' }`. Verificação: o teste de regressão falha sem o fix (`expected 2 to be 1`) e passa com; o verificador independente rodou a suíte int completa (`VITEST_MAX_WORKERS=4 pnpm test:int`) com 83 files / 744 tests verdes (antes: 83 files / 743 tests, 1 failed no CI) e os 6 specs da família Google (47 tests) verdes; `pnpm gate:fast` verde e o pin de convenções falha com o padrão antigo. Sem Issue própria — o registro é o post-mortem (`docs/postmortems/2026-09-13-deploy-verify-escopo-google-calendar.md`). CI do PR do fix e deploy de produção pendentes (dispatch manual do humano após o merge).

View file

@ -0,0 +1,79 @@
# Post-mortem: escopo do espelho Google Calendar por título deixa linha alheia vazar e bloqueia o verify do deploy
> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente.
## Registro
| Campo | Valor |
| ------------------- | ---------------------------------------------------------------------------------------- |
| Data do post-mortem | 2026-09-13 |
| Severidade | alta (bloqueou o deploy de produção; sem outage — produção permaneceu no build anterior) |
| Ambiente | CI (verify do deploy manual) |
| Issue(s) | sem Issue (fluxo `/bug-fix`; precedente: post-mortem B196 de 2026-09-12) |
| PR do fix | a preencher no merge |
| Detectado por | teste (job `verify` do `deploy.yml`, passo "Integration tests (full suite)") |
## Timeline
| Momento | Data/hora | Evento |
| ------------------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Início provável | 2026-08-11 | commit `c69990a3` ("fix(C126): isola o escopo do espelho Google nos int specs (fim do flake paralelo)") introduz o escopo/limpeza por título `{ title: { like: 'C114%' } }` em `tests/int/googleCalendarSync.int.spec.ts`; a colisão com UUID alheio é rara, então o defeito fica latente |
| Detecção | 2026-09-13 14:46:31 UTC | run de deploy 34763253924 (main `7ca708a3`), job `verify`, passo "Integration tests (full suite)": 1 failed \| 82 passed — `tests/int/googleCalendarSync.int.spec.ts` > "a cancelled event in Google cancels the confirmado activity, then the trash is cleaned" falha nas DUAS tentativas (`retry: 1`); os jobs `deploy-staging` e `deploy-production` ficam skipped |
| Correção mergeada | pendente | PR do fix a preencher no merge |
| Deploy | pendente | dispatch manual do `deploy.yml` pelo humano após o merge (nenhum merge publica sozinho) |
| Verificado em prod | pendente | confirmação do humano após o deploy |
## O bug
O deploy manual de 2026-09-13 não chegou a produção: o job `verify` falhou na suíte int e os jobs de deploy (`deploy-staging`/`deploy-production`) foram skipped — o código de main (`7ca708a3`) não foi publicado nesse dispatch, e produção permaneceu no build anterior (sem outage; nenhum usuário afetado). A falha foi o teste `tests/int/googleCalendarSync.int.spec.ts` > "a cancelled event in Google cancels the confirmado activity, then the trash is cleaned", reprovado nas duas tentativas do `retry: 1` do vitest: na 1ª, `AssertionError: expected 2 to be 1` na asserção de `cleanup.deleted` (o passe de limpeza apagou 2 eventos em vez de 1); na 2ª, o `store` ainda continha 1 evento (`{ id: 'teqo11' }`). No mesmo arquivo, os testes "creates the full mirror…" e "failures land in paused…" passaram com `(retry x1)` — a contaminação era intermitente e o retry a mascarava. **Sintoma — não a causa.**
## Causa-raiz
O escopo e a limpeza do spec usavam marcador de título (`{ title: { like: 'C114%' } }`, commit `c69990a3`, 2026-08-11), tratando o texto como ownership. No Payload 3.82 + `@payloadcms/drizzle`, porém, `like` compila para `ILIKE '%<word>%'` por palavra (split por espaço) e `contains` para `ILIKE '%<value>%'` — ambos CONTAINS, nunca prefixo (origem: `node_modules/@payloadcms/drizzle/dist/queries/parseParams.js:183-184` e `sanitizeQueryValue.js:202-212`). Um UUID de fixture de outro spec embutindo o hex `c114` (case-insensitive) entrou no espelho e podia ser DELETADO pela limpeza `afterEach` do próprio spec. A suíte int roda arquivos em paralelo contra um banco compartilhado e se isola por texto compartilhado.
5-whys:
1. **Por que o teste falhou?** As contagens exatas do espelho incluíram uma linha alheia.
2. **Por que a linha alheia entrou no escopo?** O escopo/limpeza usava marcador de título e `like`/`contains` do Payload são CONTAINS match (`ILIKE '%…%'`, sem ancoragem) — um UUID de fixture de outro spec que embutia o hex `c114` casou com o marcador e entrou no espelho (e na mira da limpeza).
3. **Por que o escopo era textual?** O spec tratou marcador de título como ownership em vez de rastrear os ids que criou; nada pinava essa suposição.
4. **Por que o defeito ficou latente desde 2026-08-11?** A colisão é rara (~29 posições hex por UUID; ~4% por arquivo/run) e o `retry: 1` (`vitest.config.mts`) redesenhava os UUIDs no retry, mascarando a contaminação intermitente.
5. **Por que virou bloqueio de release agora?** A classe é sistêmica: arquivos int paralelos compartilham um banco e se isolam por texto compartilhado; a C153 adicionou 2 arquivos int (speechCatalog/speechImport) com novos runIDs, cruzando a probabilidade, e o verify do deploy roda a suíte int completa a cada commit — qualquer colisão vira bloqueio de publicação.
**Evidência:** o teste de regressão novo falha sem o fix (`expected 2 to be 1`) e passa com; o pin de convenção falha com o padrão antigo (`title: { like: 'C114%' }`); a falha de CI veio nas duas tentativas com manifestações distintas (contagem e `store` residual), e os testes irmãos só passaram no `(retry x1)`.
## Correção
Sem migration e sem mudança em código de produção — o defeito era do isolamento do spec, não do motor de sync:
- `tests/int/googleCalendarSync.int.spec.ts`: `ownedActivityIds: Set<number>` (`:103`) rastreia as linhas criadas; `createActivity(..., { track: false })` (`:128-148`) cria deliberadamente linhas alheias fora do escopo; o `afterEach` (`:105-120`) deleta por `id: { in: [...] }` em vez de título; `runSync` (`:168-178`) usa `activityWhere: { id: { in: [...ownedActivityIds] } }`; o comentário de convenção do header foi atualizado (marcador textual não é ownership) e um teste de regressão novo — "a foreign title CONTAINING c114 stays out of the scope (id-scoped mirror)" (`:250`) — pina o cenário exato do bug.
- `tests/unit/codebaseConventions.unit.spec.ts`: novo describe "int fixture scoping avoids hex title markers" (`:771`), que varre `tests/int` e bane `title: { like|contains: '<hex-only 3+>' }`.
Resolve a causa: o escopo e a limpeza passam a ser ownership por id (à prova de colisão de UUID), e a suposição antiga ("marcador de título isola") deixa de ser aceita sem falhar um teste.
## Verificação
- Teste de regressão: `tests/int/googleCalendarSync.int.spec.ts:250` "a foreign title CONTAINING c114 stays out of the scope (id-scoped mirror)" — falha sem o fix (`expected 2 to be 1`) e passa com
- Suíte: verificador independente rodou a suíte int completa `VITEST_MAX_WORKERS=4 pnpm test:int` = 83 files / 744 tests verdes (antes: 83 files / 743 tests, 1 failed no CI) e os 6 specs da família Google (47 tests) verdes; `pnpm gate:fast` verde; o pin de convenções falha com o padrão antigo
- CI: pendente — PR do fix a preencher no merge
- Prod: pendente — dispatch manual do `deploy.yml` pelo humano após o merge; confirmação do humano pendente
## Prevenção
| Estratégia | Custo | Estado |
| --------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------- |
| Escopo e limpeza do spec Google por ids das linhas criadas (`ownedActivityIds`) + teste de regressão | barata | implementada agora (PR do fix — a preencher no merge) |
| Pin de convenção em `tests/unit/codebaseConventions.unit.spec.ts` banindo marcador de título hex em `tests/int` | barata | implementada agora (PR do fix — a preencher no merge) |
| Convenção documentada no header do spec (marcador textual não é ownership) | barata | implementada agora (PR do fix — a preencher no merge) |
| Isolamento de banco/schema por arquivo ou suíte int serial (infra de teste nova) | cara | documentada — não implementada neste fluxo; candidata a Issue futura |
| Operador de prefixo no Payload ou remoção do `retry: 1` (mudança de contrato/framework) | cara | documentada — não implementada neste fluxo; candidata a Issue futura |
**Estratégia implementada:** ownership por id no spec (escopo do espelho e limpeza), teste de regressão do cenário "título alheio contendo o marcador", pin de convenção que bane marcador de título hex em `tests/int` e a convenção documentada no header — a suposição errada passa a falhar na autoria, não no verify do deploy.
**Estratégia documentada (cara):** isolamento de banco/schema por arquivo ou suíte int serial (infra de teste nova) e operador de prefixo no Payload ou remoção do `retry: 1` (mudança de contrato/framework). Candidatas a Issue futura — não implementadas neste fluxo.
## Lições
- **Marcador textual não é ownership:** casar por texto compartilhado num banco compartilhado é isolamento frágil; rastrear os ids que o próprio spec criou é à prova de colisão.
- **`like`/`contains` do Payload são contains-match:** o `%` no padrão não ancora nada — `like: 'C114%'` vira `ILIKE '%C114%'`. Quem lê `like` esperando prefixo se engana.
- **`retry: 1` mascara flakes de isolamento:** a segunda tentativa redesenhava os UUIDs e escondia a contaminação; o verde com `(retry x1)` era sinal precoce, não ruído.
- **Comentário de convenção que documenta o mecanismo errado reforça a falsa confiança:** o header do spec declarava o isolamento por título como garantia — o comentário precisou ser corrigido junto com o código.

View file

@ -7,8 +7,12 @@
// stable without touching other specs:
//
// 1. Any NEW spec here that asserts on mirror counts MUST scope its passes
// with its own title prefix via `activityWhere` (this file uses
// `{ title: { like: 'C114%' } }`) — never count on an empty window.
// by the ids of the activities THIS spec created via `activityWhere`
// (`{ id: { in: [...] } }`) — never count on an empty window. A title
// marker is NOT enough: Payload's `like`/`contains` are CONTAINS matches
// (`ILIKE '%…%'`), so a marker of hex characters (e.g. `C114`) also
// matches a fixture UUID that embeds it — that is how a parallel spec's
// row leaked into this mirror and broke the exact counts (deploy verify 2026-09-13).
// 2. Specs elsewhere that create pass-through activities in the window
// keep the per-test cleanup from `installCampaignFixtures` (fixtures
// are owned and removed beforeEach/afterEach) — never leak rows past
@ -90,23 +94,43 @@ describe('campaign Google calendar sync engine (C114)', () => {
/** Config rows this file created — cleanup never touches other specs' rows. */
const ownedConfigIds = new Set<number>()
/**
* Activity ids this spec created — the mirror scope AND the cleanup set.
* Ownership by id is collision-proof: a title marker is not (Payload's
* `like`/`contains` match anywhere in the string — the deploy verify blocker
* of 2026-09-13).
*/
const ownedActivityIds = new Set<number>()
afterEach(async () => {
// Teqo never depends on Google: the engine must never throw into callers.
// Cleanup happens per test; the env is restored by the credential helper.
await payload.delete({
collection: 'activity',
where: { title: { like: 'C114%' } },
overrideAccess: true,
})
if (ownedActivityIds.size > 0) {
await payload.delete({
collection: 'activity',
where: { id: { in: [...ownedActivityIds] } },
overrideAccess: true,
})
ownedActivityIds.clear()
}
for (const id of ownedConfigIds) {
await payload.delete({ collection: 'googleCalendarSync', id, overrideAccess: true })
}
ownedConfigIds.clear()
})
const createActivity = async (overrides: Record<string, unknown> = {}) => {
/**
* Creates an activity this spec OWNS by default (mirror scope + cleanup).
* Pass `{ track: false }` for a deliberately foreign row: it exists in the
* window but must stay out of the scoped mirror (the fixture cleanup still
* owns it through the proxy — the flag only keeps it out of the scope).
*/
const createActivity = async (
overrides: Record<string, unknown> = {},
options: { track?: boolean } = {},
) => {
const municipality = await campaignFixtures().getMunicipality()
return payload.create({
const activity = await payload.create({
collection: 'activity',
data: hookFilledCreateData<'activity'>({
title: `C114 ${crypto.randomUUID().slice(0, 8)}`,
@ -119,6 +143,8 @@ describe('campaign Google calendar sync engine (C114)', () => {
depth: 0,
overrideAccess: true,
})
if (options.track !== false) ownedActivityIds.add(activity.id)
return activity
}
const createConfig = async (calendarId: string) => {
@ -133,11 +159,11 @@ describe('campaign Google calendar sync engine (C114)', () => {
}
/**
* Runs a sync pass scoped to THIS spec's fixture (`C114%` titles). The int
* suite runs files in parallel against one shared database and other specs
* create activities inside the mirror window — a full-scope pass would count
* them and flake the global assertions (C126). Production callers omit the
* scope and keep the espelho cheio.
* Runs a sync pass scoped to the activities THIS spec created. The int suite
* runs files in parallel against one shared database and other specs create
* activities inside the mirror window — a full-scope pass would count them
* and flake the global assertions (C126; deploy verify blocker of
* 2026-09-13). Production callers omit the scope and keep the espelho cheio.
*/
const runSync = (
client: GoogleCalendarClient,
@ -147,7 +173,7 @@ describe('campaign Google calendar sync engine (C114)', () => {
runCampaignCalendarSync(payload, {
reason,
client,
activityWhere: { title: { like: 'C114%' } },
activityWhere: { id: { in: [...ownedActivityIds] } },
}),
)
@ -195,9 +221,43 @@ describe('campaign Google calendar sync engine (C114)', () => {
// C114 fixture scope. The int suite runs files in parallel against one
// shared database, so such rows exist at arbitrary times — the mirror
// must not count them (the C126 race).
const foreign = await createActivity({
title: `C126 alheia ${crypto.randomUUID().slice(0, 8)}`,
})
const foreign = await createActivity(
{ title: `C126 alheia ${crypto.randomUUID().slice(0, 8)}` },
{ track: false },
)
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
try {
const outcome = await runSync(client)
expect(outcome.created).toBe(1)
expect(store).toHaveLength(1)
expect(store.find((event) => event.id === googleEventIdForActivity(activity.id))).toBeTruthy()
expect(
store.find((event) => event.id === googleEventIdForActivity(foreign.id)),
).toBeUndefined()
} finally {
// Never leak the foreign row into other specs (the exact bug this pin
// guards against). The fixture proxy also auto-owns it, but the
// explicit delete keeps the guarantee visible here.
if (foreign) {
await payload.delete({ collection: 'activity', id: foreign.id, overrideAccess: true })
}
}
})
it('a foreign title CONTAINING c114 stays out of the scope (id-scoped mirror)', async () => {
const activity = await createActivity()
// The scope used to be a title marker (`like: 'C114%'`), but Payload's
// `like`/`contains` match ANYWHERE in the string (`ILIKE '%…%'`) — a
// parallel spec's fixture UUID embedding `c114` pulled its row into the
// mirror, flaking the exact counts (deploy verify, 2026-09-13). The
// scope is now ownership by id, so the marker's position is irrelevant.
const foreign = await createActivity(
{ title: `C126 alheia c114 ${crypto.randomUUID().slice(0, 8)}` },
{ track: false },
)
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
@ -211,10 +271,6 @@ describe('campaign Google calendar sync engine (C114)', () => {
store.find((event) => event.id === googleEventIdForActivity(foreign.id)),
).toBeUndefined()
} finally {
// The afterEach only cleans `C114%` titles — never leak the foreign
// row into other specs (the exact bug this pin guards against). The
// fixture proxy also auto-owns it, but the explicit delete keeps the
// guarantee visible here.
if (foreign) {
await payload.delete({ collection: 'activity', id: foreign.id, overrideAccess: true })
}
@ -227,9 +283,10 @@ describe('campaign Google calendar sync engine (C114)', () => {
// scope. A scoped mirror is authoritative for its scope — its teqo
// events are reconciled even when the delete-guard's full-scope view
// would have kept them.
const foreign = await createActivity({
title: `C126 alheia ${crypto.randomUUID().slice(0, 8)}`,
})
const foreign = await createActivity(
{ title: `C126 alheia ${crypto.randomUUID().slice(0, 8)}` },
{ track: false },
)
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = [
{

View file

@ -768,6 +768,29 @@ describe('e2e name locators are anchored', () => {
})
})
describe('int fixture scoping avoids hex title markers', () => {
// Deploy verify blocker of 2026-09-13 — Payload's `like` and `contains` are
// CONTAINS matches (the Postgres adapter compiles them to `ILIKE '%…%'`), so
// a hex-only title marker like `C114` also matches a fixture UUID that
// embeds it: a parallel spec's row leaks into the scoped mirror/cleanup and
// flakes exact counts. Scope passes by the ids of the rows the spec created;
// this sweep keeps the marker assumption from coming back.
const hexTitleMarker = /title:\s*\{\s*(?:like|contains):\s*['"]([^'"]+)['"]/g
it('keeps hex-only title markers out of tests/int queries', () => {
const offenders: string[] = []
for (const file of walkSourceFiles(resolve(repoRoot, 'tests/int'), ['.ts'])) {
const source = readFileSync(file, 'utf8')
for (const match of source.matchAll(hexTitleMarker)) {
if (/^[0-9a-f]{3,}%?$/i.test(match[1]!)) offenders.push(`${repoPath(file)}: ${match[0]}`)
}
}
expect(offenders, 'scope by owned ids instead of a title marker (deploy verify 2026-09-13)').toEqual([])
})
})
describe('public site metadata global access', () => {
// An empty `metadata` global (fresh DB / poisoned unstable_cache) used to
// crash `next build` via raw field access. Every `getCachedGlobal('metadata')`