teqo/tests/unit/googleCalendarEventMapping.unit.spec.ts
Francisco Solla 70070db8dc feat(C114): agenda da campanha espelhada no Google Calendar com notificação (push Teqo → Google)
Espelho oficial da agenda em calendário Google compartilhado via API
(service account, jose + fetch): reconciliação por passada com id
determinístico teqo+base32hex (upsert idempotente, sem estado por evento),
hooks afterChange/afterDelete na activity (só campos espelhados, nunca
lança), auto-resync quando calendarId muda, retry na view da agenda,
pill de estado no header + FAB + diálogo (4 estados). Cancelado no Teqo
deleta o evento no Google. Sem participantes no v1 (decisão de produto).
Sem migration nova de tabela (hand-written). Closes #635
2026-08-11 13:53:51 -03:00

183 lines
6.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
buildGoogleCalendarDescription,
buildGoogleEventPayload,
decodeGoogleEventActivityId,
googleEventContentEquals,
googleEventIdForActivity,
type GoogleRemoteEvent,
} from '@/lib/googleCalendarEventMapping'
type MappingSource = Parameters<typeof buildGoogleEventPayload>[0]
const baseActivity = (overrides: Partial<MappingSource> = {}): MappingSource =>
({
id: 42,
title: 'Caminhada no Cabula',
startAt: '2026-08-15T19:00:00.000Z',
endAt: '2026-08-15T22:00:00.000Z',
allDay: false,
locality: 'Centro',
tags: ['Caminhada'],
deputyPresent: true,
...overrides,
}) as MappingSource
describe('googleEventIdForActivity / decodeGoogleEventActivityId', () => {
it('encodes ids in base32hex under the teqo prefix (charset do Google)', () => {
expect(googleEventIdForActivity(0)).toBe('teqo0')
expect(googleEventIdForActivity(1)).toBe('teqo1')
expect(googleEventIdForActivity(31)).toBe('teqov')
expect(googleEventIdForActivity(32)).toBe('teqo10')
expect(googleEventIdForActivity(1000)).toBe('teqov8')
})
it('round-trips any positive id', () => {
for (const id of [1, 7, 42, 255, 1024, 123_456, 999_999_999]) {
const encoded = googleEventIdForActivity(id)
expect(encoded).toMatch(/^teqo[a-v0-9]+$/)
expect(decodeGoogleEventActivityId(encoded)).toBe(id)
}
})
it('rejects foreign and malformed ids', () => {
expect(decodeGoogleEventActivityId('meu-evento-42')).toBeNull()
expect(decodeGoogleEventActivityId('teqo')).toBeNull()
expect(decodeGoogleEventActivityId('teqoz')).toBeNull()
expect(decodeGoogleEventActivityId('teqoA1')).toBeNull()
})
it('rejects invalid activity ids', () => {
expect(() => googleEventIdForActivity(-1)).toThrow()
expect(() => googleEventIdForActivity(1.5)).toThrow()
})
})
describe('buildGoogleEventPayload', () => {
it('maps a timed activity to RFC3339 dateTimes with the fixed -03:00 offset', () => {
const payload = buildGoogleEventPayload(baseActivity(), 'Salvador')
expect(payload.id).toBe('teqo1a')
expect(payload.summary).toBe('[Salvador] Caminhada no Cabula')
expect(payload.start).toEqual({ dateTime: '2026-08-15T16:00:00-03:00' })
expect(payload.end).toEqual({ dateTime: '2026-08-15T19:00:00-03:00' })
expect(payload.location).toBe('Centro')
})
it('builds the description without unnecessary PII', () => {
const payload = buildGoogleEventPayload(baseActivity(), 'Salvador')
expect(payload.description).toContain('Município: Salvador')
expect(payload.description).toContain('Local: Centro')
expect(payload.description).toContain('Tags: Caminhada')
expect(payload.description).toContain('Deputado presente')
})
it('synthesizes end = start + 1h when the activity has no endAt (Google exige end > start)', () => {
const payload = buildGoogleEventPayload(
baseActivity({ endAt: null, startAt: '2026-08-15T19:00:00.000Z' }),
'Salvador',
)
expect(payload.start).toEqual({ dateTime: '2026-08-15T16:00:00-03:00' })
expect(payload.end).toEqual({ dateTime: '2026-08-15T17:00:00-03:00' })
})
it('maps all-day activities to civil dates with the exclusive end (C104 convention)', () => {
const payload = buildGoogleEventPayload(
baseActivity({
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-12T03:00:00.000Z',
locality: '',
}),
'Feira de Santana',
)
expect(payload.start).toEqual({ date: '2026-08-10' })
expect(payload.end).toEqual({ date: '2026-08-13' })
})
it('falls back to the municipality name as the location when locality is empty', () => {
const payload = buildGoogleEventPayload(baseActivity({ locality: '' }), 'Salvador')
expect(payload.location).toBe('Salvador')
})
it('omits location entirely when neither locality nor municipality name exist', () => {
const payload = buildGoogleEventPayload(baseActivity({ locality: '' }))
expect(payload.location).toBeUndefined()
expect(payload.summary).toBe('Caminhada no Cabula')
})
})
describe('buildGoogleCalendarDescription', () => {
it('is empty when there is nothing to say', () => {
expect(buildGoogleCalendarDescription({ locality: '', tags: [], deputyPresent: false })).toBe(
'',
)
})
})
describe('googleEventContentEquals', () => {
const payload = buildGoogleEventPayload(baseActivity(), 'Salvador')
it('treats the same instant under a different timezone echo as equal', () => {
const remote: GoogleRemoteEvent = {
id: payload.id,
summary: payload.summary,
description: payload.description,
location: payload.location,
// Calendar in America/Sao_Paulo echoes the same instant with its own offset.
start: { dateTime: '2026-08-15T16:00:00-03:00', timeZone: 'America/Sao_Paulo' },
end: { dateTime: '2026-08-15T19:00:00-03:00', timeZone: 'America/Sao_Paulo' },
}
expect(googleEventContentEquals(remote, payload)).toBe(true)
})
it('flags content drift', () => {
const remote: GoogleRemoteEvent = { ...payload, summary: '[Salvador] Outro título' }
expect(googleEventContentEquals(remote, payload)).toBe(false)
})
it('flags a moved instant', () => {
const remote: GoogleRemoteEvent = {
...payload,
start: { dateTime: '2026-08-16T16:00:00-03:00' },
end: { dateTime: '2026-08-16T19:00:00-03:00' },
}
expect(googleEventContentEquals(remote, payload)).toBe(false)
})
it('compares all-day events by civil date', () => {
const allDayPayload = buildGoogleEventPayload(
baseActivity({
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-12T03:00:00.000Z',
}),
'Feira de Santana',
)
const remote: GoogleRemoteEvent = {
id: allDayPayload.id,
summary: allDayPayload.summary,
description: allDayPayload.description,
location: allDayPayload.location,
start: { date: '2026-08-10' },
end: { date: '2026-08-13' },
}
expect(googleEventContentEquals(remote, allDayPayload)).toBe(true)
})
it('treats a missing remote description as empty (not drift)', () => {
const remote: GoogleRemoteEvent = {
id: payload.id,
summary: payload.summary,
start: payload.start,
end: payload.end,
}
expect(
googleEventContentEquals(remote, { ...payload, description: '', location: undefined }),
).toBe(true)
})
})