diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 3bc173ef..b8c2337a 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-11):** **C126** (Issue #686) — os int specs do espelho Google (C114) deixam de flakear sob a suíte paralela: o motor ganhou **escopo injetável** `activityWhere?: Where` em `CampaignCalendarSyncOptions` (`src/utilities/googleCalendarSync.ts`) — appendado ao `and` do push set (`loadSyncActivities`) **e** do delete-guard set (`loadAliveActivityIds`); produção (hooks + action) omite o param → espelho cheio byte-identical. A race era mais ampla que o par da Issue: **5** specs criam atividades in-window no banco compartilhado (`campaignActivity`, `calendarFeed`, `homeSearchActivities`, `municipalityDossierData` + `campaignSuggestions` com `realizado` no lookback — o 5º que a Issue não nomeou), e `outcome.created` global contava todas; serialização não era construtível (vitest não serializa par-a-par; `fileParallelism:false` mataria o paralelismo da suíte) e "nunca relaxar a asserção" foi mantido à letra. O spec roda cada pass via helper `runSync(client, reason)` com `activityWhere: { title: { like: 'C114%' } }` (mesmo filtro do afterEach) e ganhou **dois pins**: (1) atividade estrangeira in-window **não** é espelhada; (2) o delete-guard honra o escopo (evento de atividade alheia viva é removido — espelho escopado é autoritativo no seu escopo). **Conflito com o C122 (#705, mergeado antes):** o C122 endereçou a C126 pela abordagem oposta (asserções globais **removidas** em favor de scoping por event-id, com delegação explícita do desfecho: "quem a detém decide") — a resolução do rebase mantém o contrato da Issue (contagens globais restauradas e determinísticas via escopo) e absorve a infra do C122 (credential compartilhada `googleCalendarTestKey`, cleanup de config por owned id, datas C127 derivadas do agora). **Contraprova:** fix 3/3 rodadas × 5 arquivos verdes (62/62); motor antigo re-flakeia 3–5 testes/rodada nas mesmas condições, e o pin 1 falha 100% — regressão viva. Gates: unit 2050, int 747, lint/typecheck/format/knip/cycles/build verdes. Sem migration/access/Consent/UI. Débito C127/#688 (datas hardcoded) já resolvido pelo C122 (#705). Impl [`c126-espelho-google-int-flake-isolamento-impl.md`](plans/c126-espelho-google-int-flake-isolamento-impl.md) (Issue = spec, sem intenção própria). + **Recently resolved (2026-08-11):** **OPS41** (Issue #689) — o `AGENTS.md` de main carregava **marcadores de conflito commitados** no bullet "Per-worktree environments" (` <<<<<<< HEAD` indentado + lado HEAD pré-OPS33 com artefato `- # ` + fechamento corrompido `> > > > > > > 9e18bec8`; o `=======` foi removido no próprio commit). Origem: o commit OPS33 f0f5b73f entrou com o arquivo **já conflitado** (resolve do conflito OPS33×OPS28/OPS31 commitado com marcadores); OPS37 herdou o estado. Resolução **perda zero validada por diff**: manter o lado superset (OPS28 + OPS31 + OPS33 — claim determinístico, `--issue N`, prompt citado) e descartar o lado HEAD; −3 linhas no diff; scan whitespace-tolerant de todos os arquivos trackeados confirmou `AGENTS.md` como único afetado. **Guardrail anti-reincidência** (2ª vez que marcador chega a main): novo describe `no conflict markers in committed files` em `tests/unit/codebaseConventions.unit.spec.ts` — varre `git ls-files`, falha em `<<<<<<<`/`>>>>>>>` (tolerante a indentação) e na forma corrompida commitada `^\s*(> ){7}`; roda no CI via `pnpm test:unit`. Sem migration/access/Consent/UI/schema. Intenção: body da Issue (sem plano linkado); impl [`agents-md-sem-marcadores-conflito-impl.md`](plans/agents-md-sem-marcadores-conflito-impl.md). **Recently resolved (2026-08-11):** **Plan-issue — rascunho UI/UX do gate vira HTML+Tailwind commitado no repo (ad-hoc, decisão do usuário).** O artefato deixa de ser **Cursor canvas** (`~/.cursor/projects//canvases/plan--ui-draft.canvas.tsx`, fora do repo, só renderizava no IDE de quem planejou e apodrecia) e passa a ser **HTML + Tailwind v4** (`docs/plans/-ui-draft.html` — `@tailwindcss/browser@4` via jsdelivr, mesma major do app 4.1.x; rede só na hora de renderizar) **commitado com o plano** e **renderizado em PNG** por `pnpm ui-draft:render` (`scripts/render-ui-draft.mjs`, Chromium do Playwright via `@playwright/test`, fallback `load` se o CDN não settle — nunca toca banco/app): uma PNG por cena `
` (ex. `mobile`, `desktop-vazio`) → `docs/plans/-ui-draft-.png`, ou full-page sem `data-shot`; PNGs **embutidos no plano** (visíveis no chat do gate E no PR — o aceite vira registro permanente; o HTML é a fonte iterável). **Contrato de fidelidade explícito em `ui-draft-html.md`** (substitui `ui-draft-canvas.md`, deletado; refs atualizadas em SKILL.md/frontmatter, intention-template.md — campo renomeado `Canvas UI:` → `Rascunho UI:` + secção de embed, skills-map.md, shaping.md): o rascunho DEVE ter layout real, espaçamentos/tamanhos na escala do app (`p-4`, `gap-3`, `h-11`, `text-sm`), breakpoints reais (390/1280) e estados críticos como cenas estáticas; é PROIBIDO ser funcional (zero JS de comportamento — cada estado é cena separada), usar código de app (`src/`), brand/polish (gradientes/emojis/sombras/logo/paleta do produto — cinza neutro + um acento), mock de dados realistas ou decisão de engenharia. Gate: iterar HTML → re-render → embutir até confirmação explícita. Verificado: render real do skeleton (classes v4 aplicadas — oklch zinc, rounded-lg 8px, text-lg 18px). Sem migration/access/Consent/UI de produto/schema. Sem issue — ad-hoc no worktree `work/5`. diff --git a/docs/plans/c126-espelho-google-int-flake-isolamento-impl.md b/docs/plans/c126-espelho-google-int-flake-isolamento-impl.md new file mode 100644 index 00000000..fd02afe3 --- /dev/null +++ b/docs/plans/c126-espelho-google-int-flake-isolamento-impl.md @@ -0,0 +1,76 @@ +# Impl: C126 — Int specs do espelho Google (C114) flakeam em paralelo com specs que criam atividades + +Status: em execução +Atualizado em: 2026-08-11 +Issue: #686 +Intenção: body da Issue #686 (sem plano de intenção linkado — a Issue é a spec) +Appetite restante: herdado (defect P2, ~0,25–0,5 dia — fix de teste + 1 param opcional) + +## Leitura da intenção + +- **Outcome:** o `googleCalendarSync.int.spec.ts` deixa de flakear quando a suíte int roda em paralelo: `outcome.created === 2` (full mirror), `=== 1` (converges/changing calendarId) e `=== 0` (outside window) passam a valer **independentemente** do que outras specs fazem no mesmo banco. A asserção global **não** é relaxada. +- **O que NÃO negociar:** nunca relaxar a asserção; o espelho cheio em produção (escopo do staff inteiro) permanece intacto; zero mudança de comportamento em runtime. +- **O que reavaliar:** a sugestão da Issue "escopar o espelho do teste a tag/range próprio do fixture **ou** serializar os dois specs". Reavaliação: a race é mais ampla que o par nomeado — medido no código, **4** specs criam atividades in-window no banco compartilhado (`campaignActivity.int.spec.ts:51`, `calendarFeed.int.spec.ts:41`, `homeSearchActivities.int.spec.ts:25`, `municipalityDossierData.int.spec.ts:71`, todas `startAt = now + 1 dia`). "Serializar os dois specs" não cobriria as outras três → a correção tem que isolar o espelho do fixture, não o par. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[Spec C114 cria atividades com título `C114 `] --> B[runSync: passa activityWhere title like C114%] + B --> C[loadSyncActivities merge and-array + activityWhere] + C --> D[passes do engine vêem SÓ o fixture C114] + D --> E[created === 2 determinístico] +``` + +**Opções consideradas:** + +- **A — Escopo injetável no motor** (`activityWhere?: Where` em `CampaignCalendarSyncOptions`): o motor passa a aceitar cláusulas extras no `where` do push set (`loadSyncActivities`) e do delete-guard set (`loadAliveActivityIds`); a spec passa `{ title: { like: 'C114%' } }` (o mesmo filtro do cleanup do afterEach — operador já provado no repo). Produção não passa o param → espelho cheio inalterado, byte-identical nos hooks/action. +- **B — Serializar os dois specs** (fileParallelism:false num projeto vitest para o par, ou global). +- **C — Isolar por banco/schema por spec**. +- **D — Pré-limpeza destrutiva no banco antes de cada pass** (deletar atividades alheias da janela). + +**Recomendação: A** — única que torna o resultado determinístico **para todas as fontes de poluição de uma vez** (as 4 specs), sem tocar em runtime (param opcional nunca passado em produção; hooks e action intactos) e sem custo de suíte. O param é o contrato honesto: "espelho completo por padrão; escopo estreito quando o chamador pedir" — e já serve a futuros mirrors parciais (nada a inventar depois). + +**Rejeitadas:** + +- **B porque** o vitest não tem serialização par-a-par de arquivos: `fileParallelism: false` num projeto com os 2 arquivos serializa só **esse par** — e a poluição real vem de **4** arquivos (o par nomeado na Issue é o observado, não o único); `fileParallelism: false` global mataria o paralelismo da suíte int inteira (maxWorkers 8 é deliberado, ver `vitest.config.mts`); e a dependência ordem/timing entre arquivos é exatamente a classe de flake que o repo combate (D9: "janela sem lease = race"; aqui não há lease que as outras specs tomem — não é construtível). +- **C porque** custo de infra gigante para um defect de teste; cada spec int nova exigiria banco próprio. +- **D porque** destrutivo e racioso (outra spec pode criar entre a limpeza e o pass) — não resolve, só move a janela. + +### Componentes / mudanças + +- **`CampaignCalendarSyncOptions.activityWhere`** (`src/utilities/googleCalendarSync.ts`): novo campo opcional `activityWhere?: Where`; `loadSyncActivities` e `loadAliveActivityIds` ganham o param e **appendam** ao array `and` existente (mesmo shape do `buildFeedWhere` de `calendarFeed.ts` — precedente provado); `runSyncPass` repassa; `runCampaignCalendarSync` passa `options.activityWhere`. Import `Where` de `'payload'` (precedente `calendarFeed.ts:24`). Doc comment: omitido em produção (espelho cheio); existe para espelhos escopados/isolação de teste. +- **`tests/int/googleCalendarSync.int.spec.ts`**: helper local `runSync(client, reason = 'manual')` = `withCredential(() => runCampaignCalendarSync(payload, { reason, client, activityWhere: { title: { like: 'C114%' } } }))` — substitui as ~16 chamadas de pass; as 2 chamadas do teste no-op (sem credencial) ficam diretas (short-circuit antes de qualquer query — adicionar escopo ali é ruído). +- **Teste-pin novo** no mesmo spec: "mirrors only the fixture scope" — cria UMA atividade C114 + UMA atividade "estrangeira" in-window (título fora do `C114%`), roda `runSync`, espera `created === 1` e store com só 1 evento; cleanup da estrangeira em `finally` (o afterEach só limpa `C114%` — não vazar poluição para outras specs, o exato bug que estamos matando). Esse pin é a regressão viva: no código atual (sem escopo) a estrangeira seria espelhada → `created === 2` → vermelho. +- **Migration:** sem migration (mudança de TS puro). +- **Access / Consent:** nenhum — sem chave, sem PII; motor continua `overrideAccess` com a mesma racionalidade documentada. +- **UI:** nenhuma (sem Impeccable). +- **Changelog:** entrada curta em `docs/CHANGELOG-AGENTS.md` (padrão "Recently resolved"). + +## Fases verificáveis + +1. **Motor + spec** — param `activityWhere` + threading nos 2 loads; helper `runSync`; substituição das chamadas; teste-pin. (núcleo do appetite) +2. **Prova de determinismo** — repro/contraprova local contra as 4 specs poluidoras: `pnpm vitest run --maxWorkers 8 tests/int/googleCalendarSync.int.spec.ts tests/int/campaignActivity.int.spec.ts tests/int/calendarFeed.int.spec.ts tests/int/homeSearchActivities.int.spec.ts tests/int/municipalityDossierData.int.spec.ts` × N (na base, eventualmente flakeia; com o fix, sempre verde); depois suíte int completa. +3. **Gates** — `pnpm gate:fast` (lint/typecheck/unit), `pnpm test` (int), `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm build`; e2e não afetado (sem UI — CI cobre), changelog, impl plan `aprovado`, push. + +## Rabbit holes / Não escopo (engenharia) + +- Leases/advisory locks de atividade para serializar specs — não construtível (as poluidoras não tomam lease) e não necessário (escopo resolve). +- "Melhorar" as outras 4 specs (mudar `startAt` delas) — altera semântica testada delas; não é o owner do defeito. +- Escopo no lado remoto (`listEvents`) — desnecessário: o stub remoto só contém o que o próprio spec inseriu; em produção o espelho cheio continua listando o calendário inteiro. +- C122 (estados do espelho) — follow-up independente (#pendente), sem overlap com este fix. + +## Riscos e mitigação + +- **Param opcional no motor vira API pública sem consumidor real** → mitigado: é a superfície mínima honesta (1 campo opcional, documentado), os 3 callers de produção não o passam, e o teste-pin exercita o contrato; se um dia não servir, remove-se junto do teste. +- **`like` de título com `%` em outros operadores** → o afterEach do próprio spec já usa `title: { like: 'C114%' }` há semanas sem falso-positivo (títulos alheios nunca começam com `C114`); o pin usa título estrangeiro explícito. +- **Poluição do teste-pin para outras specs** → cleanup em `finally` dentro do próprio teste; o afterEach existente cobre o resto. +- **Race residual de outra natureza** → fora de escopo da Issue (self-healing do estado é C122; o engine já recobre por passadas). + +## Aceite de engenharia + +- [ ] Aceite da Issue coberto: asserções globais intactas, determinísticas sob a suíte paralela +- [ ] Invariantes AGENTS: motor com espelho cheio em produção (callers sem `activityWhere`); zero `src/` de teste-only (param é contrato de produto do motor) +- [ ] Teste-pin da isolação (estrangeira in-window não é espelhada) — regressão viva +- [ ] Contraprova: 5 arquivos (C114 + 4 poluidoras) × N runs verdes; suíte int completa verde +- [ ] Changelog registrado; sem migration/access/Consent/UI diff --git a/src/utilities/googleCalendarSync.ts b/src/utilities/googleCalendarSync.ts index 17529d35..f5cf9dd5 100644 --- a/src/utilities/googleCalendarSync.ts +++ b/src/utilities/googleCalendarSync.ts @@ -1,7 +1,7 @@ import 'server-only' import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types' -import type { Payload, PayloadRequest } from 'payload' +import type { Payload, PayloadRequest, Where } from 'payload' import { activityMunicipalityIdOf, @@ -176,10 +176,13 @@ export const readGoogleCalendarSyncView = async ( * advisor intersection — a coordinator-managed calendar, unlike per-feed * creator scopes. Canceled activities are excluded from the PUSH set (their * events get deleted); the window is the feed's own (shared contract). + * `activityWhere` narrows the mirrored set (e.g. a scoped mirror or test + * isolation) — production callers omit it and keep the full scope. */ const loadSyncActivities = async ( payload: Payload, - req?: PayloadRequest, + req: PayloadRequest | undefined, + activityWhere?: Where, ): Promise<{ activities: Activity[]; municipalityNames: Map }> => { const { rangeStart, rangeEnd } = buildActivityWindowRange() @@ -189,7 +192,12 @@ const loadSyncActivities = async ( limit: 0, pagination: false, sort: 'startAt', - where: { and: buildActivityWindowWhereClauses(rangeStart, rangeEnd) }, + where: { + and: [ + ...buildActivityWindowWhereClauses(rangeStart, rangeEnd), + ...(activityWhere ? [activityWhere] : []), + ], + }, // Intentional admin bypass: the official mirror is the whole campaign // agenda (espelho cheio) — the collection access already gates writers. overrideAccess: true, @@ -221,6 +229,7 @@ const loadAliveActivityIds = async ( req: PayloadRequest | undefined, timeMin: string, timeMax: string, + activityWhere?: Where, ): Promise> => { const result = await payload.find({ collection: 'activity', @@ -239,6 +248,7 @@ const loadAliveActivityIds = async ( ], }, { status: { not_equals: 'cancelado' } }, + ...(activityWhere ? [activityWhere] : []), ], }, // Intentional admin bypass: same espelho-cheio rationale as above. @@ -288,9 +298,10 @@ const runSyncPass = async ( req: PayloadRequest | undefined, client: GoogleCalendarClient, calendarId: string, + activityWhere?: Where, ): Promise => { const { rangeStart, rangeEnd } = buildActivityWindowRange() - const { activities, municipalityNames } = await loadSyncActivities(payload, req) + const { activities, municipalityNames } = await loadSyncActivities(payload, req, activityWhere) const listWindow = buildListWindow(rangeStart, rangeEnd) const remoteEvents = await client.listEvents(calendarId, listWindow) const aliveActivityIds = await loadAliveActivityIds( @@ -298,6 +309,7 @@ const runSyncPass = async ( req, listWindow.timeMin, listWindow.timeMax, + activityWhere, ) const wantedById = new Map() @@ -383,6 +395,13 @@ export type CampaignCalendarSyncOptions = { reason: 'create' | 'update' | 'delete' | 'config-change' | 'manual' client?: GoogleCalendarClient req?: PayloadRequest + /** + * Narrows the mirrored activity set (push + delete-guard). Production + * callers omit it — the official mirror is the full staff scope. Exists + * for scoped mirrors and test isolation (e.g. a spec that must not see + * activities other parallel specs create in the same database). + */ + activityWhere?: Where } export const runCampaignCalendarSync = async ( @@ -412,7 +431,13 @@ export const runCampaignCalendarSync = async ( const client = options.client ?? createGoogleCalendarClient(credentials) try { - const counts = await runSyncPass(payload, options.req, client, config.calendarId) + const counts = await runSyncPass( + payload, + options.req, + client, + config.calendarId, + options.activityWhere, + ) await recordSyncState(payload, options.req, { lastSyncedAt: at, lastSuccessAt: at }) return { status: 'synced', ...counts, at } } catch (error) { diff --git a/tests/int/googleCalendarSync.int.spec.ts b/tests/int/googleCalendarSync.int.spec.ts index e9c0e6b8..40e8169a 100644 --- a/tests/int/googleCalendarSync.int.spec.ts +++ b/tests/int/googleCalendarSync.int.spec.ts @@ -11,6 +11,7 @@ import { loadGoogleCalendarSyncConfig, readGoogleCalendarSyncView, runCampaignCalendarSync, + type CampaignCalendarSyncOptions, } from '@/utilities/googleCalendarSync' import { hookFilledCreateData } from '@/utilities/hookFilledData' @@ -99,17 +100,23 @@ describe('campaign Google calendar sync engine (C114)', () => { } /** - * The mirror is the WHOLE staff scope (espelho cheio) — parallel spec files - * create activities on the same test DB while this file runs, so the - * engine's absolute created/updated/deleted counts are not deterministic - * here. The assertions are therefore scoped to this spec's own activities - * via their deterministic event ids, which IS the engine's contract. + * 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. */ - const ourEvent = (store: GoogleRemoteEvent[], activityId: number) => - store.find((event) => event.id === googleEventIdForActivity(activityId)) - - const ourEvents = (store: GoogleRemoteEvent[], activityId: number) => - store.filter((event) => event.id === googleEventIdForActivity(activityId)) + const runSync = ( + client: GoogleCalendarClient, + reason: CampaignCalendarSyncOptions['reason'] = 'manual', + ) => + withGoogleCalendarTestCredential(() => + runCampaignCalendarSync(payload, { + reason, + client, + activityWhere: { title: { like: 'C114%' } }, + }), + ) it('creates the full mirror on the first pass (timed + all-day, municipality summary)', async () => { const municipality = await campaignFixtures().getMunicipality() @@ -128,18 +135,19 @@ describe('campaign Google calendar sync engine (C114)', () => { const store: GoogleRemoteEvent[] = [] const client = createStubClient(store) - const outcome = await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client }), - ) + const outcome = await runSync(client) expect(outcome.status).toBe('synced') + expect(outcome.created).toBe(2) + expect(outcome.updated).toBe(0) + expect(outcome.deleted).toBe(0) - const timedEvent = ourEvent(store, timed.id) + const timedEvent = store.find((event) => event.id === googleEventIdForActivity(timed.id)) expect(timedEvent?.summary).toBe(`[${municipality.name}] ${timed.title}`) expect(timedEvent?.location).toBe('Centro') expect(timedEvent?.start).toEqual({ dateTime: expect.stringMatching(/-03:00$/) }) - const allDayEvent = ourEvent(store, allDay.id) + const allDayEvent = store.find((event) => event.id === googleEventIdForActivity(allDay.id)) expect(allDayEvent?.start).toEqual({ date: allDayCivilDateOf(allDayStartAt) }) expect(allDayEvent?.end).toEqual({ date: allDayExclusiveEndDate(allDayEndAt) }) @@ -148,27 +156,81 @@ describe('campaign Google calendar sync engine (C114)', () => { expect(doc?.lastError).toBeNull() }) - it('converges: a second pass with no changes touches nothing', async () => { + it('mirrors only the fixture scope — a foreign in-window activity stays out (C126)', async () => { const activity = await createActivity() + // A row "from another spec": inside the mirror window but outside the + // 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)}`, + }) await createConfig(calendarA) const store: GoogleRemoteEvent[] = [] const client = createStubClient(store) - const first = await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client }), - ) - expect(first.status).toBe('synced') - expect(ourEvent(store, activity.id)).toBeTruthy() - const contentAfterFirstPass = JSON.stringify(ourEvent(store, activity.id)) + 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 { + // 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 }) + } + } + }) - const second = await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client }), - ) - expect(second.status).toBe('synced') - // Idempotence, scoped to our activity: still exactly one event with the - // same content — no re-create, no update, no delete. - expect(ourEvents(store, activity.id)).toHaveLength(1) - expect(JSON.stringify(ourEvent(store, activity.id))).toBe(contentAfterFirstPass) + it('the delete guard honors the scope: an out-of-scope alive event is removed (C126)', async () => { + await createActivity() + // Alive and inside the mirror window, but outside the C114 fixture + // 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)}`, + }) + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [ + { + id: googleEventIdForActivity(foreign.id), + summary: 'Evento alheio', + start: { dateTime: new Date(Date.now() + 86_400_000).toISOString() }, + }, + ] + const client = createStubClient(store) + + try { + const outcome = await runSync(client) + expect(outcome.deleted).toBe(1) + // The in-scope activity is still mirrored on the same pass. + expect(outcome.created).toBe(1) + expect(store.some((event) => event.id === googleEventIdForActivity(foreign.id))).toBe(false) + } finally { + if (foreign) { + await payload.delete({ collection: 'activity', id: foreign.id, overrideAccess: true }) + } + } + }) + + it('converges: a second pass with no changes touches nothing', async () => { + await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + + const first = await runSync(client) + expect(first.created).toBe(1) + + const second = await runSync(client) + expect(second).toMatchObject({ created: 0, updated: 0, deleted: 0, status: 'synced' }) }) it('updates only the drifted event and ignores foreign calendar events', async () => { @@ -178,9 +240,7 @@ describe('campaign Google calendar sync engine (C114)', () => { { id: 'foreign-manual-event', summary: 'Mantido pela conta da campanha' }, ] const client = createStubClient(store) - await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client }), - ) + await runSync(client) // Title is immutable after creation (canonical slug rule) — drift a // mutable mirrored field instead (locality is in the mirror surface). @@ -192,12 +252,13 @@ describe('campaign Google calendar sync engine (C114)', () => { overrideAccess: true, }) - const outcome = await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client }), - ) - expect(outcome.status).toBe('synced') + const outcome = await runSync(client) + expect(outcome.updated).toBe(1) + expect(outcome.deleted).toBe(0) expect(store.find((event) => event.id === 'foreign-manual-event')).toBeTruthy() - expect(ourEvent(store, activity.id)?.location).toBe('Centro (atualizado)') + expect( + store.find((event) => event.id === googleEventIdForActivity(activity.id))?.location, + ).toBe('Centro (atualizado)') }) it('canceled activities leave Google: their events are deleted, none created', async () => { @@ -205,10 +266,8 @@ describe('campaign Google calendar sync engine (C114)', () => { await createConfig(calendarA) const store: GoogleRemoteEvent[] = [] const client = createStubClient(store) - await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client }), - ) - expect(ourEvent(store, activity.id)).toBeTruthy() + await runSync(client) + expect(store).toHaveLength(1) await payload.update({ collection: 'activity', @@ -218,11 +277,9 @@ describe('campaign Google calendar sync engine (C114)', () => { overrideAccess: true, }) - const outcome = await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client }), - ) - expect(outcome.status).toBe('synced') - expect(ourEvent(store, activity.id)).toBeUndefined() + const outcome = await runSync(client) + expect(outcome.deleted).toBe(1) + expect(store).toHaveLength(0) }) it('hard-deleted activities leave no ghost in Google', async () => { @@ -230,39 +287,33 @@ describe('campaign Google calendar sync engine (C114)', () => { await createConfig(calendarA) const store: GoogleRemoteEvent[] = [] const client = createStubClient(store) - await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client }), - ) - expect(ourEvent(store, activity.id)).toBeTruthy() + await runSync(client) + expect(store).toHaveLength(1) await payload.delete({ collection: 'activity', id: activity.id, overrideAccess: true }) - const outcome = await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client }), - ) - expect(outcome.status).toBe('synced') - expect(ourEvent(store, activity.id)).toBeUndefined() + const outcome = await runSync(client) + expect(outcome.deleted).toBe(1) + expect(store).toHaveLength(0) }) it('activities outside the window never reach Google', async () => { // C127: now + 400 days — far beyond the ~90-day push window, but never // hardcoded (a fixed far date would ENTER the window as it approaches). - const activity = await createActivity({ + await createActivity({ startAt: new Date(Date.now() + 400 * 86_400_000).toISOString(), }) await createConfig(calendarA) const store: GoogleRemoteEvent[] = [] const client = createStubClient(store) - const outcome = await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client }), - ) - expect(outcome.status).toBe('synced') - expect(ourEvent(store, activity.id)).toBeUndefined() + const outcome = await runSync(client) + expect(outcome.created).toBe(0) + expect(store).toHaveLength(0) }) it('failures land in paused and the next success recovers to synced', async () => { - const activity = await createActivity() + await createActivity() await createConfig(calendarA) const failingClient: GoogleCalendarClient = { @@ -271,9 +322,7 @@ describe('campaign Google calendar sync engine (C114)', () => { throw new Error('Google fora do ar (simulado)') }, } - const failed = await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client: failingClient }), - ) + const failed = await runSync(failingClient) expect(failed.status).toBe('paused') const doc = await loadGoogleCalendarSyncConfig(payload) @@ -287,11 +336,9 @@ describe('campaign Google calendar sync engine (C114)', () => { expect(failedView.status).toBe('paused') const store: GoogleRemoteEvent[] = [] - const recovered = await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client: createStubClient(store) }), - ) + const recovered = await runSync(createStubClient(store)) expect(recovered.status).toBe('synced') - expect(ourEvent(store, activity.id)).toBeTruthy() + expect(recovered.created).toBe(1) const recoveredView = await withGoogleCalendarTestCredential(() => readGoogleCalendarSyncView(payload), ) @@ -309,20 +356,18 @@ describe('campaign Google calendar sync engine (C114)', () => { expect(store).toHaveLength(0) await createConfig(calendarA) - // Config present but env key absent (outside withCredential). + // Config present but env key absent (outside the credential helper). const withoutKey = await runCampaignCalendarSync(payload, { reason: 'manual', client }) expect(withoutKey.status).toBe('not-configured') expect(store).toHaveLength(0) }) it('changing the calendarId reconciles into the new calendar (D7 engine side)', async () => { - const activity = await createActivity() + await createActivity() await createConfig(calendarA) const storeA: GoogleRemoteEvent[] = [] - await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { reason: 'manual', client: createStubClient(storeA) }), - ) - expect(ourEvent(storeA, activity.id)).toBeTruthy() + await runSync(createStubClient(storeA)) + expect(storeA).toHaveLength(1) const config = await loadGoogleCalendarSyncConfig(payload) await payload.update({ @@ -334,15 +379,10 @@ describe('campaign Google calendar sync engine (C114)', () => { }) const storeB: GoogleRemoteEvent[] = [] - const outcome = await withGoogleCalendarTestCredential(() => - runCampaignCalendarSync(payload, { - reason: 'config-change', - client: createStubClient(storeB), - }), - ) - expect(outcome.status).toBe('synced') - expect(ourEvent(storeB, activity.id)).toBeTruthy() + const outcome = await runSync(createStubClient(storeB), 'config-change') + expect(outcome.created).toBe(1) + expect(storeB).toHaveLength(1) // The abandoned calendar keeps its last mirror (documented, no retro-cleanup). - expect(ourEvent(storeA, activity.id)).toBeTruthy() + expect(storeA).toHaveLength(1) }) })