From fedb291592ee5d72d7f266a4151229669015cae3 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 13 Sep 2026 12:11:54 -0300 Subject: [PATCH] =?UTF-8?q?fix(int):=20escopo=20do=20espelho=20Google=20Ca?= =?UTF-8?q?lendar=20por=20id,=20n=C3=A3o=20por=20t=C3=ADtulo=20=E2=80=94?= =?UTF-8?q?=20destrava=20deploy=20verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...13-deploy-verify-escopo-google-calendar.md | 1 + ...13-deploy-verify-escopo-google-calendar.md | 79 +++++++++++++ tests/int/googleCalendarSync.int.spec.ts | 107 ++++++++++++++---- tests/unit/codebaseConventions.unit.spec.ts | 23 ++++ 4 files changed, 185 insertions(+), 25 deletions(-) create mode 100644 docs/changelog/2026-09-13-deploy-verify-escopo-google-calendar.md create mode 100644 docs/postmortems/2026-09-13-deploy-verify-escopo-google-calendar.md diff --git a/docs/changelog/2026-09-13-deploy-verify-escopo-google-calendar.md b/docs/changelog/2026-09-13-deploy-verify-escopo-google-calendar.md new file mode 100644 index 00000000..203c19a0 --- /dev/null +++ b/docs/changelog/2026-09-13-deploy-verify-escopo-google-calendar.md @@ -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 '%%'` por palavra (split por espaço) e o `contains` para `ILIKE '%%'` (`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`, `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: '' }`. 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). diff --git a/docs/postmortems/2026-09-13-deploy-verify-escopo-google-calendar.md b/docs/postmortems/2026-09-13-deploy-verify-escopo-google-calendar.md new file mode 100644 index 00000000..37dee9a5 --- /dev/null +++ b/docs/postmortems/2026-09-13-deploy-verify-escopo-google-calendar.md @@ -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 '%%'` por palavra (split por espaço) e `contains` para `ILIKE '%%'` — 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` (`: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: '' }`. + +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. diff --git a/tests/int/googleCalendarSync.int.spec.ts b/tests/int/googleCalendarSync.int.spec.ts index 5fbae5ea..73bb8ecb 100644 --- a/tests/int/googleCalendarSync.int.spec.ts +++ b/tests/int/googleCalendarSync.int.spec.ts @@ -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() + /** + * 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() + 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 = {}) => { + /** + * 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 = {}, + 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[] = [ { diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index bf0cc6c8..86a3938e 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -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')`