diff --git a/apps/web/src/lib/import/ical.test.ts b/apps/web/src/lib/import/ical.test.ts new file mode 100644 index 0000000..addc63a --- /dev/null +++ b/apps/web/src/lib/import/ical.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { icalImporter } from './ical'; +import { importFromUrl } from './index'; +import { importContext, stubFetch, textReply, fetchedPage, blockRealFetch } from './test-support'; + +// ical is a *content-based* importer: accept() and parseData() both consume +// ctx.getPage(). This exercises the harness two ways — driving the importer +// directly with a synthetic FetchedPage, and end-to-end through importFromUrl +// with a stubbed network response (the real fetch + content-sniff path). + +const ICS = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'SUMMARY:Team Offsite', + 'DESCRIPTION:Annual planning\\, in the mountains', + 'DTSTART;TZID=America/Denver:20260810T090000', + 'DTEND;TZID=America/Denver:20260810T170000', + 'LOCATION:Aspen Center', + 'URL:https://example.com/offsite', + 'END:VEVENT', + 'END:VCALENDAR' +].join('\r\n'); + +blockRealFetch(); + +describe('icalImporter.accept (content sniff)', () => { + it('accepts a text/calendar content-type', async () => { + const ctx = importContext('https://x/feed', async () => + fetchedPage({ contentType: 'text/calendar', text: ICS }) + ); + expect(await icalImporter.accept(ctx)).toBe(true); + }); + + it('accepts a BEGIN:VCALENDAR body even when the content-type is wrong', async () => { + const ctx = importContext('https://x/feed', async () => + fetchedPage({ contentType: 'application/octet-stream', text: ICS }) + ); + expect(await icalImporter.accept(ctx)).toBe(true); + }); + + it('rejects an ordinary HTML page', async () => { + const ctx = importContext('https://x/', async () => + fetchedPage({ contentType: 'text/html', text: 'not a calendar' }) + ); + expect(await icalImporter.accept(ctx)).toBe(false); + }); +}); + +describe('icalImporter.parseData', () => { + it('maps the first VEVENT with TZID-aware start/end and unescaped text', async () => { + const ctx = importContext('https://x/feed', async () => + fetchedPage({ finalUrl: 'https://x/feed', contentType: 'text/calendar', text: ICS }) + ); + + const result = await icalImporter.parseData(ctx); + + expect(result).toMatchObject({ + source: 'https://x/feed', + name: 'Team Offsite', + timezone: 'America/Denver', + location: { street: 'Aspen Center' }, + links: [{ uri: 'https://example.com/offsite', name: 'Event page' }] + }); + // August in Denver is MDT (-06:00). + expect(result?.startsAt).toBe('2026-08-10T09:00:00-06:00'); + expect(result?.endsAt).toBe('2026-08-10T17:00:00-06:00'); + expect(result?.description).toBe('Annual planning, in the mountains'); + }); +}); + +describe('ical through the full pipeline', () => { + it('routes an .ics feed to the ical importer via the real fetch path', async () => { + stubFetch([{ when: '/feed.ics', reply: () => textReply(ICS, 'text/calendar') }]); + const result = await importFromUrl('https://example.com/feed.ics'); + expect(result?.name).toBe('Team Offsite'); + expect(result?.source).toBe('https://example.com/feed.ics'); + }); +}); diff --git a/apps/web/src/lib/import/raco.test.ts b/apps/web/src/lib/import/raco.test.ts new file mode 100644 index 0000000..7f2046c --- /dev/null +++ b/apps/web/src/lib/import/raco.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { racoImporter } from './raco'; +import { importContext, stubFetch, jsonReply, blockRealFetch } from './test-support'; + +// ra.co is a URL-matched importer that POSTs to a GraphQL API — a different +// modality from guild (host-matched GET). This exercises the shared harness +// against a POST endpoint, including asserting the request the importer builds. + +const GRAPHQL = 'ra.co/graphql'; + +/** A representative ra.co GraphQL `event` payload (shape mirrors the live API). */ +const raEvent = { + id: '2065643', + title: 'Bicep Presents: Chroma', + content: 'A live audiovisual show.', + startTime: '2026-06-06T22:00:00.000', // naive venue wall-time + endTime: '2026-06-07T04:00:00.000', + flyerFront: 'https://images.ra.co/flyer-front.jpg', + flyerBack: null, + venue: { + name: 'Printworks', + address: '1 Surrey Quays Road', + area: { name: 'London', ianaTimeZone: 'Europe/London', country: { name: 'UK' } } + }, + artists: [{ name: 'Bicep' }, { name: 'Special Guest' }], + images: [{ filename: 'https://images.ra.co/big.jpg', type: 'FLYERFRONT' }] +}; + +blockRealFetch(); + +describe('racoImporter.accept', () => { + it.each([ + ['https://ra.co/events/2065643', true], + ['https://www.ra.co/events/2065643', true], + ['https://ra.co/events/2065643/tickets', true], // still a numeric event path + ['https://ra.co/events/not-a-number', false], // id must be digits + ['https://ra.co/clubs/123', false], // wrong path + ['https://example.com/events/2065643', false], // wrong host + ['nonsense', false] + ])('%s -> %s', (url, expected) => { + expect(racoImporter.accept(importContext(url))).toBe(expected); + }); +}); + +describe('racoImporter.parseData', () => { + it('POSTs the parsed event id to the GraphQL API with the ra-content-language header', async () => { + const seen: { + method?: string; + body?: { operationName?: string; variables?: unknown }; + lang?: string; + } = {}; + stubFetch([ + { + when: GRAPHQL, + reply: (_url, init) => { + seen.method = init?.method; + seen.body = init?.body ? JSON.parse(String(init.body)) : undefined; + seen.lang = (init?.headers as Record)?.['ra-content-language']; + return jsonReply({ data: { event: raEvent } }); + } + } + ]); + + await racoImporter.parseData(importContext('https://ra.co/events/2065643')); + + expect(seen.method).toBe('POST'); + expect(seen.body?.operationName).toBe('GET_EVENT_DETAIL'); + expect(seen.body?.variables).toEqual({ id: '2065643' }); + expect(seen.lang).toBe('en'); + }); + + it('maps title, lineup and venue, and applies the venue-zone offset to naive times', async () => { + stubFetch([{ when: GRAPHQL, reply: () => jsonReply({ data: { event: raEvent } }) }]); + + const result = await racoImporter.parseData(importContext('https://ra.co/events/2065643')); + + expect(result).toMatchObject({ + source: 'https://ra.co/events/2065643', + name: 'Bicep Presents: Chroma', + mode: 'inperson', + timezone: 'Europe/London', + location: { street: 'Printworks, 1 Surrey Quays Road', locality: 'London', country: 'UK' }, + imageUrl: 'https://images.ra.co/big.jpg' + }); + // June in London is BST (+01:00); the naive wall-time gets that offset appended. + expect(result?.startsAt).toBe('2026-06-06T22:00:00+01:00'); + expect(result?.endsAt).toBe('2026-06-07T04:00:00+01:00'); + expect(result?.description).toContain('Lineup: Bicep, Special Guest'); + }); + + it('returns null when the API carries no event (graceful, no throw)', async () => { + stubFetch([{ when: GRAPHQL, reply: () => jsonReply({ data: { event: null } }) }]); + const result = await racoImporter.parseData(importContext('https://ra.co/events/2065643')); + expect(result).toBeNull(); + }); + + it('throws on a non-OK GraphQL response so /api/import-event surfaces a 502', async () => { + stubFetch([{ when: GRAPHQL, reply: () => jsonReply({}, 500) }]); + await expect( + racoImporter.parseData(importContext('https://ra.co/events/2065643')) + ).rejects.toThrow(/ra\.co graphql 500/); + }); +}); diff --git a/apps/web/src/lib/import/test-support.ts b/apps/web/src/lib/import/test-support.ts new file mode 100644 index 0000000..aa8284a --- /dev/null +++ b/apps/web/src/lib/import/test-support.ts @@ -0,0 +1,132 @@ +import { vi, beforeEach, afterEach } from 'vitest'; +import type { FetchedPage, ImportContext } from './types'; + +/** + * Shared test harness for the import pipeline. + * + * Every importer reaches the network through the global `fetch` — a source API + * (ra.co), an HTML page (webpage), an `.ics` feed (ical), plus the cover image. + * These helpers stub that `fetch` with an ordered route table so an importer can + * be driven against captured fixtures with zero live network. + * + * The shape is deliberately source-agnostic: adding tests for the next importer + * is "capture a real response as a fixture, list its routes, assert the mapped + * prefill" — the same three moves used in `raco.test.ts`. + */ + +export type FetchRoute = { + /** Match against the request URL: substring, regex, or predicate. */ + when: string | RegExp | ((url: string) => boolean); + /** Build the response for a matched request. */ + reply: (url: string, init?: RequestInit) => Response | Promise; +}; + +export type StubbedFetch = { + /** Every URL `fetch` was called with, in order — for asserting what was (not) hit. */ + urls: string[]; + /** The underlying vi mock, for call-count assertions. */ + mock: ReturnType; +}; + +/** + * Replace the global `fetch` with an ordered route table (first match wins). An + * unmatched request throws, so a test can never silently escape to the real + * network. Restore with `vi.unstubAllGlobals()` in an `afterEach`. + */ +export function stubFetch(routes: FetchRoute[]): StubbedFetch { + const urls: string[] = []; + const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : String((input as Request).url ?? input); + urls.push(url); + for (const route of routes) { + const hit = + typeof route.when === 'function' + ? route.when(url) + : route.when instanceof RegExp + ? route.when.test(url) + : url.includes(route.when); + if (hit) return route.reply(url, init); + } + throw new Error(`unstubbed fetch: ${url}`); + }); + vi.stubGlobal('fetch', mock); + return { urls, mock }; +} + +/** A JSON response (200 unless overridden), Content-Type `application/json`. */ +export function jsonReply(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +/** A binary image response, for importers that call `fetchImageAsDataUrl`. */ +export function imageReply( + contentType = 'image/png', + bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]) +): Response { + return new Response(bytes, { status: 200, headers: { 'content-type': contentType } }); +} + +/** + * A text response — an HTML page (webpage importer) or an `.ics` feed (ical). + * Content-Type defaults to `text/html`; pass `text/calendar` for a feed. + */ +export function textReply(body: string, contentType = 'text/html'): Response { + return new Response(body, { status: 200, headers: { 'content-type': contentType } }); +} + +/** + * Build a `FetchedPage` for unit-testing a content-based importer (ical/webpage) + * directly, without the pipeline's real fetch: return it from the `getPage` you + * hand to `importContext`. Defaults are a benign HTML page; override what the + * case cares about (usually `contentType` and `text`). + */ +export function fetchedPage(overrides: Partial = {}): FetchedPage { + return { + finalUrl: 'https://example.com/event', + contentType: 'text/html', + text: '', + ...overrides + }; +} + +/** + * A minimal `ImportContext` for a URL-matched importer (e.g. raco). `getPage()` + * throws by default, which asserts the importer never triggers a page fetch it + * cannot use. Pass `getPage` to exercise a content-based importer. + */ +export function importContext(url: string, getPage?: ImportContext['getPage']): ImportContext { + return { + url, + getPage: + getPage ?? + (() => { + throw new Error('getPage() must not be called by a URL-matched importer'); + }) + }; +} + +/** + * Guarantee a suite never reaches the real network. Call once at the top level + * of a test file: before each test the global `fetch` is replaced with one that + * throws, so a test that forgets `stubFetch()` fails loudly instead of silently + * hitting the internet. `stubFetch()` overrides it per-test, and originals are + * restored afterward. Replaces a bare `afterEach(() => vi.unstubAllGlobals())`. + */ +export function blockRealFetch(): void { + beforeEach(() => { + vi.stubGlobal('fetch', () => { + throw new Error('real fetch blocked: call stubFetch([...routes]) in this test'); + }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); +} diff --git a/apps/web/src/lib/import/webpage.test.ts b/apps/web/src/lib/import/webpage.test.ts new file mode 100644 index 0000000..1a54127 --- /dev/null +++ b/apps/web/src/lib/import/webpage.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { importFromUrl } from './index'; +import { stubFetch, textReply, blockRealFetch } from './test-support'; + +// webpage is the generic HTML fallback (Luma/Meetup/Eventbrite/Partiful/…). It +// parses JSON-LD, then OpenGraph, and guesses an IANA zone from an ISO offset. +// Driven end-to-end through importFromUrl with a stubbed HTML response — the +// same harness, now feeding an HTML document instead of a JSON API. + +const JSONLD_HTML = ` +Art Walk`; + +const OG_HTML = ` + + + +Open mic`; + +blockRealFetch(); + +describe('webpage importer (HTML fallback) through the pipeline', () => { + it('extracts a schema.org Event from JSON-LD, stripping HTML and guessing the zone', async () => { + stubFetch([{ when: '/artwalk', reply: () => textReply(JSONLD_HTML) }]); + + const result = await importFromUrl('https://example.com/artwalk'); + + expect(result).toMatchObject({ + source: 'https://example.com/artwalk', + name: 'Downtown Art Walk', + mode: 'inperson', // OfflineEventAttendanceMode + startsAt: '2026-09-12T18:00:00-04:00', + endsAt: '2026-09-12T21:00:00-04:00', + location: { + street: 'Main Gallery, 100 Main St', + locality: 'Louisville', + region: 'KY', + country: 'US' + }, + imageUrl: 'https://img.example/artwalk.jpg' + }); + expect(result?.description).toBe('Galleries open late.'); //

stripped + expect(result?.timezone).toBe('America/New_York'); // -04:00 in September + }); + + it('falls back to OpenGraph tags when there is no JSON-LD', async () => { + stubFetch([{ when: '/mic', reply: () => textReply(OG_HTML) }]); + + const result = await importFromUrl('https://example.com/mic'); + + expect(result).toMatchObject({ + source: 'https://example.com/mic', + name: 'Open Mic Night', + description: 'Sign up at the door', + imageUrl: 'https://img.example/mic.jpg' + }); + }); + + it('returns null when the page has neither structured data nor OpenGraph', async () => { + stubFetch([{ when: '/plain', reply: () => textReply('just text') }]); + const result = await importFromUrl('https://example.com/plain'); + expect(result).toBeNull(); + }); +});