From 31f0e291d5cfe2622b9985c36ff95dfc47083049 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 14:00:50 +0000 Subject: [PATCH 1/6] feat: add /events endpoints serving Luma calendar events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve the foundation's Luma community events so the project websites can show them without each talking to Luma. GET /events lists every configured calendar with its events; GET /events/:slug serves one calendar and 404s for an unknown slug. Calendars are configured through EVENTS_CALENDARS as calendarId:slug pairs, mirroring LIVESTREAM_CHANNELS: slugs are pinned in config, display names come from each feed's X-WR-CALNAME at runtime, and malformed config fails startup. Each calendar's public iCalendar feed (api.luma.com/ics/get) is re-fetched every 15 minutes and parsed in place — folding, text escaping, UTC/all-day/TZID date forms — with the event's Luma page link lifted from the description, since the feed carries no URL property. A calendar whose fetch fails keeps serving its last good content, and updatedAt moves only when the served content changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Vp21Nq4X8pV68GfLeBwJ3e --- README.md | 79 +++- docs/architecture/model.c4 | 58 ++- docs/architecture/specification.c4 | 4 + docs/architecture/views.c4 | 7 +- example.env | 13 + src/app.module.ts | 2 + src/events/events.calendars.spec.ts | 213 ++++++++++ src/events/events.calendars.ts | 124 ++++++ src/events/events.controller.spec.ts | 133 ++++++ src/events/events.controller.ts | 58 +++ src/events/events.module.ts | 22 + src/events/events.response.ts | 139 ++++++ src/events/events.service.spec.ts | 606 +++++++++++++++++++++++++++ src/events/events.service.ts | 499 ++++++++++++++++++++++ src/events/index.ts | 4 + src/main.ts | 3 +- test/events.e2e-spec.ts | 325 ++++++++++++++ test/swagger.e2e-spec.ts | 128 ++++++ test/test-app.fixture.ts | 19 +- 19 files changed, 2414 insertions(+), 22 deletions(-) create mode 100644 src/events/events.calendars.spec.ts create mode 100644 src/events/events.calendars.ts create mode 100644 src/events/events.controller.spec.ts create mode 100644 src/events/events.controller.ts create mode 100644 src/events/events.module.ts create mode 100644 src/events/events.response.ts create mode 100644 src/events/events.service.spec.ts create mode 100644 src/events/events.service.ts create mode 100644 src/events/index.ts create mode 100644 test/events.e2e-spec.ts diff --git a/README.md b/README.md index 40886f2..6586bc4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ Public web API for [openhomefoundation.org](https://www.openhomefoundation.org). It currently serves the livestream status of the Open Home Foundation's YouTube channels — Home Assistant, ESPHome, Open Home Foundation and Music Assistant — -so the project websites can show upcoming, live and recently-ended streams. +so the project websites can show upcoming, live and recently-ended streams, and +the events of the foundation's Luma calendars, read from their iCalendar feeds, +so the sites can show meetups and other community events. Built to the [OHF engineering standards](https://standards.openhomefoundation.org): NestJS on Node LTS, TypeScript in strict mode, pnpm, and Mise for task running. @@ -31,6 +33,8 @@ Interactive documentation is generated from the code and served at | ------ | ------------------- | ------------------------------------------------- | | `GET` | `/livestream` | Livestream status for every configured channel | | `GET` | `/livestream/:slug` | Status for one channel; `404` for an unknown slug | +| `GET` | `/events` | Every configured Luma calendar with its events | +| `GET` | `/events/:slug` | One calendar's events; `404` for an unknown slug | | `GET` | `/__heartbeat__` | Application health probe | | `GET` | `/__lbheartbeat__` | Load-balancer probe | | `GET` | `/__version__` | Running build's version and commit | @@ -54,6 +58,36 @@ A livestream entry looks like this: describe. `updatedAt` changes only when the reported state changes, so it is safe to use for caching and change detection. +An events calendar entry looks like this: + +```json +{ + "calendar": "home-assistant-meetups", + "calendarName": "Home Assistant Meetups", + "events": [ + { + "id": "evt-HJ5eO3aJOiCob3z@events.lu.ma", + "summary": "Dublin - Hosted by the OHF", + "start": "2026-06-04T17:30:00.000Z", + "end": "2026-06-04T20:30:00.000Z", + "description": "Get up-to-date information at: https://luma.com/n5mzdtvb", + "location": "26 Wexford St, Portobello, Dublin, D02 HX93, Ireland", + "url": "https://luma.com/n5mzdtvb", + "latitude": 53.336691, + "longitude": -6.26573, + "status": "tentative" + } + ], + "updatedAt": "2026-08-18T12:00:00.000Z" +} +``` + +Events are everything the calendar's Luma feed advertises — past ones included, +sorted soonest first — so the consumer decides the window it shows. Times are +UTC; an all-day event carries a bare `YYYY-MM-DD` date instead. `url` is the +event's Luma page. As with livestreams, `updatedAt` moves only when the served +content changes. + Every response carries the security headers [helmet](https://helmetjs.github.io) applies by default, including a `Content-Security-Policy`, `nosniff`, and HSTS. The defaults are used unchanged; `test/security.e2e-spec.ts` asserts them and @@ -61,8 +95,8 @@ checks that the policy still fits what the Swagger UI at `/docs` needs. Reads are open to no one by default and to the origins in `CORS_ORIGINS` when it is set — the same list the Socket.IO endpoint honours, which refuses a handshake -from an origin that is not on it. A `404` reports only that the channel is -unknown, without repeating the requested slug back. Rate limiting belongs to +from an origin that is not on it. A `404` reports only that the channel or +calendar is unknown, without repeating the requested slug back. Rate limiting belongs to Cloudflare in front of this service, not to the app — see [Configuration](#configuration). @@ -72,12 +106,13 @@ Configuration is environment variables only. `example.env` documents every one; copy it to `.env` for local development (`.env` is gitignored and must never be committed). In production these are set on the container. -| Variable | Required | Purpose | -| --------------------- | -------- | ------------------------------------------------ | -| `YOUTUBE_API_KEY` | yes | YouTube Data API v3 key, used to classify videos | -| `LIVESTREAM_CHANNELS` | yes | Channels to track, as `handle:slug` pairs | -| `CORS_ORIGINS` | no | Sites allowed to read the API from a browser | -| `PORT` | no | Listen port, defaults to `3000` | +| Variable | Required | Purpose | +| --------------------- | -------- | --------------------------------------------------- | +| `YOUTUBE_API_KEY` | yes | YouTube Data API v3 key, used to classify videos | +| `LIVESTREAM_CHANNELS` | yes | Channels to track, as `handle:slug` pairs | +| `EVENTS_CALENDARS` | yes | Luma calendars to serve, as `calendarId:slug` pairs | +| `CORS_ORIGINS` | no | Sites allowed to read the API from a browser | +| `PORT` | no | Listen port, defaults to `3000` | `LIVESTREAM_CHANNELS` is a comma-separated list of `handle:slug` pairs: @@ -96,6 +131,23 @@ configured. Adding or removing a project is a configuration change, not a code change. Malformed configuration fails startup rather than silently tracking nothing. +`EVENTS_CALENDARS` works the same way for Luma calendars, as a comma-separated +list of `calendarId:slug` pairs: + +``` +EVENTS_CALENDARS=cal-6Tm2FkWzoBpLXWr:home-assistant-meetups +``` + +- `calendarId` is the Luma calendar ID the iCalendar feed is fetched by + (`api.luma.com/ics/get?entity=calendar&id=`). +- `slug` is the path this API serves the calendar under (`/events/`) and + the `calendar` field in the response — pinned in configuration for the same + reason channel slugs are. + +Calendar display names come from each feed's `X-WR-CALNAME` at runtime. The +feeds are public, so no API key is involved; they are re-fetched every 15 +minutes, and a calendar whose fetch fails keeps serving what it served before. + `CORS_ORIGINS` is a comma-separated list of the origins allowed to read the API from a browser — the sites that consume it are deployed separately, so this is configuration too: @@ -149,9 +201,12 @@ videos.list (quota) ─▶ reconcile poll ──┘ notifies on uploads and metadata edits, not on a broadcast going live, so it cannot deliver the signal this service is about. -Architecturally the feature is one Nest module (`src/livestream`) with a -controller over an in-memory state map; there is no database. State is rebuilt -from YouTube on every boot. +Events are simpler: one fetch of each calendar's iCalendar feed every 15 +minutes, parsed in place — no API key, no quota, no per-event classification. + +Architecturally each feature is one Nest module (`src/livestream`, `src/events`) +with a controller over an in-memory state map; there is no database. State is +rebuilt from the upstream feeds on every boot. The C4 diagrams in [`docs/architecture`](docs/architecture) say the same thing at each level the [OHF architecture standards](https://standards.openhomefoundation.org/architecture/c4-documentation/) diff --git a/docs/architecture/model.c4 b/docs/architecture/model.c4 index 48b3806..ab6500c 100644 --- a/docs/architecture/model.c4 +++ b/docs/architecture/model.c4 @@ -61,6 +61,16 @@ model { } } + luma = external 'Luma' { + technology 'iCalendar over HTTPS' + description ''' + Hosts the foundation's community-event calendars. Their public iCalendar + exports (api.luma.com/ics/get?entity=calendar&id=…) are the source of + every event this API serves — unauthenticated and unmetered, so unlike + YouTube there is no quota to design around. + ''' + } + // The two elements below are described by the `deployments` repository // (Terraform Cloud workspace "web-api"), not by this one. edge = external 'Cloudflare' { @@ -118,15 +128,17 @@ model { description ''' Public, read-only HTTP API for openhomefoundation.org. It reports the livestream status of the foundation's YouTube channels — live, upcoming, - recently ended, or nothing — so the project websites can show it without - each of them talking to YouTube. + recently ended, or nothing — and the events of the foundation's Luma + calendars, so the project websites can show both without each of them + talking to YouTube or Luma. ''' api = container 'Web API service' { technology 'NestJS 11 / Express on Node 24, port 3000' description ''' One process, one container image. It answers HTTP requests and runs the - two YouTube polling loops on timers side by side. + polling loops — two against YouTube, one against Luma — on timers side + by side. There is no database and no cache container: all state is in the process, and it is rebuilt from YouTube on every boot. That is the @@ -159,6 +171,15 @@ model { ''' } + eventsController = component 'Events controller' { + technology 'src/events/events.controller.ts' + description ''' + GET /events and GET /events/:slug. Like the livestream endpoints, a + pure read of already parsed state: a request never triggers a call to + Luma, and an unknown slug is a 404. + ''' + } + healthController = component 'Health controller' { technology 'src/health/health.controller.ts' description ''' @@ -178,6 +199,15 @@ model { ''' } + calendarConfig = component 'Calendar configuration' { + technology 'src/events/events.calendars.ts' + description ''' + Parses EVENTS_CALENDARS into validated calendarId:slug pairs once, at + boot — the same contract as the channel list: configuration change, + not code change, and malformed configuration fails startup. + ''' + } + livestreamService = component 'Livestream service' { technology 'src/livestream/livestream.service.ts' description ''' @@ -188,6 +218,23 @@ model { ''' } + eventsService = component 'Events service' { + technology 'src/events/events.service.ts' + description ''' + Fetches each calendar's iCalendar feed every 15 minutes, parses it in + place, and keeps the last successfully parsed content — a calendar + whose fetch fails serves stale events rather than none. + ''' + } + + eventsState = store 'Calendar state' { + technology 'In-process Map' + description ''' + The ready-to-serve event list per calendar slug. Not a database for + the same reason channel state is not: one refresh rebuilds it. + ''' + } + state = store 'Channel state' { technology 'In-process Maps' description ''' @@ -221,11 +268,15 @@ model { // middleware in registration order, so the security headers and the CORS // decision really do come first. bootstrap -[inproc]-> livestreamController 'routes /livestream' + bootstrap -[inproc]-> eventsController 'routes /events' bootstrap -[inproc]-> healthController 'routes the probe endpoints' bootstrap -[inproc]-> gateway 'admits handshakes from the configured origins' livestreamController -[inproc]-> livestreamService 'reads the current status' livestreamService -[inproc]-> state 'derives from, and writes back' channelConfig -[inproc]-> livestreamService 'injects the validated channel list at boot' + eventsController -[inproc]-> eventsService 'reads the parsed events' + eventsService -[inproc]-> eventsState 'parses into, and reads back' + calendarConfig -[inproc]-> eventsService 'injects the validated calendar list at boot' } } @@ -240,6 +291,7 @@ model { livestreamService -[atom]-> feeds 'polls each channel feed every 5 min; skips the classify step when unchanged' livestreamService -[rest]-> dataApi 'resolves handles, and classifies changed or imminent videos' + eventsService -[ical]-> luma 'fetches each calendar feed every 15 min; keeps the last good content on failure' // Delivery path, as the release workflow performs it. engineer -> github 'merges, and publishes a release' diff --git a/docs/architecture/specification.c4 b/docs/architecture/specification.c4 index 74f7ff8..6ca5a27 100644 --- a/docs/architecture/specification.c4 +++ b/docs/architecture/specification.c4 @@ -50,6 +50,10 @@ specification { technology 'Atom XML over HTTPS' } + relationship ical { + technology 'iCalendar over HTTPS' + } + relationship api { technology 'HTTPS/JSON' } diff --git a/docs/architecture/views.c4 b/docs/architecture/views.c4 index 431d624..4191eaa 100644 --- a/docs/architecture/views.c4 +++ b/docs/architecture/views.c4 @@ -29,6 +29,7 @@ views { edge, webapi, youtube, + luma, platform autoLayout TopBottom @@ -54,6 +55,7 @@ views { edge, youtube, youtube.*, + luma, platform autoLayout TopBottom @@ -65,8 +67,8 @@ views { Inside the process. Two things are worth reading off this view: every request passes through Bootstrap first, which is where the security headers and the CORS decision happen; and the HTTP path never reaches - YouTube — it reads state the polling loops maintain, so a slow or failing - YouTube cannot slow down a response. + YouTube or Luma — it reads state the polling loops maintain, so a slow or + failing upstream cannot slow down a response. ''' include @@ -75,6 +77,7 @@ views { edge, youtube, youtube.*, + luma, platform autoLayout TopBottom diff --git a/example.env b/example.env index 5da95a5..1ab7283 100644 --- a/example.env +++ b/example.env @@ -14,6 +14,19 @@ YOUTUBE_API_KEY= # change; malformed or missing config fails startup rather than tracking nothing. LIVESTREAM_CHANNELS=home_assistant:home-assistant,esphomeio:esphome,OpenHomeFndn:open-home-foundation,musicassistantio:music-assistant +# Luma calendars to serve events from: a comma-separated list of +# calendarId:slug pairs. +# - calendarId: the Luma calendar ID the iCalendar feed is fetched by, e.g. +# cal-6Tm2FkWzoBpLXWr (from api.luma.com/ics/get?entity=calendar&id=…). +# - slug: the path this API serves the calendar under (/events/) and +# the "calendar" field in the response. Pinned here rather than +# derived from the calendar's Luma name so renaming the calendar +# cannot silently change our public URLs. +# Display names are read from each feed at runtime, so they are not configured +# here. Adding or removing a calendar is a config change, not a code change; +# malformed or missing config fails startup rather than tracking nothing. +EVENTS_CALENDARS=cal-6Tm2FkWzoBpLXWr:home-assistant-meetups + # Origins allowed to read this API from a browser: a comma-separated list of # scheme-and-host entries, e.g. https://esphome.io,https://*.esphome.io. # A port is part of an origin (http://localhost:8123); a path is not. A leading diff --git a/src/app.module.ts b/src/app.module.ts index 2469d5e..1067ea0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppGateway } from './app.gateway'; +import { EventsModule } from './events'; import { HealthModule } from './health'; import { getVersionInfo } from './health/version'; import { LivestreamModule } from './livestream'; @@ -10,6 +11,7 @@ import { LivestreamModule } from './livestream'; ConfigModule.forRoot({ isGlobal: true }), HealthModule.register({ version: getVersionInfo() }), LivestreamModule, + EventsModule, ], providers: [AppGateway], }) diff --git a/src/events/events.calendars.spec.ts b/src/events/events.calendars.spec.ts new file mode 100644 index 0000000..b7e6b93 --- /dev/null +++ b/src/events/events.calendars.spec.ts @@ -0,0 +1,213 @@ +import { + Calendar, + EVENTS_CALENDARS, + icsUrl, + parseCalendars, +} from './events.calendars'; + +const FORMAT = 'expected a comma-separated list of calendarId:slug pairs'; + +describe('parseCalendars', () => { + describe('valid configuration', () => { + it('parses a single calendarId:slug pair', () => { + expect( + parseCalendars('cal-6Tm2FkWzoBpLXWr:home-assistant-meetups'), + ).toEqual([ + { slug: 'home-assistant-meetups', calendarId: 'cal-6Tm2FkWzoBpLXWr' }, + ]); + }); + + it('parses several pairs and preserves their configured order', () => { + const calendars = parseCalendars( + 'cal-alpha:alpha-events,cal-bravo:bravo-events,cal-charlie:charlie-events', + ); + + expect(calendars).toEqual([ + { slug: 'alpha-events', calendarId: 'cal-alpha' }, + { slug: 'bravo-events', calendarId: 'cal-bravo' }, + { slug: 'charlie-events', calendarId: 'cal-charlie' }, + ]); + }); + + it('trims whitespace around entries, IDs and slugs', () => { + expect( + parseCalendars( + ' cal-alpha : alpha-events ,\n\tcal-bravo\t:\tbravo\n', + ), + ).toEqual([ + { slug: 'alpha-events', calendarId: 'cal-alpha' }, + { slug: 'bravo', calendarId: 'cal-bravo' }, + ]); + }); + + it.each([ + ['an underscore', 'cal_alpha'], + ['a hyphen', 'cal-alpha'], + ['digits', 'cal123'], + ['mixed case', 'cal-6Tm2FkWzoBpLXWr'], + ])('accepts a calendar ID containing %s', (_description, calendarId) => { + const [calendar] = parseCalendars(`${calendarId}:some-slug`); + + expect(calendar.calendarId).toBe(calendarId); + }); + + it.each([ + ['a single word', 'meetups'], + ['several hyphen-separated words', 'home-assistant-meetups'], + ['digits only', '123'], + ['letters and digits', 'meetups-2026'], + ])('accepts a slug that is %s', (_description, slug) => { + const [calendar] = parseCalendars(`cal-alpha:${slug}`); + + expect(calendar.slug).toBe(slug); + }); + + it('tolerates a trailing comma', () => { + expect(parseCalendars('cal-alpha:alpha,')).toEqual([ + { slug: 'alpha', calendarId: 'cal-alpha' }, + ]); + }); + + it('tolerates a doubled comma between entries', () => { + expect(parseCalendars('cal-alpha:alpha,,cal-bravo:bravo')).toEqual([ + { slug: 'alpha', calendarId: 'cal-alpha' }, + { slug: 'bravo', calendarId: 'cal-bravo' }, + ]); + }); + + it('returns objects with exactly the slug and calendarId keys', () => { + const [calendar] = parseCalendars(' cal-Alpha : alpha-events '); + + expect(Object.keys(calendar).sort()).toEqual(['calendarId', 'slug']); + expect(calendar).toEqual({ + slug: 'alpha-events', + calendarId: 'cal-Alpha', + }); + }); + }); + + describe('missing or empty configuration', () => { + it('throws when the variable is not set', () => { + expect(() => parseCalendars(undefined)).toThrow( + `EVENTS_CALENDARS is not set: ${FORMAT}`, + ); + }); + + it.each([ + ['an empty string', ''], + ['only whitespace', ' \n\t '], + ['only commas', ',,,'], + ])('throws when the variable is %s', (_description, raw) => { + expect(() => parseCalendars(raw)).toThrow( + `EVENTS_CALENDARS is set but lists no calendars: ${FORMAT}`, + ); + }); + }); + + describe('malformed entries', () => { + it('rejects an entry with no colon', () => { + expect(() => parseCalendars('cal-alpha')).toThrow( + `EVENTS_CALENDARS[0] "cal-alpha" must be one calendar ID and one slug separated by ":" — ${FORMAT}`, + ); + }); + + it('rejects an entry with too many colons', () => { + expect(() => parseCalendars('cal-alpha:alpha:extra')).toThrow( + 'must be one calendar ID and one slug separated by ":"', + ); + }); + + it('names the pasted-URL mistake instead of blaming the colon count', () => { + expect(() => + parseCalendars( + 'https://api.luma.com/ics/get?entity=calendar&id=cal-a:alpha', + ), + ).toThrow('looks like a URL — use the bare calendar ID and slug'); + }); + + it('rejects an entry with an empty calendar ID', () => { + expect(() => parseCalendars(':alpha')).toThrow( + 'has no calendar ID before the ":"', + ); + }); + + it('rejects an entry with an empty slug', () => { + expect(() => parseCalendars('cal-alpha:')).toThrow( + 'has no slug after the ":"', + ); + }); + + it.each([ + ['punctuation only', '---'], + ['a space inside', 'cal alpha'], + ['a slash', 'cal/alpha'], + ])('rejects a calendar ID that is %s', (_description, calendarId) => { + expect(() => parseCalendars(`${calendarId}:alpha`)).toThrow( + 'must be a bare Luma calendar ID', + ); + }); + + it.each([ + ['uppercase', 'Alpha-Events'], + ['an underscore', 'alpha_events'], + ['a doubled hyphen', 'alpha--events'], + ['a leading hyphen', '-alpha'], + ['a trailing hyphen', 'alpha-'], + ])('rejects a slug containing %s', (_description, slug) => { + expect(() => parseCalendars(`cal-alpha:${slug}`)).toThrow( + 'must be lowercase letters, digits and single hyphens', + ); + }); + + it('reports the failing entry by index and content', () => { + expect(() => parseCalendars('cal-alpha:alpha,cal-bravo')).toThrow( + 'EVENTS_CALENDARS[1] "cal-bravo"', + ); + }); + }); + + describe('duplicates', () => { + it('rejects a duplicate slug', () => { + expect(() => + parseCalendars('cal-alpha:meetups,cal-bravo:meetups'), + ).toThrow('EVENTS_CALENDARS has a duplicate slug "meetups"'); + }); + + it('rejects two entries pointing at the same calendar ID', () => { + expect(() => parseCalendars('cal-alpha:alpha,cal-alpha:bravo')).toThrow( + 'EVENTS_CALENDARS has two calendars pointing at the same ID "cal-alpha"', + ); + }); + }); +}); + +describe('icsUrl', () => { + it("builds Luma's iCalendar export URL for a calendar", () => { + expect(icsUrl('cal-6Tm2FkWzoBpLXWr')).toBe( + 'https://api.luma.com/ics/get?entity=calendar&id=cal-6Tm2FkWzoBpLXWr', + ); + }); + + it('URL-encodes the calendar ID', () => { + // parseCalendars never lets one through, but the function should still be + // safe on its own. + expect(icsUrl('cal a&b')).toBe( + 'https://api.luma.com/ics/get?entity=calendar&id=cal%20a%26b', + ); + }); +}); + +describe('EVENTS_CALENDARS token', () => { + it('is a symbol, so no string can collide with it in the DI container', () => { + expect(typeof EVENTS_CALENDARS).toBe('symbol'); + }); +}); + +describe('Calendar type', () => { + it('is exercised by the parser (compile-time check)', () => { + const calendar: Calendar = parseCalendars('cal-alpha:alpha')[0]; + + expect(calendar.slug).toBe('alpha'); + expect(calendar.calendarId).toBe('cal-alpha'); + }); +}); diff --git a/src/events/events.calendars.ts b/src/events/events.calendars.ts new file mode 100644 index 0000000..f9089f2 --- /dev/null +++ b/src/events/events.calendars.ts @@ -0,0 +1,124 @@ +export interface Calendar { + /** URL-safe identifier used in the API path, e.g. "home-assistant-meetups". */ + slug: string; + /** Luma calendar ID, e.g. "cal-6Tm2FkWzoBpLXWr". */ + calendarId: string; +} + +/** DI token for the parsed, validated calendar list. */ +export const EVENTS_CALENDARS = Symbol('EVENTS_CALENDARS'); + +/** Slugs appear in the API path, so keep them unambiguous. */ +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * What a Luma calendar ID may contain: letters, digits, underscores and + * hyphens, and at least one alphanumeric so punctuation alone ("--", "__") is + * rejected. Enforced so a malformed ID fails the deploy rather than every + * feed request at runtime. + */ +const CALENDAR_ID_PATTERN = /^(?=.*[A-Za-z0-9])[A-Za-z0-9_-]+$/; + +const ENV_VAR = 'EVENTS_CALENDARS'; +const FORMAT = 'expected a comma-separated list of calendarId:slug pairs'; + +/** + * Luma's iCalendar export for a calendar — the source of every event this API + * serves. Public and unauthenticated; the calendar ID is the only input. + */ +export const icsUrl = (calendarId: string): string => + `https://api.luma.com/ics/get?entity=calendar&id=${encodeURIComponent( + calendarId, + )}`; + +/** + * Parse the tracked calendars out of the EVENTS_CALENDARS environment + * variable, e.g. `cal-6Tm2FkWzoBpLXWr:home-assistant-meetups`. Adding or + * removing a calendar is a config change, not a code change. + * + * Each entry pairs the Luma calendar ID the feed is fetched by with the slug + * this API serves it under. The slug is pinned here rather than derived from + * the calendar's Luma name so that renaming the calendar cannot silently + * change our public URLs. Display names are read from the feed at runtime, so + * they are deliberately not configured. + * + * Throws on missing or malformed config rather than quietly tracking nothing, + * so a bad deploy fails loudly instead of serving an empty calendar list. + */ +export function parseCalendars(raw: string | undefined): Calendar[] { + if (raw === undefined || raw === null) { + throw new Error(`${ENV_VAR} is not set: ${FORMAT}`); + } + + // A trailing or doubled comma is a typo, not a calendar. + const entries = raw + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + // Reported distinctly from unset, so an operator who did set the variable is + // not sent looking for a missing one. Blank and comma-only values are the + // same mistake, so they share this message. + if (entries.length === 0) { + throw new Error(`${ENV_VAR} is set but lists no calendars: ${FORMAT}`); + } + + const calendars = entries.map(toCalendar); + const slugs = new Set(); + const ids = new Set(); + for (const { slug, calendarId } of calendars) { + if (slugs.has(slug)) { + throw new Error(`${ENV_VAR} has a duplicate slug "${slug}"`); + } + slugs.add(slug); + // Two entries with the same ID would poll one calendar twice and serve it + // under two slugs. + if (ids.has(calendarId)) { + throw new Error( + `${ENV_VAR} has two calendars pointing at the same ID "${calendarId}"`, + ); + } + ids.add(calendarId); + } + return calendars; +} + +function toCalendar(entry: string, index: number): Calendar { + const at = `${ENV_VAR}[${index}] "${entry}"`; + + // A pasted feed URL carries its own colon, so name the real mistake rather + // than blaming the colon count. + if (entry.includes('://')) { + throw new Error( + `${at} looks like a URL — use the bare calendar ID and slug, e.g. cal-6Tm2FkWzoBpLXWr:home-assistant-meetups`, + ); + } + + const parts = entry.split(':'); + if (parts.length !== 2) { + throw new Error( + `${at} must be one calendar ID and one slug separated by ":" — ${FORMAT}`, + ); + } + + const calendarId = parts[0].trim(); + if (!calendarId) { + throw new Error(`${at} has no calendar ID before the ":"`); + } + if (!CALENDAR_ID_PATTERN.test(calendarId)) { + throw new Error( + `${at} calendar ID "${calendarId}" must be a bare Luma calendar ID — letters, digits, underscores and hyphens only, not a URL`, + ); + } + + const slug = parts[1].trim(); + if (!slug) { + throw new Error(`${at} has no slug after the ":"`); + } + if (!SLUG_PATTERN.test(slug)) { + throw new Error( + `${at} slug "${slug}" must be lowercase letters, digits and single hyphens — it appears in the API path`, + ); + } + + return { slug, calendarId }; +} diff --git a/src/events/events.controller.spec.ts b/src/events/events.controller.spec.ts new file mode 100644 index 0000000..93f213b --- /dev/null +++ b/src/events/events.controller.spec.ts @@ -0,0 +1,133 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { EventsController } from './events.controller'; +import { CalendarInfo, EventInfo, EventsService } from './events.service'; + +const event = (overrides: Partial = {}): EventInfo => ({ + id: 'evt-abc@events.lu.ma', + summary: 'Dublin - Hosted by the OHF', + start: '2026-06-04T17:30:00.000Z', + ...overrides, +}); + +const info = (overrides: Partial = {}): CalendarInfo => ({ + calendar: 'home-assistant-meetups', + calendarName: 'Home Assistant Meetups', + events: [], + updatedAt: '2026-08-18T12:00:00.000Z', + ...overrides, +}); + +describe('EventsController', () => { + let controller: EventsController; + let service: { getAll: jest.Mock; getCalendar: jest.Mock }; + + beforeEach(async () => { + service = { getAll: jest.fn(), getCalendar: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [EventsController], + providers: [{ provide: EventsService, useValue: service }], + }).compile(); + + controller = module.get(EventsController); + }); + + describe('getAll', () => { + it('returns the full list of calendars from the service', () => { + const all = [ + info({ events: [event()] }), + info({ calendar: 'esphome-events', calendarName: 'ESPHome Events' }), + ]; + service.getAll.mockReturnValue(all); + + expect(controller.getAll()).toEqual(all); + }); + + it('asks the service exactly once and passes no arguments', () => { + service.getAll.mockReturnValue([]); + + controller.getAll(); + + expect(service.getAll).toHaveBeenCalledTimes(1); + expect(service.getAll).toHaveBeenCalledWith(); + }); + + it('hands back the service array untouched, without copying or reshaping it', () => { + const all = [info()]; + service.getAll.mockReturnValue(all); + + const result = controller.getAll(); + + expect(result).toBe(all); + expect(result[0]).toBe(all[0]); + }); + + it('returns an empty list when the service has no calendars to report', () => { + service.getAll.mockReturnValue([]); + + expect(controller.getAll()).toEqual([]); + }); + }); + + describe('getCalendar', () => { + it('returns the calendar the service reports for the requested slug', () => { + const calendar = info({ events: [event(), event({ id: 'evt-two' })] }); + service.getCalendar.mockReturnValue(calendar); + + expect(controller.getCalendar('home-assistant-meetups')).toEqual( + calendar, + ); + }); + + it('forwards the slug to the service verbatim', () => { + service.getCalendar.mockReturnValue(info()); + + controller.getCalendar('esphome-events'); + + expect(service.getCalendar).toHaveBeenCalledTimes(1); + expect(service.getCalendar).toHaveBeenCalledWith('esphome-events'); + }); + + it('does not sanitise or normalise the slug before delegating', () => { + service.getCalendar.mockReturnValue(info()); + + // Slug validation belongs to the service, which owns the calendar list. + controller.getCalendar(' Home-Assistant-Meetups '); + + expect(service.getCalendar).toHaveBeenCalledWith( + ' Home-Assistant-Meetups ', + ); + }); + + it('hands back the service object untouched, without copying or reshaping it', () => { + const calendar = info({ events: [event()] }); + service.getCalendar.mockReturnValue(calendar); + + const result = controller.getCalendar('home-assistant-meetups'); + + expect(result).toBe(calendar); + expect(Object.keys(result)).toEqual(Object.keys(calendar)); + }); + + it('propagates the NotFoundException raised for an unknown slug', () => { + service.getCalendar.mockImplementation(() => { + throw new NotFoundException('Unknown calendar'); + }); + + expect(() => controller.getCalendar('nope')).toThrow(NotFoundException); + expect(() => controller.getCalendar('nope')).toThrow('Unknown calendar'); + }); + + it('propagates non-HTTP service errors unchanged', () => { + service.getCalendar.mockImplementation(() => { + throw new Error('boom'); + }); + + expect(() => controller.getCalendar('home-assistant-meetups')).toThrow( + 'boom', + ); + }); + }); +}); diff --git a/src/events/events.controller.ts b/src/events/events.controller.ts new file mode 100644 index 0000000..45353e2 --- /dev/null +++ b/src/events/events.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; + +import { CalendarInfoResponse } from './events.response'; +import { EventsService } from './events.service'; + +@ApiTags('events') +@Controller('events') +export class EventsController { + constructor(private readonly events: EventsService) {} + + @Get() + @ApiOperation({ + summary: 'List every tracked calendar with its events', + description: + 'One entry per configured Luma calendar, in configuration order. ' + + 'Calendars with nothing to report are still listed, with an empty ' + + 'event list.', + }) + @ApiOkResponse({ + description: 'Every tracked calendar and its events.', + type: CalendarInfoResponse, + isArray: true, + }) + getAll(): CalendarInfoResponse[] { + return this.events.getAll(); + } + + @Get(':slug') + @ApiOperation({ + summary: "Get one calendar's events", + description: + "Every event the calendar's Luma feed advertises, soonest first — " + + 'including past ones, so the consumer decides the window it shows.', + }) + @ApiParam({ + name: 'slug', + description: + 'Slug of a configured calendar, as served in the "calendar" field.', + example: 'home-assistant-meetups', + }) + @ApiOkResponse({ + description: "The calendar's events.", + type: CalendarInfoResponse, + }) + @ApiNotFoundResponse({ + description: 'No calendar is configured with that slug.', + }) + getCalendar(@Param('slug') slug: string): CalendarInfoResponse { + return this.events.getCalendar(slug); + } +} diff --git a/src/events/events.module.ts b/src/events/events.module.ts new file mode 100644 index 0000000..844be03 --- /dev/null +++ b/src/events/events.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { EVENTS_CALENDARS, parseCalendars } from './events.calendars'; +import { EventsController } from './events.controller'; +import { EventsService } from './events.service'; + +@Module({ + controllers: [EventsController], + providers: [ + { + provide: EVENTS_CALENDARS, + inject: [ConfigService], + // Parsed once at startup, so malformed config fails the boot rather than + // every request. + useFactory: (config: ConfigService) => + parseCalendars(config.get('EVENTS_CALENDARS')), + }, + EventsService, + ], +}) +export class EventsModule {} diff --git a/src/events/events.response.ts b/src/events/events.response.ts new file mode 100644 index 0000000..2e7a894 --- /dev/null +++ b/src/events/events.response.ts @@ -0,0 +1,139 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { CalendarInfo, EventInfo, EventStatus } from './events.service'; + +/** + * Every member of the status union, with what it means. Typed as a Record so a + * new EventStatus fails to compile until it is documented here. + */ +const STATUS_DESCRIPTIONS: Record = { + confirmed: 'the event is definitely happening', + tentative: 'the event is planned but not final', + cancelled: 'the event was called off', +}; + +export const EVENT_STATUSES = Object.keys(STATUS_DESCRIPTIONS) as EventStatus[]; + +const STATUS_DESCRIPTION = `The event's iCalendar status: ${Object.entries( + STATUS_DESCRIPTIONS, +) + .map(([status, meaning]) => `\`${status}\` — ${meaning}`) + .join('; ')}. Absent when the feed does not carry one.`; + +/** + * The documented shape of one event. + * + * `EventInfo` is an interface, and interfaces leave no runtime metadata for + * `@nestjs/swagger` to read, so the served schema lives here instead — see + * LivestreamInfoResponse for the full rationale. Drift is a compile error in + * both directions: `implements` requires every documented field to exist on + * `EventInfo`, and the controller assigning service objects to these types + * requires every field here to be one the service actually serves. + */ +export class EventInfoResponse implements EventInfo { + @ApiProperty({ + description: "Stable event identifier, the feed's UID.", + example: 'evt-HJ5eO3aJOiCob3z@events.lu.ma', + }) + id!: string; + + @ApiProperty({ + description: 'Event title.', + example: 'Dublin - Hosted by the OHF', + }) + summary!: string; + + @ApiProperty({ + description: + 'ISO 8601 start: a UTC date-time for timed events, a bare date ' + + '(YYYY-MM-DD) for all-day ones.', + example: '2026-06-04T17:30:00.000Z', + }) + start!: string; + + @ApiProperty({ + description: + 'ISO 8601 end, in the same form as "start". Absent when the feed ' + + 'omits it.', + example: '2026-06-04T20:30:00.000Z', + required: false, + }) + end?: string; + + @ApiProperty({ + description: "The event's description, as the feed carries it.", + example: 'Get up-to-date information at: https://luma.com/n5mzdtvb', + required: false, + }) + description?: string; + + @ApiProperty({ + description: 'Human-readable venue or address.', + example: '26 Wexford St, Portobello, Dublin, D02 HX93, Ireland', + required: false, + }) + location?: string; + + @ApiProperty({ + description: "The event's Luma page.", + example: 'https://luma.com/n5mzdtvb', + required: false, + }) + url?: string; + + @ApiProperty({ + description: + 'Venue latitude in decimal degrees, when the feed carries coordinates.', + example: 53.336691, + required: false, + }) + latitude?: number; + + @ApiProperty({ + description: + 'Venue longitude in decimal degrees, when the feed carries coordinates.', + example: -6.26573, + required: false, + }) + longitude?: number; + + @ApiProperty({ + description: STATUS_DESCRIPTION, + enum: EVENT_STATUSES, + example: 'confirmed', + required: false, + }) + status?: EventStatus; +} + +/** The documented shape of one calendar and its events. */ +export class CalendarInfoResponse implements CalendarInfo { + @ApiProperty({ + description: 'Calendar slug, e.g. "home-assistant-meetups".', + example: 'home-assistant-meetups', + }) + calendar!: string; + + @ApiProperty({ + description: 'Human-friendly calendar name, as the Luma feed reports it.', + example: 'Home Assistant Meetups', + }) + calendarName!: string; + + @ApiProperty({ + description: + 'Every event the feed advertises, soonest first — including past ' + + 'ones, so the consumer decides the window it shows.', + type: EventInfoResponse, + isArray: true, + }) + events!: EventInfoResponse[]; + + @ApiProperty({ + description: + "ISO 8601 timestamp of when this calendar's served content last changed.", + example: '2026-08-18T12:00:00.000Z', + format: 'date-time', + }) + updatedAt!: string; +} diff --git a/src/events/events.service.spec.ts b/src/events/events.service.spec.ts new file mode 100644 index 0000000..a3944ac --- /dev/null +++ b/src/events/events.service.spec.ts @@ -0,0 +1,606 @@ +import { Logger, NotFoundException } from '@nestjs/common'; + +import { Calendar } from './events.calendars'; +import { EventsService } from './events.service'; + +const MINUTE_MS = 60_000; +/** The service's feed refresh interval. */ +const REFRESH_MS = 15 * MINUTE_MS; + +/** + * The calendar list is configuration, injected into the service, so these + * tests own their own fixture instead of depending on whatever is deployed. + * Display names are deliberately absent: they come from the feed, not config. + */ +const CALENDARS: Calendar[] = [ + { slug: 'home-assistant-meetups', calendarId: 'cal-ha' }, + { slug: 'esphome-events', calendarId: 'cal-esphome' }, +]; + +const [HA, ESPHOME] = CALENDARS; + +/** A VCALENDAR wrapping the given VEVENT bodies, CRLF-separated like Luma's. */ +const vcalendar = ( + name: string | undefined, + events: string[][], + { header = [] as string[] } = {}, +): string => + [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Luma//Test//EN', + ...(name === undefined ? [] : [`X-WR-CALNAME:${name}`]), + ...header, + ...events.flatMap((lines) => ['BEGIN:VEVENT', ...lines, 'END:VEVENT']), + 'END:VCALENDAR', + ].join('\r\n'); + +/** A complete, realistic VEVENT; tests override or drop lines as needed. */ +const lumaEvent = ( + uid: string, + overrides: Partial> = {}, +): string[] => { + const lines: Record = { + DTSTART: '20260604T173000Z', + DTEND: '20260604T203000Z', + UID: `${uid}@events.lu.ma`, + SUMMARY: 'Dublin - Hosted by the OHF', + DESCRIPTION: + 'Get up-to-date information at: https://luma.com/n5mzdtvb\\n\\nHosted by the OHF', + LOCATION: '26 Wexford St\\, Dublin\\, Ireland', + GEO: '53.336691;-6.26573', + STATUS: 'TENTATIVE', + ...overrides, + }; + return Object.entries(lines) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .map(([name, value]) => `${name}:${value}`); +}; + +/** + * Mutable stand-in for Luma's iCalendar export. Tests mutate it between time + * advances to simulate the calendar changing, or its feed breaking. + */ +class FakeLuma { + private readonly bodies = new Map(); + private readonly broken = new Set(); + + serve(calendarId: string, body: string): void { + this.bodies.set(calendarId, body); + this.broken.delete(calendarId); + } + + break(calendarId: string): void { + this.broken.add(calendarId); + } + + readonly fetch = async (input: unknown): Promise => { + const url = new URL(String(input)); + if ( + url.hostname !== 'api.luma.com' || + url.pathname !== '/ics/get' || + url.searchParams.get('entity') !== 'calendar' + ) { + throw new Error(`unexpected fetch: ${url.toString()}`); + } + const id = url.searchParams.get('id') ?? ''; + if (this.broken.has(id)) { + return new Response('boom', { status: 500 }); + } + const body = this.bodies.get(id); + if (body === undefined) { + return new Response('not found', { status: 404 }); + } + return new Response(body, { + status: 200, + headers: { 'content-type': 'text/calendar' }, + }); + }; +} + +describe('EventsService', () => { + let luma: FakeLuma; + let fetchMock: jest.Mock; + let originalFetch: typeof globalThis.fetch; + let services: EventsService[]; + /** Frozen wall clock for the test; all expected times derive from it. */ + let now: number; + + const createService = (calendars: Calendar[] = CALENDARS): EventsService => { + const service = new EventsService(calendars); + services.push(service); + return service; + }; + + /** onModuleInit kicks off a refresh without awaiting it; let it finish. */ + const settle = async (): Promise => { + for (let i = 0; i < 20; i++) { + await jest.advanceTimersByTimeAsync(0); + } + }; + + const start = async ( + service: EventsService = createService(), + ): Promise => { + service.onModuleInit(); + await settle(); + return service; + }; + + const advance = async (ms: number): Promise => { + await jest.advanceTimersByTimeAsync(ms); + await settle(); + }; + + beforeEach(() => { + jest.useFakeTimers(); + now = Date.parse('2026-08-18T12:00:00.000Z'); + jest.setSystemTime(now); + services = []; + luma = new FakeLuma(); + originalFetch = globalThis.fetch; + fetchMock = jest.fn(luma.fetch); + globalThis.fetch = fetchMock; + // The refresh-failure tests exercise paths that log; keep the output quiet. + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + + luma.serve( + HA.calendarId, + vcalendar('Home Assistant Meetups', [lumaEvent('evt-dublin')]), + ); + luma.serve(ESPHOME.calendarId, vcalendar('ESPHome Events', [])); + }); + + afterEach(() => { + for (const service of services) { + service.onModuleDestroy(); + } + globalThis.fetch = originalFetch; + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('parsing a Luma feed', () => { + it('parses each VEVENT into the documented event shape', async () => { + const service = await start(); + + const [event] = service.getCalendar(HA.slug).events; + expect(event).toEqual({ + id: 'evt-dublin@events.lu.ma', + summary: 'Dublin - Hosted by the OHF', + start: '2026-06-04T17:30:00.000Z', + end: '2026-06-04T20:30:00.000Z', + description: + 'Get up-to-date information at: https://luma.com/n5mzdtvb\n\nHosted by the OHF', + location: '26 Wexford St, Dublin, Ireland', + url: 'https://luma.com/n5mzdtvb', + latitude: 53.336691, + longitude: -6.26573, + status: 'tentative', + }); + }); + + it('unfolds continuation lines the way Luma folds long descriptions', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + [ + 'DTSTART:20260604T173000Z', + 'UID:evt-folded', + 'SUMMARY:Fol', + ' ded summary', + 'DESCRIPTION:Get up-to-date information at: https://luma.com/n5', + ' mzdtvb\\n\\nAd', + '\tdress continues', + ], + ]), + ); + const service = await start(); + + const [event] = service.getCalendar(HA.slug).events; + expect(event.summary).toBe('Folded summary'); + expect(event.description).toBe( + 'Get up-to-date information at: https://luma.com/n5mzdtvb\n\nAddress continues', + ); + expect(event.url).toBe('https://luma.com/n5mzdtvb'); + }); + + it('unescapes \\n, commas, semicolons and backslashes in text values', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + [ + 'DTSTART:20260604T173000Z', + 'UID:evt-escaped', + 'SUMMARY:One\\, two\\; three \\\\ four\\nfive', + ], + ]), + ); + const service = await start(); + + expect(service.getCalendar(HA.slug).events[0].summary).toBe( + 'One, two; three \\ four\nfive', + ); + }); + + it('is not derailed by quoted parameters carrying colons and semicolons', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + [ + 'DTSTART:20260604T173000Z', + 'UID:evt-organizer', + 'ORGANIZER;CN="Quarry; Missy: OHF":MAILTO:calendar-invite@lu.ma', + 'SUMMARY:Still parsed', + ], + ]), + ); + const service = await start(); + + expect(service.getCalendar(HA.slug).events[0].summary).toBe( + 'Still parsed', + ); + }); + + it('prefers an explicit URL property over a link found in the description', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + lumaEvent('evt-url', { URL: 'https://luma.com/explicit' }), + ]), + ); + const service = await start(); + + expect(service.getCalendar(HA.slug).events[0].url).toBe( + 'https://luma.com/explicit', + ); + }); + + it('also recognises lu.ma links in the description', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + lumaEvent('evt-luma', { DESCRIPTION: 'See https://lu.ma/abc123.' }), + ]), + ); + const service = await start(); + + expect(service.getCalendar(HA.slug).events[0].url).toBe( + 'https://lu.ma/abc123.', + ); + }); + + it('serves no url when neither a URL property nor a Luma link exists', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + lumaEvent('evt-nourl', { DESCRIPTION: 'No link here' }), + ]), + ); + const service = await start(); + + expect(service.getCalendar(HA.slug).events[0].url).toBeUndefined(); + }); + + it('omits coordinates when GEO is malformed', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [lumaEvent('evt-geo', { GEO: 'not;numbers' })]), + ); + const service = await start(); + + const [event] = service.getCalendar(HA.slug).events; + expect(event.latitude).toBeUndefined(); + expect(event.longitude).toBeUndefined(); + }); + + it('omits a STATUS the spec does not define', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [lumaEvent('evt-status', { STATUS: 'MAYBE' })]), + ); + const service = await start(); + + expect(service.getCalendar(HA.slug).events[0].status).toBeUndefined(); + }); + + it('skips a VEVENT with no UID, keeping the rest', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + lumaEvent('evt-kept'), + lumaEvent('ignored', { UID: undefined }), + ]), + ); + const service = await start(); + + expect( + service.getCalendar(HA.slug).events.map((event) => event.id), + ).toEqual(['evt-kept@events.lu.ma']); + }); + + it('skips a VEVENT whose DTSTART is missing or unparseable', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + lumaEvent('evt-nostart', { DTSTART: undefined }), + lumaEvent('evt-garbage', { DTSTART: 'whenever' }), + lumaEvent('evt-kept'), + ]), + ); + const service = await start(); + + expect( + service.getCalendar(HA.slug).events.map((event) => event.id), + ).toEqual(['evt-kept@events.lu.ma']); + }); + + it('sorts events soonest first regardless of feed order', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + lumaEvent('evt-later', { DTSTART: '20261001T170000Z' }), + lumaEvent('evt-sooner', { DTSTART: '20260901T170000Z' }), + lumaEvent('evt-soonest', { DTSTART: '20260801T170000Z' }), + ]), + ); + const service = await start(); + + expect( + service.getCalendar(HA.slug).events.map((event) => event.id), + ).toEqual([ + 'evt-soonest@events.lu.ma', + 'evt-sooner@events.lu.ma', + 'evt-later@events.lu.ma', + ]); + }); + }); + + describe('date forms', () => { + it('serves an all-day VALUE=DATE event as a bare date', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + [ + 'DTSTART;VALUE=DATE:20260604', + 'DTEND;VALUE=DATE:20260605', + 'UID:evt-allday', + 'SUMMARY:All day', + ], + ]), + ); + const service = await start(); + + const [event] = service.getCalendar(HA.slug).events; + expect(event.start).toBe('2026-06-04'); + expect(event.end).toBe('2026-06-05'); + }); + + it('converts a TZID-local time to the UTC instant it names', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + [ + // Irish Standard Time is UTC+1 in June. + 'DTSTART;TZID=Europe/Dublin:20260604T183000', + 'UID:evt-zoned', + 'SUMMARY:Zoned', + ], + ]), + ); + const service = await start(); + + expect(service.getCalendar(HA.slug).events[0].start).toBe( + '2026-06-04T17:30:00.000Z', + ); + }); + + it('reads a floating time (no zone, no Z) as UTC', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + ['DTSTART:20260604T173000', 'UID:evt-floating', 'SUMMARY:Floating'], + ]), + ); + const service = await start(); + + expect(service.getCalendar(HA.slug).events[0].start).toBe( + '2026-06-04T17:30:00.000Z', + ); + }); + + it('falls back to a UTC reading for a TZID Intl does not know', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + [ + 'DTSTART;TZID=Atlantis/Lost:20260604T173000', + 'UID:evt-badzone', + 'SUMMARY:Bad zone', + ], + ]), + ); + const service = await start(); + + expect(service.getCalendar(HA.slug).events[0].start).toBe( + '2026-06-04T17:30:00.000Z', + ); + }); + + it('rejects a date whose components roll the calendar over', async () => { + luma.serve( + HA.calendarId, + vcalendar('HA', [ + ['DTSTART:20261340T173000Z', 'UID:evt-month13', 'SUMMARY:Month 13'], + ]), + ); + const service = await start(); + + expect(service.getCalendar(HA.slug).events).toEqual([]); + }); + }); + + describe('calendar metadata', () => { + it('reads the display name from the feed X-WR-CALNAME', async () => { + const service = await start(); + + expect(service.getCalendar(HA.slug).calendarName).toBe( + 'Home Assistant Meetups', + ); + }); + + it('falls back to the slug when the feed carries no name', async () => { + luma.serve(HA.calendarId, vcalendar(undefined, [lumaEvent('evt-x')])); + const service = await start(); + + expect(service.getCalendar(HA.slug).calendarName).toBe(HA.slug); + }); + + it('serves calendars in configured order', async () => { + const service = await start(); + + expect(service.getAll().map((entry) => entry.calendar)).toEqual([ + HA.slug, + ESPHOME.slug, + ]); + }); + + it('serves a calendar with an empty feed as an empty event list', async () => { + const service = await start(); + + expect(service.getCalendar(ESPHOME.slug).events).toEqual([]); + }); + }); + + describe('before and without a successful fetch', () => { + it('serves default entries before onModuleInit has run', () => { + const service = createService(); + + expect(service.getAll()).toEqual( + CALENDARS.map(({ slug }) => ({ + calendar: slug, + calendarName: slug, + events: [], + updatedAt: new Date(now).toISOString(), + })), + ); + }); + + it('serves an empty event list for a calendar that has never fetched', async () => { + luma.break(HA.calendarId); + const service = await start(); + + expect(service.getCalendar(HA.slug)).toEqual({ + calendar: HA.slug, + calendarName: HA.slug, + events: [], + updatedAt: new Date(now).toISOString(), + }); + }); + + it('one broken calendar does not stop the others from refreshing', async () => { + luma.break(ESPHOME.calendarId); + const service = await start(); + + expect(service.getCalendar(HA.slug).events).toHaveLength(1); + }); + }); + + describe('refresh cycle', () => { + it('picks up feed changes on the next interval', async () => { + const service = await start(); + expect(service.getCalendar(ESPHOME.slug).events).toEqual([]); + + luma.serve( + ESPHOME.calendarId, + vcalendar('ESPHome Events', [lumaEvent('evt-new')]), + ); + await advance(REFRESH_MS); + + expect(service.getCalendar(ESPHOME.slug).events).toHaveLength(1); + }); + + it('keeps serving the last good content when the feed breaks later', async () => { + const service = await start(); + const before = service.getCalendar(HA.slug); + expect(before.events).toHaveLength(1); + + luma.break(HA.calendarId); + await advance(REFRESH_MS); + + expect(service.getCalendar(HA.slug)).toEqual(before); + }); + + it('treats a 200 that is not an iCalendar feed as a failed fetch', async () => { + const service = await start(); + const before = service.getCalendar(HA.slug); + + luma.serve(HA.calendarId, 'captive portal'); + await advance(REFRESH_MS); + + expect(service.getCalendar(HA.slug)).toEqual(before); + }); + + it('keeps updatedAt still while the feed content is unchanged', async () => { + const service = await start(); + const before = service.getCalendar(HA.slug).updatedAt; + + await advance(REFRESH_MS); + + expect(service.getCalendar(HA.slug).updatedAt).toBe(before); + // The feed really was re-fetched; the content just did not change. + expect( + fetchMock.mock.calls.filter(([input]) => + String(input).includes(HA.calendarId), + ).length, + ).toBeGreaterThanOrEqual(2); + }); + + it('moves updatedAt when the served content changes', async () => { + const service = await start(); + const before = service.getCalendar(HA.slug).updatedAt; + + luma.serve( + HA.calendarId, + vcalendar('Home Assistant Meetups', [ + lumaEvent('evt-dublin'), + lumaEvent('evt-galway', { SUMMARY: 'Galway Meetup' }), + ]), + ); + await advance(REFRESH_MS); + + const after = service.getCalendar(HA.slug).updatedAt; + expect(after).not.toBe(before); + expect(Date.parse(after)).toBeGreaterThan(Date.parse(before)); + }); + + it('stops refreshing once the module is destroyed', async () => { + const service = await start(); + service.onModuleDestroy(); + const calls = fetchMock.mock.calls.length; + + await advance(REFRESH_MS); + + expect(fetchMock.mock.calls.length).toBe(calls); + }); + }); + + describe('getCalendar', () => { + it('throws NotFoundException for an unknown slug', async () => { + const service = await start(); + + expect(() => service.getCalendar('nope')).toThrow(NotFoundException); + }); + + it('does not echo the requested slug back in the error', async () => { + const service = await start(); + + expect(() => service.getCalendar('