mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
fix(C126): isola o escopo do espelho Google nos int specs (fim do flake paralelo)
This commit is contained in:
parent
c08be6c46a
commit
c69990a3f0
4 changed files with 233 additions and 90 deletions
|
|
@ -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/<workspace>/canvases/plan-<id>-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/<slug>-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 `<section data-shot="<rótulo>">` (ex. `mobile`, `desktop-vazio`) → `docs/plans/<slug>-ui-draft-<rótulo>.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`.
|
||||
|
|
|
|||
76
docs/plans/c126-espelho-google-int-flake-isolamento-impl.md
Normal file
76
docs/plans/c126-espelho-google-int-flake-isolamento-impl.md
Normal file
|
|
@ -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 <uuid>`] --> 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
|
||||
|
|
@ -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<number, string> }> => {
|
||||
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<Set<number>> => {
|
||||
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<SyncCounts> => {
|
||||
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<number, Activity>()
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue