From c7c9556ff3908fca8edfa37a90e7a48ff44099fe Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 19:43:19 -0300 Subject: [PATCH] =?UTF-8?q?test(C122):=20cobertura=20dos=20estados=20do=20?= =?UTF-8?q?espelho=20Google=20=E2=80=94=20unit=20do=20chrome,=20e2e=20dos?= =?UTF-8?q?=204=20estados=20+=20FAB,=20int=20da=20action=20de=20desativar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...14-followup-testes-estados-espelho-impl.md | 183 ++++++++++++++++++ playwright.config.ts | 9 + .../e2e/campaignAgendaGoogleSync.e2e.spec.ts | 182 ++++++++++++++++- tests/e2e/fixtures/campaignE2EFixtures.ts | 2 + tests/helpers/googleCalendarTestKey.ts | 37 ++++ tests/int/googleCalendarSync.int.spec.ts | 176 +++++++++-------- .../int/googleCalendarSyncAction.int.spec.ts | 131 +++++++++++++ .../unit/agendaGoogleSyncChrome.unit.spec.tsx | 140 ++++++++++++++ 8 files changed, 771 insertions(+), 89 deletions(-) create mode 100644 docs/plans/c122-c114-followup-testes-estados-espelho-impl.md create mode 100644 tests/helpers/googleCalendarTestKey.ts create mode 100644 tests/int/googleCalendarSyncAction.int.spec.ts create mode 100644 tests/unit/agendaGoogleSyncChrome.unit.spec.tsx diff --git a/docs/plans/c122-c114-followup-testes-estados-espelho-impl.md b/docs/plans/c122-c114-followup-testes-estados-espelho-impl.md new file mode 100644 index 00000000..a2c1d952 --- /dev/null +++ b/docs/plans/c122-c114-followup-testes-estados-espelho-impl.md @@ -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. diff --git a/playwright.config.ts b/playwright.config.ts index 92227ab6..cba84db6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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 `/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 diff --git a/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts index 20fd5911..60a57a97 100644 --- a/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts +++ b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts @@ -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 = {}, +) => + 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() + }) + }) }) diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index 2abf4c51..28d193e7 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -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', diff --git a/tests/helpers/googleCalendarTestKey.ts b/tests/helpers/googleCalendarTestKey.ts new file mode 100644 index 00000000..aff34e98 --- /dev/null +++ b/tests/helpers/googleCalendarTestKey.ts @@ -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 (run: () => Promise): Promise => { + 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 + } + } +} diff --git a/tests/int/googleCalendarSync.int.spec.ts b/tests/int/googleCalendarSync.int.spec.ts index 055a2b6c..e9c0e6b8 100644 --- a/tests/int/googleCalendarSync.int.spec.ts +++ b/tests/int/googleCalendarSync.int.spec.ts @@ -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 (run: () => Promise): Promise => { - 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() + 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 = {}) => { @@ -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() }) }) diff --git a/tests/int/googleCalendarSyncAction.int.spec.ts b/tests/int/googleCalendarSyncAction.int.spec.ts new file mode 100644 index 00000000..fe090751 --- /dev/null +++ b/tests/int/googleCalendarSyncAction.int.spec.ts @@ -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() + 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 => { + const actor = await campaignFixtures().createCampaignUser('coordinator') + mockedGetContext.mockResolvedValue({ payload, actor }) + mockedReloadStaff.mockResolvedValue(actor) + } + + const createConfig = async (overrides: Record = {}) => { + 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') + }) +}) diff --git a/tests/unit/agendaGoogleSyncChrome.unit.spec.tsx b/tests/unit/agendaGoogleSyncChrome.unit.spec.tsx new file mode 100644 index 00000000..4996db4c --- /dev/null +++ b/tests/unit/agendaGoogleSyncChrome.unit.spec.tsx @@ -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 = { + 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 +}) => + render( + + + stateFor('disabled'))} + /> + + + , + ) + +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) + }) +})