teqo-backup/tests/int/googleCalendarSync.int.spec.ts

1379 lines
54 KiB
TypeScript

// @vitest-environment node
//
// C115-F1 — window hermeticity convention (read before adding tests here):
// this engine mirrors the WHOLE activity window by design, and the int suite
// runs files in parallel against ONE shared database — sibling spec files
// keep activities inside that window while they run. Two rules keep counts
// stable without touching other specs:
//
// 1. Any NEW spec here that asserts on mirror counts MUST scope its passes
// by the ids of the activities THIS spec created via `activityWhere`
// (`{ id: { in: [...] } }`) — never count on an empty window. A title
// marker is NOT enough: Payload's `like`/`contains` are CONTAINS matches
// (`ILIKE '%…%'`), so a marker of hex characters (e.g. `C114`) also
// matches a fixture UUID that embeds it — that is how a parallel spec's
// row leaked into this mirror and broke the exact counts (deploy verify 2026-09-13).
// 2. Specs elsewhere that create pass-through activities in the window
// keep the per-test cleanup from `installCampaignFixtures` (fixtures
// are owned and removed beforeEach/afterEach) — never leak rows past
// the test that created them.
//
// A shared "window-safe fixture" helper stays OUT until a second spec needs
// it (DRY < 3 call sites).
import { getPayload, type Payload } from 'payload'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import {
allDayCivilDateOf,
allDayEndInstantFromExclusive,
allDayExclusiveEndDate,
allDayStartInstant,
} from '@/lib/activityAllDay'
import { googleEventIdForActivity } from '@/lib/googleCalendarEventMapping'
import config from '@/payload.config'
import type { GoogleCalendarClient, GoogleRemoteEvent } from '@/utilities/googleCalendarClient'
import {
loadGoogleCalendarSyncConfig,
readGoogleCalendarSyncView,
runCampaignCalendarSync,
type CampaignCalendarSyncOptions,
} from '@/utilities/googleCalendarSync'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { serializeGoogleCalendarSyncSpec } from '../helpers/googleCalendarSyncLock'
import { withGoogleCalendarTestCredential } from '../helpers/googleCalendarTestKey'
serializeGoogleCalendarSyncSpec()
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
/** In-memory Google calendar stub — list/insert/update/delete over an array. */
const createStubClient = (store: GoogleRemoteEvent[] = []): GoogleCalendarClient => ({
// C150 — the picker lists calendars; the engine never calls this.
listCalendars: async () => [],
listEvents: async (_calendarId, range) =>
store.filter((event) => {
const start = event.start?.dateTime ?? event.start?.date ?? ''
return start >= range.timeMin && start <= range.timeMax
}),
insertEvent: async (_calendarId, event) => {
store.push(event)
},
updateEvent: async (_calendarId, eventId, event) => {
const index = store.findIndex((entry) => entry.id === eventId)
if (index >= 0) store[index] = event
},
patchEvent: async (_calendarId, eventId, patch) => {
const index = store.findIndex((entry) => entry.id === eventId)
if (index >= 0) store[index] = { ...store[index], ...patch }
},
deleteEvent: async (_calendarId, eventId) => {
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'
const calendarB = 'c_campanha_b@group.calendar.google.com'
describe('campaign Google calendar sync engine (C114)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
/** Config rows this file created — cleanup never touches other specs' rows. */
const ownedConfigIds = new Set<number>()
/**
* Activity ids this spec created — the mirror scope AND the cleanup set.
* Ownership by id is collision-proof: a title marker is not (Payload's
* `like`/`contains` match anywhere in the string — the deploy verify blocker
* of 2026-09-13).
*/
const ownedActivityIds = new Set<number>()
afterEach(async () => {
// Teqo never depends on Google: the engine must never throw into callers.
// Cleanup happens per test; the env is restored by the credential helper.
if (ownedActivityIds.size > 0) {
await payload.delete({
collection: 'activity',
where: { id: { in: [...ownedActivityIds] } },
overrideAccess: true,
})
ownedActivityIds.clear()
}
for (const id of ownedConfigIds) {
await payload.delete({ collection: 'googleCalendarSync', id, overrideAccess: true })
}
ownedConfigIds.clear()
})
/**
* Creates an activity this spec OWNS by default (mirror scope + cleanup).
* Pass `{ track: false }` for a deliberately foreign row: it exists in the
* window but must stay out of the scoped mirror (the fixture cleanup still
* owns it through the proxy — the flag only keeps it out of the scope).
*/
const createActivity = async (
overrides: Record<string, unknown> = {},
options: { track?: boolean } = {},
) => {
const municipality = await campaignFixtures().getMunicipality()
const activity = await payload.create({
collection: 'activity',
data: hookFilledCreateData<'activity'>({
title: `C114 ${crypto.randomUUID().slice(0, 8)}`,
tags: ['Caminhada'],
status: 'confirmado',
startAt: new Date(Date.now() + 86_400_000).toISOString(),
municipality: municipality.id,
...overrides,
}),
depth: 0,
overrideAccess: true,
})
if (options.track !== false) ownedActivityIds.add(activity.id)
return activity
}
const createConfig = async (calendarId: string) => {
const doc = await payload.create({
collection: 'googleCalendarSync',
data: { calendarId },
depth: 0,
overrideAccess: true,
})
ownedConfigIds.add(doc.id)
return doc
}
/**
* Runs a sync pass scoped to the activities THIS spec created. The int suite
* runs files in parallel against one shared database and other specs create
* activities inside the mirror window — a full-scope pass would count them
* and flake the global assertions (C126; deploy verify blocker of
* 2026-09-13). Production callers omit the scope and keep the espelho cheio.
*/
const runSync = (
client: GoogleCalendarClient,
reason: CampaignCalendarSyncOptions['reason'] = 'manual',
) =>
withGoogleCalendarTestCredential(() =>
runCampaignCalendarSync(payload, {
reason,
client,
activityWhere: { id: { in: [...ownedActivityIds] } },
}),
)
/** 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`
/** Replaces one event's fields in the stub store (throws when absent). */
const replaceEvent = (
store: GoogleRemoteEvent[],
eventId: string,
patch: Partial<GoogleRemoteEvent>,
) => {
const index = store.findIndex((entry) => entry.id === eventId)
if (index < 0) throw new Error(`Evento ${eventId} não está no stub store.`)
store[index] = { ...store[index], ...patch }
}
it('creates the full mirror on the first pass (timed + all-day, municipality summary)', async () => {
const municipality = await campaignFixtures().getMunicipality()
const timed = await createActivity({ municipality: municipality.id, locality: 'Centro' })
// C127: dates derived from now — hardcoded instants would leave the sync
// window and break the assertion deterministically after ~90 days.
const allDayStartAt = new Date(Date.now() + 2 * 86_400_000).toISOString()
const allDayEndAt = new Date(Date.now() + 4 * 86_400_000).toISOString()
const allDay = await createActivity({
municipality: municipality.id,
allDay: true,
startAt: allDayStartAt,
endAt: allDayEndAt,
})
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
const outcome = await runSync(client)
expect(outcome.status).toBe('synced')
expect(outcome.created).toBe(2)
expect(outcome.updated).toBe(0)
expect(outcome.deleted).toBe(0)
const timedEvent = store.find((event) => event.id === googleEventIdForActivity(timed.id))
expect(timedEvent?.summary).toBe(`[${municipality.name}] ${timed.title}`)
expect(timedEvent?.location).toBe('Centro')
expect(timedEvent?.start).toEqual({ dateTime: expect.stringMatching(/-03:00$/) })
const allDayEvent = store.find((event) => event.id === googleEventIdForActivity(allDay.id))
expect(allDayEvent?.start).toEqual({ date: allDayCivilDateOf(allDayStartAt) })
expect(allDayEvent?.end).toEqual({ date: allDayExclusiveEndDate(allDayEndAt) })
const doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.lastSuccessAt).toBeTruthy()
expect(doc?.lastError).toBeNull()
})
it('mirrors only the fixture scope — a foreign in-window activity stays out (C126)', async () => {
const activity = await createActivity()
// A row "from another spec": inside the mirror window but outside the
// C114 fixture scope. The int suite runs files in parallel against one
// shared database, so such rows exist at arbitrary times — the mirror
// must not count them (the C126 race).
const foreign = await createActivity(
{ title: `C126 alheia ${crypto.randomUUID().slice(0, 8)}` },
{ track: false },
)
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
try {
const outcome = await runSync(client)
expect(outcome.created).toBe(1)
expect(store).toHaveLength(1)
expect(store.find((event) => event.id === googleEventIdForActivity(activity.id))).toBeTruthy()
expect(
store.find((event) => event.id === googleEventIdForActivity(foreign.id)),
).toBeUndefined()
} finally {
// Never leak the foreign row into other specs (the exact bug this pin
// guards against). The fixture proxy also auto-owns it, but the
// explicit delete keeps the guarantee visible here.
if (foreign) {
await payload.delete({ collection: 'activity', id: foreign.id, overrideAccess: true })
}
}
})
it('a foreign title CONTAINING c114 stays out of the scope (id-scoped mirror)', async () => {
const activity = await createActivity()
// The scope used to be a title marker (`like: 'C114%'`), but Payload's
// `like`/`contains` match ANYWHERE in the string (`ILIKE '%…%'`) — a
// parallel spec's fixture UUID embedding `c114` pulled its row into the
// mirror, flaking the exact counts (deploy verify, 2026-09-13). The
// scope is now ownership by id, so the marker's position is irrelevant.
const foreign = await createActivity(
{ title: `C126 alheia c114 ${crypto.randomUUID().slice(0, 8)}` },
{ track: false },
)
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
try {
const outcome = await runSync(client)
expect(outcome.created).toBe(1)
expect(store).toHaveLength(1)
expect(store.find((event) => event.id === googleEventIdForActivity(activity.id))).toBeTruthy()
expect(
store.find((event) => event.id === googleEventIdForActivity(foreign.id)),
).toBeUndefined()
} finally {
if (foreign) {
await payload.delete({ collection: 'activity', id: foreign.id, overrideAccess: true })
}
}
})
it('the delete guard honors the scope: an out-of-scope alive event is removed (C126)', async () => {
await createActivity()
// Alive and inside the mirror window, but outside the C114 fixture
// scope. A scoped mirror is authoritative for its scope — its teqo
// events are reconciled even when the delete-guard's full-scope view
// would have kept them.
const foreign = await createActivity(
{ title: `C126 alheia ${crypto.randomUUID().slice(0, 8)}` },
{ track: false },
)
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = [
{
id: googleEventIdForActivity(foreign.id),
summary: 'Evento alheio',
start: { dateTime: new Date(Date.now() + 86_400_000).toISOString() },
},
]
const client = createStubClient(store)
try {
const outcome = await runSync(client)
expect(outcome.deleted).toBe(1)
// The in-scope activity is still mirrored on the same pass.
expect(outcome.created).toBe(1)
expect(store.some((event) => event.id === googleEventIdForActivity(foreign.id))).toBe(false)
} finally {
if (foreign) {
await payload.delete({ collection: 'activity', id: foreign.id, overrideAccess: true })
}
}
})
it('converges: a second pass with no changes touches nothing', async () => {
await createActivity()
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
const first = await runSync(client)
expect(first.created).toBe(1)
const second = await runSync(client)
expect(second).toMatchObject({ created: 0, updated: 0, deleted: 0, status: 'synced' })
})
it('updates only the drifted event and ignores foreign calendar events', async () => {
const activity = await createActivity()
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = [
{ id: 'foreign-manual-event', summary: 'Mantido pela conta da campanha' },
]
const client = createStubClient(store)
await runSync(client)
// Title is immutable after creation (canonical slug rule) — drift a
// mutable mirrored field instead (locality is in the mirror surface).
await payload.update({
collection: 'activity',
id: activity.id,
data: { locality: 'Centro (atualizado)' },
depth: 0,
overrideAccess: true,
})
const outcome = await runSync(client)
expect(outcome.updated).toBe(1)
expect(outcome.deleted).toBe(0)
expect(store.find((event) => event.id === 'foreign-manual-event')).toBeTruthy()
expect(
store.find((event) => event.id === googleEventIdForActivity(activity.id))?.location,
).toBe('Centro (atualizado)')
})
it('canceled activities leave Google: their events are deleted, none created', async () => {
const activity = await createActivity()
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
await runSync(client)
expect(store).toHaveLength(1)
await payload.update({
collection: 'activity',
id: activity.id,
data: { status: 'cancelado' },
depth: 0,
overrideAccess: true,
})
const outcome = await runSync(client)
expect(outcome.deleted).toBe(1)
expect(store).toHaveLength(0)
})
it('hard-deleted activities leave no ghost in Google', async () => {
const activity = await createActivity()
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
await runSync(client)
expect(store).toHaveLength(1)
await payload.delete({ collection: 'activity', id: activity.id, overrideAccess: true })
const outcome = await runSync(client)
expect(outcome.deleted).toBe(1)
expect(store).toHaveLength(0)
})
it('activities outside the window never reach Google', async () => {
// C127: now + 400 days — far beyond the ~90-day push window, but never
// hardcoded (a fixed far date would ENTER the window as it approaches).
await createActivity({
startAt: new Date(Date.now() + 400 * 86_400_000).toISOString(),
})
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
const outcome = await runSync(client)
expect(outcome.created).toBe(0)
expect(store).toHaveLength(0)
})
it('failures land in paused and the next success recovers to synced', async () => {
await createActivity()
await createConfig(calendarA)
const failingClient: GoogleCalendarClient = {
...createStubClient(),
listEvents: async () => {
throw new Error('Google fora do ar (simulado)')
},
}
const failed = await runSync(failingClient)
expect(failed.status).toBe('paused')
const doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.lastErrorAt).toBeTruthy()
expect(doc?.lastError).toContain('Google fora do ar')
// The pill reads with the credential present (same context the run had).
const failedView = await withGoogleCalendarTestCredential(() =>
readGoogleCalendarSyncView(payload),
)
expect(failedView.status).toBe('paused')
const store: GoogleRemoteEvent[] = []
const recovered = await runSync(createStubClient(store))
expect(recovered.status).toBe('synced')
expect(recovered.created).toBe(1)
const recoveredView = await withGoogleCalendarTestCredential(() =>
readGoogleCalendarSyncView(payload),
)
expect(recoveredView.status).toBe('synced')
})
it('without a credential or calendar the engine is a no-op (fail-closed)', async () => {
await createActivity()
const store: GoogleRemoteEvent[] = []
const client = createStubClient(store)
// No config doc, no env key.
const outcome = await runCampaignCalendarSync(payload, { reason: 'manual', client })
expect(outcome.status).toBe('not-configured')
expect(store).toHaveLength(0)
await createConfig(calendarA)
// Config present but env key absent (outside the credential helper).
const withoutKey = await runCampaignCalendarSync(payload, { reason: 'manual', client })
expect(withoutKey.status).toBe('not-configured')
expect(store).toHaveLength(0)
})
it('changing the calendarId reconciles into the new calendar (D7 engine side)', async () => {
await createActivity()
await createConfig(calendarA)
const storeA: GoogleRemoteEvent[] = []
await runSync(createStubClient(storeA))
expect(storeA).toHaveLength(1)
const config = await loadGoogleCalendarSyncConfig(payload)
await payload.update({
collection: 'googleCalendarSync',
id: config!.id,
data: { calendarId: calendarB },
depth: 0,
overrideAccess: true,
})
const storeB: GoogleRemoteEvent[] = []
const outcome = await runSync(createStubClient(storeB), 'config-change')
expect(outcome.created).toBe(1)
expect(storeB).toHaveLength(1)
// The abandoned calendar keeps its last mirror (documented, no retro-cleanup).
expect(storeA).toHaveLength(1)
})
describe('bidirectional reconciliation (C115)', () => {
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('an overlapping stale pass does not resurrect a permanent removal (snapshot CAS)', async () => {
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const stub = createStubClient(store)
// Pass B mirrors the webhook-vs-hook overlap: it loads its activity
// view BEFORE X exists, then parks inside `listEvents`. The park is
// awaited so the staleness is deterministic — never timing-dependent.
let parkB!: () => void
let releaseB!: () => void
const parked = new Promise<void>((resolve) => {
parkB = resolve
})
const gate = new Promise<void>((resolve) => {
releaseB = resolve
})
const clientB: GoogleCalendarClient = {
...stub,
listEvents: async (calendarId, range) => {
parkB()
await gate
return stub.listEvents(calendarId, range)
},
}
const passB = runSync(clientB)
await parked
// X is born mid-flight (its own hook pass runs credential-less: no-op).
const activity = await createActivity()
// Pass A completes cleanly: creates x and records it as seen.
const outcomeA = await runSync(stub)
expect(outcomeA.created).toBe(1)
// The user permanently removes x from Google…
store.splice(0, 1)
// …and B — whose view predates X — finishes LAST. Erasing x from the
// last-seen snapshot here is what resurrects it on the next pass.
releaseB()
const outcomeB = await passB
expect(outcomeB.status).toBe('synced')
// The removal stands: previously seen & gone cancels, never re-creates.
const outcomeC = await runSync(createStubClient(store))
expect(outcomeC.created).toBe(0)
expect(outcomeC.reverseEdits).toBe(1)
expect((await reloadActivity(activity.id)).status).toBe('cancelado')
expect(store).toHaveLength(0)
})
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 }
}> = []
// C164 — the client hands the engine millis (the raw Google string is
// normalized at the API boundary by the client itself). The renewal
// pass overwrites the manually-set `now + 60s` below, so the assertion
// distinguishes the stub's value from a stale one.
const channelExpiration = Date.now() + 30 * 86_400_000
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: channelExpiration,
}
},
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()
expect(doc?.pushChannelExpiresAt).toBe(new Date(channelExpiration).toISOString())
// 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)
expect(doc?.pushChannelExpiresAt).toBe(new Date(channelExpiration).toISOString())
})
it('registers the channel through the TTL fallback when Google omits the expiration (C164)', async () => {
await createActivity()
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client: GoogleCalendarClient = {
...createStubClient(store),
watchEvents: async () => ({
id: 'watch-no-expiration',
resourceId: 'resource-no-expiration',
expiration: null,
}),
}
const before = Date.now()
const outcome = await runSync(client)
expect(outcome.status).toBe('synced')
const doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.pushChannelId).toBe('watch-no-expiration')
expect(doc?.pushChannelResourceId).toBe('resource-no-expiration')
expect(doc?.pushChannelError).toBeNull()
const expiresAt = Date.parse(doc?.pushChannelExpiresAt ?? '')
expect(expiresAt).toBeGreaterThanOrEqual(before + 29 * 86_400_000)
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 31 * 86_400_000)
})
it('records the transport cause in the channel error (C164)', async () => {
await createActivity()
await createConfig(calendarA)
const store: GoogleRemoteEvent[] = []
const client: GoogleCalendarClient = {
...createStubClient(store),
watchEvents: async () => {
throw new TypeError('fetch failed', { cause: new Error('connect ETIMEDOUT') })
},
}
const outcome = await runSync(client)
expect(outcome.status).toBe('synced')
const doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.pushChannelError).toContain('fetch failed')
expect(doc?.pushChannelError).toContain('connect ETIMEDOUT')
})
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.
// The expected instants come from the SAME lib the engine maps through
// (`activityAllDay`) — no manual timezone arithmetic in the test.
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 = allDayStartInstant(startCivil)
const expectedEndAt = allDayEndInstantFromExclusive(endExclusive)
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(reloaded.startAt).toBe(expectedStartAt)
expect(reloaded.endAt).toBe(expectedEndAt)
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')
})
})
describe('imported Google events (C165)', () => {
/** A foreign event inside the window and after the legal-start cut. */
const foreignEvent = (
overrides: Partial<GoogleRemoteEvent> & { id: string },
): GoogleRemoteEvent => {
const start = new Date(Date.now() + 2 * 86_400_000)
return {
summary: `C165 ${crypto.randomUUID().slice(0, 8)}`,
description: 'descrição que vive no Google',
location: 'Gabinete',
start: { dateTime: formatBahiaDateTime(start.toISOString()) },
end: {
dateTime: formatBahiaDateTime(new Date(start.getTime() + 3_600_000).toISOString()),
},
...overrides,
}
}
/**
* The imported activity is born DURING the pass, so it enters the file's
* scope/cleanup only after this read — subsequent scoped passes then see
* it as a linked row.
*/
const trackImported = async (eventId: string) => {
const found = await payload.find({
collection: 'activity',
where: { googleEventId: { equals: eventId } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const activity = found.docs[0]
if (activity) ownedActivityIds.add(activity.id)
return activity
}
it('imports an eligible foreign event once — confirmado, sem município, título verbatim', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
const outcome = await runSync(client)
expect(outcome.status).toBe('synced')
expect(outcome.imported).toBe(1)
expect(outcome.created).toBe(0)
const activity = await trackImported(event.id!)
expect(activity).toBeTruthy()
expect(activity?.status).toBe('confirmado')
expect(activity?.title).toBe(event.summary)
expect(activity?.municipality).toBeNull()
expect(activity?.locality).toBe('Gabinete')
expect(activity?.googleCalendarId).toBe(calendarA)
// Description deliberately ignored: the text lives in Google.
expect(activity?.description ?? null).toBeNull()
// The mirror never materializes an imported activity as a `teqo…` event.
expect(store.filter((entry) => entry.id?.startsWith('teqo'))).toHaveLength(0)
// Idempotent: a second pass converges and never re-imports.
const again = await runSync(client)
expect(again).toMatchObject({ imported: 0, created: 0, updated: 0, reverseEdits: 0 })
expect(store).toHaveLength(1)
})
it('leaves pre-cut events out — no backfill — and imports the cut day', async () => {
await createConfig(calendarA)
const beforeCut = foreignEvent({
id: `foreign-${crypto.randomUUID()}`,
// 2026-08-15 22:00 in Bahia — the civil date before the legal start.
start: { dateTime: '2026-08-15T22:00:00-03:00' },
end: { dateTime: '2026-08-15T23:00:00-03:00' },
})
const onCut = foreignEvent({
id: `foreign-${crypto.randomUUID()}`,
start: { dateTime: '2026-08-16T09:00:00-03:00' },
end: { dateTime: '2026-08-16T10:00:00-03:00' },
})
const store: GoogleRemoteEvent[] = [beforeCut, onCut]
const outcome = await runSync(createStubClient(store))
expect(outcome.imported).toBe(1)
expect(await trackImported(beforeCut.id!)).toBeUndefined()
expect(await trackImported(onCut.id!)).toBeTruthy()
// The pre-cut event stays untouched in Google.
expect(store).toHaveLength(2)
})
it('skips events without a usable title without pausing the pass', async () => {
await createConfig(calendarA)
const emptyTitle = foreignEvent({ id: `foreign-${crypto.randomUUID()}`, summary: '' })
const oneChar = foreignEvent({ id: `foreign-${crypto.randomUUID()}`, summary: 'A' })
const valid = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [emptyTitle, oneChar, valid]
const outcome = await runSync(createStubClient(store))
expect(outcome.status).toBe('synced')
expect(outcome.imported).toBe(1)
expect(await trackImported(valid.id!)).toBeTruthy()
expect(store).toHaveLength(3)
})
it('applies a newer Google edit back with an audit record, then converges', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
expect(activity).toBeTruthy()
const renamed = `C165 ${crypto.randomUUID().slice(0, 8)} (renomeada no Google)`
// Whole seconds: the Calendar API echoes second precision, and the
// helper below formats the same way.
const newStart = new Date(Math.floor((Date.now() + 3 * 86_400_000) / 1000) * 1000)
replaceEvent(store, event.id!, {
summary: renamed,
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 payload.findByID({
collection: 'activity',
id: activity!.id,
depth: 0,
overrideAccess: true,
})
expect(reloaded.title).toBe(renamed)
expect(reloaded.slug).toBe(activity!.slug)
expect(new Date(reloaded.startAt!).getTime()).toBe(newStart.getTime())
expect((reloaded.updates ?? []).at(-1)?.body).toContain('Google Calendar:')
const again = await runSync(client)
expect(again).toMatchObject({ imported: 0, updated: 0, reverseEdits: 0 })
expect(store).toHaveLength(1)
})
it('edits the Teqo into the SAME remote event — never a second `teqo…`, description preserved', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
expect(activity).toBeTruthy()
await payload.update({
collection: 'activity',
id: activity!.id,
data: { locality: 'Centro (atualizado no Teqo)' },
depth: 0,
overrideAccess: true,
})
const outcome = await runSync(client)
expect(outcome.updated).toBe(1)
expect(outcome.imported).toBe(0)
expect(store).toHaveLength(1)
expect(store[0].id).toBe(event.id)
expect(store[0].location).toBe('Centro (atualizado no Teqo)')
// The partial PATCH never touches the user's description.
expect(store[0].description).toBe('descrição que vive no Google')
expect(store.filter((entry) => entry.id?.startsWith('teqo'))).toHaveLength(0)
})
it('assigning a município keeps the verbatim title (no `[Município] ` prefix)', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
const municipality = await campaignFixtures().getMunicipality()
await payload.update({
collection: 'activity',
id: activity!.id,
data: { municipality: municipality.id },
depth: 0,
overrideAccess: true,
})
const outcome = await runSync(client)
expect(outcome.updated).toBe(0)
expect(store[0].summary).toBe(event.summary)
expect(store[0].summary).not.toContain('[')
})
it('cancelling in the Teqo trashes the SAME remote event — never deletes it', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
await payload.update({
collection: 'activity',
id: activity!.id,
data: { status: 'cancelado' },
depth: 0,
overrideAccess: true,
})
const outcome = await runSync(client)
expect(outcome.deleted).toBe(1)
expect(store).toHaveLength(1)
expect(store[0].status).toBe('cancelled')
// Converged: no repeated patch on the next pass.
const again = await runSync(client)
expect(again).toMatchObject({ imported: 0, updated: 0, deleted: 0, reverseEdits: 0 })
})
it('a newer Google cancellation cancels the activity; a staff reopen re-asserts the event', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
replaceEvent(store, event.id!, {
status: 'cancelled',
// +5s: newer than the import stamp (the cancel wins), yet older than
// the reopen's stamp after the 3.5s sleep below (the reopen wins).
updated: new Date(Date.now() + 5_000).toISOString(),
})
let outcome = await runSync(client)
expect(outcome.reverseEdits).toBe(1)
let reloaded = await payload.findByID({
collection: 'activity',
id: activity!.id,
depth: 0,
overrideAccess: true,
})
expect(reloaded.status).toBe('cancelado')
expect((reloaded.updates ?? []).at(-1)?.body).toContain('Google Calendar: cancelada')
// Staff reopens after the cancel: the clock rule says Teqo wins — the
// SAME event is patched back to confirmed (never deleted/recreated).
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.updated).toBe(1)
expect(outcome.reverseEdits).toBe(0)
expect(store).toHaveLength(1)
expect(store[0].status).toBe('confirmed')
reloaded = await payload.findByID({
collection: 'activity',
id: activity!.id,
depth: 0,
overrideAccess: true,
})
expect(reloaded.status).toBe('confirmado')
})
it('a permanently removed remote event cancels the linked activity (snapshot rule)', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
store.splice(0, 1)
const outcome = await runSync(client)
expect(outcome.reverseEdits).toBe(1)
const reloaded = await payload.findByID({
collection: 'activity',
id: activity!.id,
depth: 0,
overrideAccess: true,
})
expect(reloaded.status).toBe('cancelado')
})
it('a hard-deleted imported activity leaves its foreign event alone — never resurrected', async () => {
await createConfig(calendarA)
const event = foreignEvent({ id: `foreign-${crypto.randomUUID()}` })
const store: GoogleRemoteEvent[] = [event]
const client = createStubClient(store)
await runSync(client)
const activity = await trackImported(event.id!)
expect(activity).toBeTruthy()
await payload.delete({ collection: 'activity', id: activity!.id, overrideAccess: true })
ownedActivityIds.delete(activity!.id)
const outcome = await runSync(client)
expect(outcome.imported).toBe(0)
expect(store).toHaveLength(1)
expect(await trackImported(event.id!)).toBeUndefined()
// The snapshot kept the dismissed id: the FIRST pass after the delete
// must not have dropped it, or this second pass would re-import.
const again = await runSync(client)
expect(again.imported).toBe(0)
expect(await trackImported(event.id!)).toBeUndefined()
})
it('a municipality-less native activity whose mirrored event vanished is cancelled (seen-and-gone)', 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)
expect(store).toHaveLength(1)
// Triage clears the município; the user then permanently removes the
// event in Google. The seen-and-gone rule must still cancel it (the C165
// mirror guard only skips the CREATE of municipality-less activities).
await payload.update({
collection: 'activity',
id: activity.id,
data: { municipality: null },
depth: 0,
overrideAccess: true,
})
store.splice(0, 1)
const outcome = await runSync(client)
expect(outcome.reverseEdits).toBe(1)
const reloaded = await payload.findByID({
collection: 'activity',
id: activity.id,
depth: 0,
overrideAccess: true,
})
expect(reloaded.status).toBe('cancelado')
})
it('never touches a different foreign event and never imports a mirrored `teqo…` event', async () => {
const activity = await createActivity()
await createConfig(calendarA)
const untouched: GoogleRemoteEvent = { id: 'foreign-sem-data', summary: 'Sem data' }
const store: GoogleRemoteEvent[] = [untouched]
const client = createStubClient(store)
await runSync(client)
expect(store).toHaveLength(2)
// A manual `teqo…`-looking event is NOT a mirror id and is out of the
// import criteria: it is never imported (the mirror's stray-event
// cleanup owns the native namespace, not the import pass).
const manual: GoogleRemoteEvent = {
id: 'teqo999',
summary: 'Evento manual com cara de espelho',
start: { dateTime: '2026-09-20T09:00:00-03:00' },
end: { dateTime: '2026-09-20T10:00:00-03:00' },
}
store.push(manual)
const outcome = await runSync(client)
expect(outcome.imported).toBe(0)
expect(store.find((entry) => entry.id === 'foreign-sem-data')).toBe(untouched)
const importedManual = await payload.find({
collection: 'activity',
where: { googleEventId: { equals: 'teqo999' } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
expect(importedManual.docs).toHaveLength(0)
expect(store.find((entry) => entry.id === googleEventIdForActivity(activity.id))).toBeTruthy()
})
})
})