mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
11605a2ef3
commit
c7c9556ff3
8 changed files with 771 additions and 89 deletions
183
docs/plans/c122-c114-followup-testes-estados-espelho-impl.md
Normal file
183
docs/plans/c122-c114-followup-testes-estados-espelho-impl.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
37
tests/helpers/googleCalendarTestKey.ts
Normal file
37
tests/helpers/googleCalendarTestKey.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
131
tests/int/googleCalendarSyncAction.int.spec.ts
Normal file
131
tests/int/googleCalendarSyncAction.int.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
140
tests/unit/agendaGoogleSyncChrome.unit.spec.tsx
Normal file
140
tests/unit/agendaGoogleSyncChrome.unit.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue