diff --git a/apps/web/src/lib/import/__fixtures__/guild-london-graphql.json b/apps/web/src/lib/import/__fixtures__/guild-london-graphql.json new file mode 100644 index 0000000..5798b87 --- /dev/null +++ b/apps/web/src/lib/import/__fixtures__/guild-london-graphql.json @@ -0,0 +1 @@ +{"id":"WyJldmVudHMiLCI5MzA3NTM5Yy0zYTgzLTRlZjMtOTRkMC1mMDUxZjI1MjYzNjkiXQ==","slug":"tujgnp","prettyUrl":"london-graphql-spring-tujgnp","fullUrl":"https://guild.host/events/london-graphql-spring-tujgnp","shortUrl":"https://guild.host/e-tujgnp","name":"London GraphQL Spring 2026","description":"Come and join us for the latest edition of London GraphQL; Two talks on the most up-to-date GraphQL topics interspersed with pizza and networking, and a casual get together afterwards at a local pub.\nApproximate Schedule:\n\n6:00pm - Arrival, networking, pizzas 🍕\n\n6:30pm - Greeting, event overview, code of conduct\n\n6:40pm - **Stitch Happens**, Sam Ransom, Lendable\n\n6:50pm - Pizza and networking break\n\n7:30pm - **Celebrating ten years of PostGraphile**, Benjie Gillam, Graphile\n\n8:00pm - Short break\n\n8:15pm - **Discussion with Benjie,** hosted by Taz Singh, Guild.host \n\n8:30pm - Closing notes, Benjie Gillam, Graphile\n\n9:00pm - Continued networking over drinks 🍻 at a local pub\n\nHosted by Lendable\n\n**NEW Location** Lendable office, Telephone House, 69-77 Paul St, London, EC2A 4NW\n\nCall for Speakers\n\nShow off a project, discuss a proposed feature, share your patterns and solutions\n\nNew and seasoned speakers welcome\n\nMentoring, help and slides review available\n\nFlexible talk format\n\nhttp://tinyurl.com/LondonGraphQLcfp\n\nAll events ran by the GraphQL Foundation adhere to the code of conduct: https://graphql.org/codeofconduct/\n\n(Code of Conduct contact for this event is the MC Benjie, team@graphile.com or any member of the GraphQL TSC)","startAt":"2026-04-14T17:00:00+00:00","endAt":"2026-04-14T20:00:00+00:00","timeZone":"Europe/London","visibility":"LISTED","hasVenue":true,"hasExternalUrl":false,"owner":{"__typename":"Guild","id":"WyJndWlsZHMiLCIyOWVkZjYwMi0xMWFlLTRmMDktODA1NS04OWZiNjI1Y2I5NDYiXQ==","name":"London GraphQL","__isNode":"Guild"},"uploadedSocialCard":null,"generatedSocialCardURL":"https://guild.host/social/event/london-graphql-spring-tujgnp/card.svg","presentations":{"edges":[{"node":{"id":"WyJwcmVzZW50YXRpb25zIiwiZTBmODY3ZjEtMGQ5My00OWVkLWI0NWItZjE4NmVmMWU2N2JlIl0=","slug":"xzztm9","prettyUrl":"stitch-happens-graphql-xzztm9","presenter":null,"presenterFirstName":"Sam","presenterLastName":"Ransom","title":"Stitch Happens (GraphQL Schema Stitching) ","description":"Demonstrating how GraphQL schema stitching can combine two independent services into one cohesive API, enabling clients to query data across both systems through a single schema","videoSourceUrl":""},"cursor":"WyJuYXR1cmFsIiwxXQ=="},{"node":{"id":"WyJwcmVzZW50YXRpb25zIiwiYmIwNTM1NzAtMTExMy00MDhlLWFkOTAtNTQyNjc1Yjk1MmUxIl0=","slug":"cyhe4w","prettyUrl":"discussion-with-benjie-cyhe4w","presenter":{"id":"WyJ1c2VycyIsImE3NDk3ZjY2LTBlMzktNGE1Yy1iMzE4LThhZDUwMjQ5MDY1OCJd","firstName":"Taz","lastName":"Singh"},"presenterFirstName":null,"presenterLastName":null,"title":"Discussion with Benjie","description":"A relaxed discussion and Q&A with GraphQL TSC member Benjie Gillam. There is excitement in the air as Benjie celebrates the 10th anniversary of his open source project PostGraphile. This is your chance to ask Benjie about GraphQL, what are his preferred workflows, PostGraphile V5, the new GraphQL Golden Path or anything at all including his favourite colour or what he had for breakfast!\n\nThe session will be conducted by Taz Singh with participation from the audience.","videoSourceUrl":""},"cursor":"WyJuYXR1cmFsIiwyXQ=="},{"node":{"id":"WyJwcmVzZW50YXRpb25zIiwiNjRiZmJlODUtYjY0Zi00ZDUyLTlkY2MtZDlkMGUxMzA5OGExIl0=","slug":"l462yp","prettyUrl":"celebrating-ten-years-l462yp","presenter":{"id":"WyJ1c2VycyIsIjUwZTQ0NzQzLWRjNDctNDU5MS1hYzlhLWUwZjkxZDZkMzRmNSJd","firstName":"Benjie","lastName":"Gillam"},"presenterFirstName":null,"presenterLastName":null,"title":"Celebrating ten years of PostGraphile","description":"GraphQL celebrated it's 10 year birthday last year, today it's PostGraphile's turn! Hear from maintainer Benjie about the origins of the project, the lessons learned along the way, and how the project has evolved to meet the needs of its users despite being MIT-licensed OSS.","videoSourceUrl":""},"cursor":"WyJuYXR1cmFsIiwzXQ=="}]},"venue":{"address":{"location":{"__typename":"GeometryPoint","geojson":{"type":"Point","coordinates":[-0.084633,51.5249615]}},"id":"WyJhZGRyZXNzZXMiLCJlYWYzNTMxYy02ZmExLTRjYzMtYjJmNi1lZjgzY2IwZTUyYTAiXQ=="},"id":"WyJ2ZW51ZXMiLCI4M2Q2OTM3NS1jNmQ3LTQ3MDItOTMyMy03NzYxOGQxODVkMDIiXQ=="},"createdAt":"2026-03-18T13:54:48.664502","updatedAt":"2026-04-14T11:45:44.244134"} \ No newline at end of file diff --git a/apps/web/src/lib/import/__fixtures__/guild-react-summit.json b/apps/web/src/lib/import/__fixtures__/guild-react-summit.json new file mode 100644 index 0000000..52c470b --- /dev/null +++ b/apps/web/src/lib/import/__fixtures__/guild-react-summit.json @@ -0,0 +1 @@ +{"id":"WyJldmVudHMiLCIyNWY4MzJmNi02YzQwLTRjY2ItYTk0OC1mODdhNmU1MDBjYjUiXQ==","slug":"0kzjnm","prettyUrl":"react-summit-amsterdam-0kzjnm","fullUrl":"https://guild.host/events/react-summit-amsterdam-0kzjnm","shortUrl":"https://guild.host/e-0kzjnm","name":"React Summit Amsterdam 2025","description":"**Join us at the biggest React conference on June 13 & 17, 2025 in Amsterdam & Online!** \n\n[React Summit](https://reactsummit.com/) — the annual conference dedicated to all things React, bringing together thousands of Front-end and Full-stack engineers from around the globe.\n\n**🚨 Important! To secure your spot, make sure to get your ticket through this [LINK](https://reactsummit.com/).**\n\n🎙 **Call for Proposals**\nAre you ready to speak in front of the world’s largest React audience? Submit your talk proposal via our [CFP form](https://docs.google.com/forms/d/e/1FAIpQLSf3hJVEYAj1auSk3lWCke-JSsJbiD0DdKTgQYWHsReadPQAyQ/viewform) and take the stage at the upcoming conference!\n\n**What to expect at the conference:**\n\n🌍 New Venue: Circa Amsterdam – a creative and sustainable space.\n🗣️ 60+ React Experts: Hear from top OSS contributors and industry rising stars.\n🏆 React OSS Awards: Celebrating open-source achievements and collaboration.\n🚤 Amsterdam Vibes: Connect with fellow developers while exploring the city by boat or on foot.\n🎉 React’s Biggest Party: Enjoy a festival atmosphere with activities, music, and more!\n\nThis year we expect the authors and core teams of **Next.js, React 19, React Compiler, Cursor, TypeScript, Tanstack Start, Zustand, React Server Components, Tailwind, Remix & more.**\n\nDon’t miss out on this celebration of React!\n\n👉  Follow us on social media: [Bluesky,](https://bsky.app/profile/reactsummit.gitnation.org) [Twitter (X)](https://twitter.com/reactsummit), [Instagram](https://www.instagram.com/reactsummit/), [Linkedin](https://www.linkedin.com/showcase/reactsummit), [Facebook](https://www.facebook.com/reactsummit/)","startAt":"2025-06-13T06:00:00+00:00","endAt":"2025-06-17T19:00:00+00:00","timeZone":"Europe/Warsaw","visibility":"LISTED","hasVenue":true,"hasExternalUrl":false,"owner":{"__typename":"Guild","id":"WyJndWlsZHMiLCIyMjA4ODQ3Mi05ZmRkLTRjMjgtODdiYy1lNzA0ZTM3Yjc4YjUiXQ==","name":"ReactJS Dublin","__isNode":"Guild"},"uploadedSocialCard":{"url":"https://guild.host/i/523df140-2900-4030-9cc4-290990b0ba1a/png","id":"WyJpbWFnZXMiLCI1MjNkZjE0MC0yOTAwLTQwMzAtOWNjNC0yOTA5OTBiMGJhMWEiXQ=="},"generatedSocialCardURL":"https://guild.host/social/event/react-summit-amsterdam-0kzjnm/card.svg","presentations":{"edges":[]},"venue":{"address":{"location":{"__typename":"GeometryPoint","geojson":{"type":"Point","coordinates":[4.8168455,52.3908459]}},"id":"WyJhZGRyZXNzZXMiLCI2Njk1Y2NiNi04ZGEwLTQzNGItYjMxMi1hOWUxM2M2N2Q1Y2IiXQ=="},"id":"WyJ2ZW51ZXMiLCI0Nzk0YTE0ZC1lNzZjLTQ4MmUtYmU4ZC01NDY5YzBjMDgzMWEiXQ=="},"createdAt":"2025-01-09T12:38:17.386347","updatedAt":"2026-03-09T11:20:44.593302"} \ No newline at end of file diff --git a/apps/web/src/lib/import/__fixtures__/guild-svelte-chicago.json b/apps/web/src/lib/import/__fixtures__/guild-svelte-chicago.json new file mode 100644 index 0000000..726ace3 --- /dev/null +++ b/apps/web/src/lib/import/__fixtures__/guild-svelte-chicago.json @@ -0,0 +1 @@ +{"id":"WyJldmVudHMiLCJlNmJhOTk5Ny00YzJmLTQwNzEtOTM4MS1lYjAwYjcwZWI5ZjAiXQ==","slug":"nbdhmo","prettyUrl":"svelte-chicago-march-nbdhmo","fullUrl":"https://guild.host/events/svelte-chicago-march-nbdhmo","shortUrl":"https://guild.host/e-nbdhmo","name":"Svelte Chicago - March 2026","description":"🏙️ **Come hang out with us at Svelte Chicago!**\n\nInterested in Svelte? Join us for pizza, beverages, and networking at the [M1 Finance](https://m1.com) office for the next meeting of the Chicago chapter of Svelte Society!\n\n📄 Web Serialization and SvelteKit: JSON.stringify, Devalue, and Beyond\n\nDevalue is the client-server serialization library used by SvelteKit. In this talk, [Robert Ismo](https://www.linkedin.com/in/robertismo/) will go over why we want serialization libraries, the what and why of devalue, and the current limitations of devalue and other solutions.\n\n🕖 **Agenda**\n\n- Pizza and networking at 5:30pm\n- Introductions at 6:15pm\n- Web Serialization and SvelteKit at 6:30pm\n- More networking\n- At 8pm, you don't have to go home, but you can't stay there\n\nSpecial thanks to [M1 Finance](https://m1.com) for hosting and [Storyblok](https://www.storyblok.com/) for sponsoring!","startAt":"2026-03-24T22:30:00+00:00","endAt":"2026-03-25T01:00:00+00:00","timeZone":"America/Chicago","visibility":"LISTED","hasVenue":true,"hasExternalUrl":false,"owner":{"__typename":"Guild","id":"WyJndWlsZHMiLCI4MWY2YzE1Ni0yNjdjLTRjMmEtOTUyMC05YTEwYTdlMWJiMzYiXQ==","name":"Svelte Chicago","__isNode":"Guild"},"uploadedSocialCard":{"url":"https://guild.host/i/1d9d947b-6f1a-4695-8dc3-f54b90c5aaa9/jpeg","id":"WyJpbWFnZXMiLCIxZDlkOTQ3Yi02ZjFhLTQ2OTUtOGRjMy1mNTRiOTBjNWFhYTkiXQ=="},"generatedSocialCardURL":"https://guild.host/social/event/svelte-chicago-march-nbdhmo/card.svg","presentations":{"edges":[{"node":{"id":"WyJwcmVzZW50YXRpb25zIiwiZDIwZTQ1YjctMDgzYy00NGJiLTgxYjUtNjViZTA4NGU2MDUzIl0=","slug":"d8vnoi","prettyUrl":"web-serialization-and-d8vnoi","presenter":null,"presenterFirstName":"Robert","presenterLastName":"Ismo","title":"Web Serialization and SvelteKit: JSON.stringify, Devalue, and Beyond","description":"Devalue is the client-server serialization library used by SvelteKit. In this talk, I will go over why we want serialization libraries, the what and why of devalue, and the current limitations of devalue and other solutions.\n","videoSourceUrl":""},"cursor":"WyJuYXR1cmFsIiwxXQ=="}]},"venue":{"address":{"location":{"__typename":"GeometryPoint","geojson":{"type":"Point","coordinates":[-87.6332791,41.8860378]}},"id":"WyJhZGRyZXNzZXMiLCJmN2E3MThmZC0zYjBiLTQ5NDEtYThjOC0xOTg4NTY0NWMzZWUiXQ=="},"id":"WyJ2ZW51ZXMiLCI1ZmNkYTdjOS1lNDliLTQ1NWQtYjVjNC1jMGI1OWYzNzFjMzMiXQ=="},"createdAt":"2026-03-06T14:18:54.435146","updatedAt":"2026-03-09T11:20:11.5769"} \ No newline at end of file diff --git a/apps/web/src/lib/import/guild.test.ts b/apps/web/src/lib/import/guild.test.ts new file mode 100644 index 0000000..3a93b99 --- /dev/null +++ b/apps/web/src/lib/import/guild.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { guildImporter } from './guild'; +import { + importContext, + stubFetch, + jsonReply, + imageReply, + blockRealFetch, + type FetchRoute +} from './test-support'; + +// Real API responses captured from https://guild.host/api/next/events/. +import svelteChicago from './__fixtures__/guild-svelte-chicago.json'; +import reactSummit from './__fixtures__/guild-react-summit.json'; +import londonGraphql from './__fixtures__/guild-london-graphql.json'; + +const API = '/api/next/events/'; + +/** Route table for one Guild event: the JSON API plus whatever cover image it points at. */ +function guildRoutes(event: unknown): FetchRoute[] { + return [ + { when: API, reply: () => jsonReply(event) }, + { + when: /card\.svg(\?|$)/, + reply: () => imageReply('image/svg+xml', new Uint8Array([0x3c, 0x73, 0x76, 0x67])) + }, + { when: '/i/', reply: () => imageReply('image/png') } // uploadedSocialCard lives at guild.host/i// + ]; +} + +blockRealFetch(); + +describe('guildImporter.accept', () => { + it.each([ + ['https://guild.host/events/svelte-chicago-march-nbdhmo', true], + ['https://www.guild.host/events/react-summit-amsterdam-0kzjnm', true], + ['https://guild.host/events/foo/tickets', true], // still an event path + ['https://guild.host/', false], // no event + ['https://guild.host/some-community', false], // community page, not an event + ['https://guild.host/events', false], // listing, no slug + ['https://example.com/events/x', false], // wrong host + ['https://notguild.host/events/x', false], // host must match exactly + ['not a url', false] + ])('%s -> %s', (url, expected) => { + expect(guildImporter.accept(importContext(url))).toBe(expected); + }); +}); + +describe('guildImporter.parseData maps real captured events', () => { + it('maps an in-person event with an uploaded social card (Svelte Chicago)', async () => { + stubFetch(guildRoutes(svelteChicago)); + const url = 'https://guild.host/events/svelte-chicago-march-nbdhmo'; + + const result = await guildImporter.parseData(importContext(url)); + + expect(result).toMatchObject({ + source: 'https://guild.host/events/svelte-chicago-march-nbdhmo', + name: 'Svelte Chicago - March 2026', + timezone: 'America/Chicago', + startsAt: '2026-03-24T22:30:00+00:00', + endsAt: '2026-03-25T01:00:00+00:00', + mode: 'inperson', // hasVenue: true, hasExternalUrl: false + links: [{ uri: 'https://guild.host/events/svelte-chicago-march-nbdhmo', name: 'Event page' }] + }); + expect(result?.description).toContain('Svelte Chicago'); + // Cover image was fetched from the uploaded card and inlined as a data URL. + expect(result?.imageDataUrl).toMatch(/^data:image\//); + }); + + it('maps a multi-day event (React Summit Amsterdam)', async () => { + stubFetch(guildRoutes(reactSummit)); + const url = 'https://guild.host/events/react-summit-amsterdam-0kzjnm'; + + const result = await guildImporter.parseData(importContext(url)); + + expect(result).toMatchObject({ + name: 'React Summit Amsterdam 2025', + timezone: 'Europe/Warsaw', + startsAt: '2025-06-13T06:00:00+00:00', + endsAt: '2025-06-17T19:00:00+00:00', + mode: 'inperson' + }); + expect(result?.imageDataUrl).toMatch(/^data:image\//); + }); +}); + +describe('guildImporter robustness', () => { + it('imports an event whose description is absent (optional field degrades)', async () => { + const { description, ...noDescription } = svelteChicago as Record; + void description; + stubFetch(guildRoutes(noDescription)); + const result = await guildImporter.parseData( + importContext('https://guild.host/events/svelte-chicago-march-nbdhmo') + ); + expect(result?.name).toBe('Svelte Chicago - March 2026'); + expect(result?.description).toBeUndefined(); + }); + + it('throws on a non-OK Guild API response so /api/import-event surfaces a 502', async () => { + stubFetch([{ when: API, reply: () => jsonReply({ error: 'nope' }, 503) }]); + await expect( + guildImporter.parseData(importContext('https://guild.host/events/whatever')) + ).rejects.toThrow(/failed to fetch guild event/); + }); + + // The Guild API returns `uploadedSocialCard: null` (not absent) for events with + // no uploaded card — e.g. the real event London GraphQL Spring 2026. The schema + // accepts that (v.nullish) and the importer falls back to generatedSocialCardURL, + // rather than throwing and 502-ing the whole import. Fixture is the real, + // unmodified API response. + it('imports an event whose uploadedSocialCard is null, via the generated-card fallback (London GraphQL)', async () => { + stubFetch(guildRoutes(londonGraphql)); + const result = await guildImporter.parseData( + importContext('https://guild.host/events/london-graphql-spring-tujgnp') + ); + expect(result?.name).toBe('London GraphQL Spring 2026'); + expect(result?.imageDataUrl).toMatch(/^data:image\//); + }); +}); diff --git a/apps/web/src/lib/import/pipeline.test.ts b/apps/web/src/lib/import/pipeline.test.ts new file mode 100644 index 0000000..aad26cb --- /dev/null +++ b/apps/web/src/lib/import/pipeline.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { importFromUrl, importers } from './index'; +import { stubFetch, jsonReply, imageReply, blockRealFetch } from './test-support'; +import svelteChicago from './__fixtures__/guild-svelte-chicago.json'; + +blockRealFetch(); + +describe('import registry order', () => { + it('registers the host-matched guild importer before the fetch-based ical importer', () => { + // guildImporter.accept() is a cheap URL match; icalImporter.accept() calls + // getPage() and downloads the whole source page. If guild came after ical, + // every guild paste would fetch (and throw away) the ~170 KB guild.host HTML + // page first — and fail outright if that heavy page rate-limits while the + // small JSON API is fine. Order is the guarantee; pin it. + const names = importers.map((i) => i.name); + expect(names.indexOf('guild')).toBeGreaterThanOrEqual(0); + expect(names.indexOf('guild')).toBeLessThan(names.indexOf('ical')); + expect(names.indexOf('guild')).toBeLessThan(names.indexOf('webpage')); + }); + + it('routes a guild.host URL straight to the Guild API without fetching the HTML page', async () => { + const { urls } = stubFetch([ + { when: '/api/next/events/', reply: () => jsonReply(svelteChicago) }, + { when: '/i/', reply: () => imageReply('image/png') } + ]); + + const result = await importFromUrl('https://guild.host/events/svelte-chicago-march-nbdhmo'); + + expect(result?.name).toBe('Svelte Chicago - March 2026'); + // It must have hit the JSON API... + expect(urls.some((u) => u.includes('/api/next/events/'))).toBe(true); + // ...and never the guild.host event *page* (which would mean a wasted getPage() + // fetch by an earlier fetch-based importer). Note the API path is + // /api/next/events/, which is intentionally excluded by this pattern. + expect(urls.some((u) => /guild\.host\/events\//.test(u))).toBe(false); + }); +});