// @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() /** * 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() 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 = {}, 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, ) => { 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((resolve) => { parkB = resolve }) const gate = new Promise((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 & { 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() }) }) })