fix(C115): revisão /simplify — relógio com baseline de espelho, reopen sem re-cancelamento, hardening do webhook
- lastMirroredChangeAt (activity, migration): clock rule compara contra a última mudança espelhada, não updatedAt (task toggle não silencia edição pendente do Google) - snapshot exclui ids que o motor deletou (removedEventIds separado do remoteEventIds): reopen pós-cancelamento não vira re-cancelamento fantasma - cancelamento do Google respeita o relógio: cancel mais novo cancela; Teqo mais novo (reopen) limpa o trashed e recria - escrita reversa sem req.user: autoria do registro correta + sem gate 403 do deputado; hook de sync pula a própria escrita reversa (sem passada aninhada) - canal: segredo rotaciona por criação; novo canal persistido antes do stop do antigo; identity fields read:false; create admin-only; leitura prefere linha configurada; pre-filter do webhook antes do bootstrap do Payload - testes: all-day ida-e-volta, task-toggle, reopen, slug congelado, e2e dos caminhos 200, render tests do dialog/feed; asserções herméticas a atividades ambiente dos arquivos irmãos (DB compartilhado)
This commit is contained in:
parent
7629a505e0
commit
2f247d40a6
15 changed files with 744 additions and 11 deletions
|
|
@ -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/<workspace>/canvases/plan-<id>-ui-draft.canvas.tsx`, fora do repo, só renderizava no IDE de quem planejou e apodrecia) e passa a ser **HTML + Tailwind v4** (`docs/plans/<slug>-ui-draft.html` — `@tailwindcss/browser@4` via jsdelivr, mesma major do app 4.1.x; rede só na hora de renderizar) **commitado com o plano** e **renderizado em PNG** por `pnpm ui-draft:render` (`scripts/render-ui-draft.mjs`, Chromium do Playwright via `@playwright/test`, fallback `load` se o CDN não settle — nunca toca banco/app): uma PNG por cena `<section data-shot="<rótulo>">` (ex. `mobile`, `desktop-vazio`) → `docs/plans/<slug>-ui-draft-<rótulo>.png`, ou full-page sem `data-shot`; PNGs **embutidos no plano** (visíveis no chat do gate E no PR — o aceite vira registro permanente; o HTML é a fonte iterável). **Contrato de fidelidade explícito em `ui-draft-html.md`** (substitui `ui-draft-canvas.md`, deletado; refs atualizadas em SKILL.md/frontmatter, intention-template.md — campo renomeado `Canvas UI:` → `Rascunho UI:` + secção de embed, skills-map.md, shaping.md): o rascunho DEVE ter layout real, espaçamentos/tamanhos na escala do app (`p-4`, `gap-3`, `h-11`, `text-sm`), breakpoints reais (390/1280) e estados críticos como cenas estáticas; é PROIBIDO ser funcional (zero JS de comportamento — cada estado é cena separada), usar código de app (`src/`), brand/polish (gradientes/emojis/sombras/logo/paleta do produto — cinza neutro + um acento), mock de dados realistas ou decisão de engenharia. Gate: iterar HTML → re-render → embutir até confirmação explícita. Verificado: render real do skeleton (classes v4 aplicadas — oklch zinc, rounded-lg 8px, text-lg 18px). Sem migration/access/Consent/UI de produto/schema. Sem issue — ad-hoc no worktree `work/5`.
|
||||
**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).
|
||||
|
|
|
|||
|
|
@ -43,6 +43,19 @@ export const GET = async (): Promise<Response> => notFoundResponse()
|
|||
export const POST = async (request: Request, context: RouteContext): Promise<Response> => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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 }),
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ export const GoogleCalendarSyncDialog = ({
|
|||
{state.pushChannelExpiresAt ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Notificações ativas até {formatBahiaDateTimeLabel(state.pushChannelExpiresAt)} — renovadas
|
||||
automaticamente.
|
||||
automaticamente a cada sincronização.
|
||||
</p>
|
||||
) : null}
|
||||
{state.pushChannelError ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "activity" DROP COLUMN IF EXISTS "last_mirrored_change_at";
|
||||
`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<T extends boolean = true> {
|
|||
resultMedia?: T;
|
||||
resultRecordedBy?: T;
|
||||
resultRecordedAt?: T;
|
||||
lastMirroredChangeAt?: T;
|
||||
createdBy?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ export const activityGoogleCalendarSyncHook = async ({
|
|||
req: PayloadRequest
|
||||
}): Promise<unknown> => {
|
||||
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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<GoogleRemoteEvent>,
|
||||
) => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
|
||||
|
|
|
|||
145
tests/unit/googleCalendarSyncDialog.unit.spec.tsx
Normal file
145
tests/unit/googleCalendarSyncDialog.unit.spec.tsx
Normal file
|
|
@ -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> = {},
|
||||
): 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(
|
||||
<GoogleCalendarSyncDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
state={state}
|
||||
onSyncNow={vi.fn()}
|
||||
onSetDisabled={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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<typeof ActivityUpdateFeed>[0]['updates']) =>
|
||||
render(<ActivityUpdateFeed updates={updates} />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue