test(C122): cobertura dos estados do espelho Google — unit do chrome, e2e dos 4 estados + FAB, int da action de desativar

This commit is contained in:
Francisco Solla 2026-08-11 19:43:19 -03:00
parent 11605a2ef3
commit c7c9556ff3
8 changed files with 771 additions and 89 deletions

View file

@ -0,0 +1,183 @@
# Impl: C122 — C114 follow-up: cobertura de testes dos estados do espelho Google
Status: aprovado
Atualizado em: 2026-08-11
Issue: #666
Intenção: docs/plans/c122-c114-followup-testes-estados-espelho.md
Appetite restante: ~0,5 dia eng (fill-in de cobertura — herdado)
## Leitura da intenção
- **Outcome:** S14 (unit do chrome: rótulo da pill por status + auto-retry uma
vez quando `paused`), S17 (e2e dialog em disabled/paused/synced + FAB mobile)
e S18 (int da action: disable grava `disabledAt`, re-enable limpa e o hook da
config re-sincroniza) cobertos. Zero mudança de comportamento de produção.
- **O que NÃO negociar:** estados determinísticos SEM rede (nenhuma chamada ao
Google real); `leader` lockdown e gates staff intactos; nada de mock que
mascare o write path real (a action grava em DB de teste com hooks reais).
- **O que reavaliar** (hipóteses da intenção que exigem decisão de engenharia):
1. "env key fake" no e2e só existe no processo do servidor Playwright → a
chave entra no `webServer.env` (suite-wide, não por teste).
2. O e2e roda `fullyParallel` com specs de activity escrevendo em paralelo;
o hook de activity roda o engine REAL quando o doc `googleCalendarSync`
existe → escreve `lastErrorAt` no doc da nossa spec. O estado `synced`
precisa de `lastSuccessAt` no futuro (2099) para ser imune a esse ruído.
3. A int da action: mockar `getCampaignActionContext`/`reloadStaffActor` (a
action lê `cookies()` de next/headers — invocável só com request real) e
observar a reconciliação D7 pelo efeito no DB (`lastErrorAt`), com a chave
fake que falha localmente em `importPKCS8` — spy desnecessário.
4. A pill é registrada via `SetCampaignHeaderAction` (retorna `null`) → o
unit test precisa de um probe que renderize `headerActions['google-calendar-sync']`
do contexto.
## Abordagem recomendada
```mermaid
flowchart LR
A[Unit chrome jsdom+RTL] --> D[Pill label por status + auto-retry 1x]
B[e2e dialog estados + FAB] --> E[seed doc via fixtures + env key fake no webServer]
C[int action] --> F[mock campaignActionContext + engine real + chave fake]
G[tests/helpers/googleCalendarTestKey.ts] --> B & C
```
**Opções consideradas:**
- **E2E "como injetar a chave fake":** A) `webServer.env` no playwright.config
(suite-wide, key fake idêntica à do int) — **recomendada**; B) apenas seed do
doc sem env → impossível: `deriveGoogleCalendarSyncStatus` retorna
`not-configured` sem `hasCredential` (fail-closed primeiro); C) estado via
query/env por teste → não existe mecanismo (server actions rodam no processo
único do servidor).
- **E2E `synced` sob paralelismo:** A) seed `lastSuccessAt` no futuro
(qualquer `lastErrorAt` de hook paralelo fica mais velho → deriva `synced`)
— **recomendada**; B) serializar a spec → não protege contra hooks de OUTROS
spec files (fullyParallel); C) minimizar a janela seed→SSR → racy por
definição (specs de activity escrevem a cada poucos segundos).
- **Int da action:** A) mockar `@/utilities/campaignActionContext`
(getCampaignActionContext + reloadStaffActor) e manter engine/hooks reais com
a chave fake — **recomendada**: o D7 é observado pelo efeito no DB
(`lastErrorAt` gravado pelo engine DENTRO do afterChange da re-enable), que é
a prova mais forte sem rede; B) mockar o engine com spy → não prova o write
real e duplica o padrão do `googleCalendarSyncHooks.unit.spec.ts`; C) testar
pela UI → é o e2e, que já cobre o fluxo; o int é o lugar do D7.
- **DRY da chave fake:** A) helper compartilhado `tests/helpers/googleCalendarTestKey.ts`
— **recomendada** (3 call sites: int engine, int action, playwright config;
drift entre chaves quebraria a premissa "falha local sem rede"); B) copiar a
base64 em cada site → barato hoje, envenenado amanhã.
**Rejeitadas:** B/C de cada bloco acima (razões in-line).
### Componentes / mudanças
- **`tests/unit/agendaGoogleSyncChrome.unit.spec.tsx`** (novo): RTL + jsdom
(precedente `calendarFeedDialog.unit.spec.tsx` — stubs de matchMedia desktop,
ResizeObserver, scrollIntoView). Harness: `CampaignPageChromeProvider` +
`CampaignQuickActionContextProvider` + probe que renderiza
`headerActions['google-calendar-sync']`. Casos: label da pill por status
(synced/paused/not-configured/disabled); auto-retry chama `onSyncNow` UMA vez
com `paused` e zero com `synced`/`not-configured` (via `waitFor`).
- **`tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts`** (expandir): mantém o
teste `not-configured`; novos: `disabled` (pill + dialog + Reativar →
transição determinística para `paused`, porque o sync pós-re-enable falha
local na chave fake), `paused` (pill + dialog + link "Abrir Google Calendar";
o auto-retry de mount falha local e reforça o estado), `synced` (pill +
dialog com link block/copiar/instruções + Desativar), e FAB mobile em
describe aninhado com `test.use({ viewport: 390×844 })` → "Ações rápidas" →
"Agenda da Campanha" → sheet "Agenda da Campanha no Google" (estado
`not-configured`, sem doc).
- **`tests/int/googleCalendarSyncAction.int.spec.ts`** (novo): mock de
`campaignActionContext`; fixture coordinator real; seed do doc; (1) disable →
`ok` + status `disabled` + `disabledAt` no DB, engine NÃO rodou
(verificável: `lastErrorAt` continua null); (2) re-enable → `disabledAt`
null + `lastErrorAt` gravado (o hook D7 rodou) + view `paused`. Chave fake
via helper; `// @vitest-environment node`.
- **`tests/helpers/googleCalendarTestKey.ts`** (novo): base64 do JSON fake
(client_email + private_key 'MOCK…') — falha em `importPKCS8` localmente,
sem rede (verificado: `Invalid keyData`).
- **`tests/int/googleCalendarSync.int.spec.ts`** (refactor): `FAKE_KEY` →
helper (mesmo valor, sem mudança de comportamento).
- **`playwright.config.ts`**: `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` no
`webServer.env` (chave fake do helper). Colateral: nenhum — a derivação de
status só muda com doc; o engine só roda com doc; só esta spec cria doc.
- **`tests/e2e/fixtures/campaignE2EFixtures.ts`**: `'googleCalendarSync'` em
`OwnedCollection` + `deletionOrder` (auto-own/cleanup do doc da spec).
- **Migration:** sem migration. **Access/Consent:** inalterados. **UI:** sem
mudança de superfície (Impeccable B — só testes).
## Fases verificáveis
1. **Unit do chrome** — `pnpm test:unit -- agendaGoogleSyncChrome`
2. **Int da action** — `pnpm test:int -- googleCalendarSyncAction`
3. **E2e** — `pnpm test:e2e -- tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts`
(dev server local do worktree; PLAYWRIGHT_BASE_URL=http://localhost:3222)
4. **Gates** — `pnpm lint` + `pnpm typecheck` + `pnpm test` + `pnpm format:check`
- `pnpm exec knip` + `pnpm check:cycles`
## Rabbit holes / Não escopo (engenharia)
- Testar os estados do pill no modo mobile (decisão C114: FAB é a superfície
mobile) — fora.
- Refactor do chrome de instruções addbyurl (2 call sites) — defer, gatilho:
3ª superfície (C115).
- Testar o role gate da action (`reloadStaffActor` falhando) — o gate já é
exercitado em outras frentes; o mock o neutraliza aqui de propósito.
- Unit do dialog por estado (copy) — o e2e cobre a superfície; unit fica no
chrome (aceite da intenção).
## Riscos e mitigação
- **Hooks paralelos escrevem `lastErrorAt` no doc da spec** → `synced` imune
via `lastSuccessAt` 2099; `disabled` imune (`disabledAt` vence); `paused`
reforçado. Nenhum outro spec é afetado (o engine só roda quando há doc).
- **Rede acidental no e2e** → chave fake falha em `importPKCS8` ANTES de
qualquer fetch (verificado em node: `Invalid keyData`); int usa o mesmo
helper.
- **Radix Dialog em jsdom** → precedente `calendarFeedDialog.unit.spec.tsx`
(stubs de matchMedia/ResizeObserver/scrollIntoView).
- **`recordSyncState` aninhado no afterChange da própria config** → é o
comportamento de produção já shipped (C114); o teste apenas o observa.
- **CI sem Docker/Cloud** → nenhuma dependência nova; DB de teste local.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (S14/S17/S18, sem rede,
determinístico)
- [x] Invariantes AGENTS/engineering-standards (sem mudança em código de
produção; testes só em `tests/` + config de teste)
- [x] Testes de domínio previstos: unit (chrome), int (action D7), e2e
(estados + FAB)
## Já resolvido no simplify/critique (não reabrir)
- **C126 (#686, pre-existente, in-progress por outro agente):** o flake do
`googleCalendarSync.int.spec.ts` sob carga paralela (contagens globais vs
atividades de specs paralelas) foi resolvido nesta sessão com asserções
escopadas ao próprio fixture pelos ids determinísticos (`ourEvent(s)` + o
contrato do store stub). A abordagem preferida do C126 ("escopar o espelho
do teste" = mudança de produção; "serializar os dois specs" = não existe em
vitest) é inviável sem tocar produção — a asserção escopada preserva as
garantias por atividade (espelho criado, convergência idempotente, update/
delete, recuperação). Não editada (in-progress); o dono decide o desfecho.
- **C127 (#688):** datas hardcoded no int spec (`2026-08-10` all-day e
`2029-01-01` outside-window) substituídas por instantes derivados do agora
(all-day: now+2d..+4d com datas esperadas via `allDayCivilDateOf`/
`allDayExclusiveEndDate`; fora-da-janela: now+400d) — sem expiração
determinística.
- Todos os achados `[apply]` dos 3 reviewers do /simplify (label matrix via
`PILL_LABELS`, default `onSyncNow` consistente com o status — remove uma
corrida real —, `it.each`, `stubMatchMedia` compartilhado, `syncDialog`
helper, overrides tipados, const do env, cleanup de config por id próprio
nos dois int specs, remoção do alias `withCredential`).
## Explicitamente fora
- **Constante compartilhada de calendarId** entre os dois int specs (2 call
sites — DRY <3): defer, gatilho = 3º spec do espelho.
- **Serialização de passes concorrentes** (advisory lock): defer da intenção,
gatilho = reclamação de pill preso em `paused` ou o C115.
- **Refactor do chrome de instruções addbyurl** (2 call sites): defer da
intenção, gatilho = 3ª superfície de calendário (C115).
- **Teste do pill no modo mobile** (decisão C114: FAB é a superfície mobile).
- **Flake do `campaignSuggestions.int.spec.ts`** (claims de município sob
carga): já rastreado em #553 — não reaberto aqui.
- Timeouts de dev server sob carga (load ~73 no host): ambiente, não débito.

View file

@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'
import { config as loadEnv } from 'dotenv'
import { assertTestDatabase } from './tests/helpers/assertTestDatabase'
import { GOOGLE_CALENDAR_TEST_KEY } from './tests/helpers/googleCalendarTestKey'
/**
* e2e tests boot a real dev server that seeds/deletes records, so they must run
@ -142,6 +143,14 @@ export default defineConfig({
env: {
DATABASE_URL: process.env.DATABASE_URL as string,
PORT: webServerPort,
/*
* C122 — the fake service-account key lets the agenda Google mirror
* derive real states (synced/disabled/paused) in the server process.
* It parses as a credential but fails locally at JWT signing, so any
* sync pass the hooks/auto-retry run fails fast WITHOUT network — the
* e2e states stay deterministic (see `googleCalendarTestKey.ts`).
*/
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY: GOOGLE_CALENDAR_TEST_KEY,
// Fixed dist dir: Next dev appends `<distDir>/types/**/*.ts` to
// tsconfig.json's include with an EXACT-string check, so per-process
// names polluted tsconfig with one entry per run. A prior Playwright

View file

@ -1,13 +1,54 @@
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
import type { Page } from '@playwright/test'
import type { GoogleCalendarSync } from '../../src/payload-types.js'
import {
campaignPageChrome,
expect,
test,
type CampaignE2EOwnership,
} from './fixtures/campaignE2EFixtures.js'
/**
* C114 — Google Calendar mirror surface on the agenda. The e2e only pins the
* deterministic "não configurado" state (no credential env in the test
* runtime, no sync doc in the seeded DB): engine behavior (create/update/
* delete/recover) is covered by the int tests with an in-memory calendar
* stub — this spec must never depend on the real Google API.
* C114 + C122 — Google Calendar mirror surface on the agenda. The e2e pins the
* four derived states deterministically: the sync doc is seeded per test and
* the dev server runs with the FAKE service-account key
* (`googleCalendarTestKey.ts`, injected via `webServer.env`) — it parses as a
* credential (so the view derives real states) but fails locally at JWT
* signing, so any sync pass the auto-retry/hooks run fails fast WITHOUT
* touching the real Google API.
*
* The `synced` seed uses a far-future `lastSuccessAt`: parallel activity
* specs' hooks write `lastErrorAt` on the shared single sync doc while this
* test runs, and a stale `lastSuccessAt` would flip the derived state to
* `paused`. `disabled`/`paused` are naturally immune (their derivation wins).
* The no-seed states (`not-configured`, mobile) are safe for the same reason
* no other spec ever creates a `googleCalendarSync` doc — only this spec
* seeds it.
*
* Serial mode: the four states share the ONE `googleCalendarSync` doc — if the
* file's tests ran in parallel they would race each other on it (same pattern
* as `campaignNearestMunicipality`).
*/
test.describe('Agenda — sincronização Google (C114)', () => {
test.describe.configure({ mode: 'serial' })
const CALENDAR_ID = 'c_campanha_e2e@group.calendar.google.com'
const FAR_FUTURE = '2099-01-01T00:00:00.000Z'
const syncDialog = (page: Page) =>
page.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
const seedSyncConfig = (
fixtures: CampaignE2EOwnership,
overrides: Partial<GoogleCalendarSync> = {},
) =>
fixtures.payload.create({
collection: 'googleCalendarSync',
data: { calendarId: CALENDAR_ID, ...overrides },
depth: 0,
overrideAccess: true,
})
test.describe('Agenda — sincronização Google (C114/C122)', () => {
test('staff vê a pill "não configurado" e o diálogo explica o passo de operação', async ({
campaign,
page,
@ -24,10 +65,135 @@ test.describe('Agenda — sincronização Google (C114)', () => {
await expect(pill).toBeVisible({ timeout: 15_000 })
await pill.click()
const dialog = page.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
const dialog = syncDialog(page)
await expect(dialog).toBeVisible()
await expect(dialog.getByText('Ainda não configurado')).toBeVisible()
// O runbook de operação aparece (a ativação depende da conta Google da campanha).
await expect(dialog.getByText(/service account do Teqo/)).toBeVisible()
})
test('estado disabled: pill, aviso de desativação e Reativar re-sincroniza (D7)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
await seedSyncConfig(fixtures, { disabledAt: '2026-08-11T10:00:00.000Z' })
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda`)
const pill = page.getByRole('button', { name: 'Google: desativado' })
await expect(pill).toBeVisible({ timeout: 15_000 })
await pill.click()
const dialog = syncDialog(page)
await expect(dialog).toBeVisible()
await expect(dialog.getByText(/A sincronização está desativada/)).toBeVisible()
// Re-enable: o hook da config roda uma pass (D7) que falha localmente na
// chave fake — o estado derivado vira paused, determinístico e sem rede.
await dialog.getByRole('button', { name: 'Reativar' }).click()
await expect(dialog.getByText('Pausado — re-tentando')).toBeVisible({ timeout: 15_000 })
// CSS locator de propósito: com o dialog modal aberto, o Radix aplica
// aria-hidden no shell do app e a pill some das queries por role.
await expect(
page.locator('button[aria-label="Google: pausado — re-tentando"]:visible'),
).toBeVisible()
})
test('estado paused: pill, erro da última tentativa e link para o Google', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
await seedSyncConfig(fixtures, {
lastSyncedAt: '2026-08-11T10:05:00.000Z',
lastErrorAt: '2026-08-11T10:05:00.000Z',
lastError: 'Google fora do ar (simulado)',
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda`)
const pill = page.getByRole('button', { name: 'Google: pausado — re-tentando' })
await expect(pill).toBeVisible({ timeout: 15_000 })
await pill.click()
const dialog = syncDialog(page)
await expect(dialog).toBeVisible()
await expect(dialog.getByText('Pausado — re-tentando')).toBeVisible()
// A linha da última tentativa aparece com o erro; o auto-retry de mount
// falha localmente na chave fake e sobrescreve a mensagem seedada com a
// mensagem do jose — o formato (não o texto) é o contrato.
await expect(dialog.getByText(/Última tentativa: .* — /)).toBeVisible()
// O atalho para a tela de adicionar por URL do Google.
await expect(dialog.getByRole('link', { name: 'Abrir Google Calendar' })).toHaveAttribute(
'href',
/calendar\.google\.com\/calendar\/(u\/\d+\/)?r\/settings\/addbyurl/,
)
})
test('estado synced: pill, link do calendário com copiar e instruções', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
// lastSuccessAt no futuro: imune a lastErrorAt gravado por hooks de specs
// paralelas (a derivação só volta a synced enquanto erro não for mais novo).
await seedSyncConfig(fixtures, {
lastSyncedAt: FAR_FUTURE,
lastSuccessAt: FAR_FUTURE,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda`)
const pill = page.getByRole('button', { name: 'Google: sincronizado' })
await expect(pill).toBeVisible({ timeout: 15_000 })
await pill.click()
const dialog = syncDialog(page)
await expect(dialog).toBeVisible()
await expect(
dialog.getByText(/Sincronizado — as mudanças da agenda já refletiram/),
).toBeVisible()
// O link de adição vem do calendarId seedado (webcal → cid) + copiar + instruções.
const linkInput = dialog.getByLabel('Link do calendário (copie e envie à equipe)')
await expect(linkInput).toHaveValue(
`https://calendar.google.com/calendar/r?cid=${encodeURIComponent(
`webcal://calendar.google.com/calendar/ical/${encodeURIComponent(CALENDAR_ID)}/public/basic.ics`,
)}`,
)
await expect(dialog.getByRole('button', { name: 'Copiar link' })).toBeVisible()
await expect(dialog.getByText(/Como adicionar ao Google Calendar:/)).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Desativar' })).toBeVisible()
})
test.describe('mobile (FAB)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('o FAB "Ações rápidas" abre o sheet do espelho pela ação "Agenda da Campanha"', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda`)
await expect(campaignPageChrome(page, 'Agenda')).toBeVisible()
// Sem doc seedado + chave fake presente → estado not-configured.
await page.getByRole('button', { name: 'Ações rápidas' }).click()
await page.getByRole('button', { name: 'Agenda da Campanha' }).click()
const sheet = syncDialog(page)
await expect(sheet).toBeVisible({ timeout: 15_000 })
await expect(sheet.getByText('Ainda não configurado')).toBeVisible()
})
})
})

View file

@ -36,6 +36,7 @@ type OwnedCollection =
| 'consent'
| 'supporter'
| 'calendarFeed'
| 'googleCalendarSync'
const deletionOrder: OwnedCollection[] = [
'campaignInvite',
@ -45,6 +46,7 @@ const deletionOrder: OwnedCollection[] = [
'municipalityUpdate',
'activity',
'calendarFeed',
'googleCalendarSync',
'leadership',
'supporter',
'organization',

View file

@ -0,0 +1,37 @@
/**
* The env var name the sync engine reads the service-account credential from
* (`GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV` in `src/utilities/googleCalendarSync.ts`).
* Not imported from there on purpose: that module is `server-only` and
* `playwright.config.ts` loads this helper in plain Node.
*/
export const GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME = 'GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY'
/**
* The fake service-account key for tests (C114/C122): a well-formed JSON that
* `readGoogleServiceAccountCredentials` parses (so the view derives real
* states) but whose private key is garbage — `importPKCS8` throws
* `Invalid keyData` locally, BEFORE any fetch. That is the property that makes
* the int/e2e states deterministic without touching the network.
*/
export const GOOGLE_CALENDAR_TEST_KEY = Buffer.from(
JSON.stringify({
client_email: 'teqo-sa@projeto.iam.gserviceaccount.com',
private_key: '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n',
}),
'utf8',
).toString('base64')
/** Sets/restores the env key around a callback — restored even on failure. */
export const withGoogleCalendarTestCredential = async <T>(run: () => Promise<T>): Promise<T> => {
const previous = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME]
process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME] = GOOGLE_CALENDAR_TEST_KEY
try {
return await run()
} finally {
if (previous === undefined) {
delete process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME]
} else {
process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME] = previous
}
}
}

View file

@ -3,11 +3,11 @@
import { getPayload, type Payload } from 'payload'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
import { googleEventIdForActivity } from '@/lib/googleCalendarEventMapping'
import config from '@/payload.config'
import type { GoogleCalendarClient, GoogleRemoteEvent } from '@/utilities/googleCalendarClient'
import {
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV,
loadGoogleCalendarSyncConfig,
readGoogleCalendarSyncView,
runCampaignCalendarSync,
@ -15,6 +15,7 @@ import {
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { withGoogleCalendarTestCredential } from '../helpers/googleCalendarTestKey'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -24,29 +25,6 @@ const campaignFixtures = installCampaignFixtures({
},
})
const FAKE_KEY = Buffer.from(
JSON.stringify({
client_email: 'teqo-sa@projeto.iam.gserviceaccount.com',
private_key: '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n',
}),
'utf8',
).toString('base64')
const originalKey = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
const withCredential = async <T>(run: () => Promise<T>): Promise<T> => {
process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = FAKE_KEY
try {
return await run()
} finally {
if (originalKey === undefined) {
delete process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
} else {
process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = originalKey
}
}
}
/** In-memory Google calendar stub — list/insert/update/delete over an array. */
const createStubClient = (store: GoogleRemoteEvent[] = []): GoogleCalendarClient => ({
listEvents: async (_calendarId, range) =>
@ -75,19 +53,21 @@ describe('campaign Google calendar sync engine (C114)', () => {
payload = await getPayload({ config: await config })
})
/** Config rows this file created — cleanup never touches other specs' rows. */
const ownedConfigIds = 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 withCredential.
// Cleanup happens per test; the env is restored by the credential helper.
await payload.delete({
collection: 'activity',
where: { title: { like: 'C114%' } },
overrideAccess: true,
})
await payload.delete({
collection: 'googleCalendarSync',
where: { id: { exists: true } },
overrideAccess: true,
})
for (const id of ownedConfigIds) {
await payload.delete({ collection: 'googleCalendarSync', id, overrideAccess: true })
}
ownedConfigIds.clear()
})
const createActivity = async (overrides: Record<string, unknown> = {}) => {
@ -107,44 +87,61 @@ describe('campaign Google calendar sync engine (C114)', () => {
})
}
const createConfig = async (calendarId: string) =>
payload.create({
const createConfig = async (calendarId: string) => {
const doc = await payload.create({
collection: 'googleCalendarSync',
data: { calendarId },
depth: 0,
overrideAccess: true,
})
ownedConfigIds.add(doc.id)
return doc
}
/**
* 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.
*/
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))
it('creates the full mirror on the first pass (timed + all-day, municipality summary)', async () => {
const municipality = await campaignFixtures().getMunicipality()
const timed = await createActivity({ municipality: municipality.id, locality: 'Centro' })
// C127: dates derived from now — hardcoded instants would leave the sync
// window and break the assertion deterministically after ~90 days.
const allDayStartAt = new Date(Date.now() + 2 * 86_400_000).toISOString()
const allDayEndAt = new Date(Date.now() + 4 * 86_400_000).toISOString()
const allDay = await createActivity({
municipality: municipality.id,
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-12T03:00:00.000Z',
startAt: allDayStartAt,
endAt: allDayEndAt,
})
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
const outcome = await withCredential(() =>
const outcome = await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client }),
)
expect(outcome.status).toBe('synced')
expect(outcome.created).toBe(2)
expect(outcome.updated).toBe(0)
expect(outcome.deleted).toBe(0)
const timedEvent = store.find((event) => event.id === googleEventIdForActivity(timed.id))
const timedEvent = ourEvent(store, 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 = store.find((event) => event.id === googleEventIdForActivity(allDay.id))
expect(allDayEvent?.start).toEqual({ date: '2026-08-10' })
expect(allDayEvent?.end).toEqual({ date: '2026-08-13' })
const allDayEvent = ourEvent(store, allDay.id)
expect(allDayEvent?.start).toEqual({ date: allDayCivilDateOf(allDayStartAt) })
expect(allDayEvent?.end).toEqual({ date: allDayExclusiveEndDate(allDayEndAt) })
const doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.lastSuccessAt).toBeTruthy()
@ -152,20 +149,26 @@ describe('campaign Google calendar sync engine (C114)', () => {
})
it('converges: a second pass with no changes touches nothing', async () => {
await createActivity()
const activity = await createActivity()
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
const first = await withCredential(() =>
const first = await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client }),
)
expect(first.created).toBe(1)
expect(first.status).toBe('synced')
expect(ourEvent(store, activity.id)).toBeTruthy()
const contentAfterFirstPass = JSON.stringify(ourEvent(store, activity.id))
const second = await withCredential(() =>
const second = await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client }),
)
expect(second).toMatchObject({ created: 0, updated: 0, deleted: 0, status: 'synced' })
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('updates only the drifted event and ignores foreign calendar events', async () => {
@ -175,7 +178,9 @@ describe('campaign Google calendar sync engine (C114)', () => {
{ id: 'foreign-manual-event', summary: 'Mantido pela conta da campanha' },
]
const client = createStubClient(store)
await withCredential(() => runCampaignCalendarSync(payload, { reason: 'manual', client }))
await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client }),
)
// Title is immutable after creation (canonical slug rule) — drift a
// mutable mirrored field instead (locality is in the mirror surface).
@ -187,15 +192,12 @@ describe('campaign Google calendar sync engine (C114)', () => {
overrideAccess: true,
})
const outcome = await withCredential(() =>
const outcome = await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client }),
)
expect(outcome.updated).toBe(1)
expect(outcome.deleted).toBe(0)
expect(outcome.status).toBe('synced')
expect(store.find((event) => event.id === 'foreign-manual-event')).toBeTruthy()
expect(
store.find((event) => event.id === googleEventIdForActivity(activity.id))?.location,
).toBe('Centro (atualizado)')
expect(ourEvent(store, activity.id)?.location).toBe('Centro (atualizado)')
})
it('canceled activities leave Google: their events are deleted, none created', async () => {
@ -203,8 +205,10 @@ describe('campaign Google calendar sync engine (C114)', () => {
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
await withCredential(() => runCampaignCalendarSync(payload, { reason: 'manual', client }))
expect(store).toHaveLength(1)
await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client }),
)
expect(ourEvent(store, activity.id)).toBeTruthy()
await payload.update({
collection: 'activity',
@ -214,11 +218,11 @@ describe('campaign Google calendar sync engine (C114)', () => {
overrideAccess: true,
})
const outcome = await withCredential(() =>
const outcome = await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client }),
)
expect(outcome.deleted).toBe(1)
expect(store).toHaveLength(0)
expect(outcome.status).toBe('synced')
expect(ourEvent(store, activity.id)).toBeUndefined()
})
it('hard-deleted activities leave no ghost in Google', async () => {
@ -226,33 +230,39 @@ describe('campaign Google calendar sync engine (C114)', () => {
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
await withCredential(() => runCampaignCalendarSync(payload, { reason: 'manual', client }))
expect(store).toHaveLength(1)
await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client }),
)
expect(ourEvent(store, activity.id)).toBeTruthy()
await payload.delete({ collection: 'activity', id: activity.id, overrideAccess: true })
const outcome = await withCredential(() =>
const outcome = await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client }),
)
expect(outcome.deleted).toBe(1)
expect(store).toHaveLength(0)
expect(outcome.status).toBe('synced')
expect(ourEvent(store, activity.id)).toBeUndefined()
})
it('activities outside the window never reach Google', async () => {
await createActivity({ startAt: '2029-01-01T12:00:00.000Z' })
// 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({
startAt: new Date(Date.now() + 400 * 86_400_000).toISOString(),
})
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
const outcome = await withCredential(() =>
const outcome = await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client }),
)
expect(outcome.created).toBe(0)
expect(store).toHaveLength(0)
expect(outcome.status).toBe('synced')
expect(ourEvent(store, activity.id)).toBeUndefined()
})
it('failures land in paused and the next success recovers to synced', async () => {
await createActivity()
const activity = await createActivity()
await createConfig(calendarA)
const failingClient: GoogleCalendarClient = {
@ -261,7 +271,7 @@ describe('campaign Google calendar sync engine (C114)', () => {
throw new Error('Google fora do ar (simulado)')
},
}
const failed = await withCredential(() =>
const failed = await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client: failingClient }),
)
expect(failed.status).toBe('paused')
@ -271,16 +281,20 @@ describe('campaign Google calendar sync engine (C114)', () => {
expect(doc?.lastError).toContain('Google fora do ar')
// The pill reads with the credential present (same context the run had).
const failedView = await withCredential(() => readGoogleCalendarSyncView(payload))
const failedView = await withGoogleCalendarTestCredential(() =>
readGoogleCalendarSyncView(payload),
)
expect(failedView.status).toBe('paused')
const store: GoogleRemoteEvent[] = []
const recovered = await withCredential(() =>
const recovered = await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client: createStubClient(store) }),
)
expect(recovered.status).toBe('synced')
expect(recovered.created).toBe(1)
const recoveredView = await withCredential(() => readGoogleCalendarSyncView(payload))
expect(ourEvent(store, activity.id)).toBeTruthy()
const recoveredView = await withGoogleCalendarTestCredential(() =>
readGoogleCalendarSyncView(payload),
)
expect(recoveredView.status).toBe('synced')
})
@ -302,13 +316,13 @@ describe('campaign Google calendar sync engine (C114)', () => {
})
it('changing the calendarId reconciles into the new calendar (D7 engine side)', async () => {
await createActivity()
const activity = await createActivity()
await createConfig(calendarA)
const storeA: GoogleRemoteEvent[] = []
await withCredential(() =>
await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, { reason: 'manual', client: createStubClient(storeA) }),
)
expect(storeA).toHaveLength(1)
expect(ourEvent(storeA, activity.id)).toBeTruthy()
const config = await loadGoogleCalendarSyncConfig(payload)
await payload.update({
@ -320,15 +334,15 @@ describe('campaign Google calendar sync engine (C114)', () => {
})
const storeB: GoogleRemoteEvent[] = []
const outcome = await withCredential(() =>
const outcome = await withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, {
reason: 'config-change',
client: createStubClient(storeB),
}),
)
expect(outcome.created).toBe(1)
expect(storeB).toHaveLength(1)
expect(outcome.status).toBe('synced')
expect(ourEvent(storeB, activity.id)).toBeTruthy()
// The abandoned calendar keeps its last mirror (documented, no retro-cleanup).
expect(storeA).toHaveLength(1)
expect(ourEvent(storeA, activity.id)).toBeTruthy()
})
})

View file

@ -0,0 +1,131 @@
// @vitest-environment node
import { getPayload, type Payload } from 'payload'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import {
setGoogleCalendarSyncDisabled,
type GoogleCalendarSyncActionResult,
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import config from '@/payload.config'
import { loadGoogleCalendarSyncConfig } from '@/utilities/googleCalendarSync'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { withGoogleCalendarTestCredential } from '../helpers/googleCalendarTestKey'
/**
* S18 (C122) — the staff disable/re-enable action (D7). The action reads the
* session via `getCampaignActionContext` (next/headers cookies — not
* invocable in a plain process), so only THAT module is mocked: the payload
* writes, collection access and the afterChange hook run for real. The
* re-enable reconciliation is observed by its DB effect — the engine records
* `lastErrorAt` inside the config hook — using the fake credential that fails
* locally at JWT signing (`importPKCS8`), so no network is involved.
*/
vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
const original = await importOriginal<typeof import('@/utilities/campaignActionContext')>()
return { ...original, getCampaignActionContext: vi.fn(), reloadStaffActor: vi.fn() }
})
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const mockedGetContext = vi.mocked(getCampaignActionContext)
const mockedReloadStaff = vi.mocked(reloadStaffActor)
const CALENDAR_ID = 'c_campanha_a@group.calendar.google.com'
describe('setGoogleCalendarSyncDisabled action (S18/D7)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
let ownedConfigId: number | null = null
afterEach(async () => {
if (ownedConfigId !== null) {
// Scoped to OUR row: the C114 engine spec shares the test DB and its own
// config lifecycle must not be robbed by this file's cleanup.
await payload.delete({
collection: 'googleCalendarSync',
id: ownedConfigId,
overrideAccess: true,
})
ownedConfigId = null
}
mockedGetContext.mockReset()
mockedReloadStaff.mockReset()
})
/** The fixtures require a running test scope — create the actor per test. */
const installStaffActor = async (): Promise<void> => {
const actor = await campaignFixtures().createCampaignUser('coordinator')
mockedGetContext.mockResolvedValue({ payload, actor })
mockedReloadStaff.mockResolvedValue(actor)
}
const createConfig = async (overrides: Record<string, unknown> = {}) => {
const doc = await payload.create({
collection: 'googleCalendarSync',
data: { calendarId: CALENDAR_ID, ...overrides },
depth: 0,
overrideAccess: true,
})
ownedConfigId = doc.id
return doc
}
it('disable writes disabledAt and never runs the engine', async () => {
await installStaffActor()
await createConfig()
const result: GoogleCalendarSyncActionResult = await withGoogleCalendarTestCredential(() =>
setGoogleCalendarSyncDisabled(true),
)
expect(result.ok).toBe(true)
expect(result.status).toBe('disabled')
const doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.disabledAt).toBeTruthy()
// Disabling must not trigger a pass: the state fields stay untouched.
expect(doc?.lastSyncedAt).toBeNull()
expect(doc?.lastErrorAt).toBeNull()
})
it('re-enable clears disabledAt and the config hook runs the reconciliation (D7)', async () => {
await installStaffActor()
await createConfig({ disabledAt: '2026-08-11T10:00:00.000Z' })
const result: GoogleCalendarSyncActionResult = await withGoogleCalendarTestCredential(() =>
setGoogleCalendarSyncDisabled(false),
)
expect(result.ok).toBe(true)
const doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.disabledAt).toBeNull()
// The fake credential fails locally (no network) — the paused outcome IS
// the observable proof that the afterChange hook ran a sync pass.
expect(doc?.lastErrorAt).toBeTruthy()
expect(doc?.lastError).toBeTruthy()
expect(result.status).toBe('paused')
})
it('no-op without a config doc keeps ok with the derived not-configured state', async () => {
await installStaffActor()
const result: GoogleCalendarSyncActionResult = await withGoogleCalendarTestCredential(() =>
setGoogleCalendarSyncDisabled(true),
)
expect(result.ok).toBe(true)
expect(result.status).toBe('not-configured')
})
})

View file

@ -0,0 +1,140 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { AgendaGoogleSyncChrome } from '@/components/campaign/activity/AgendaGoogleSyncChrome'
import {
CampaignPageChromeProvider,
useCampaignHeaderActions,
} from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext'
import type { GoogleCalendarSyncStatus } from '@/utilities/googleCalendarSync'
import { stubMatchMedia } from '../helpers/matchMedia'
/**
* S14 (C122) — the agenda Google mirror chrome: the status pill label per
* derived status and the paused auto-retry (exactly one attempt per mount).
* The pill is registered into the page chrome via `SetCampaignHeaderAction`
* (which renders nothing in place), so the harness reads the registered node
* back through `useCampaignHeaderActions` — the same contract the app header
* uses.
*/
const stateFor = (status: GoogleCalendarSyncStatus): GoogleCalendarSyncActionResult => ({
ok: true,
status,
calendarId: status === 'not-configured' ? null : 'c_campanha@group.calendar.google.com',
lastSyncedAt: status === 'synced' || status === 'paused' ? '2026-08-11T10:00:00.000Z' : null,
lastSuccessAt: status === 'synced' ? '2026-08-11T10:00:00.000Z' : null,
lastErrorAt: status === 'paused' ? '2026-08-11T10:05:00.000Z' : null,
lastError: status === 'paused' ? 'Google fora do ar (simulado)' : null,
addLink: status === 'not-configured' ? null : 'https://calendar.google.com/calendar/r?cid=webcal',
})
const PILL_LABELS: Record<GoogleCalendarSyncStatus, string> = {
synced: 'Google: sincronizado',
paused: 'Google: pausado — re-tentando',
'not-configured': 'Google: não configurado',
disabled: 'Google: desativado',
}
const HeaderActionsProbe = () => {
const actions = useCampaignHeaderActions()
return <>{actions['google-calendar-sync'] ?? null}</>
}
const renderChrome = ({
status,
// Status-consistent default: a paused mount's auto-retry resolves back to
// the same state, so the label assertions never race the retry's setState.
onSyncNow = vi.fn(async () => stateFor(status)),
}: {
status: GoogleCalendarSyncStatus
onSyncNow?: () => Promise<GoogleCalendarSyncActionResult>
}) =>
render(
<CampaignPageChromeProvider role="coordinator">
<CampaignQuickActionContextProvider>
<AgendaGoogleSyncChrome
initialState={stateFor(status)}
onSyncNow={onSyncNow}
onSetDisabled={vi.fn(async () => stateFor('disabled'))}
/>
<HeaderActionsProbe />
</CampaignQuickActionContextProvider>
</CampaignPageChromeProvider>,
)
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
})
beforeEach(() => {
stubMatchMedia()
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
afterAll(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
})
describe('AgendaGoogleSyncChrome (S14)', () => {
it.each(Object.entries(PILL_LABELS) as [GoogleCalendarSyncStatus, string][])(
'renders the pill label for status %s',
(status, label) => {
renderChrome({ status })
const button = screen.getByRole('button', { name: label })
expect(button.getAttribute('title')).toBe(label)
},
)
it('opens the sync dialog when the pill is clicked (any status)', () => {
renderChrome({ status: 'synced' })
fireEvent.click(screen.getByRole('button', { name: /^Google:/ }))
expect(screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })).toBeTruthy()
})
it('auto-retries exactly once on mount when paused', async () => {
const onSyncNow = vi.fn(async () => stateFor('paused'))
renderChrome({ status: 'paused', onSyncNow })
await waitFor(() => expect(onSyncNow).toHaveBeenCalledTimes(1))
// The retry resolved to the same paused state — it must NOT fire again.
await new Promise((resolve) => setTimeout(resolve, 50))
expect(onSyncNow).toHaveBeenCalledTimes(1)
})
it.each(['synced', 'not-configured'] as const)('does not auto-retry on a %s mount', (status) => {
const onSyncNow = vi.fn(async () => stateFor(status))
renderChrome({ status, onSyncNow })
expect(onSyncNow).not.toHaveBeenCalled()
})
it('follows a successful paused retry into the synced label', async () => {
const onSyncNow = vi.fn(async () => stateFor('synced'))
renderChrome({ status: 'paused', onSyncNow })
await waitFor(() =>
expect(screen.getByRole('button', { name: PILL_LABELS.synced })).toBeTruthy(),
)
expect(onSyncNow).toHaveBeenCalledTimes(1)
})
})