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();
+ });
+});