diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 79c2cde8..14a15e2e 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -5,10 +5,15 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +<<<<<<< HEAD **Recently resolved (2026-08-11):** **C115** (Issue #636) — **Google Calendar → Teqo**: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). **Motor único bidirecional** no `runSyncPass` do C114 (estende o dono): para cada evento nosso — conteúdo difere? → **regra do relógio** (`remote.updated` > `activity.updatedAt` + tolerância 2s, `googleEditIsNewer` puro): Google mais novo aplica **título + horário** (all-day incluído, `googleScheduleToActivityFields` via `activityAllDay`; prefixo `[Município] ` do summary só é destripado quando bate com o município — desfazemos só a nossa prefixação; título cap 160) com **registro auditável** no `updates` feed (`Google Calendar: …`, `author: null`, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta (forward). **Cancelamento:** `listEvents` ganhou `showDeleted=true` — evento com `status: 'cancelled'` (lixeira do Google) + atividade `confirmado` → cancela a atividade no Teqo (próxima passada apaga o trashed; `realizado` é histórico protegido — no-op); **remoção permanente** distinguida de "nunca criado" por snapshot `lastSeenEventIds` (JSONB `{calendarId, ids}` na collection, gravado só em passada bem-sucedida — create falhou → próxima passada cria, não cancela; troca de `calendarId` → snapshot stale → cria tudo de novo sem cancelar). **Webhook público** `POST /campanha/agenda/google-webhook/[secret]` (fora de `(app)`, precedente `ical/[secret]`, `maxDuration=60`, `force-dynamic`, allowlist do `campaignJsonMutationRoute` com razão): validação fail-closed em lib pura testada (`isGooglePushNotificationValid` — secret da URL + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, constant-time, 404; `not_exists` → estado de erro; `sync` → ignora); ping não traz payload → passa completa. **Canal lazy sem cron (Hobby):** `ensureGoogleCalendarPushChannel` em toda passada — watch com UUID novo, token=secret da URL, TTL 30d; expira em <48h → watch com **id novo** (doc oficial: id único por canal) + `channels.stop` do antigo; troca de calendário → re-watch; falha → `pushChannelError` (state), **nunca** pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na `googleCalendarSync` (system-write; migration **hand-written** — mesmo motivo do C114: gerador re-emitiria CREATE TABLE). **Título editável pelo Google** exigiu kind de contexto dedicado `mutationKind: 'googleCalendarSync'` no `setCanonicalActivitySlug` (título muda, slug canônico congelado — URLs públicas intactas; validação de schedule continua rodando). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, allowlist), int (10 novos no motor com stub: Google novo aplica+converge, Teqo novo re-asserta, cancelled cancela+limpa, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, realizado protegido, description-only não gera registro, ciclo do canal cria/renova/stop, falha de canal não pausa), e2e (webhook 404 fail-closed). 19/19 int do motor isolado; suíte completa verde. Intenção [`c115-google-calendar-edicao-bidirecional.md`](plans/c115-google-calendar-edicao-bidirecional.md); impl [`c115-google-calendar-edicao-bidirecional-impl.md`](plans/c115-google-calendar-edicao-bidirecional-impl.md). **Recently resolved (2026-08-11):** **C126** (Issue #686) — os int specs do espelho Google (C114) deixam de flakear sob a suíte paralela: o motor ganhou **escopo injetável** `activityWhere?: Where` em `CampaignCalendarSyncOptions` (`src/utilities/googleCalendarSync.ts`) — appendado ao `and` do push set (`loadSyncActivities`) **e** do delete-guard set (`loadAliveActivityIds`); produção (hooks + action) omite o param → espelho cheio byte-identical. A race era mais ampla que o par da Issue: **5** specs criam atividades in-window no banco compartilhado (`campaignActivity`, `calendarFeed`, `homeSearchActivities`, `municipalityDossierData` + `campaignSuggestions` com `realizado` no lookback — o 5º que a Issue não nomeou), e `outcome.created` global contava todas; serialização não era construtível (vitest não serializa par-a-par; `fileParallelism:false` mataria o paralelismo da suíte) e "nunca relaxar a asserção" foi mantido à letra. O spec roda cada pass via helper `runSync(client, reason)` com `activityWhere: { title: { like: 'C114%' } }` (mesmo filtro do afterEach) e ganhou **dois pins**: (1) atividade estrangeira in-window **não** é espelhada; (2) o delete-guard honra o escopo (evento de atividade alheia viva é removido — espelho escopado é autoritativo no seu escopo). **Conflito com o C122 (#705, mergeado antes):** o C122 endereçou a C126 pela abordagem oposta (asserções globais **removidas** em favor de scoping por event-id, com delegação explícita do desfecho: "quem a detém decide") — a resolução do rebase mantém o contrato da Issue (contagens globais restauradas e determinísticas via escopo) e absorve a infra do C122 (credential compartilhada `googleCalendarTestKey`, cleanup de config por owned id, datas C127 derivadas do agora). **Contraprova:** fix 3/3 rodadas × 5 arquivos verdes (62/62); motor antigo re-flakeia 3–5 testes/rodada nas mesmas condições, e o pin 1 falha 100% — regressão viva. Gates: unit 2050, int 747, lint/typecheck/format/knip/cycles/build verdes. Sem migration/access/Consent/UI. Débito C127/#688 (datas hardcoded) já resolvido pelo C122 (#705). Impl [`c126-espelho-google-int-flake-isolamento-impl.md`](plans/c126-espelho-google-int-flake-isolamento-impl.md) (Issue = spec, sem intenção própria). **Recently resolved (2026-08-11):** **OPS41** (Issue #689) — o `AGENTS.md` de main carregava **marcadores de conflito commitados** no bullet "Per-worktree environments" (` <<<<<<< HEAD` indentado + lado HEAD pré-OPS33 com artefato `- # ` + fechamento corrompido `> > > > > > > 9e18bec8`; o `=======` foi removido no próprio commit). Origem: o commit OPS33 f0f5b73f entrou com o arquivo **já conflitado** (resolve do conflito OPS33×OPS28/OPS31 commitado com marcadores); OPS37 herdou o estado. Resolução **perda zero validada por diff**: manter o lado superset (OPS28 + OPS31 + OPS33 — claim determinístico, `--issue N`, prompt citado) e descartar o lado HEAD; −3 linhas no diff; scan whitespace-tolerant de todos os arquivos trackeados confirmou `AGENTS.md` como único afetado. **Guardrail anti-reincidência** (2ª vez que marcador chega a main): novo describe `no conflict markers in committed files` em `tests/unit/codebaseConventions.unit.spec.ts` — varre `git ls-files`, falha em `<<<<<<<`/`>>>>>>>` (tolerante a indentação) e na forma corrompida commitada `^\s*(> ){7}`; roda no CI via `pnpm test:unit`. Sem migration/access/Consent/UI/schema. Intenção: body da Issue (sem plano linkado); impl [`agents-md-sem-marcadores-conflito-impl.md`](plans/agents-md-sem-marcadores-conflito-impl.md). +======= +**Recently resolved (2026-08-11):** **C115** (Issue #636) — **Google Calendar → Teqo**: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). **Motor único bidirecional** no `runSyncPass` do C114 (estende o dono): para cada evento nosso — conteúdo difere? → **regra do relógio**: Google mais novo aplica **título + horário** (all-day incluído; prefixo `[Município] ` só é destripado quando bate com o município — desfazemos só a nossa prefixação; cap 160) com **registro auditável** no `updates` feed (`Google Calendar: …`, `author: null`, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta. **Baseline do relógio = `activity.lastMirroredChangeAt`** (novo campo system-write, carimbado no beforeChange só quando um campo espelhado muda): task toggle/update/resultado bumpam `updatedAt` sem tocar o espelho — comparar contra `updatedAt` silenciaria uma edição pendente do Google. **Escrita reversa sem usuário** (`req.user` stripped): staff que dispara a passada não ganha autoria do registro nem passa pelo gate de re-agendamento do deputado. **Cancelamento:** `listEvents` com `showDeleted=true` — evento `status: 'cancelled'` (lixeira) com atividade `confirmado` → cancela **se o cancelamento for mais novo que o último carimbo** (senão é reopen do staff: o trashed é limpo e o evento recriado — nunca re-cancela); `realizado` é histórico protegido. **Remoção permanente** distinguida de "nunca criado" por snapshot `lastSeenEventIds` (JSONB `{calendarId, ids}`, gravado só em passada bem-sucedida e **excluindo ids que o próprio motor deletou** — senão o reopen virava re-cancelamento fantasma); troca de `calendarId` → snapshot stale → recria sem cancelar. **Webhook público** `POST /campanha/agenda/google-webhook/[secret]` (fora de `(app)`, precedente `ical/[secret]`, `maxDuration=60`, `force-dynamic`, allowlist do `campaignJsonMutationRoute` com razão): pre-filter barato (secret ≥32 + headers) ANTES do bootstrap do Payload + validação fail-closed em lib pura testada (`isGooglePushNotificationValid` — secret da URL + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, constant-time, 404; `not_exists` → estado de erro; `sync` → ignora); ping sem payload → passada completa; 200 mesmo se a passada falhar (sem retry storm). **Canal lazy sem cron (Hobby):** `ensureGoogleCalendarPushChannel` em toda passada — watch com UUID novo, TTL 30d, **segredo rotaciona a cada criação** (URL vazada auto-cura na renovação), **novo canal persistido ANTES do stop do antigo** (falha do stop não orfana o novo); expira em <48h → watch com id novo + `channels.stop` do antigo; troca de calendário → re-watch; falha → `pushChannelError` (state), nunca pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na `googleCalendarSync` (system-write; **identity fields com `read: false`** — o conjunto de credenciais do webhook nunca sai por path de usuário; create admin-only + leitura prefere linha configurada); migration **hand-written** (mesmo motivo do C114). **Título editável pelo Google** exigiu `mutationKind: 'googleCalendarSync'` dedicado (título muda, slug canônico congelado) + **skip do hook de sync na própria escrita reversa** (sem passada aninhada). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, dialog/feed render 6, allowlist), int (13 novos no motor com stub: Google novo aplica+converge, task toggle não silencia edição pendente, Teqo novo re-asserta, cancelled cancela+limpa, reopen pós-cancelamento não re-cancela, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, all-day ida-e-volta, realizado protegido, description-only sem registro, ciclo do canal cria/renova/rotaciona/stop, falha de canal não pausa — asserções herméticas a atividades ambiente de arquivos irmãos no DB compartilhado), e2e (webhook 404 fail-closed + caminhos 200 validados: sync/not_exists/change). Suíte completa verde (unit 2064, int 749, e2e 5/5). Intenção [`c115-google-calendar-edicao-bidirecional.md`](plans/c115-google-calendar-edicao-bidirecional.md); impl [`c115-google-calendar-edicao-bidirecional-impl.md`](plans/c115-google-calendar-edicao-bidirecional-impl.md). + +>>>>>>> 4d195696 (fix(C115): revisão /simplify — relógio com baseline de espelho, reopen sem re-cancelamento, hardening do webhook) **Recently resolved (2026-08-11):** **Plan-issue — rascunho UI/UX do gate vira HTML+Tailwind commitado no repo (ad-hoc, decisão do usuário).** O artefato deixa de ser **Cursor canvas** (`~/.cursor/projects//canvases/plan--ui-draft.canvas.tsx`, fora do repo, só renderizava no IDE de quem planejou e apodrecia) e passa a ser **HTML + Tailwind v4** (`docs/plans/-ui-draft.html` — `@tailwindcss/browser@4` via jsdelivr, mesma major do app 4.1.x; rede só na hora de renderizar) **commitado com o plano** e **renderizado em PNG** por `pnpm ui-draft:render` (`scripts/render-ui-draft.mjs`, Chromium do Playwright via `@playwright/test`, fallback `load` se o CDN não settle — nunca toca banco/app): uma PNG por cena `
` (ex. `mobile`, `desktop-vazio`) → `docs/plans/-ui-draft-.png`, ou full-page sem `data-shot`; PNGs **embutidos no plano** (visíveis no chat do gate E no PR — o aceite vira registro permanente; o HTML é a fonte iterável). **Contrato de fidelidade explícito em `ui-draft-html.md`** (substitui `ui-draft-canvas.md`, deletado; refs atualizadas em SKILL.md/frontmatter, intention-template.md — campo renomeado `Canvas UI:` → `Rascunho UI:` + secção de embed, skills-map.md, shaping.md): o rascunho DEVE ter layout real, espaçamentos/tamanhos na escala do app (`p-4`, `gap-3`, `h-11`, `text-sm`), breakpoints reais (390/1280) e estados críticos como cenas estáticas; é PROIBIDO ser funcional (zero JS de comportamento — cada estado é cena separada), usar código de app (`src/`), brand/polish (gradientes/emojis/sombras/logo/paleta do produto — cinza neutro + um acento), mock de dados realistas ou decisão de engenharia. Gate: iterar HTML → re-render → embutir até confirmação explícita. Verificado: render real do skeleton (classes v4 aplicadas — oklch zinc, rounded-lg 8px, text-lg 18px). Sem migration/access/Consent/UI de produto/schema. Sem issue — ad-hoc no worktree `work/5`. **Recently resolved (2026-08-11):** **OPS40** (Issue #682) — `pnpm dev` honra o `PORT` do `.env.local`: `next dev` resolve a porta **antes** de `@next/env` carregar env-files (commander `.env('PORT')` no `bin/next` — causa confirmada no fonte 15.4.11), então o `PORT=3100+slot` do provisionador de worktree era ignorado e todo dev server subia em 3000 (colisão entre worktrees paralelos; o e2e escapava porque o webServer injeta `PORT` no env real). **`pnpm dev`/`devsafe` agora passam por `scripts/dev.mjs`** (wrapper): importa o `guard-dev-db.mjs` (preflight fail-closed inline, mesma saída), `loadCliEnv()` (`.env.local` → `.env`, `override:false`) e entrega a porta ao `next dev` como **flag CLI `-p`** — a única alavanca que o repo controla. Precedência: env real do processo (webServer do Playwright manda) → `.env.local` (worktree) → `.env` → sem `-p` (default 3000 + `allowRetry` do Next intactos no main repo). Derivação pura unit-testada em `scripts/lib/cli.mjs` (`resolveDevPort` — null quando ausente, throw fail-closed em PORT inválida; `nextDevArgs`); spawn `node next/dist/bin/next` com `NODE_OPTIONS=--no-deprecation` (paridade cross-env), stdio inherit, forward SIGINT/SIGTERM/SIGHUP, propagação de exit code; extras (`--turbopack`) repassados após o `-p` derivado. Sem migration/access/Consent/UI. Verificação: boot manual no worktree → "Local: http://localhost:3140" (antes 3000), unit 8/8 novos, e2e dev-mode `campaignAuth` 3/3 (2 runs de prewarm estouraram o orçamento sob load ~50+ da máquina — flake de contenção fichado B17/B27/B32/B42, terceira run verde com cache quente). Nota fora de escopo: `AGENTS.md` em `main` carrega marcadores de conflito commitados (OPS33/OPS37) — área de main-mess da OPS38; o novo bullet do Database section foi adicionado **fora** da região conflitada. Intenção [`worktree-dev-port-nao-honrado.md`](plans/worktree-dev-port-nao-honrado.md); impl [`worktree-dev-port-nao-honrado-impl.md`](plans/worktree-dev-port-nao-honrado-impl.md). diff --git a/src/app/(campaign)/campanha/agenda/google-webhook/[secret]/route.ts b/src/app/(campaign)/campanha/agenda/google-webhook/[secret]/route.ts index 084ca974..98a000e1 100644 --- a/src/app/(campaign)/campanha/agenda/google-webhook/[secret]/route.ts +++ b/src/app/(campaign)/campanha/agenda/google-webhook/[secret]/route.ts @@ -43,6 +43,19 @@ export const GET = async (): Promise => notFoundResponse() export const POST = async (request: Request, context: RouteContext): Promise => { const { secret } = await context.params + // Cheap pre-filter BEFORE Payload bootstrap: every valid delivery carries + // the three channel headers and a ≥32-char URL secret — scanner noise is + // rejected without paying for a Payload init + DB read per hit. The full + // constant-time validation below still runs (defense in depth). + if ( + secret.length < 32 || + !request.headers.get('x-goog-channel-id') || + !request.headers.get('x-goog-resource-id') || + !request.headers.get('x-goog-channel-token') + ) { + return notFoundResponse() + } + const payload = await getPayload({ config }) // Intentional admin bypass: the endpoint is unauthenticated by design diff --git a/src/collections/Activity.ts b/src/collections/Activity.ts index 6af8345f..375d8dec 100644 --- a/src/collections/Activity.ts +++ b/src/collections/Activity.ts @@ -5,6 +5,8 @@ import type { } from 'payload' import { APIError } from 'payload' +import type { Activity as ActivityRecord } from '@/payload-types' + import { allDayRangeValid } from '@/lib/activityAllDay' import { relationshipId } from '@/lib/relationship' import { @@ -29,6 +31,7 @@ import { eligibleCampaignStaffWhere, } from '@/utilities/campaignAccess' import { systemStampedActorField } from '@/utilities/campaignAuditFields' +import { shouldSyncActivityOperation } from '@/utilities/googleCalendarSync' import { activityGoogleCalendarSyncHook } from '@/utilities/googleCalendarSyncHooks' const isActivityMutationShortcut = (context: Record | undefined) => @@ -376,6 +379,20 @@ const deriveActivityFields: CollectionBeforeChangeHook = ({ } } + // C115 — the clock rule baseline: stamped only when a mirrored field + // actually changed (task toggles/updates/result records never stamp, so a + // pending newer Google edit is not silenced by an unrelated `updatedAt`). + if ( + operation === 'create' || + shouldSyncActivityOperation({ + operation: 'update', + doc: { ...originalDoc, ...data } as ActivityRecord, + previousDoc: originalDoc, + }) + ) { + data.lastMirroredChangeAt = new Date().toISOString() + } + return data } @@ -719,6 +736,22 @@ export const Activity: CollectionConfig = { update: canSetActivitySystemField, }, }, + { + // C115 — last time a field the Google mirror reflects changed in the + // Teqo. The conflict clock rule compares the Google event's `updated` + // against this, not `updatedAt` (task toggles, updates and result + // records bump `updatedAt` without ever reaching the mirror). + name: 'lastMirroredChangeAt', + type: 'date', + label: 'Última mudança espelhada', + admin: { + readOnly: true, + }, + access: { + create: canSetActivitySystemField, + update: canSetActivitySystemField, + }, + }, systemStampedActorField({ label: 'Criada por', setAccess: canSetActivitySystemField }), ], } diff --git a/src/collections/GoogleCalendarSync.ts b/src/collections/GoogleCalendarSync.ts index a7da2bee..50157443 100644 --- a/src/collections/GoogleCalendarSync.ts +++ b/src/collections/GoogleCalendarSync.ts @@ -4,6 +4,7 @@ import { canCreateGoogleCalendarSync, canDeleteGoogleCalendarSync, canReadGoogleCalendarSync, + canReadGoogleCalendarSyncIdentityField, canSetGoogleCalendarSyncConfigField, canSetGoogleCalendarSyncDisabled, canSetGoogleCalendarSyncSystemField, @@ -27,7 +28,13 @@ export const GoogleCalendarSync: CollectionConfig = { admin: { group: 'Campanha', useAsTitle: 'calendarId', - defaultColumns: ['calendarId', 'lastSyncedAt', 'lastSuccessAt', 'lastError'], + defaultColumns: [ + 'calendarId', + 'lastSyncedAt', + 'lastSuccessAt', + 'lastError', + 'pushChannelExpiresAt', + ], }, access: { create: canCreateGoogleCalendarSync, @@ -114,6 +121,7 @@ export const GoogleCalendarSync: CollectionConfig = { admin: { readOnly: true }, access: { create: canSetGoogleCalendarSyncSystemField, + read: canReadGoogleCalendarSyncIdentityField, update: canSetGoogleCalendarSyncSystemField, }, }, @@ -124,6 +132,7 @@ export const GoogleCalendarSync: CollectionConfig = { admin: { readOnly: true }, access: { create: canSetGoogleCalendarSyncSystemField, + read: canReadGoogleCalendarSyncIdentityField, update: canSetGoogleCalendarSyncSystemField, }, }, @@ -144,6 +153,7 @@ export const GoogleCalendarSync: CollectionConfig = { admin: { readOnly: true }, access: { create: canSetGoogleCalendarSyncSystemField, + read: canReadGoogleCalendarSyncIdentityField, update: canSetGoogleCalendarSyncSystemField, }, }, diff --git a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx index 6bc55479..70748d39 100644 --- a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx +++ b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx @@ -164,7 +164,7 @@ export const GoogleCalendarSyncDialog = ({ {state.pushChannelExpiresAt ? (

Notificações ativas até {formatBahiaDateTimeLabel(state.pushChannelExpiresAt)} — renovadas - automaticamente. + automaticamente a cada sincronização.

) : null} {state.pushChannelError ? ( diff --git a/src/lib/googleCalendarEventMapping.ts b/src/lib/googleCalendarEventMapping.ts index ed767064..5610dcc4 100644 --- a/src/lib/googleCalendarEventMapping.ts +++ b/src/lib/googleCalendarEventMapping.ts @@ -178,6 +178,12 @@ const startEndInstantEquals = ( return remote.date === wanted.date } +/** + * Same instant comparison, exported for the engine's editable-fields check + * (C115 reverse direction) — single implementation of the compare. + */ +export { startEndInstantEquals as googleStartEndInstantEquals } + /** * True when the remote event already carries exactly what we would write — * the decision rule that makes repeated reconciliation passes converge diff --git a/src/migrations/20260811_213648_add_activity_last_mirrored_change_at.ts b/src/migrations/20260811_213648_add_activity_last_mirrored_change_at.ts new file mode 100644 index 00000000..0d090714 --- /dev/null +++ b/src/migrations/20260811_213648_add_activity_last_mirrored_change_at.ts @@ -0,0 +1,22 @@ +import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres' + +/** + * C115 — `activity.lastMirroredChangeAt`: the last time a field the Google + * mirror reflects changed in the Teqo. The conflict clock rule (D3) compares + * the Google event's `updated` against THIS instant instead of `updatedAt`, + * which non-mirrored writes (task toggles, updates feed, result records) + * bump without ever reaching the mirror. Hand-written for the same reason as + * its C114/C115 siblings: the migration generator has no snapshot baseline + * for `google_calendar_sync` and would re-emit its whole CREATE TABLE. + */ +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + ALTER TABLE "activity" ADD COLUMN IF NOT EXISTS "last_mirrored_change_at" timestamp(3) with time zone; + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "activity" DROP COLUMN IF EXISTS "last_mirrored_change_at"; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 3a27cfea..68dd3c40 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -52,6 +52,7 @@ import * as migration_20260811_133332_add_google_calendar_sync from './20260811_ import * as migration_20260811_155116_add_lembranca_support_status from './20260811_155116_add_lembranca_support_status'; import * as migration_20260811_183016_drop_activity_task_due from './20260811_183016_drop_activity_task_due'; import * as migration_20260811_205157_add_google_calendar_push_channel from './20260811_205157_add_google_calendar_push_channel'; +import * as migration_20260811_213648_add_activity_last_mirrored_change_at from './20260811_213648_add_activity_last_mirrored_change_at'; export const migrations = [ { @@ -324,4 +325,9 @@ export const migrations = [ down: migration_20260811_205157_add_google_calendar_push_channel.down, name: '20260811_205157_add_google_calendar_push_channel', }, + { + up: migration_20260811_213648_add_activity_last_mirrored_change_at.up, + down: migration_20260811_213648_add_activity_last_mirrored_change_at.down, + name: '20260811_213648_add_activity_last_mirrored_change_at', + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 484ea3f3..534a2999 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -802,6 +802,7 @@ export interface Activity { resultMedia?: (number | Media)[] | null; resultRecordedBy?: (number | null) | CampaignUser; resultRecordedAt?: string | null; + lastMirroredChangeAt?: string | null; createdBy?: (number | null) | CampaignUser; updatedAt: string; createdAt: string; @@ -1878,6 +1879,7 @@ export interface ActivitySelect { resultMedia?: T; resultRecordedBy?: T; resultRecordedAt?: T; + lastMirroredChangeAt?: T; createdBy?: T; updatedAt?: T; createdAt?: T; diff --git a/src/utilities/access/googleCalendarSync.ts b/src/utilities/access/googleCalendarSync.ts index f7479b56..cedce989 100644 --- a/src/utilities/access/googleCalendarSync.ts +++ b/src/utilities/access/googleCalendarSync.ts @@ -8,11 +8,15 @@ import { } from '@/utilities/access/shared' /** - * C114 — access for the `googleCalendarSync` configuration/state collection. - * Staff (coordinator/advisor/candidate) read the sync state (pill) and toggle - * `disabledAt`; only Payload admin sets the operational `calendarId`; the - * system state fields are written solely by the sync engine through an - * admin bypass — never by a user path. + * C114+C115 — access for the `googleCalendarSync` configuration/state + * collection. Staff (coordinator/advisor/candidate) read the sync state + * (pill) and toggle `disabledAt`; only Payload admin sets the operational + * `calendarId` and creates the row (the single-row invariant is enforced at + * the read: the configured row wins); the system state fields are written + * solely by the sync engine through an admin bypass — never by a user path. + * The push-channel identity fields (id/resource/secret — the webhook + * credential set) are unreadable to any user path, admin included: the + * engine and the webhook read them with the admin bypass. */ export const canReadGoogleCalendarSync: Access = async ({ req }) => { @@ -20,10 +24,7 @@ export const canReadGoogleCalendarSync: Access = async ({ req }) => { return isCampaignStaff(await getFreshCampaignUser(req)) } -export const canCreateGoogleCalendarSync: Access = async ({ req }) => { - if (isPayloadAdmin(req.user)) return true - return isCampaignStaff(await getFreshCampaignUser(req)) -} +export const canCreateGoogleCalendarSync: Access = payloadAdminOnly export const canUpdateGoogleCalendarSync: Access = async ({ req }) => { if (isPayloadAdmin(req.user)) return true @@ -44,3 +45,11 @@ export const canSetGoogleCalendarSyncDisabled: FieldAccess = async ({ req }) => /** System state fields — only the sync engine writes them (overrideAccess). */ export const canSetGoogleCalendarSyncSystemField: FieldAccess = () => false + +/** + * C115 — the webhook credential set never leaves the engine: field-level + * `read` denies every user path (the engine/webhook read with the admin + * bypass). A future server action reading the config row would otherwise + * hand out full push-forging authority to any staff member. + */ +export const canReadGoogleCalendarSyncIdentityField: FieldAccess = () => false diff --git a/src/utilities/googleCalendarSyncHooks.ts b/src/utilities/googleCalendarSyncHooks.ts index ec79ee6b..aaceef6b 100644 --- a/src/utilities/googleCalendarSyncHooks.ts +++ b/src/utilities/googleCalendarSyncHooks.ts @@ -35,6 +35,10 @@ export const activityGoogleCalendarSyncHook = async ({ req: PayloadRequest }): Promise => { try { + // C115 — the reverse direction's own write must not re-enter the engine: + // the pass that applied the Google edit is still running; a nested pass + // would only re-list and converge (content equality) at double cost. + if (req.context?.mutationKind === 'googleCalendarSync') return doc const resolvedOperation = operation ?? 'delete' if (shouldSyncActivityOperation({ operation: resolvedOperation, doc, previousDoc })) { await runCampaignCalendarSync(req.payload, { reason: resolvedOperation, req }) diff --git a/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts index 21310ebe..e35176df 100644 --- a/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts +++ b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts @@ -287,4 +287,72 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => { } }) }) + + test('o webhook aceita uma entrega válida e trata os estados do recurso (C115)', async ({ + campaign, + request, + }) => { + const { fixtures } = campaign + const secret = 'c'.repeat(32) + const channelId = 'channel-e2e' + const resourceId = 'resource-e2e' + + await fixtures.payload.create({ + collection: 'googleCalendarSync', + data: { + calendarId: 'c_campanha_e2e@group.calendar.google.com', + pushChannelId: channelId, + pushChannelResourceId: resourceId, + pushChannelSecret: secret, + }, + depth: 0, + overrideAccess: true, + }) + + const url = `${campaign.baseURL}/campanha/agenda/google-webhook/${secret}` + const headers = { + 'x-goog-channel-id': channelId, + 'x-goog-resource-id': resourceId, + 'x-goog-channel-token': secret, + } + + try { + // `sync` — channel-creation ping: acknowledged, nothing recorded. + const syncPing = await request.post(url, { + headers: { ...headers, 'x-goog-resource-state': 'sync' }, + }) + expect(syncPing.status()).toBe(200) + + // A valid change ping: acknowledged and the reconciliation runs (no + // credential env in the test runtime → the engine no-ops as + // `not-configured`; the 200 contract is what matters — Google must not + // retry a delivery whose pass failed). + const changePing = await request.post(url, { + headers: { ...headers, 'x-goog-resource-state': 'exists' }, + }) + expect(changePing.status()).toBe(200) + + // `not_exists` — the watched calendar is gone: still 200 (no retry + // storm), and the staff-visible error state records WHY. + const gonePing = await request.post(url, { + headers: { ...headers, 'x-goog-resource-state': 'not_exists' }, + }) + expect(gonePing.status()).toBe(200) + + const doc = await fixtures.payload.find({ + collection: 'googleCalendarSync', + depth: 0, + limit: 1, + pagination: false, + overrideAccess: true, + }) + expect(doc.docs[0]?.lastError).toContain('não existe mais') + } finally { + await fixtures.payload.delete({ + collection: 'googleCalendarSync', + where: { pushChannelId: { equals: channelId } }, + overrideAccess: true, + }) + } + }) }) diff --git a/tests/int/googleCalendarSync.int.spec.ts b/tests/int/googleCalendarSync.int.spec.ts index 40e8169a..2910983f 100644 --- a/tests/int/googleCalendarSync.int.spec.ts +++ b/tests/int/googleCalendarSync.int.spec.ts @@ -44,6 +44,12 @@ const createStubClient = (store: GoogleRemoteEvent[] = []): GoogleCalendarClient const index = store.findIndex((entry) => entry.id === eventId) if (index >= 0) store.splice(index, 1) }, + watchEvents: async () => ({ + id: 'watch-' + crypto.randomUUID(), + resourceId: 'resource-' + crypto.randomUUID(), + expiration: Date.now() + 30 * 24 * 60 * 60 * 1000, + }), + stopChannel: async () => {}, }) const calendarA = 'c_campanha_a@group.calendar.google.com' @@ -385,4 +391,406 @@ describe('campaign Google calendar sync engine (C114)', () => { // The abandoned calendar keeps its last mirror (documented, no retro-cleanup). expect(storeA).toHaveLength(1) }) + + describe('bidirectional reconciliation (C115)', () => { + /** UTC ISO → the `-03:00` dateTime shape the Calendar API echoes. */ + const formatBahiaDateTime = (iso: string): string => + `${new Date(new Date(iso).getTime() - 3 * 3_600_000).toISOString().slice(0, 19)}-03:00` + + const replaceEvent = ( + store: GoogleRemoteEvent[], + eventId: string, + patch: Partial, + ) => { + const index = store.findIndex((entry) => entry.id === eventId) + store[index] = { ...store[index], ...patch } + } + + const reloadActivity = async (id: number) => + payload.findByID({ + collection: 'activity', + id, + depth: 0, + overrideAccess: true, + }) + + it('a newer Google edit applies title and schedule back with an audit record, then converges', async () => { + const municipality = await campaignFixtures().getMunicipality() + const activity = await createActivity({ municipality: municipality.id }) + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await runSync(client) + + // The renamed title keeps the `C114 ` cleanup prefix so the file's + // afterEach can still reach this activity. + const renamedTitle = `C114 ${crypto.randomUUID().slice(0, 8)} (renomeada no Google)` + const eventId = googleEventIdForActivity(activity.id) + const originalStart = new Date(store.find((entry) => entry.id === eventId)!.start!.dateTime!) + const newStart = new Date(originalStart.getTime() + 3_600_000) + replaceEvent(store, eventId, { + summary: `[${municipality.name}] ${renamedTitle}`, + start: { dateTime: formatBahiaDateTime(newStart.toISOString()) }, + end: { dateTime: formatBahiaDateTime(new Date(newStart.getTime() + 3_600_000).toISOString()) }, + updated: new Date(Date.now() + 60_000).toISOString(), + }) + + const outcome = await runSync(client) + expect(outcome.reverseEdits).toBe(1) + expect(outcome.updated).toBe(0) + + const reloaded = await reloadActivity(activity.id) + expect(reloaded.title).toBe(renamedTitle) + expect(reloaded.slug).toBe(activity.slug) + expect(new Date(reloaded.startAt!).getTime()).toBe(newStart.getTime()) + const record = (reloaded.updates ?? []).at(-1) + expect(record?.body).toContain('Google Calendar:') + expect(record?.author).toBeNull() + + // Converges: the next pass touches nothing (no loop). + const again = await runSync(client) + expect(again).toMatchObject({ updated: 0, deleted: 0, reverseEdits: 0 }) + expect(store).toHaveLength(1) + }) + + it('an older Google edit loses the clock rule — the Teqo re-asserts', async () => { + const municipality = await campaignFixtures().getMunicipality() + const activity = await createActivity({ municipality: municipality.id }) + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await runSync(client) + + replaceEvent(store, googleEventIdForActivity(activity.id), { + summary: `[${municipality.name}] Editado antes do Teqo`, + updated: new Date(Date.now() - 60_000).toISOString(), + }) + + const outcome = await runSync(client) + expect(outcome.updated).toBe(1) + expect(outcome.reverseEdits).toBe(0) + + const reloaded = await reloadActivity(activity.id) + expect(reloaded.title).not.toBe('Editado antes do Teqo') + expect(store.find((entry) => entry.id === googleEventIdForActivity(activity.id))?.summary).toBe( + `[${municipality.name}] ${activity.title}`, + ) + }) + + it('a cancelled event in Google cancels the confirmado activity, then the trash is cleaned', async () => { + const activity = await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await runSync(client) + + // The cancel carries Google's own `updated` clock (newer than the + // activity's last mirrored change) — otherwise the clock rule would + // treat it as a stale cancel and clean the trash instead. + replaceEvent(store, googleEventIdForActivity(activity.id), { + status: 'cancelled', + updated: new Date(Date.now() + 60_000).toISOString(), + }) + + const outcome = await runSync(client) + expect(outcome.reverseEdits).toBe(1) + const reloaded = await reloadActivity(activity.id) + expect(reloaded.status).toBe('cancelado') + expect((reloaded.updates ?? []).at(-1)?.body).toContain('Google Calendar: cancelada') + + // The trashed event leaves Google on the next pass (Teqo SoT). + const cleanup = await runSync(client) + expect(cleanup.deleted).toBe(1) + expect(store).toHaveLength(0) + }) + + it('a permanently removed event cancels the activity (snapshot rule)', async () => { + const activity = await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await runSync(client) + expect(store).toHaveLength(1) + + store.splice(0, 1) + + const outcome = await runSync(client) + expect(outcome.reverseEdits).toBe(1) + expect((await reloadActivity(activity.id)).status).toBe('cancelado') + }) + + it('a failed creation is never "seen" — the next pass creates instead of cancelling', async () => { + const activity = await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const flakyClient: GoogleCalendarClient = { + ...createStubClient(store), + insertEvent: async () => { + throw new Error('falha simulada na criação') + }, + } + + const failed = await runSync(flakyClient) + expect(failed.status).toBe('paused') + + const recovered = await runSync(createStubClient(store)) + expect(recovered.created).toBe(1) + expect(recovered.reverseEdits).toBe(0) + expect((await reloadActivity(activity.id)).status).toBe('confirmado') + }) + + it('switching calendars re-creates the mirror without cancelling, and re-watches the channel', async () => { + const activity = await createActivity() + await createConfig(calendarA) + const storeA: GoogleRemoteEvent[] = [] + const watched: string[] = [] + const stopped: Array<{ id: string; resourceId: string }> = [] + const client: GoogleCalendarClient = { + ...createStubClient(storeA), + watchEvents: async (calendarId) => { + watched.push(calendarId) + return { + id: 'watch-' + watched.length, + resourceId: 'resource-' + watched.length, + expiration: Date.now() + 30 * 86_400_000, + } + }, + stopChannel: async (channel) => { + stopped.push(channel) + }, + } + await runSync(client) + expect(watched).toEqual([calendarA]) + expect(storeA).toHaveLength(1) + + const config = await loadGoogleCalendarSyncConfig(payload) + await payload.update({ + collection: 'googleCalendarSync', + id: config!.id, + data: { calendarId: calendarB }, + depth: 0, + overrideAccess: true, + }) + storeA.length = 0 + + const outcome = await runSync(client, 'config-change') + expect(outcome.created).toBe(1) + expect(outcome.reverseEdits).toBe(0) + expect(storeA).toHaveLength(1) + expect((await reloadActivity(activity.id)).status).toBe('confirmado') + // Channel re-created for the new calendar; the old one stopped. + expect(watched).toEqual([calendarA, calendarB]) + expect(stopped).toEqual([{ id: 'watch-1', resourceId: 'resource-1' }]) + }) + + it('a newer Google edit never touches realizado history', async () => { + const municipality = await campaignFixtures().getMunicipality() + const activity = await createActivity({ municipality: municipality.id, status: 'realizado' }) + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await runSync(client) + + replaceEvent(store, googleEventIdForActivity(activity.id), { + summary: `[${municipality.name}] Editado depois`, + updated: new Date(Date.now() + 60_000).toISOString(), + }) + + const outcome = await runSync(client) + expect(outcome.updated).toBe(1) + expect(outcome.reverseEdits).toBe(0) + expect((await reloadActivity(activity.id)).status).toBe('realizado') + }) + + it('a description-only drift is Teqo-owned: forward re-asserts without a reverse record', async () => { + const activity = await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await runSync(client) + + replaceEvent(store, googleEventIdForActivity(activity.id), { + description: 'descrição adulterada no Google', + }) + + const outcome = await runSync(client) + expect(outcome.updated).toBe(1) + expect(outcome.reverseEdits).toBe(0) + expect((await reloadActivity(activity.id)).updates ?? []).toHaveLength(0) + }) + + it('ensures a push channel on the first pass and renews before expiry with a fresh id', async () => { + await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const watched: Array<{ calendarId: string; channel: { id: string; token: string; address: string } }> = [] + const stopped: Array<{ id: string; resourceId: string }> = [] + const client: GoogleCalendarClient = { + ...createStubClient(store), + watchEvents: async (calendarId, channel) => { + watched.push({ calendarId, channel }) + return { + id: 'watch-' + watched.length, + resourceId: 'resource-' + watched.length, + expiration: Date.now() + 30 * 86_400_000, + } + }, + stopChannel: async (channel) => { + stopped.push(channel) + }, + } + await runSync(client) + + let doc = await loadGoogleCalendarSyncConfig(payload) + expect(watched).toHaveLength(1) + expect(watched[0].channel.address).toContain('/campanha/agenda/google-webhook/') + expect(watched[0].channel.token).toBe(doc?.pushChannelSecret) + expect(doc?.pushChannelId).toBe('watch-1') + expect(doc?.pushChannelSecret).toHaveLength(43) + expect(doc?.pushChannelError).toBeNull() + + // Expiring soon → renewal with a NEW unique id + stop of the old + // channel; the URL secret ROTATES per channel (a leaked URL self-heals + // on the next renewal). + await payload.update({ + collection: 'googleCalendarSync', + id: doc!.id, + data: { pushChannelExpiresAt: new Date(Date.now() + 60_000).toISOString() }, + depth: 0, + overrideAccess: true, + }) + await runSync(client) + + doc = await loadGoogleCalendarSyncConfig(payload) + expect(watched).toHaveLength(2) + expect(stopped).toEqual([{ id: 'watch-1', resourceId: 'resource-1' }]) + expect(doc?.pushChannelId).toBe('watch-2') + expect(doc?.pushChannelSecret).toBe(watched[1].channel.token) + expect(doc?.pushChannelSecret).not.toBe(watched[0].channel.token) + }) + + it('a channel failure is recorded but never pauses the mirror', async () => { + await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client: GoogleCalendarClient = { + ...createStubClient(store), + watchEvents: async () => { + throw new Error('watch indisponível (simulado)') + }, + } + + const outcome = await runSync(client) + expect(outcome.status).toBe('synced') + expect(outcome.created).toBe(1) + + const doc = await loadGoogleCalendarSyncConfig(payload) + expect(doc?.pushChannelError).toContain('watch indisponível') + expect(doc?.pushChannelId).toBeNull() + }) + + it('a newer all-day Google edit round-trips through the reverse path', async () => { + const municipality = await campaignFixtures().getMunicipality() + const activity = await createActivity({ municipality: municipality.id }) + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await runSync(client) + + // C127: dates derived from now — hardcoded instants would leave the + // sync window and break the assertion deterministically after ~90 days. + const startCivil = allDayCivilDateOf(new Date(Date.now() + 2 * 86_400_000).toISOString()) + const endExclusive = allDayCivilDateOf(new Date(Date.now() + 4 * 86_400_000).toISOString()) + const expectedStartAt = new Date(Date.now() + 2 * 86_400_000) + expectedStartAt.setUTCHours(3, 0, 0, 0) + const expectedEndAt = new Date(expectedStartAt.getTime() + 2 * 86_400_000) + + replaceEvent(store, googleEventIdForActivity(activity.id), { + start: { date: startCivil }, + end: { date: endExclusive }, + updated: new Date(Date.now() + 60_000).toISOString(), + }) + + const outcome = await runSync(client) + expect(outcome.reverseEdits).toBe(1) + + const reloaded = await reloadActivity(activity.id) + expect(reloaded.allDay).toBe(true) + expect(new Date(reloaded.startAt!).getTime()).toBe(expectedStartAt.getTime()) + expect(new Date(reloaded.endAt!).getTime()).toBe(expectedEndAt.getTime()) + expect((reloaded.updates ?? []).at(-1)?.body).toContain('remarcada') + + // Converges — the forward does not flip allDay back. + const again = await runSync(client) + expect(again).toMatchObject({ updated: 0, deleted: 0, reverseEdits: 0 }) + expect(store).toHaveLength(1) + }) + + it('a pending newer Google edit survives a task toggle (clock baseline = lastMirroredChangeAt)', async () => { + const municipality = await campaignFixtures().getMunicipality() + const activity = await createActivity({ municipality: municipality.id }) + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await runSync(client) + + // Google edit lands (newer than the last mirrored change)… + const googleTitle = `C114 ${crypto.randomUUID().slice(0, 8)} (título do Google)` + replaceEvent(store, googleEventIdForActivity(activity.id), { + summary: `[${municipality.name}] ${googleTitle}`, + updated: new Date(Date.now() + 60_000).toISOString(), + }) + // …and a task toggle bumps `updatedAt` BEFORE the pass runs (it never + // reaches the mirror — no pass, no stamp). + await payload.update({ + collection: 'activity', + id: activity.id, + data: { tasks: [{ title: 'Convidar a rádio', done: true }] }, + depth: 0, + overrideAccess: true, + }) + + const outcome = await runSync(client) + expect(outcome.reverseEdits).toBe(1) + expect((await reloadActivity(activity.id)).title).toBe(googleTitle) + }) + + it('a staff reopen after a Google cancel is not re-cancelled', async () => { + const activity = await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await runSync(client) + + // 1) user cancels in Google — the cancel beats the 2s clock tolerance + // (anchored on the last mirrored change), so the activity is cancelled. + const stampedAt = (await reloadActivity(activity.id)).lastMirroredChangeAt! + replaceEvent(store, googleEventIdForActivity(activity.id), { + status: 'cancelled', + updated: new Date(new Date(stampedAt).getTime() + 5_000).toISOString(), + }) + let outcome = await runSync(client) + expect(outcome.reverseEdits).toBe(1) + expect((await reloadActivity(activity.id)).status).toBe('cancelado') + + // 2) staff reopens the activity AFTER the cancel instant — the next + // pass must NOT re-cancel: the trashed event is cleaned instead. + await new Promise((resolve) => setTimeout(resolve, 3_500)) + await payload.update({ + collection: 'activity', + id: activity.id, + data: { status: 'confirmado' }, + depth: 0, + overrideAccess: true, + }) + outcome = await runSync(client) + expect(outcome.reverseEdits).toBe(0) + expect(outcome.deleted).toBe(1) + + // 3) …and a fresh event is created for the reopened commitment. + outcome = await runSync(client) + expect(outcome.created).toBe(1) + expect((await reloadActivity(activity.id)).status).toBe('confirmado') + }) + }) }) diff --git a/tests/unit/agendaGoogleSyncChrome.unit.spec.tsx b/tests/unit/agendaGoogleSyncChrome.unit.spec.tsx index 4996db4c..3bc99489 100644 --- a/tests/unit/agendaGoogleSyncChrome.unit.spec.tsx +++ b/tests/unit/agendaGoogleSyncChrome.unit.spec.tsx @@ -29,6 +29,8 @@ const stateFor = (status: GoogleCalendarSyncStatus): GoogleCalendarSyncActionRes 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, + pushChannelExpiresAt: null, + pushChannelError: null, addLink: status === 'not-configured' ? null : 'https://calendar.google.com/calendar/r?cid=webcal', }) diff --git a/tests/unit/googleCalendarSyncDialog.unit.spec.tsx b/tests/unit/googleCalendarSyncDialog.unit.spec.tsx new file mode 100644 index 00000000..79a7a5c1 --- /dev/null +++ b/tests/unit/googleCalendarSyncDialog.unit.spec.tsx @@ -0,0 +1,145 @@ +import { cleanup, render, screen, within } 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 { ActivityUpdateFeed } from '@/components/campaign/activity/ActivityUpdateFeed' +import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog' + +const syncedState = ( + overrides: Partial = {}, +): GoogleCalendarSyncActionResult => ({ + ok: true, + status: 'synced', + calendarId: 'c_campanha@group.calendar.google.com', + lastSyncedAt: '2026-08-11T12:00:00.000Z', + lastSuccessAt: '2026-08-11T12:00:00.000Z', + lastErrorAt: null, + lastError: null, + pushChannelExpiresAt: null, + pushChannelError: null, + addLink: 'https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2F…', + ...overrides, +}) + +const renderDialog = (state: GoogleCalendarSyncActionResult) => + render( + , + ) + +const matchMediaMock = vi.fn() + +beforeAll(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + Element.prototype.scrollIntoView = () => {} +}) + +beforeEach(() => { + matchMediaMock.mockImplementation((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + vi.stubGlobal('matchMedia', matchMediaMock) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +afterAll(() => { + vi.unstubAllGlobals() +}) + +describe('GoogleCalendarSyncDialog — seção "Edições pelo Google" (C115)', () => { + it('mostra a data de expiração do canal quando ativo', () => { + renderDialog(syncedState({ pushChannelExpiresAt: '2026-09-10T12:00:00.000Z' })) + const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ }) + expect(within(dialog).getByText(/Edições pelo Google/)).toBeTruthy() + expect(within(dialog).getByText(/Notificações ativas até/)).toBeTruthy() + }) + + it('mostra o erro do canal quando a notificação falhou, sem prometer renovação', () => { + renderDialog(syncedState({ pushChannelError: 'NEXT_PUBLIC_SITE_URL não configurada' })) + const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ }) + expect( + within(dialog).getByText(/Notificações de mudanças feitas no Google indisponíveis/), + ).toBeTruthy() + expect(within(dialog).queryByText(/Notificações ativas até/)).toBeNull() + }) + + it('não mostra a seção no estado não configurado', () => { + renderDialog({ + ok: true, + status: 'not-configured', + calendarId: null, + lastSyncedAt: null, + lastSuccessAt: null, + lastErrorAt: null, + lastError: null, + pushChannelExpiresAt: null, + pushChannelError: null, + addLink: null, + }) + const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ }) + expect(within(dialog).queryByText(/Edições pelo Google/)).toBeNull() + }) +}) + +describe('ActivityUpdateFeed — atribuição do registro reverso (C115)', () => { + const renderFeed = (updates: Parameters[0]['updates']) => + render() + + it('atribui "Google Calendar" ao registro reverso sem autor', () => { + renderFeed([ + { + id: '1', + body: 'Google Calendar: remarcada — antes 14/08 às 10:00, agora 14/08 às 16:00', + authorName: null, + createdAt: '2026-08-11T12:00:00.000Z', + }, + ]) + expect(screen.getByText('Google Calendar')).toBeTruthy() + }) + + it('mantém "Autor removido" para entradas sem autor fora do prefixo', () => { + renderFeed([ + { + id: '2', + body: 'Atualização de um autor que saiu da campanha', + authorName: null, + createdAt: '2026-08-11T12:00:00.000Z', + }, + ]) + expect(screen.getByText('Autor removido')).toBeTruthy() + }) + + it('mantém o nome do autor quando presente (o prefixo não rouba a atribuição)', () => { + renderFeed([ + { + id: '3', + body: 'Google Calendar: título alterado', + authorName: 'Ana Coordenadora', + createdAt: '2026-08-11T12:00:00.000Z', + }, + ]) + expect(screen.getByText('Ana Coordenadora')).toBeTruthy() + }) +})