From 8c0bbf289a955b3a35d88df832b1f489dcfd41a9 Mon Sep 17 00:00:00 2001 From: Michiel de Jong Date: Wed, 9 Sep 2026 14:27:42 +0200 Subject: [PATCH 1/2] Show one-way Google Calendar imports in a calendar view --- TESTING_COVERAGE.md | 6 + .../chunks/PluginRuns/ConnectLocalThought.tsx | 47 ++++++- .../e2e/tests/google-calendar-import.spec.ts | 106 +++++++++++++++ integrations/localthought/README.md | 26 ++++ integrations/localthought/calendar.test.ts | 123 ++++++++++++++++++ integrations/localthought/calendar.ts | 108 +++++++++++++++ integrations/localthought/tsconfig.json | 2 +- 7 files changed, 413 insertions(+), 5 deletions(-) create mode 100644 browser/e2e/tests/google-calendar-import.spec.ts create mode 100644 integrations/localthought/calendar.test.ts create mode 100644 integrations/localthought/calendar.ts diff --git a/TESTING_COVERAGE.md b/TESTING_COVERAGE.md index c19db74c5c..fac2d50678 100644 --- a/TESTING_COVERAGE.md +++ b/TESTING_COVERAGE.md @@ -1084,3 +1084,9 @@ cancelled on teardown. Old plugin-name grants are deliberately not migrated. commit. It failed with the server signer before `Resource::destroy_as` was used; installation deletion must use the same selected identity as create/update. LocalThought: Rust handler tests cover connection binding, request signing, duplicate-page rejection, typed paginated previews, and Calendar UTC date-range validation. Live Calendar OAuth, bounded fetch, review/apply and event table display were verified against proxy v39 (54 records). + +Google Calendar one-way projection: `integrations/localthought/calendar.test.ts` +covers all-day/timed start dates, offset boundaries, exclusive end preservation, +feature notes, cancellations without start data, invalid active events, +namespace isolation and repeat import/local-field preservation. `browser/e2e/tests/google-calendar-import.spec.ts` covers synthetic provider responses through real schema installation, sandbox review/apply and Calendar rendering, including feature notes. Local verification passed with the React compiler disabled to bypass a dependency-loader problem; live-provider verification of the new view remains outstanding. Missing rows in a bounded snapshot +are retained, not interpreted as deletions. diff --git a/browser/data-browser/src/chunks/PluginRuns/ConnectLocalThought.tsx b/browser/data-browser/src/chunks/PluginRuns/ConnectLocalThought.tsx index 4be4f39df2..1448434a53 100644 --- a/browser/data-browser/src/chunks/PluginRuns/ConnectLocalThought.tsx +++ b/browser/data-browser/src/chunks/PluginRuns/ConnectLocalThought.tsx @@ -32,6 +32,10 @@ import { import type { Config } from '../../../../../integrations/localthought/plugin'; import { localImportVerdict } from './localImportVerdict'; import source from '../../../../../integrations/localthought/plugin.js?raw'; +import { + calendarProjection, + calendarFields, +} from '../../../../../integrations/localthought/calendar'; export function ConnectLocalThought({ drive, @@ -130,12 +134,13 @@ export function ConnectLocalThought({ setError(''); try { - const fetched = await proxyRequest(store, 'fetch', { + const response = await proxyRequest(store, 'fetch', { drive, connection: connection.connection, constants, ...(platform === 'google-calendar' ? { calendarRange } : {}), }); + const fetched = calendarProjection(response); if (fetched.platform !== platform) throw new Error('Imported platform did not match this connection'); const schemaStore = localSchemaStore(store); @@ -195,13 +200,47 @@ export function ConnectLocalThought({ [dataBrowser.properties.viewColumns]: columns, }, }); + const calendar = + platform === 'google-calendar' && term.shortname === 'event' + ? await ensureInstallationResource(store, drive, { + parent: destination.subject, + localId: `${identity}:calendar:${term.shortname}`, + isA: [dataBrowser.classes.view], + propVals: { + [core.properties.name]: tableName, + [dataBrowser.properties.viewKind]: 'calendar', + [dataBrowser.properties.viewGroupBy]: + properties[calendarFields.day], + [dataBrowser.properties.viewColumns]: columns, + }, + }) + : undefined; + const existingViews = destination.get( + dataBrowser.properties.tableViews, + ) as string[] | undefined; await destination.set(dataBrowser.properties.tableViews, [ - view.subject, + ...new Set([ + ...(existingViews ?? []), + view.subject, + ...(calendar ? [calendar.subject] : []), + ]), ]); - await destination.set( + const currentDefault = destination.get( dataBrowser.properties.tableDefaultView, - view.subject, ); + + if ( + !currentDefault || + (calendar && + !existingViews?.includes(calendar.subject) && + currentDefault === view.subject) + ) { + await destination.set( + dataBrowser.properties.tableDefaultView, + calendar?.subject ?? view.subject, + ); + } + await destination.save(); destinations[term.shortname] = { table: destination.subject, rowClass }; } diff --git a/browser/e2e/tests/google-calendar-import.spec.ts b/browser/e2e/tests/google-calendar-import.spec.ts new file mode 100644 index 0000000000..a1aa30a209 --- /dev/null +++ b/browser/e2e/tests/google-calendar-import.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from '@playwright/test'; +import { before } from './test-utils'; + +// Provider responses are synthetic; schema installation, sandbox review/apply +// and Calendar rendering use the real app and AtomicServer. +test('Google import installs a Calendar view with all-day and timed events', async ({ + page, +}) => { + test.setTimeout(120_000); + await before({ page }); + const today = new Date().toISOString().slice(0, 10); + const fields = ['start', 'end', 'attendees']; + const fetched = { + platform: 'google-calendar', + ontology: { + description: 'Synthetic Calendar', + terms: [ + { + path: 'event', + kind: 'class', + shortname: 'event', + description: 'Events', + requires: [], + recommends: fields, + }, + ...fields.map(shortname => ({ + path: shortname, + kind: 'property', + shortname, + description: shortname, + datatype: 'https://atomicdata.dev/datatypes/json', + requires: [], + recommends: [], + })), + ], + }, + records: [ + { + resource: 'event', + namespace: 'synthetic', + id: 'all-day', + name: 'Calendar all-day fixture', + values: { start: { date: today }, end: { date: today } }, + }, + { + resource: 'event', + namespace: 'synthetic', + id: 'timed', + name: 'Calendar timed fixture', + values: { + start: { dateTime: `${today}T00:30:00+02:00` }, + attendees: [{ email: 'synthetic@example.com' }], + }, + }, + ], + }; + await page.route('**/integration-proxy/catalog', route => + route.fulfill({ json: { platforms: ['google-calendar'] } }), + ); + await page.route('**/integration-proxy/platform?*', route => + route.fulfill({ json: { parameters: [], collections: ['events'] } }), + ); + await page.route('**/integration-proxy/fetch', route => + route.fulfill({ json: fetched }), + ); + await page.evaluate(() => { + const store = window.store!; + const drive = store.getDrive()!; + const actor = store.getAgent()!.subject; + localStorage.setItem( + `localthought:${JSON.stringify([drive, actor, 'google-calendar'])}`, + JSON.stringify({ + drive, + actor, + platform: 'google-calendar', + connection: 'synthetic-calendar', + }), + ); + }); + await page.getByRole('link', { name: 'Integrations', exact: true }).click(); + await page + .locator('[data-integration="google-calendar"]') + .getByRole('button', { name: 'Set up connection' }) + .click(); + await page.getByRole('button', { name: 'Fetch and preview' }).click(); + await expect( + page.getByRole('button', { name: 'Apply 2 changes', exact: true }), + ).toBeEnabled({ timeout: 60_000 }); + await page + .getByRole('button', { name: 'Apply 2 changes', exact: true }) + .click(); + await page + .getByRole('link', { name: 'Open imported records', exact: true }) + .click(); + await expect(page.getByTestId('calendar-view')).toBeVisible({ + timeout: 30_000, + }); + const day = page.locator( + `[data-testid="calendar-day"][data-date="${today}"]`, + ); + await expect(day.getByTestId('calendar-event')).toHaveCount(2); + await day.getByText('Calendar timed fixture', { exact: true }).click(); + await expect(page.getByRole('dialog').last()).toContainText( + 'Attendees and RSVP', + ); +}); diff --git a/integrations/localthought/README.md b/integrations/localthought/README.md index 9ea97f7efc..4e42ee32ec 100644 --- a/integrations/localthought/README.md +++ b/integrations/localthought/README.md @@ -82,3 +82,29 @@ An unbounded fetch successfully traversed multiple pages but exceeded the 5,000-record preview limit; the UI now defaults to the next 30 days. Date bounds and recurrence expansion are passed to Syncables as collection query settings. The importer remains a manual snapshot, not a background sync. + +## Calendar view + +Google event imports now install a Calendar view alongside the source table. +The projected date uses the day in Google's supplied start offset (or the +unchanged all-day date), so mixed all-day/timed events share one DATE column. +The original Start and End objects retain timezones and exclusive end values. +The month view shows each event on its start day; it does not draw duration or +multi-day spans. Additional notes identify recurring events, attendees, +reminders and conferencing when those fields are returned by the catalog. +Recurring instances are expanded by the existing bounded provider fetch. + +Use **Fetch and preview** again to refresh, then review and apply changes. +Existing source identities and import baselines prevent duplicates and preserve +Atomic-only fields and local edits. Cancellation records are retained with a +note; absence from a bounded fetch never deletes an Atomic resource. Google +may omit cancelled events from list results, so this is not a deletion feed. +Removed optional provider fields are not cleared by the shared snapshot importer. +This first version deliberately remains manual and one-way. No OAuth write +scope is requested and edits in Atomic do not update Google. + +`calendar.test.ts` exercises mixed dates, offset boundaries, exclusive ends, +recurrence/attendee notes, cancellations, malformed starts, cross-calendar +identity and repeated imports with private local fields. + +Browser regression: `browser/e2e/tests/google-calendar-import.spec.ts` uses synthetic provider responses with real schema installation, sandbox review/apply and Calendar rendering. Local run passed with a temporary compiler-disabled Vite configuration; the default local compiler runtime had a dependency-loader error before app startup. The workaround is not shipped. diff --git a/integrations/localthought/calendar.test.ts b/integrations/localthought/calendar.test.ts new file mode 100644 index 0000000000..0515a7d429 --- /dev/null +++ b/integrations/localthought/calendar.test.ts @@ -0,0 +1,123 @@ +import { expect, it } from "vitest"; +import { calendarProjection, calendarFields as fields } from "./calendar"; +import { Datatype } from "../../browser/lib/src/index"; +import { run } from "./plugin"; +import type { FetchedPlatform } from "./schema"; +const fixture = (): FetchedPlatform => ({ + platform: "google-calendar", + ontology: { + description: "", + terms: [ + { + path: "event", + kind: "class", + shortname: "event", + description: "", + datatype: Datatype.STRING, + requires: [], + recommends: [], + }, + ], + }, + records: [ + { + resource: "event", + namespace: "calendar-a", + id: "1", + name: "Meeting", + values: { + start: { dateTime: "2026-09-10T00:30:00+02:00", timeZone: "Europe/Amsterdam" }, + end: { dateTime: "2026-09-10T01:30:00+02:00" }, + attendees: [{ email: "test@example.com" }], + "recurring-event-id": "series", + reminders: { useDefault: true }, + }, + }, + ], +}); +it("projects timed events without shifting their day and retains provider structures", () => { + const input = fixture(); + const output = calendarProjection(input); + expect(output.records[0].values).toMatchObject({ + ...input.records[0].values, + [fields.day]: "2026-09-10", + [fields.allDay]: false, + }); + expect(output.records[0].values[fields.notes]).toContain("Recurring"); + expect(output.records[0].values[fields.notes]).toContain("Attendees"); + expect(input.records[0].values[fields.day]).toBeUndefined(); + expect(output.ontology.terms.find((t) => t.shortname === fields.day)?.datatype).toBe( + Datatype.DATE, + ); +}); +it("supports all-day dates and preserves exclusive multi-day ends", () => { + const input = fixture(); + input.records[0].values = { start: { date: "2026-09-10" }, end: { date: "2026-09-13" } }; + expect(calendarProjection(input).records[0].values).toMatchObject({ + [fields.day]: "2026-09-10", + [fields.allDay]: true, + end: { date: "2026-09-13" }, + }); +}); +it("retains cancelled events even when Google omits start", () => { + const input = fixture(); + input.records[0].values = { status: "cancelled" }; + expect(calendarProjection(input).records[0].values[fields.notes]).toContain("Cancelled"); +}); +it("fails the projection on malformed active events instead of silently losing rows", () => { + for (const start of [ + { date: "2026-02-30" }, + { date: "2026-09-10garbage" }, + {}, + { dateTime: "2026-09-10T12:00:00" }, + ]) { + const input = fixture(); + input.records[0].values = { start }; + expect(() => calendarProjection(input)).toThrow(); + } +}); +it("leaves other platforms untouched", () => { + const input = fixture(); + input.platform = "pets"; + expect(calendarProjection(input)).toBe(input); +}); +it("repeated imports reconcile IDs, preserve local fields and don't delete out-of-window events", () => { + const data = calendarProjection(fixture()); + const properties = Object.fromEntries( + Object.keys(data.records[0].values).map((k) => [k, `https://example.com/${k}`]), + ); + const config = { + platform: data.platform, + destinations: { event: { table: "did:ad:table", rowClass: "did:ad:event" } }, + properties, + records: data.records, + }; + const first = run({ config, query: () => [], read: () => ({}) }).intents[0]; + if (first.op !== "create") throw new Error("Expected create"); + const saved = { + ...first.set, + "https://atomicdata.dev/properties/parent": "did:ad:table", + "https://atomicdata.dev/properties/isA": ["did:ad:event"], + "https://example.com/private": "my notes", + }; + const host = { + config, + query: (p: string, v: string) => (saved[p] === v ? ["did:ad:row"] : []), + read: () => saved, + }; + expect(run(host).intents).toHaveLength(0); + expect(run({ ...host, config: { ...config, records: [] } }).intents).toHaveLength(0); + const changed = calendarProjection(fixture()); + changed.records[0].name = "Updated meeting"; + const result = run({ ...host, config: { ...config, records: changed.records } }); + expect(result.intents[0]).toMatchObject({ + op: "set", + subject: "did:ad:row", + set: { "https://atomicdata.dev/properties/name": "Updated meeting" }, + }); + expect(JSON.stringify(result.intents)).not.toContain("my notes"); + const secondCalendar = { ...data.records[0], namespace: "calendar-b" }; + expect(run({ ...host, config: { ...config, records: [secondCalendar] } }).intents[0].op).toBe( + "create", + ); +}); diff --git a/integrations/localthought/calendar.ts b/integrations/localthought/calendar.ts new file mode 100644 index 0000000000..19f0e894de --- /dev/null +++ b/integrations/localthought/calendar.ts @@ -0,0 +1,108 @@ +// @wc-ignore-file +import { Datatype } from "../../browser/lib/src/index.js"; +import type { JSONValue } from "../../browser/lib/src/value.js"; +import type { FetchedPlatform, Term } from "./schema.js"; + +export const calendarFields = { + day: "atomic-calendar-day", + allDay: "atomic-calendar-all-day", + notes: "atomic-calendar-notes", +}; + +/** An additional projection, never a replacement for the provider's fields. + * One DATE column supports both all-day dates and timed events in a single view. + * Timed events use the date in Google's supplied offset; raw start/end retain + * the instant, zone and exclusive-end semantics for future richer rendering. + */ +export function calendarProjection(fetched: FetchedPlatform): FetchedPlatform { + if (fetched.platform !== "google-calendar") return fetched; + const event = fetched.ontology.terms.find((t) => t.kind === "class" && t.shortname === "event"); + if (!event) return fetched; + const definitions: [string, Datatype, string][] = [ + [ + calendarFields.day, + Datatype.DATE, + "Start date in the event's supplied offset; all-day dates stay unchanged.", + ], + [ + calendarFields.allDay, + Datatype.BOOLEAN, + "Whether Google represents this as an all-day event.", + ], + [ + calendarFields.notes, + Datatype.STRING, + "Display limitations and Google-only features. Edits here remain local.", + ], + ]; + const terms: Term[] = definitions.map(([shortname, datatype, description]) => ({ + path: `urn:atomic:google-calendar:${shortname}`, + kind: "property", + shortname, + datatype, + description, + requires: [], + recommends: [], + })); + if (fetched.ontology.terms.some((t) => terms.some((extra) => extra.shortname === t.shortname))) + throw new Error("Calendar projection property collides with provider ontology"); + return { + ...fetched, + ontology: { + ...fetched.ontology, + terms: [ + ...fetched.ontology.terms.map((t) => + t === event + ? { ...t, recommends: [...t.recommends, ...terms.map((extra) => extra.path)] } + : t, + ), + ...terms, + ], + }, + records: fetched.records.map((row) => { + if (row.resource !== "event") return row; + const start = object(row.values.start); + const end = object(row.values.end); + const date = start.date ?? start.dateTime; + const cancelled = row.values.status === "cancelled"; + if (!cancelled && (typeof date !== "string" || !validDay(date.slice(0, 10)))) + throw new Error(`Calendar event ${row.id} has no valid start date`); + if (start.date !== undefined && (typeof start.date !== "string" || !validDay(start.date))) + throw new Error(`Calendar event ${row.id} has an invalid all-day date`); + if ( + start.dateTime !== undefined && + (typeof start.dateTime !== "string" || + !/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(start.dateTime) || + !Number.isFinite(Date.parse(start.dateTime))) + ) + throw new Error(`Calendar event ${row.id} has no offset-qualified start time`); + const notes = ["One-way import: edits stay in Atomic"]; + if (cancelled) notes.push("Cancelled in Google; retained in Atomic"); + if (row.values["recurring-event-id"] || row.values.recurringEventId || row.values.recurrence) + notes.push("Recurring event: manage the series in Google"); + if (Array.isArray(row.values.attendees) && row.values.attendees.length) + notes.push("Attendees and RSVP: manage in Google"); + if (row.values.reminders) notes.push("Reminders: manage in Google"); + if (row.values["conference-data"] || row.values.conferenceData) + notes.push("Conferencing: manage in Google"); + if (end.date || end.dateTime) notes.push("Duration retained in End; shown on start day only"); + const values: Record = { + ...row.values, + [calendarFields.notes]: notes.join(". "), + }; + if (typeof date === "string" && validDay(date.slice(0, 10))) { + values[calendarFields.day] = date.slice(0, 10); + values[calendarFields.allDay] = typeof start.date === "string"; + } + return { ...row, values }; + }), + }; +} +function object(value: JSONValue | undefined): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} +function validDay(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const date = new Date(`${value}T00:00:00Z`); + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; +} diff --git a/integrations/localthought/tsconfig.json b/integrations/localthought/tsconfig.json index e412e3c473..8a41739785 100644 --- a/integrations/localthought/tsconfig.json +++ b/integrations/localthought/tsconfig.json @@ -8,5 +8,5 @@ "types": ["node"], "noEmit": true }, - "include": ["data.ts", "schema.ts", "plugin.ts", "browser.ts"] + "include": ["calendar.ts", "schema.ts", "plugin.ts", "browser.ts"] } From 844e651286c9a8d9a5d8a0b907b87cdc204349fa Mon Sep 17 00:00:00 2001 From: Michiel de Jong Date: Wed, 9 Sep 2026 15:33:01 +0200 Subject: [PATCH 2/2] Verify browser-only Calendar sync through shared mock proxy --- TESTING_COVERAGE.md | 12 +- .../e2e/tests/google-calendar-import.spec.mts | 169 ++++++++++++++++++ .../e2e/tests/google-calendar-import.spec.ts | 106 ----------- integrations/localthought/README.md | 26 ++- integrations/localthought/calendar.test.ts | 142 +++++++++------ integrations/localthought/calendar.ts | 126 ++++++++----- integrations/localthought/mock-calendar.mjs | 159 ++++++++++++++++ integrations/localthought/mock-proxy.d.mts | 16 +- integrations/localthought/mock-proxy.mjs | 9 + 9 files changed, 553 insertions(+), 212 deletions(-) create mode 100644 browser/e2e/tests/google-calendar-import.spec.mts delete mode 100644 browser/e2e/tests/google-calendar-import.spec.ts create mode 100644 integrations/localthought/mock-calendar.mjs diff --git a/TESTING_COVERAGE.md b/TESTING_COVERAGE.md index fac2d50678..21f7cf04d3 100644 --- a/TESTING_COVERAGE.md +++ b/TESTING_COVERAGE.md @@ -1087,6 +1087,12 @@ LocalThought: Rust handler tests cover connection binding, request signing, dupl Google Calendar one-way projection: `integrations/localthought/calendar.test.ts` covers all-day/timed start dates, offset boundaries, exclusive end preservation, -feature notes, cancellations without start data, invalid active events, -namespace isolation and repeat import/local-field preservation. `browser/e2e/tests/google-calendar-import.spec.ts` covers synthetic provider responses through real schema installation, sandbox review/apply and Calendar rendering, including feature notes. Local verification passed with the React compiler disabled to bypass a dependency-loader problem; live-provider verification of the new view remains outstanding. Missing rows in a bounded snapshot -are retained, not interpreted as deletions. +feature notes (including WASM-normalized field names), cancellations without +start data, invalid active events, namespace isolation and repeat import/local +field preservation. `browser/e2e/tests/google-calendar-import.spec.mts` uses the +shared HTTP mock integration-proxy with a paginated Google Calendar, tenant +secret entry and OAuth consent. It covers browser WASM fetching, local +schema/proposal/apply, Calendar display, provider updates, OPFS reload and +stable identities while AtomicServer HTTP/WebSockets are unavailable. Missing +rows in a bounded snapshot are retained, not interpreted as deletions. +Live-provider browser OAuth verification remains separate from this fixture test. diff --git a/browser/e2e/tests/google-calendar-import.spec.mts b/browser/e2e/tests/google-calendar-import.spec.mts new file mode 100644 index 0000000000..22d00c5e81 --- /dev/null +++ b/browser/e2e/tests/google-calendar-import.spec.mts @@ -0,0 +1,169 @@ +import { test, expect } from '@playwright/test'; +import { + mockProxy, + tenantSecret, +} from '../../../integrations/localthought/mock-proxy.mjs'; +const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:6747'; +const SERVER_URL = process.env.SERVER_URL ?? 'http://localhost:9883'; +test.use({ serviceWorkers: 'block' }); + +// Reuse the browser transport and HTTP mock from the Devonian integration. +// The tenant secret is a public fixture. No real provider credentials are used. +test('Calendar imports, refreshes and persists through the browser with AtomicServer unavailable', async ({ + page, +}) => { + test.setTimeout(180_000); + const proxy = mockProxy({ frontendOrigin: new URL(FRONTEND_URL).origin }); + await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve)); + const port = (proxy.address() as { port: number }).port; + const proxyOrigin = `http://127.0.0.1:${port}`; + const configuredProxy = + process.env.VITE_INTEGRATION_PROXY_URL || 'https://localthought.io'; + await page.routeWebSocket('**/*', socket => socket.close()); + const forbidden: string[] = []; + const providerMethods: string[] = []; + // Forward the configured proxy to this test's isolated HTTP fixture. All + // tenant proof, consent, code rotation, pagination and WASM code remain real. + await page.route('**/*', async route => { + const request = route.request(); + const url = new URL(request.url()); + + if (/^\/(integration-proxy|plugin-run|commit)(\/|$)/.test(url.pathname)) { + forbidden.push(url.pathname); + + return route.abort(); + } + + if (url.origin === configuredProxy) { + if (url.pathname.startsWith('/proxy/')) + providerMethods.push(request.method()); + const response = await route.fetch({ + url: `${proxyOrigin}${url.pathname}${url.search}`, + maxRedirects: 0, + }); + + return route.fulfill({ response }); + } + + if ( + url.origin === new URL(SERVER_URL).origin && + url.origin !== new URL(FRONTEND_URL).origin + ) + return route.abort(); + + return route.continue(); + }); + + try { + await page.goto(`${FRONTEND_URL}/app/dev-drive`); + await page.waitForURL(/app\/show\?subject=/, { timeout: 60000 }); + + const setup = async () => { + await page + .getByRole('link', { name: 'Integrations', exact: true }) + .click(); + await page + .locator('[data-integration="google-calendar"]') + .getByRole('button', { name: 'Set up connection' }) + .click(); + }; + + await setup(); + await page.getByLabel('LocalThought tenant secret').fill(tenantSecret); + await page + .getByRole('button', { name: 'Install and connect', exact: true }) + .click(); + await page + .getByRole('button', { name: 'Connect test account', exact: true }) + .click(); + await expect( + page.getByRole('button', { name: 'Fetch and preview', exact: true }), + ).toBeVisible(); + expect(page.url()).not.toContain('connection_code'); + expect( + await page.evaluate(() => + JSON.stringify({ ...localStorage, ...sessionStorage }), + ), + ).not.toContain(tenantSecret); + + const apply = async (count: number) => { + await page + .getByRole('button', { name: 'Fetch and preview', exact: true }) + .click(); + await page + .getByRole('button', { name: new RegExp(`^Apply ${count} changes?$`) }) + .click(); + await page + .getByRole('link', { name: 'Open imported records', exact: true }) + .click(); + await expect(page.getByTestId('calendar-view')).toBeVisible(); + }; + + await apply(2); + await expect(page.getByTestId('calendar-event')).toHaveCount(2); + await page.getByText('Calendar timed fixture', { exact: true }).click(); + await expect(page.getByRole('dialog').last()).toContainText( + 'Recurring event', + ); + await expect(page.getByRole('dialog').last()).toContainText( + 'Attendees and RSVP', + ); + await page.keyboard.press('Escape'); + const original = await page.evaluate(async () => { + const store = window.store!; + const table = new URL(location.href).searchParams.get('subject')!; + const result = await store.queryLocalDb({ + drive: store.getDrive()!, + property: 'https://atomicdata.dev/properties/parent', + value: table, + limit: 100, + }); + const subjects = result!.subjects.sort(); + const row = await store.getResource(subjects[0]); + await row.set( + 'https://atomicdata.dev/properties/description', + 'Atomic-only notes', + ); + await row.save(); + await store.getClientDb()!.flush(); + + return { subjects, table, annotated: row.subject }; + }); + await page.reload(); + await expect(page.getByTestId('calendar-event')).toHaveCount(2); + proxy.calendar.events[1].summary = 'Calendar refreshed fixture'; + await setup(); + await apply(1); + await expect( + page.getByText('Calendar refreshed fixture', { exact: true }), + ).toBeVisible(); + await expect(page.getByTestId('calendar-event')).toHaveCount(2); + const restored = await page.evaluate(async saved => { + const store = window.store!; + const result = await store.queryLocalDb({ + drive: store.getDrive()!, + property: 'https://atomicdata.dev/properties/parent', + value: saved.table, + limit: 100, + }); + + return { + subjects: result!.subjects.sort(), + note: (await store.getResource(saved.annotated)).get( + 'https://atomicdata.dev/properties/description', + ), + }; + }, original); + expect(restored.subjects).toEqual(original.subjects); + expect(restored.note).toBe('Atomic-only notes'); + expect(providerMethods).toEqual(['GET', 'GET', 'GET', 'GET']); + expect( + proxy.calendar.requests.filter(r => r.query.pageToken === 'second'), + ).toHaveLength(2); + expect(forbidden).toEqual([]); + } finally { + await new Promise((resolve, reject) => + proxy.close(error => (error ? reject(error) : resolve())), + ); + } +}); diff --git a/browser/e2e/tests/google-calendar-import.spec.ts b/browser/e2e/tests/google-calendar-import.spec.ts deleted file mode 100644 index a1aa30a209..0000000000 --- a/browser/e2e/tests/google-calendar-import.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { before } from './test-utils'; - -// Provider responses are synthetic; schema installation, sandbox review/apply -// and Calendar rendering use the real app and AtomicServer. -test('Google import installs a Calendar view with all-day and timed events', async ({ - page, -}) => { - test.setTimeout(120_000); - await before({ page }); - const today = new Date().toISOString().slice(0, 10); - const fields = ['start', 'end', 'attendees']; - const fetched = { - platform: 'google-calendar', - ontology: { - description: 'Synthetic Calendar', - terms: [ - { - path: 'event', - kind: 'class', - shortname: 'event', - description: 'Events', - requires: [], - recommends: fields, - }, - ...fields.map(shortname => ({ - path: shortname, - kind: 'property', - shortname, - description: shortname, - datatype: 'https://atomicdata.dev/datatypes/json', - requires: [], - recommends: [], - })), - ], - }, - records: [ - { - resource: 'event', - namespace: 'synthetic', - id: 'all-day', - name: 'Calendar all-day fixture', - values: { start: { date: today }, end: { date: today } }, - }, - { - resource: 'event', - namespace: 'synthetic', - id: 'timed', - name: 'Calendar timed fixture', - values: { - start: { dateTime: `${today}T00:30:00+02:00` }, - attendees: [{ email: 'synthetic@example.com' }], - }, - }, - ], - }; - await page.route('**/integration-proxy/catalog', route => - route.fulfill({ json: { platforms: ['google-calendar'] } }), - ); - await page.route('**/integration-proxy/platform?*', route => - route.fulfill({ json: { parameters: [], collections: ['events'] } }), - ); - await page.route('**/integration-proxy/fetch', route => - route.fulfill({ json: fetched }), - ); - await page.evaluate(() => { - const store = window.store!; - const drive = store.getDrive()!; - const actor = store.getAgent()!.subject; - localStorage.setItem( - `localthought:${JSON.stringify([drive, actor, 'google-calendar'])}`, - JSON.stringify({ - drive, - actor, - platform: 'google-calendar', - connection: 'synthetic-calendar', - }), - ); - }); - await page.getByRole('link', { name: 'Integrations', exact: true }).click(); - await page - .locator('[data-integration="google-calendar"]') - .getByRole('button', { name: 'Set up connection' }) - .click(); - await page.getByRole('button', { name: 'Fetch and preview' }).click(); - await expect( - page.getByRole('button', { name: 'Apply 2 changes', exact: true }), - ).toBeEnabled({ timeout: 60_000 }); - await page - .getByRole('button', { name: 'Apply 2 changes', exact: true }) - .click(); - await page - .getByRole('link', { name: 'Open imported records', exact: true }) - .click(); - await expect(page.getByTestId('calendar-view')).toBeVisible({ - timeout: 30_000, - }); - const day = page.locator( - `[data-testid="calendar-day"][data-date="${today}"]`, - ); - await expect(day.getByTestId('calendar-event')).toHaveCount(2); - await day.getByText('Calendar timed fixture', { exact: true }).click(); - await expect(page.getByRole('dialog').last()).toContainText( - 'Attendees and RSVP', - ); -}); diff --git a/integrations/localthought/README.md b/integrations/localthought/README.md index 4e42ee32ec..3674472724 100644 --- a/integrations/localthought/README.md +++ b/integrations/localthought/README.md @@ -107,4 +107,28 @@ scope is requested and edits in Atomic do not update Google. recurrence/attendee notes, cancellations, malformed starts, cross-calendar identity and repeated imports with private local fields. -Browser regression: `browser/e2e/tests/google-calendar-import.spec.ts` uses synthetic provider responses with real schema installation, sandbox review/apply and Calendar rendering. Local run passed with a temporary compiler-disabled Vite configuration; the default local compiler runtime had a dependency-loader error before app startup. The workaround is not shipped. +## Browser-only Calendar regression + +`browser/e2e/tests/google-calendar-import.spec.mts` starts the shared mock +integration-proxy from #1399 with a synthetic Google Calendar. The test enters +the public fixture tenant secret, completes consent, and exercises real browser +credential rotation, WASM pagination, local schema installation, proposal review, +OPFS application, and Calendar rendering. It refreshes changed provider data +and checks that native identities and Atomic-only notes survive reload. +AtomicServer HTTP and all WebSockets are blocked throughout; only GET requests +are permitted for provider data. The configured LocalThought origin is forwarded +to the isolated HTTP fixture, so no live provider credentials or data are used. + +Run with a dev frontend built from this branch and its matching WASM bundle: + +```sh +FRONTEND_URL=http://127.0.0.1:6747 SERVER_URL=http://127.0.0.1:19999 \ + browser/e2e/node_modules/.bin/playwright test \ + --config browser/e2e/playwright.config.ts \ + browser/e2e/tests/google-calendar-import.spec.mts --project chromium +``` + +If the frontend uses `VITE_INTEGRATION_PROXY_URL`, pass the same value to the +test process. The test forwards that origin to its own fixture. Live Google +OAuth on the browser path still depends on the proxy CORS deployment described +above; this fixture test does not claim live-provider verification. diff --git a/integrations/localthought/calendar.test.ts b/integrations/localthought/calendar.test.ts index 0515a7d429..1e14884ff4 100644 --- a/integrations/localthought/calendar.test.ts +++ b/integrations/localthought/calendar.test.ts @@ -1,18 +1,18 @@ -import { expect, it } from "vitest"; -import { calendarProjection, calendarFields as fields } from "./calendar"; -import { Datatype } from "../../browser/lib/src/index"; -import { run } from "./plugin"; -import type { FetchedPlatform } from "./schema"; +import { expect, it } from 'vitest'; +import { calendarProjection, calendarFields as fields } from './calendar'; +import { Datatype } from '../../browser/lib/src/index'; +import { run } from './plugin'; +import type { FetchedPlatform } from './schema'; const fixture = (): FetchedPlatform => ({ - platform: "google-calendar", + platform: 'google-calendar', ontology: { - description: "", + description: '', terms: [ { - path: "event", - kind: "class", - shortname: "event", - description: "", + path: 'event', + kind: 'class', + shortname: 'event', + description: '', datatype: Datatype.STRING, requires: [], recommends: [], @@ -21,103 +21,131 @@ const fixture = (): FetchedPlatform => ({ }, records: [ { - resource: "event", - namespace: "calendar-a", - id: "1", - name: "Meeting", + resource: 'event', + namespace: 'calendar-a', + id: '1', + name: 'Meeting', values: { - start: { dateTime: "2026-09-10T00:30:00+02:00", timeZone: "Europe/Amsterdam" }, - end: { dateTime: "2026-09-10T01:30:00+02:00" }, - attendees: [{ email: "test@example.com" }], - "recurring-event-id": "series", + start: { + dateTime: '2026-09-10T00:30:00+02:00', + timeZone: 'Europe/Amsterdam', + }, + end: { dateTime: '2026-09-10T01:30:00+02:00' }, + attendees: [{ email: 'test@example.com' }], + 'recurring-event-id': 'series', reminders: { useDefault: true }, }, }, ], }); -it("projects timed events without shifting their day and retains provider structures", () => { +it('projects timed events without shifting their day and retains provider structures', () => { const input = fixture(); const output = calendarProjection(input); expect(output.records[0].values).toMatchObject({ ...input.records[0].values, - [fields.day]: "2026-09-10", + [fields.day]: '2026-09-10', [fields.allDay]: false, }); - expect(output.records[0].values[fields.notes]).toContain("Recurring"); - expect(output.records[0].values[fields.notes]).toContain("Attendees"); + expect(output.records[0].values[fields.notes]).toContain('Recurring'); + expect(output.records[0].values[fields.notes]).toContain('Attendees'); expect(input.records[0].values[fields.day]).toBeUndefined(); - expect(output.ontology.terms.find((t) => t.shortname === fields.day)?.datatype).toBe( - Datatype.DATE, - ); + expect( + output.ontology.terms.find(t => t.shortname === fields.day)?.datatype, + ).toBe(Datatype.DATE); }); -it("supports all-day dates and preserves exclusive multi-day ends", () => { +it('supports all-day dates and preserves exclusive multi-day ends', () => { const input = fixture(); - input.records[0].values = { start: { date: "2026-09-10" }, end: { date: "2026-09-13" } }; + input.records[0].values = { + start: { date: '2026-09-10' }, + end: { date: '2026-09-13' }, + }; expect(calendarProjection(input).records[0].values).toMatchObject({ - [fields.day]: "2026-09-10", + [fields.day]: '2026-09-10', [fields.allDay]: true, - end: { date: "2026-09-13" }, + end: { date: '2026-09-13' }, }); }); -it("retains cancelled events even when Google omits start", () => { +it('retains cancelled events even when Google omits start', () => { const input = fixture(); - input.records[0].values = { status: "cancelled" }; - expect(calendarProjection(input).records[0].values[fields.notes]).toContain("Cancelled"); + input.records[0].values = { status: 'cancelled' }; + expect(calendarProjection(input).records[0].values[fields.notes]).toContain( + 'Cancelled', + ); }); -it("fails the projection on malformed active events instead of silently losing rows", () => { +it('fails the projection on malformed active events instead of silently losing rows', () => { for (const start of [ - { date: "2026-02-30" }, - { date: "2026-09-10garbage" }, + { date: '2026-02-30' }, + { date: '2026-09-10garbage' }, {}, - { dateTime: "2026-09-10T12:00:00" }, + { dateTime: '2026-09-10T12:00:00' }, ]) { const input = fixture(); input.records[0].values = { start }; expect(() => calendarProjection(input)).toThrow(); } }); -it("leaves other platforms untouched", () => { +it('leaves other platforms untouched', () => { const input = fixture(); - input.platform = "pets"; + input.platform = 'pets'; expect(calendarProjection(input)).toBe(input); }); it("repeated imports reconcile IDs, preserve local fields and don't delete out-of-window events", () => { const data = calendarProjection(fixture()); const properties = Object.fromEntries( - Object.keys(data.records[0].values).map((k) => [k, `https://example.com/${k}`]), + Object.keys(data.records[0].values).map(k => [ + k, + `https://example.com/${k}`, + ]), ); const config = { platform: data.platform, - destinations: { event: { table: "did:ad:table", rowClass: "did:ad:event" } }, + destinations: { + event: { table: 'did:ad:table', rowClass: 'did:ad:event' }, + }, properties, records: data.records, }; const first = run({ config, query: () => [], read: () => ({}) }).intents[0]; - if (first.op !== "create") throw new Error("Expected create"); + if (first.op !== 'create') throw new Error('Expected create'); const saved = { ...first.set, - "https://atomicdata.dev/properties/parent": "did:ad:table", - "https://atomicdata.dev/properties/isA": ["did:ad:event"], - "https://example.com/private": "my notes", + 'https://atomicdata.dev/properties/parent': 'did:ad:table', + 'https://atomicdata.dev/properties/isA': ['did:ad:event'], + 'https://example.com/private': 'my notes', }; const host = { config, - query: (p: string, v: string) => (saved[p] === v ? ["did:ad:row"] : []), + query: (p: string, v: string) => (saved[p] === v ? ['did:ad:row'] : []), read: () => saved, }; expect(run(host).intents).toHaveLength(0); - expect(run({ ...host, config: { ...config, records: [] } }).intents).toHaveLength(0); + expect( + run({ ...host, config: { ...config, records: [] } }).intents, + ).toHaveLength(0); const changed = calendarProjection(fixture()); - changed.records[0].name = "Updated meeting"; - const result = run({ ...host, config: { ...config, records: changed.records } }); + changed.records[0].name = 'Updated meeting'; + const result = run({ + ...host, + config: { ...config, records: changed.records }, + }); expect(result.intents[0]).toMatchObject({ - op: "set", - subject: "did:ad:row", - set: { "https://atomicdata.dev/properties/name": "Updated meeting" }, + op: 'set', + subject: 'did:ad:row', + set: { 'https://atomicdata.dev/properties/name': 'Updated meeting' }, }); - expect(JSON.stringify(result.intents)).not.toContain("my notes"); - const secondCalendar = { ...data.records[0], namespace: "calendar-b" }; - expect(run({ ...host, config: { ...config, records: [secondCalendar] } }).intents[0].op).toBe( - "create", - ); + expect(JSON.stringify(result.intents)).not.toContain('my notes'); + const secondCalendar = { ...data.records[0], namespace: 'calendar-b' }; + expect( + run({ ...host, config: { ...config, records: [secondCalendar] } }) + .intents[0].op, + ).toBe('create'); +}); +it('recognizes the lowercase field names produced by the WASM ontology', () => { + const input = fixture(); + delete input.records[0].values['recurring-event-id']; + input.records[0].values.recurringeventid = 'series'; + input.records[0].values.conferencedata = { conferenceId: 'meeting' }; + const notes = calendarProjection(input).records[0].values[fields.notes]; + expect(notes).toContain('Recurring event'); + expect(notes).toContain('Conferencing'); }); diff --git a/integrations/localthought/calendar.ts b/integrations/localthought/calendar.ts index 19f0e894de..f9706e8792 100644 --- a/integrations/localthought/calendar.ts +++ b/integrations/localthought/calendar.ts @@ -1,12 +1,12 @@ // @wc-ignore-file -import { Datatype } from "../../browser/lib/src/index.js"; -import type { JSONValue } from "../../browser/lib/src/value.js"; -import type { FetchedPlatform, Term } from "./schema.js"; +import { Datatype } from '../../browser/lib/src/index.js'; +import type { JSONValue } from '../../browser/lib/src/value.js'; +import type { FetchedPlatform, Term } from './schema.js'; export const calendarFields = { - day: "atomic-calendar-day", - allDay: "atomic-calendar-all-day", - notes: "atomic-calendar-notes", + day: 'atomic-calendar-day', + allDay: 'atomic-calendar-all-day', + notes: 'atomic-calendar-notes', }; /** An additional projection, never a replacement for the provider's fields. @@ -15,8 +15,10 @@ export const calendarFields = { * the instant, zone and exclusive-end semantics for future richer rendering. */ export function calendarProjection(fetched: FetchedPlatform): FetchedPlatform { - if (fetched.platform !== "google-calendar") return fetched; - const event = fetched.ontology.terms.find((t) => t.kind === "class" && t.shortname === "event"); + if (fetched.platform !== 'google-calendar') return fetched; + const event = fetched.ontology.terms.find( + t => t.kind === 'class' && t.shortname === 'event', + ); if (!event) return fetched; const definitions: [string, Datatype, string][] = [ [ @@ -27,82 +29,118 @@ export function calendarProjection(fetched: FetchedPlatform): FetchedPlatform { [ calendarFields.allDay, Datatype.BOOLEAN, - "Whether Google represents this as an all-day event.", + 'Whether Google represents this as an all-day event.', ], [ calendarFields.notes, Datatype.STRING, - "Display limitations and Google-only features. Edits here remain local.", + 'Display limitations and Google-only features. Edits here remain local.', ], ]; - const terms: Term[] = definitions.map(([shortname, datatype, description]) => ({ - path: `urn:atomic:google-calendar:${shortname}`, - kind: "property", - shortname, - datatype, - description, - requires: [], - recommends: [], - })); - if (fetched.ontology.terms.some((t) => terms.some((extra) => extra.shortname === t.shortname))) - throw new Error("Calendar projection property collides with provider ontology"); + const terms: Term[] = definitions.map( + ([shortname, datatype, description]) => ({ + path: `urn:atomic:google-calendar:${shortname}`, + kind: 'property', + shortname, + datatype, + description, + requires: [], + recommends: [], + }), + ); + if ( + fetched.ontology.terms.some(t => + terms.some(extra => extra.shortname === t.shortname), + ) + ) + throw new Error( + 'Calendar projection property collides with provider ontology', + ); return { ...fetched, ontology: { ...fetched.ontology, terms: [ - ...fetched.ontology.terms.map((t) => + ...fetched.ontology.terms.map(t => t === event - ? { ...t, recommends: [...t.recommends, ...terms.map((extra) => extra.path)] } + ? { + ...t, + recommends: [ + ...t.recommends, + ...terms.map(extra => extra.path), + ], + } : t, ), ...terms, ], }, - records: fetched.records.map((row) => { - if (row.resource !== "event") return row; + records: fetched.records.map(row => { + if (row.resource !== 'event') return row; const start = object(row.values.start); const end = object(row.values.end); const date = start.date ?? start.dateTime; - const cancelled = row.values.status === "cancelled"; - if (!cancelled && (typeof date !== "string" || !validDay(date.slice(0, 10)))) + const cancelled = row.values.status === 'cancelled'; + if ( + !cancelled && + (typeof date !== 'string' || !validDay(date.slice(0, 10))) + ) throw new Error(`Calendar event ${row.id} has no valid start date`); - if (start.date !== undefined && (typeof start.date !== "string" || !validDay(start.date))) + if ( + start.date !== undefined && + (typeof start.date !== 'string' || !validDay(start.date)) + ) throw new Error(`Calendar event ${row.id} has an invalid all-day date`); if ( start.dateTime !== undefined && - (typeof start.dateTime !== "string" || + (typeof start.dateTime !== 'string' || !/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(start.dateTime) || !Number.isFinite(Date.parse(start.dateTime))) ) - throw new Error(`Calendar event ${row.id} has no offset-qualified start time`); - const notes = ["One-way import: edits stay in Atomic"]; - if (cancelled) notes.push("Cancelled in Google; retained in Atomic"); - if (row.values["recurring-event-id"] || row.values.recurringEventId || row.values.recurrence) - notes.push("Recurring event: manage the series in Google"); + throw new Error( + `Calendar event ${row.id} has no offset-qualified start time`, + ); + const notes = ['One-way import: edits stay in Atomic']; + if (cancelled) notes.push('Cancelled in Google; retained in Atomic'); + if ( + row.values['recurring-event-id'] || + row.values.recurringEventId || + row.values.recurringeventid || + row.values.recurrence + ) + notes.push('Recurring event: manage the series in Google'); if (Array.isArray(row.values.attendees) && row.values.attendees.length) - notes.push("Attendees and RSVP: manage in Google"); - if (row.values.reminders) notes.push("Reminders: manage in Google"); - if (row.values["conference-data"] || row.values.conferenceData) - notes.push("Conferencing: manage in Google"); - if (end.date || end.dateTime) notes.push("Duration retained in End; shown on start day only"); + notes.push('Attendees and RSVP: manage in Google'); + if (row.values.reminders) notes.push('Reminders: manage in Google'); + if ( + row.values['conference-data'] || + row.values.conferenceData || + row.values.conferencedata + ) + notes.push('Conferencing: manage in Google'); + if (end.date || end.dateTime) + notes.push('Duration retained in End; shown on start day only'); const values: Record = { ...row.values, - [calendarFields.notes]: notes.join(". "), + [calendarFields.notes]: notes.join('. '), }; - if (typeof date === "string" && validDay(date.slice(0, 10))) { + if (typeof date === 'string' && validDay(date.slice(0, 10))) { values[calendarFields.day] = date.slice(0, 10); - values[calendarFields.allDay] = typeof start.date === "string"; + values[calendarFields.allDay] = typeof start.date === 'string'; } return { ...row, values }; }), }; } function object(value: JSONValue | undefined): Record { - return value && typeof value === "object" && !Array.isArray(value) ? value : {}; + return value && typeof value === 'object' && !Array.isArray(value) + ? value + : {}; } function validDay(value: string): boolean { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; const date = new Date(`${value}T00:00:00Z`); - return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; + return ( + Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value + ); } diff --git a/integrations/localthought/mock-calendar.mjs b/integrations/localthought/mock-calendar.mjs new file mode 100644 index 0000000000..13ba1e576a --- /dev/null +++ b/integrations/localthought/mock-calendar.mjs @@ -0,0 +1,159 @@ +/** Synthetic Google Calendar fixtures. No real calendar data or provider writes. */ +const string = { type: 'string' }; +const dateTime = { + type: 'object', + properties: { + date: { ...string, format: 'date' }, + dateTime: { ...string, format: 'date-time' }, + timeZone: string, + }, +}; +export const calendarDocument = { + openapi: '3.0.3', + info: { title: 'Synthetic Calendar', version: 'v3' }, + servers: [{ url: 'https://www.googleapis.com/calendar/v3' }], + paths: { + '/calendars/{calendarId}/events': { + get: { + parameters: [ + { name: 'calendarId', in: 'path', required: true, schema: string }, + ...['pageToken', 'timeMin', 'timeMax'].map(name => ({ + name, + in: 'query', + schema: string, + })), + { name: 'singleEvents', in: 'query', schema: { type: 'boolean' } }, + ], + 'x-pagination': [{ scheme: 'pageToken' }], + responses: { + 200: { + description: 'Events', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { $ref: '#/components/schemas/event' }, + }, + nextPageToken: string, + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + paginationSchemes: { + pageToken: { + type: 'pageToken', + request: { queryParameters: { pageToken: { role: 'pageToken' } } }, + response: { bodyFields: { nextPageToken: { role: 'nextPageToken' } } }, + }, + }, + schemas: { + event: { + type: 'object', + properties: { + id: string, + summary: string, + status: string, + start: dateTime, + end: dateTime, + recurringEventId: string, + htmlLink: string, + attendees: { + type: 'array', + items: { + type: 'object', + properties: { email: string, responseStatus: string }, + }, + }, + }, + }, + }, + crudResources: { + event: { + schema: { $ref: '#/components/schemas/event' }, + identity: { + urlTemplate: '/calendars/{calendarId}/events/{eventId}', + bindings: { eventId: { field: 'id' } }, + }, + collections: { + events: { urlTemplate: '/calendars/{calendarId}/events' }, + }, + }, + }, + }, +}; +export function calendarFixture(day = new Date().toISOString().slice(0, 10)) { + const tomorrow = new Date(Date.parse(`${day}T00:00:00Z`) + 86400000) + .toISOString() + .slice(0, 10); + const events = [ + { + id: 'all-day', + summary: 'Calendar all-day fixture', + status: 'confirmed', + start: { date: day }, + end: { date: tomorrow }, + }, + { + id: 'timed', + summary: 'Calendar timed fixture', + status: 'confirmed', + start: { + dateTime: `${day}T00:30:00+02:00`, + timeZone: 'Europe/Amsterdam', + }, + end: { dateTime: `${day}T01:30:00+02:00`, timeZone: 'Europe/Amsterdam' }, + recurringEventId: 'series', + attendees: [ + { email: 'synthetic@example.com', responseStatus: 'accepted' }, + ], + }, + ]; + const requests = []; + return { + events, + requests, + request(method, url) { + requests.push({ + method, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + }); + if (method !== 'GET') return { status: 405, body: {} }; + if ( + !/^\/proxy\/google-calendar\/calendar\/v3\/calendars\/[^/]+\/events$/.test( + url.pathname, + ) + ) + return { status: 404, body: {} }; + // Require the production adapter to send its safety bounds and recurrence expansion. + if ( + !url.searchParams.has('timeMin') || + !url.searchParams.has('timeMax') || + url.searchParams.get('singleEvents') !== 'true' + ) + return { + status: 400, + body: { error: 'Missing bounded recurrence query' }, + }; + const token = url.searchParams.get('pageToken'); + if (token && token !== 'second') return { status: 400, body: {} }; + return { + status: 200, + body: structuredClone( + token + ? { items: events.slice(1) } + : { items: events.slice(0, 1), nextPageToken: 'second' }, + ), + }; + }, + }; +} diff --git a/integrations/localthought/mock-proxy.d.mts b/integrations/localthought/mock-proxy.d.mts index 269895004e..72bdfcb1bd 100644 --- a/integrations/localthought/mock-proxy.d.mts +++ b/integrations/localthought/mock-proxy.d.mts @@ -29,7 +29,21 @@ interface GitHubTracker { input: { body: string }, ): Comment | undefined; } +interface CalendarFixture { + events: Array<{ + id: string; + summary: string; + status: string; + start: { date?: string; dateTime?: string; timeZone?: string }; + end: { date?: string; dateTime?: string; timeZone?: string }; + }>; + requests: Array<{ + method: string; + path: string; + query: Record; + }>; +} export const tenantSecret: string; export function mockProxy(options?: { frontendOrigin?: string; -}): Server & { github: GitHubTracker }; +}): Server & { github: GitHubTracker; calendar: CalendarFixture }; diff --git a/integrations/localthought/mock-proxy.mjs b/integrations/localthought/mock-proxy.mjs index 36a99d46a7..445f8d1749 100644 --- a/integrations/localthought/mock-proxy.mjs +++ b/integrations/localthought/mock-proxy.mjs @@ -1,4 +1,5 @@ /** Local-only integration-proxy fixture. Never deploy this service. */ +import { calendarDocument, calendarFixture } from './mock-calendar.mjs'; import { githubTracker } from './mock-github.mjs'; import { readFileSync } from 'node:fs'; import { createServer } from 'node:http'; @@ -26,6 +27,7 @@ export function mockProxy({ frontendOrigin = process.env.MOCK_FRONTEND_ORIGIN ?? 'http://localhost:6747', } = {}) { const github = githubTracker(); + const calendar = calendarFixture(); const codes = new Map(); const challenges = new Set(); const issueCode = platform => { @@ -62,6 +64,8 @@ export function mockProxy({ readFileSync(new URL('./mock-document.json', import.meta.url)), ); } + if (url.pathname === '/catalog/google-calendar.yaml') + return json(200, calendarDocument); if (url.pathname === '/session') { const challenge = randomBytes(32).toString('base64url'); challenges.add(challenge); @@ -128,6 +132,10 @@ export function mockProxy({ return json(400, { error: 'Invalid request body' }, headers); } } + if (platform === 'google-calendar') { + const result = calendar.request(req.method, url); + return json(result.status, result.body, headers); + } if (req.method !== 'GET') return json(403, {}, headers); const data = platform === 'pets' @@ -145,6 +153,7 @@ export function mockProxy({ json(404, {}); }); server.github = github; + server.calendar = calendar; return server; } if (