mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:25:18 +00:00
1379 lines
54 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|