From 8b2d89920e5a7a5dc3451f0962eb7eb5200759a0 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 1 Oct 2026 17:54:32 +0300 Subject: [PATCH] Sync launches into native Google Calendars Google refreshes subscribed ICS feeds only every 12-24h. A service account now writes the same events into native public calendars every publish run, diffed by launch id and a content hash, so changes show up within one run. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 26 ++++++ CLAUDE.md | 4 +- package.json | 1 + src/calendars.ts | 18 +++- src/gcal.ts | 72 ++++++++++++++++ src/google.ts | 83 +++++++++++++++++++ src/ll2.ts | 10 ++- src/sync.ts | 51 ++++++++++++ test/gcal.test.ts | 150 ++++++++++++++++++++++++++++++++++ test/google.test.ts | 79 ++++++++++++++++++ 10 files changed, 488 insertions(+), 6 deletions(-) create mode 100644 src/gcal.ts create mode 100644 src/google.ts create mode 100644 src/sync.ts create mode 100644 test/gcal.test.ts create mode 100644 test/google.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bc554ec..40a2046 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,6 +33,32 @@ jobs: - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: site + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: snapshot + path: data/snapshot.json + retention-days: 1 + + # Separate job: a Google failure must not block the ICS deploy, and only this job sees the key. + sync: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: google-calendar + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: snapshot + path: data + - run: npm run sync + env: + GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }} deploy: needs: build diff --git a/CLAUDE.md b/CLAUDE.md index c3c1c2d..a086141 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,14 +10,16 @@ Node 24 runs the TypeScript in `src/` directly (type stripping, no build). Impor - `npm run fetch`: fetch upcoming launches and those of the last 30 days from Launch Library 2, write `data/snapshot.json`. - `npm run build`: render `data/snapshot.json` into `site/cal/{all,crewed,starship,no-starlink}.ics`. +- `npm run sync [-- --dry-run] [-- --calendar all=]`: sync `data/snapshot.json` into the Google calendars. Key from env `GOOGLE_SERVICE_ACCOUNT_KEY` (JSON) or `GOOGLE_SERVICE_ACCOUNT_KEY_FILE` (locally `%USERPROFILE%\.launchcal\sa.json`, never in the repo). - `npm test`, `npm run typecheck`. ## Layout - `src/launch.ts`: launchcal's own `Launch` model. `src/ll2.ts`: LL2 client and normaliser. `src/fetch.ts`: fetch entry point. - `src/calendars.ts`: the 4 calendars and their filters (ids are public URLs, never rename). `src/event.ts`: format-neutral event text and timing, shared by ICS and the Google sync. `src/ics.ts`: RFC 5545 writer. `src/build.ts`: build entry point. +- `src/gcal.ts`: Google event mapping and the insert/update/delete plan (launch id + hash in private extended properties; events older than the snapshot window are kept). `src/google.ts`: service account JWT and Calendar API calls. `src/sync.ts`: sync entry point. - `test/fixtures/ll2-upcoming.json` (5) and `ll2-previous.json` (1): real, unedited LL2 detailed records (recorded 2026-10-01). -- `data/snapshot.json`, `site/cal/*.ics`: generated, git-ignored. `site/` is deployed to Pages by `.github/workflows/publish.yml` (every 2h, on push to main, manual). +- `data/snapshot.json`, `site/cal/*.ics`: generated, git-ignored. `site/` is deployed to Pages by `.github/workflows/publish.yml` (every 2h, on push to main, manual); its `sync` job runs `npm run sync` in the `google-calendar` environment (secret `GOOGLE_SERVICE_ACCOUNT_KEY`). - `.github/workflows/ci.yml`: typecheck + tests, required check on `main`. Actions are pinned by commit SHA. ## Known facts diff --git a/package.json b/package.json index 6b743de..b5eed56 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "fetch": "node src/fetch.ts", "build": "node src/build.ts", + "sync": "node src/sync.ts", "test": "node --test \"test/**/*.test.ts\"", "typecheck": "tsc" }, diff --git a/src/calendars.ts b/src/calendars.ts index 914d7b8..f4e6022 100644 --- a/src/calendars.ts +++ b/src/calendars.ts @@ -4,6 +4,8 @@ import type { Launch } from './launch.ts'; export interface CalendarDef { id: string; name: string; + /** Public Google Calendar id the sync writes to; public like `id`, never change it once published. */ + googleId: string; includes: (launch: Launch) => boolean; } @@ -11,16 +13,28 @@ export const CALENDAR_DESCRIPTION = 'Unofficial, auto-updating calendar from https://launchcal.github.io. Not affiliated with SpaceX. Launch data: The Space Devs.'; export const CALENDARS: CalendarDef[] = [ - { id: 'all', name: 'SpaceX launches', includes: () => true }, + { + id: 'all', + name: 'SpaceX launches', + googleId: '24f5ae18242c0bd83698f9ee64ec2cc2da185cc5357718efe3bb1d2d800c42bf@group.calendar.google.com', + includes: () => true, + }, { id: 'crewed', name: 'SpaceX crewed launches', + googleId: 'cbc8fd4367b790cf6e18fbf78abb919112e54936254d3b3b6bdfdda45ea3786d@group.calendar.google.com', includes: (launch) => launch.crew.length > 0 || (launch.spacecraft?.startsWith('Crew Dragon') ?? false), }, - { id: 'starship', name: 'Starship launches', includes: (launch) => launch.rocket === 'Starship' }, + { + id: 'starship', + name: 'Starship launches', + googleId: 'a72ea698dbec5c3fc71148b928dd5b1c12fef27f98676bfe7b4fcc4ef444a2fa@group.calendar.google.com', + includes: (launch) => launch.rocket === 'Starship', + }, { id: 'no-starlink', name: 'SpaceX launches without Starlink', + googleId: 'c1884006e046fbd621afe8756f6b4ec003f2ac9e9590ca380f511ddeaab24470@group.calendar.google.com', includes: (launch) => launch.rocket === 'Starship' || !launch.name.startsWith('Starlink'), }, ]; diff --git a/src/gcal.ts b/src/gcal.ts new file mode 100644 index 0000000..6c52818 --- /dev/null +++ b/src/gcal.ts @@ -0,0 +1,72 @@ +import { createHash } from 'node:crypto'; +import type { CalendarEvent } from './event.ts'; + +/** A Calendar API v3 event resource, limited to the fields launchcal writes. */ +export interface GoogleEvent { + summary: string; + description: string; + location: string; + start: { dateTime: string; timeZone: 'UTC' } | { date: string }; + end: { dateTime: string; timeZone: 'UTC' } | { date: string }; + status: 'confirmed' | 'tentative'; + transparency: 'transparent'; + source?: { title: string; url: string }; + extendedProperties: { private: { launchcal: '1'; launchId: string; hash: string } }; +} + +/** An event launchcal wrote earlier, as listed back from Google. */ +export interface ExistingEvent { + /** Google's event id. */ + id: string; + launchId: string; + hash: string; + /** `start.dateTime` or `start.date`. */ + start: string; +} + +export interface SyncPlan { + insert: GoogleEvent[]; + update: { id: string; event: GoogleEvent }[]; + delete: ExistingEvent[]; +} + +/** Maps an event to Google's format; `hash` covers every written field, so any change triggers an update. */ +export function toGoogleEvent(event: CalendarEvent): GoogleEvent { + const body = { + summary: event.title, + description: event.description, + location: event.location, + start: event.timed ? { dateTime: event.start, timeZone: 'UTC' as const } : { date: event.start }, + end: event.timed ? { dateTime: event.end, timeZone: 'UTC' as const } : { date: event.end }, + status: event.confirmed ? ('confirmed' as const) : ('tentative' as const), + transparency: 'transparent' as const, + ...(event.url ? { source: { title: 'Webcast', url: event.url } } : {}), + }; + const hash = createHash('sha256').update(JSON.stringify(body)).digest('hex').slice(0, 16); + return { ...body, extendedProperties: { private: { launchcal: '1', launchId: event.id, hash } } }; +} + +/** + * Diffs the wanted events against Google by launch id. Events starting before `windowStart` are outside + * the snapshot and stay as history; duplicates of one launch are deleted. + */ +export function planSync(wanted: GoogleEvent[], existing: ExistingEvent[], windowStart: string): SyncPlan { + const plan: SyncPlan = { insert: [], update: [], delete: [] }; + const byLaunch = new Map(); + for (const event of existing) { + if (byLaunch.has(event.launchId)) plan.delete.push(event); + else byLaunch.set(event.launchId, event); + } + for (const event of wanted) { + const { launchId, hash } = event.extendedProperties.private; + const current = byLaunch.get(launchId); + byLaunch.delete(launchId); + if (!current) plan.insert.push(event); + else if (current.hash !== hash) plan.update.push({ id: current.id, event }); + } + const since = Date.parse(windowStart); + for (const event of byLaunch.values()) { + if (Date.parse(event.start) >= since) plan.delete.push(event); + } + return plan; +} diff --git a/src/google.ts b/src/google.ts new file mode 100644 index 0000000..6be63bd --- /dev/null +++ b/src/google.ts @@ -0,0 +1,83 @@ +import { createSign } from 'node:crypto'; +import type { ExistingEvent, GoogleEvent } from './gcal.ts'; + +/** The fields launchcal reads from a service account JSON key. */ +export interface ServiceAccountKey { + client_email: string; + private_key: string; +} + +const TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +const SCOPE = 'https://www.googleapis.com/auth/calendar.events'; + +const API = 'https://www.googleapis.com/calendar/v3'; + +/** Signs the RS256 assertion a service account exchanges for an access token (valid one hour). */ +export function signJwt(key: ServiceAccountKey, now: Date): string { + const iat = Math.floor(now.getTime() / 1000); + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); + const claims = Buffer.from(JSON.stringify({ iss: key.client_email, scope: SCOPE, aud: TOKEN_URL, iat, exp: iat + 3600 })).toString( + 'base64url', + ); + const signature = createSign('RSA-SHA256').update(`${header}.${claims}`).sign(key.private_key, 'base64url'); + return `${header}.${claims}.${signature}`; +} + +export async function accessToken(key: ServiceAccountKey): Promise { + const res = await fetch(TOKEN_URL, { + method: 'POST', + body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: signJwt(key, new Date()) }), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) throw new Error(`Google token exchange returned HTTP ${res.status}: ${await res.text()}`); + return ((await res.json()) as { access_token: string }).access_token; +} + +/** Lists every event launchcal wrote to the calendar, across all pages. */ +export async function listEvents(token: string, calendarId: string): Promise { + const events: ExistingEvent[] = []; + let pageToken: string | undefined; + do { + const params = new URLSearchParams({ privateExtendedProperty: 'launchcal=1', maxResults: '2500' }); + if (pageToken) params.set('pageToken', pageToken); + const page = (await call(token, 'GET', `${eventsPath(calendarId)}?${params}`)) as { + items: (GoogleEvent & { id: string; start: { dateTime?: string; date?: string } })[]; + nextPageToken?: string; + }; + for (const item of page.items) { + const { launchId, hash } = item.extendedProperties.private; + events.push({ id: item.id, launchId, hash, start: item.start.dateTime ?? item.start.date! }); + } + pageToken = page.nextPageToken; + } while (pageToken); + return events; +} + +export async function insertEvent(token: string, calendarId: string, event: GoogleEvent): Promise { + await call(token, 'POST', eventsPath(calendarId), event); +} + +export async function updateEvent(token: string, calendarId: string, id: string, event: GoogleEvent): Promise { + await call(token, 'PUT', `${eventsPath(calendarId)}/${encodeURIComponent(id)}`, event); +} + +export async function deleteEvent(token: string, calendarId: string, id: string): Promise { + await call(token, 'DELETE', `${eventsPath(calendarId)}/${encodeURIComponent(id)}`); +} + +function eventsPath(calendarId: string): string { + return `${API}/calendars/${encodeURIComponent(calendarId)}/events`; +} + +/** Throws with Google's error text on any non-2xx, so a sync run stops at the first failure. */ +async function call(token: string, method: string, url: string, body?: unknown): Promise { + const res = await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) throw new Error(`Google Calendar ${method} ${url} returned HTTP ${res.status}: ${await res.text()}`); + return res.status === 204 ? null : res.json(); +} diff --git a/src/ll2.ts b/src/ll2.ts index 6f4396a..e387cca 100644 --- a/src/ll2.ts +++ b/src/ll2.ts @@ -8,10 +8,14 @@ export const UPCOMING_URL = `https://ll.thespacedevs.com/2.3.0/launches/upcoming /** How far back launched flights stay in the calendars. */ const RECENT_DAYS = 30; -/** Previous launches since midnight UTC `RECENT_DAYS` ago, so the query only moves once a day. */ +/** Midnight UTC `RECENT_DAYS` ago: the start of the snapshot, moving only once a day. */ +export function recentSince(now: Date): string { + return `${new Date(now.getTime() - RECENT_DAYS * 86_400_000).toISOString().slice(0, 10)}T00:00:00Z`; +} + +/** Previous launches since `recentSince`. */ export function recentUrl(now: Date): string { - const since = new Date(now.getTime() - RECENT_DAYS * 86_400_000).toISOString().slice(0, 10); - return `https://ll.thespacedevs.com/2.3.0/launches/previous/?${QUERY}&net__gte=${since}T00:00:00Z`; + return `https://ll.thespacedevs.com/2.3.0/launches/previous/?${QUERY}&net__gte=${recentSince(now)}`; } const USER_AGENT = 'launchcal (+https://github.com/launchcal/launchcal.github.io)'; diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..f0ad9ca --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,51 @@ +import { readFile } from 'node:fs/promises'; +import { parseArgs } from 'node:util'; +import { CALENDARS } from './calendars.ts'; +import { toEvent, type CalendarEvent } from './event.ts'; +import { planSync, toGoogleEvent } from './gcal.ts'; +import { accessToken, deleteEvent, insertEvent, listEvents, updateEvent, type ServiceAccountKey } from './google.ts'; +import type { Launch } from './launch.ts'; +import { recentSince } from './ll2.ts'; + +// --calendar = syncs only that calendar, into the given Google calendar. +const { values: args } = parseArgs({ options: { 'dry-run': { type: 'boolean' }, calendar: { type: 'string' } } }); +const separator = args.calendar?.indexOf('=') ?? -1; +const onlyId = args.calendar?.slice(0, separator); +const overrideGoogleId = args.calendar?.slice(separator + 1); +if (args.calendar && (separator <= 0 || !overrideGoogleId)) throw new Error('Use --calendar ='); +if (onlyId && !CALENDARS.some((calendar) => calendar.id === onlyId)) throw new Error(`Unknown calendar ${onlyId}`); + +const launches = JSON.parse(await readFile('data/snapshot.json', 'utf8')) as Launch[]; +const feeds = CALENDARS.map((calendar) => ({ + calendar, + events: launches + .filter(calendar.includes) + .map(toEvent) + .filter((event): event is CalendarEvent => event !== null), +})); +if (feeds.find((feed) => feed.calendar.id === 'all')!.events.length === 0) { + throw new Error('No launch has a usable date; refusing to empty the Google calendars'); +} + +const keyJson = process.env.GOOGLE_SERVICE_ACCOUNT_KEY + ?? (process.env.GOOGLE_SERVICE_ACCOUNT_KEY_FILE && (await readFile(process.env.GOOGLE_SERVICE_ACCOUNT_KEY_FILE, 'utf8'))); +if (!keyJson) throw new Error('Set GOOGLE_SERVICE_ACCOUNT_KEY or GOOGLE_SERVICE_ACCOUNT_KEY_FILE'); +const token = await accessToken(JSON.parse(keyJson) as ServiceAccountKey); +const windowStart = recentSince(new Date()); + +for (const { calendar, events } of feeds) { + if (onlyId && calendar.id !== onlyId) continue; + const googleId = overrideGoogleId ?? calendar.googleId; + if (!googleId) throw new Error(`Calendar ${calendar.id} has no Google calendar id`); + + const plan = planSync(events.map(toGoogleEvent), await listEvents(token, googleId), windowStart); + console.log(`${calendar.id}: ${plan.insert.length} insert, ${plan.update.length} update, ${plan.delete.length} delete`); + for (const event of plan.insert) console.log(` + ${event.summary}`); + for (const { event } of plan.update) console.log(` ~ ${event.summary}`); + for (const event of plan.delete) console.log(` - ${event.launchId} (${event.start})`); + if (args['dry-run']) continue; + + for (const event of plan.insert) await insertEvent(token, googleId, event); + for (const { id, event } of plan.update) await updateEvent(token, googleId, id, event); + for (const event of plan.delete) await deleteEvent(token, googleId, event.id); +} diff --git a/test/gcal.test.ts b/test/gcal.test.ts new file mode 100644 index 0000000..72f1dcd --- /dev/null +++ b/test/gcal.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { CalendarEvent } from '../src/event.ts'; +import { planSync, toGoogleEvent, type ExistingEvent } from '../src/gcal.ts'; + +const timed: CalendarEvent = { + id: 'launch-a', + title: '๐Ÿš€ Falcon 9 ยท Crew-13', + description: 'Status: Go', + location: 'Space Launch Complex 40, Cape Canaveral SFS, FL, USA', + url: 'https://www.youtube.com/watch?v=7ZiUbT1-djA', + timed: true, + start: '2026-10-01T15:10:06Z', + end: '2026-10-01T16:10:06Z', + confirmed: true, +}; + +const allDay: CalendarEvent = { + ...timed, + id: 'launch-b', + title: '๐Ÿš€ Falcon 9 ยท Crew-13 (TBD)', + url: null, + timed: false, + start: '2026-10-14', + end: '2026-10-15', + confirmed: false, +}; + +const WINDOW_START = '2026-09-01T00:00:00Z'; + +function existing(launchId: string, hash: string, start: string, id = `g-${launchId}`): ExistingEvent { + return { id, launchId, hash, start }; +} + +describe('toGoogleEvent', () => { + it('writes a timed confirmed event in UTC with the webcast as source', () => { + const event = toGoogleEvent(timed); + + assert.equal(event.summary, '๐Ÿš€ Falcon 9 ยท Crew-13'); + assert.equal(event.description, 'Status: Go'); + assert.equal(event.location, 'Space Launch Complex 40, Cape Canaveral SFS, FL, USA'); + assert.deepEqual(event.start, { dateTime: '2026-10-01T15:10:06Z', timeZone: 'UTC' }); + assert.deepEqual(event.end, { dateTime: '2026-10-01T16:10:06Z', timeZone: 'UTC' }); + assert.equal(event.status, 'confirmed'); + assert.equal(event.transparency, 'transparent'); + assert.deepEqual(event.source, { title: 'Webcast', url: 'https://www.youtube.com/watch?v=7ZiUbT1-djA' }); + assert.equal(event.extendedProperties.private.launchcal, '1'); + assert.equal(event.extendedProperties.private.launchId, 'launch-a'); + assert.match(event.extendedProperties.private.hash, /^[0-9a-f]{16}$/); + }); + + it('writes an all-day tentative event without a source when there is no webcast', () => { + const event = toGoogleEvent(allDay); + + assert.deepEqual(event.start, { date: '2026-10-14' }); + assert.deepEqual(event.end, { date: '2026-10-15' }); + assert.equal(event.status, 'tentative'); + assert.equal('source' in event, false); + }); + + it('gives the same event the same hash', () => { + assert.equal( + toGoogleEvent(timed).extendedProperties.private.hash, + toGoogleEvent({ ...timed }).extendedProperties.private.hash, + ); + }); + + it('changes the hash when any written field changes', () => { + const base = toGoogleEvent(timed).extendedProperties.private.hash; + const changes: Partial[] = [ + { title: '๐Ÿš€ Falcon 9 ยท Crew-13 (Hold)' }, + { description: 'Status: Hold' }, + { location: 'Launch Complex 39A, Kennedy Space Center, FL, USA' }, + { url: null }, + { start: '2026-10-02T15:10:06Z' }, + { end: '2026-10-02T16:10:06Z' }, + { confirmed: false }, + ]; + + for (const change of changes) { + assert.notEqual(toGoogleEvent({ ...timed, ...change }).extendedProperties.private.hash, base, JSON.stringify(change)); + } + }); +}); + +describe('planSync', () => { + const a = toGoogleEvent(timed); + const b = toGoogleEvent(allDay); + + it('inserts launches Google does not have yet', () => { + const plan = planSync([a, b], [], WINDOW_START); + + assert.deepEqual(plan.insert, [a, b]); + assert.deepEqual(plan.update, []); + assert.deepEqual(plan.delete, []); + }); + + it('leaves unchanged events alone', () => { + const plan = planSync([a], [existing('launch-a', a.extendedProperties.private.hash, '2026-10-01T15:10:06Z')], WINDOW_START); + + assert.deepEqual(plan, { insert: [], update: [], delete: [] }); + }); + + it('updates the same Google event when the hash differs', () => { + const plan = planSync([a], [existing('launch-a', '0000000000000000', '2026-09-30T15:10:06Z')], WINDOW_START); + + assert.deepEqual(plan.insert, []); + assert.deepEqual(plan.update, [{ id: 'g-launch-a', event: a }]); + assert.deepEqual(plan.delete, []); + }); + + it('deletes events of launches gone from the snapshot window', () => { + const gone = existing('launch-gone', '0000000000000000', '2026-12-31'); + + const plan = planSync([a], [existing('launch-a', a.extendedProperties.private.hash, '2026-10-01T15:10:06Z'), gone], WINDOW_START); + + assert.deepEqual(plan.delete, [gone]); + }); + + it('deletes an event starting exactly at the window start', () => { + const edge = existing('launch-edge', '0000000000000000', '2026-09-01'); + + assert.deepEqual(planSync([], [edge], WINDOW_START).delete, [edge]); + }); + + it('keeps events older than the window as history', () => { + const old = [existing('launch-old', '0000000000000000', '2026-08-31T23:59:59Z'), existing('launch-older', '0000000000000000', '2026-08-31')]; + + assert.deepEqual(planSync([], old, WINDOW_START).delete, []); + }); + + it('deletes duplicate Google events of one launch and keeps the first', () => { + const hash = a.extendedProperties.private.hash; + const first = existing('launch-a', hash, '2026-10-01T15:10:06Z', 'g-1'); + const second = existing('launch-a', hash, '2026-10-01T15:10:06Z', 'g-2'); + + const plan = planSync([a], [first, second], WINDOW_START); + + assert.deepEqual(plan.insert, []); + assert.deepEqual(plan.update, []); + assert.deepEqual(plan.delete, [second]); + }); + + it('deletes an old duplicate even though old events are otherwise kept', () => { + const first = existing('launch-old', '0000000000000000', '2026-08-01', 'g-1'); + const second = existing('launch-old', '0000000000000000', '2026-08-01', 'g-2'); + + assert.deepEqual(planSync([], [first, second], WINDOW_START).delete, [second]); + }); +}); diff --git a/test/google.test.ts b/test/google.test.ts new file mode 100644 index 0000000..5ac3a8d --- /dev/null +++ b/test/google.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { createVerify, generateKeyPairSync } from 'node:crypto'; +import { describe, it, mock } from 'node:test'; +import { listEvents, signJwt } from '../src/google.ts'; + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const key = { + client_email: 'launchcal-sync@launchcal.iam.gserviceaccount.com', + private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), +}; + +describe('signJwt', () => { + const jwt = signJwt(key, new Date('2026-10-01T12:00:00Z')); + const [header, claims, signature] = jwt.split('.'); + + it('builds an RS256 header and the service account claims', () => { + assert.deepEqual(JSON.parse(Buffer.from(header, 'base64url').toString()), { alg: 'RS256', typ: 'JWT' }); + assert.deepEqual(JSON.parse(Buffer.from(claims, 'base64url').toString()), { + iss: 'launchcal-sync@launchcal.iam.gserviceaccount.com', + scope: 'https://www.googleapis.com/auth/calendar.events', + aud: 'https://oauth2.googleapis.com/token', + iat: 1790856000, + exp: 1790859600, + }); + }); + + it('signs header and claims with the private key', () => { + const verifier = createVerify('RSA-SHA256').update(`${header}.${claims}`); + + assert.equal(verifier.verify(publicKey, signature, 'base64url'), true); + }); + + it('uses base64url without padding', () => { + assert.match(jwt, /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); + }); +}); + +describe('listEvents', () => { + function item(id: string, launchId: string, start: { dateTime?: string; date?: string }) { + return { id, start, extendedProperties: { private: { launchcal: '1', launchId, hash: `hash-${launchId}` } } }; + } + + it('follows page tokens, filters to launchcal events and reads timed and all-day starts', async () => { + const urls: string[] = []; + const pages = [ + { items: [item('g-1', 'launch-a', { dateTime: '2026-10-05T11:16:00+03:00' })], nextPageToken: 'page-2' }, + { items: [item('g-2', 'launch-b', { date: '2028-07-05' })] }, + ]; + const fetchMock = mock.method(globalThis, 'fetch', async (url: string) => { + urls.push(url); + return new Response(JSON.stringify(pages[urls.length - 1])); + }); + + try { + const events = await listEvents('token', 'cal@group.calendar.google.com'); + + assert.deepEqual(events, [ + { id: 'g-1', launchId: 'launch-a', hash: 'hash-launch-a', start: '2026-10-05T11:16:00+03:00' }, + { id: 'g-2', launchId: 'launch-b', hash: 'hash-launch-b', start: '2028-07-05' }, + ]); + assert.deepEqual(urls, [ + 'https://www.googleapis.com/calendar/v3/calendars/cal%40group.calendar.google.com/events?privateExtendedProperty=launchcal%3D1&maxResults=2500', + 'https://www.googleapis.com/calendar/v3/calendars/cal%40group.calendar.google.com/events?privateExtendedProperty=launchcal%3D1&maxResults=2500&pageToken=page-2', + ]); + } finally { + fetchMock.mock.restore(); + } + }); + + it('throws with the Google error text on a non-2xx response', async () => { + const fetchMock = mock.method(globalThis, 'fetch', async () => new Response('{"error":"forbidden"}', { status: 403 })); + + try { + await assert.rejects(listEvents('token', 'cal'), /HTTP 403: \{"error":"forbidden"\}/); + } finally { + fetchMock.mock.restore(); + } + }); +});