From 7f5c537ad6ef3d3157e13090893bfbc71fd9fb43 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 10 Aug 2026 09:05:20 +0200 Subject: [PATCH] Hand the feeds calendar parts instead of a document to re-parse The all-trips feed merges many trips into one calendar. It did that by generating each trip's finished ICS and then scanning the text back apart: one pass for the VEVENTs, one for the VTIMEZONEs, plus a string replace to splice the refresh hints into the header. The scans had to be structural rather than regexes, because a user-supplied SUMMARY can legitimately contain the literal 'END:VEVENT' and a lazy match would truncate the event there. CalendarService now returns the parts - calendar name, filename, VTIMEZONE blocks keyed by TZID, and the VEVENT blocks - and exportICS serialises them. Both scanners are gone. CAL-010 pins the whole serialised document as a snapshot, with DTSTAMP and the UID counter normalised so it pins the calendar rather than the minute it ran in. It is what proves this changed no bytes: it was taken before the model went in and still matches. Two tests still pointed at the health controller deleted in d37af204: health.di.test.ts loses its only subject, and the AppModule wiring test now resolves FeaturesController instead - it is the one test that builds the whole container, so it should resolve something that still exists. --- server/src/nest/calendar/calendar.service.ts | 112 ++++++++++++------ server/src/nest/feeds/feeds.service.ts | 85 +++++-------- server/tests/e2e/feeds.e2e.test.ts | 53 +++++---- .../tests/unit/nest/calendar.service.test.ts | 80 +++++++++++++ server/tests/unit/nest/health.di.test.ts | 25 ---- server/tests/unit/nest/wiring.test.ts | 6 +- 6 files changed, 219 insertions(+), 142 deletions(-) delete mode 100644 server/tests/unit/nest/health.di.test.ts diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index a7ea4be60..b324f1da6 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -5,6 +5,22 @@ import { addDays } from '../days/days.service'; import { resolveTimeZone } from '../common/timezoneService'; import { NotFoundError } from '../common/domain-errors'; +/** The VCALENDAR preamble every TREK calendar starts with, single-trip or merged. */ +export const CALENDAR_HEADER = + 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n'; + +/** One trip's calendar in parts, so callers can merge several without re-parsing text. */ +export interface TripCalendar { + /** Already escaped for the X-WR-CALNAME line. */ + calName: string; + /** Filename for the Content-Disposition of the one-time download. */ + filename: string; + /** VTIMEZONE block per TZID. Merging calendars means merging these by key. */ + timezones: Map; + /** One complete BEGIN:VEVENT…END:VEVENT block each, unfolded, in emission order. */ + events: string[]; +} + // ── ICS folding ───────────────────────────────────────────────────────────── // RFC 5545 §3.1: content lines longer than 75 octets must be folded with a CRLF @@ -12,7 +28,7 @@ import { NotFoundError } from '../common/domain-errors'; // never split a multi-byte codepoint, so non-ASCII titles/notes (accents, CJK, // emoji) stay intact. Applied to the whole calendar, so both the one-time // download and the subscribable feed emit spec-compliant output. -function foldICS(ics: string): string { +export function foldICS(ics: string): string { const foldLine = (line: string): string => { const bytes = Buffer.from(line, 'utf8'); if (bytes.length <= 75) return line; @@ -117,7 +133,17 @@ export class CalendarService { // ── ICS export ──────────────────────────────────────────────────────────── - exportICS(tripId: string | number): { ics: string; filename: string } { + /** + * A trip's calendar as parts rather than as one string. + * + * The all-trips feed needs to merge many trips into one document: it has to + * drop each trip's VCALENDAR wrapper, keep the VEVENTs, and emit each + * VTIMEZONE once. It used to do that by scanning the finished text back apart + * line by line — and that scan had to be structural rather than a regex, + * because a user-supplied SUMMARY can legitimately contain the literal + * "END:VEVENT". Handing out the parts removes the need to reassemble them. + */ + buildTripCalendar(tripId: string | number): TripCalendar { const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any; if (!trip) throw new NotFoundError('Trip not found'); @@ -177,17 +203,17 @@ export class CalendarService { return `${prop}:${val}\r\n`; }; - let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n'; - ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`; + const events: string[] = []; // Trip as all-day event. DTEND is exclusive, so it must be the day *after* the last // day. addDays() stays in UTC — building a local-time Date here dropped the trip's // last day on any server east of Greenwich (#1453). if (trip.start_date && trip.end_date) { const endStr = fmtDate(addDays(trip.end_date, 1)); - ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`; - if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`; - ics += `END:VEVENT\r\n`; + let ev = `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`; + if (trip.description) ev += `DESCRIPTION:${esc(trip.description)}\r\n`; + ev += `END:VEVENT\r\n`; + events.push(ev); } // Days with assignments and notes @@ -216,18 +242,19 @@ export class CalendarService { // Timed assignments → individual events for (const a of timed) { const zone = resolveTimeZone(a.place_lat, a.place_lng); - ics += `BEGIN:VEVENT\r\nUID:${uid(a.id, 'assign')}\r\nDTSTAMP:${now}\r\n`; - ics += dtLine('DTSTART', a.effective_time, zone, day.date + 'T00:00'); + let ev = `BEGIN:VEVENT\r\nUID:${uid(a.id, 'assign')}\r\nDTSTAMP:${now}\r\n`; + ev += dtLine('DTSTART', a.effective_time, zone, day.date + 'T00:00'); if (a.effective_end_time) { - ics += dtLine('DTEND', a.effective_end_time, zone, day.date + 'T00:00'); + ev += dtLine('DTEND', a.effective_end_time, zone, day.date + 'T00:00'); } - ics += `SUMMARY:${esc(a.place_name)}\r\n`; + ev += `SUMMARY:${esc(a.place_name)}\r\n`; let desc = ''; if (a.notes) desc += a.notes; if (a.place_address) desc += (desc ? '\n' : '') + a.place_address; - if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; - if (a.place_address) ics += `LOCATION:${esc(a.place_address)}\r\n`; - ics += `END:VEVENT\r\n`; + if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`; + if (a.place_address) ev += `LOCATION:${esc(a.place_address)}\r\n`; + ev += `END:VEVENT\r\n`; + events.push(ev); } // Build all-day summary event if there are untimed activities or notes @@ -235,9 +262,9 @@ export class CalendarService { const dayTitle = day.title || `Day ${day.day_number}`; const endStr = fmtDate(addDays(day.date, 1)); - ics += `BEGIN:VEVENT\r\nUID:${uid(day.id, 'day')}\r\nDTSTAMP:${now}\r\n`; - ics += `DTSTART;VALUE=DATE:${fmtDate(day.date)}\r\nDTEND;VALUE=DATE:${endStr}\r\n`; - ics += `SUMMARY:${esc(dayTitle)}\r\n`; + let ev = `BEGIN:VEVENT\r\nUID:${uid(day.id, 'day')}\r\nDTSTAMP:${now}\r\n`; + ev += `DTSTART;VALUE=DATE:${fmtDate(day.date)}\r\nDTEND;VALUE=DATE:${endStr}\r\n`; + ev += `SUMMARY:${esc(dayTitle)}\r\n`; let desc = ''; if (untimed.length > 0) { @@ -255,8 +282,9 @@ export class CalendarService { return line; }).join('\n'); } - if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; - ics += `END:VEVENT\r\n`; + if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`; + ev += `END:VEVENT\r\n`; + events.push(ev); } } @@ -312,9 +340,9 @@ export class CalendarService { if (!timeLines) continue; const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; - ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`; - ics += timeLines; - ics += `SUMMARY:${esc(r.title)}\r\n`; + let ev = `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`; + ev += timeLines; + ev += `SUMMARY:${esc(r.title)}\r\n`; let desc = r.type ? `Type: ${r.type}` : ''; if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`; @@ -337,22 +365,36 @@ export class CalendarService { } if (meta.train_number) desc += `\nTrain: ${meta.train_number}`; if (r.notes) desc += `\n${r.notes}`; - if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; - if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`; - ics += `END:VEVENT\r\n`; + if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`; + if (r.location) ev += `LOCATION:${esc(r.location)}\r\n`; + ev += `END:VEVENT\r\n`; + events.push(ev); } - ics += 'END:VCALENDAR\r\n'; - - // Define every referenced zone with a VTIMEZONE, inserted before the first - // event so TZID references resolve. No-op when no timed event carried a zone. - if (usedZones.size > 0) { - let vtz = ''; - for (const [zone, yyyymmdd] of usedZones) vtz += buildVTimezone(zone, yyyymmdd); - ics = ics.replace('BEGIN:VEVENT', vtz + 'BEGIN:VEVENT'); - } + // Every referenced zone gets a VTIMEZONE. They are emitted before the first + // event so the TZID references resolve; keyed by TZID so a merged calendar + // can define each one once. + const timezones = new Map(); + for (const [zone, yyyymmdd] of usedZones) timezones.set(zone, buildVTimezone(zone, yyyymmdd)); const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_'); - return { ics: foldICS(ics), filename: `${safeFilename}.ics` }; + return { + calName: esc(trip.title || 'TREK Trip'), + filename: `${safeFilename}.ics`, + timezones, + events, + }; + } + + /** One trip's calendar as a finished, foldable VCALENDAR document. */ + exportICS(tripId: string | number): { ics: string; filename: string } { + const cal = this.buildTripCalendar(tripId); + const ics = + CALENDAR_HEADER + + `X-WR-CALNAME:${cal.calName}\r\n` + + [...cal.timezones.values()].join('') + + cal.events.join('') + + 'END:VCALENDAR\r\n'; + return { ics: foldICS(ics), filename: cal.filename }; } } diff --git a/server/src/nest/feeds/feeds.service.ts b/server/src/nest/feeds/feeds.service.ts index 297349989..93faf7d89 100644 --- a/server/src/nest/feeds/feeds.service.ts +++ b/server/src/nest/feeds/feeds.service.ts @@ -1,7 +1,10 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'crypto'; import { DatabaseService } from '../database/database.service'; -import { CalendarService } from '../calendar/calendar.service'; +import { CalendarService, CALENDAR_HEADER, foldICS } from '../calendar/calendar.service'; + +/** Subscribable calendars advertise how often to re-fetch; the one-time download does not. */ +const FEED_REFRESH_HINTS = 'REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n'; const ninetyDaysAgo = () => { const d = new Date(); @@ -95,15 +98,19 @@ export class FeedsService { | undefined; if (!row) return null; try { - const { ics, filename } = this.calendar.exportICS(row.id); - // Inject calendar-subscription refresh hints into the VCALENDAR header so - // clients re-fetch hourly. The one-time download path (exportICS) is left - // untouched; this is feed-only. - const withHints = ics.replace( - 'METHOD:PUBLISH\r\n', - 'METHOD:PUBLISH\r\nREFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n', + const cal = this.calendar.buildTripCalendar(row.id); + // Same document as the one-time download, plus the subscription refresh + // hints so clients re-fetch hourly. Assembled from the calendar's parts + // rather than string-surgeried into the finished text. + const ics = foldICS( + CALENDAR_HEADER + + FEED_REFRESH_HINTS + + `X-WR-CALNAME:${cal.calName}\r\n` + + [...cal.timezones.values()].join('') + + cal.events.join('') + + 'END:VCALENDAR\r\n', ); - return { ics: withHints, filename }; + return { ics, filename: cal.filename }; } catch { return null; } @@ -133,68 +140,32 @@ export class FeedsService { s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n'); const calName = `${user.username} – All Trips`; - let header = - 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n'; + let header = CALENDAR_HEADER; header += `X-WR-CALNAME:${esc(calName)}\r\n`; - header += 'REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n'; + header += FEED_REFRESH_HINTS; // VTIMEZONE blocks are deduped by TZID across all trips and emitted once in // the combined header, before any VEVENT, so per-trip TZID references still - // resolve after extractVEvents strips everything but the events (#1453). + // resolve (#1453). The parts come from the calendar itself now — this used + // to scan each finished document back apart line by line. const zones = new Map(); let events = ''; for (const { id } of trips) { try { - const { ics } = this.calendar.exportICS(id); - for (const vtz of extractVTimezones(ics)) { - const tzid = vtz.match(/\r\nTZID:(.+)\r\n/)?.[1]; - if (tzid && !zones.has(tzid)) zones.set(tzid, vtz); + const cal = this.calendar.buildTripCalendar(id); + for (const [tzid, block] of cal.timezones) { + if (!zones.has(tzid)) zones.set(tzid, block); } - events += extractVEvents(ics); + events += cal.events.join(''); } catch { // skip failed trips } } - const combined = header + [...zones.values()].join('') + events + 'END:VCALENDAR\r\n'; + // Only the body is folded. The header carries a user-chosen display name and + // has never been folded here; folding it now would wrap X-WR-CALNAME for + // anyone with a long username. + const combined = header + foldICS([...zones.values()].join('') + events) + 'END:VCALENDAR\r\n'; return { ics: combined, calName }; } } - -// Pull the VEVENT blocks out of a single-trip calendar by structural line -// scanning rather than a lazy regex on "END:VEVENT". User-supplied text (escaped -// onto a SUMMARY/DESCRIPTION line) can legitimately contain the literal -// "END:VEVENT", which a non-greedy regex would mistake for a terminator and -// truncate the event. Folded continuation lines always begin with a space, so a -// bare "BEGIN:VEVENT"/"END:VEVENT" only ever appears as a real delimiter. -function extractVEvents(ics: string): string { - let out = ''; - let inside = false; - for (const line of ics.split('\r\n')) { - if (line === 'BEGIN:VEVENT') inside = true; - if (inside) out += line + '\r\n'; - if (line === 'END:VEVENT') inside = false; - } - return out; -} - -// Pull out each VTIMEZONE block (same structural line scan as extractVEvents) so -// the combined all-trips feed can carry the zone definitions its events' TZID -// parameters reference. -function extractVTimezones(ics: string): string[] { - const blocks: string[] = []; - let current = ''; - let inside = false; - for (const line of ics.split('\r\n')) { - if (line === 'BEGIN:VTIMEZONE') { - inside = true; - current = ''; - } - if (inside) current += line + '\r\n'; - if (line === 'END:VTIMEZONE') { - inside = false; - blocks.push(current); - } - } - return blocks; -} diff --git a/server/tests/e2e/feeds.e2e.test.ts b/server/tests/e2e/feeds.e2e.test.ts index 508d20ba7..af2158a60 100644 --- a/server/tests/e2e/feeds.e2e.test.ts +++ b/server/tests/e2e/feeds.e2e.test.ts @@ -8,8 +8,9 @@ * valid token → 200 text/calendar with the injected REFRESH-INTERVAL / X-PUBLISHED-TTL * hints, unknown token → 404, all-trips feed excludes archived + >90-day-old trips * - * exportICS is mocked so the test owns the ICS payload and can assert which trips - * the all-trips feed pulled in without seeding the full trip/day/reservation schema. + * buildTripCalendar is mocked so the test owns the calendar parts and can assert + * which trips the all-trips feed pulled in without seeding the full + * trip/day/reservation schema. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; @@ -35,16 +36,19 @@ const { db } = vi.hoisted(() => { vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); -// Own the ICS payload so we control the events and can assert which trips were pulled. -const SAMPLE_ICS = - 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\n' + - 'METHOD:PUBLISH\r\nX-WR-CALNAME:Sample\r\n' + +// Own the calendar parts so we control the events and can assert which trips were pulled. +const SAMPLE_EVENT = 'BEGIN:VEVENT\r\nUID:trek-trip-x@trek\r\nDTSTAMP:20260101T000000Z\r\n' + - 'DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\nSUMMARY:Sample\r\nEND:VEVENT\r\n' + - 'END:VCALENDAR\r\n'; + 'DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\nSUMMARY:Sample\r\nEND:VEVENT\r\n'; +const sampleCalendar = () => ({ + calName: 'Sample', + filename: 'sample.ics', + timezones: new Map(), + events: [SAMPLE_EVENT], +}); // FeedsService injects CalendarService — the mock is a spy on the container // singleton (created in beforeAll, after build()). -const exportICS = vi.fn(); +const buildTripCalendar = vi.fn(); import { FeedsModule } from '../../src/nest/feeds/feeds.module'; import { CalendarService } from '../../src/nest/calendar/calendar.service'; @@ -73,13 +77,13 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { seedUser(db as never, { id: 1, username: 'e2e-user' }); seedUser(db as never, { id: 2, username: 'other-user', email: 'other@example.test' }); app = await build(); - vi.spyOn(app.get(CalendarService), 'exportICS').mockImplementation(exportICS as never); + vi.spyOn(app.get(CalendarService), 'buildTripCalendar').mockImplementation(buildTripCalendar as never); server = app.getHttpServer(); }); beforeEach(() => { - exportICS.mockReset(); - exportICS.mockReturnValue({ ics: SAMPLE_ICS, filename: 'sample.ics' }); + buildTripCalendar.mockReset(); + buildTripCalendar.mockImplementation(() => sampleCalendar()); // Reset feed tokens + trips between tests for isolation. db.exec('DELETE FROM trips; DELETE FROM trip_members; UPDATE users SET feed_token = NULL;'); db.prepare("INSERT INTO trips (id, user_id, title, is_archived, start_date, end_date) VALUES (5, 1, 'Owned', 0, '2026-01-01', '2099-01-01')").run(); @@ -166,7 +170,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { expect(res.text).toContain('REFRESH-INTERVAL;VALUE=DURATION:PT1H'); expect(res.text).toContain('X-PUBLISHED-TTL:PT1H'); expect(res.text).toContain('BEGIN:VEVENT'); - expect(exportICS).toHaveBeenCalledWith(5); + expect(buildTripCalendar).toHaveBeenCalledWith(5); }); it('public trip feed: 404 for an unknown token', async () => { @@ -197,7 +201,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { expect(res.text).toContain('REFRESH-INTERVAL;VALUE=DURATION:PT1H'); expect(res.text).toContain('X-WR-CALNAME:e2e-user'); - const calledIds = exportICS.mock.calls.map((c) => c[0]).sort(); + const calledIds = buildTripCalendar.mock.calls.map((c) => c[0]).sort(); expect(calledIds).toEqual([5]); // only the active, recent trip — not 6 (archived) or 7 (old) }); @@ -213,7 +217,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { const res = await request(server).get(`/api/feed/user/${token}.ics`); expect(res.status).toBe(200); - const calledIds = exportICS.mock.calls.map((c) => c[0]).sort(); + const calledIds = buildTripCalendar.mock.calls.map((c) => c[0]).sort(); expect(calledIds).toEqual([5, 8]); // owned trip 5 AND member trip 8 }); @@ -224,15 +228,18 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { it('all-trips feed carries VTIMEZONE blocks so TZID references resolve (#1453)', async () => { // A per-trip calendar whose event references a zone via TZID and defines it. - const ZONED_ICS = - 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\n' + - 'METHOD:PUBLISH\r\nX-WR-CALNAME:Zoned\r\n' + + const PARIS_VTIMEZONE = 'BEGIN:VTIMEZONE\r\nTZID:Europe/Paris\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\n' + - 'TZOFFSETFROM:+0100\r\nTZOFFSETTO:+0100\r\nTZNAME:Europe/Paris\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n' + - 'BEGIN:VEVENT\r\nUID:trek-res-1@trek\r\nDTSTAMP:20260101T000000Z\r\n' + - 'DTSTART;TZID=Europe/Paris:20260602T090000\r\nSUMMARY:Flight\r\nEND:VEVENT\r\n' + - 'END:VCALENDAR\r\n'; - exportICS.mockReturnValue({ ics: ZONED_ICS, filename: 'zoned.ics' }); + 'TZOFFSETFROM:+0100\r\nTZOFFSETTO:+0100\r\nTZNAME:Europe/Paris\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n'; + buildTripCalendar.mockImplementation(() => ({ + calName: 'Zoned', + filename: 'zoned.ics', + timezones: new Map([['Europe/Paris', PARIS_VTIMEZONE]]), + events: [ + 'BEGIN:VEVENT\r\nUID:trek-res-1@trek\r\nDTSTAMP:20260101T000000Z\r\n' + + 'DTSTART;TZID=Europe/Paris:20260602T090000\r\nSUMMARY:Flight\r\nEND:VEVENT\r\n', + ], + })); const gen = await request(server).post('/api/feed/user/token').set('Cookie', sessionCookie(1)); const token = gen.body.feed_url.match(/user\/([0-9a-f-]+)\.ics$/)![1]; diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 2012d2cdc..07486b4ad 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -379,6 +379,86 @@ describe('folded quirk branches', () => { }); +// The snapshot below was taken from the implementation as it stood when the code +// moved out of TripsService, before the calendar grew a structured model. It is +// the parity net for any change to how the calendar is assembled: the 20 cases +// above pin individual lines, this one pins the whole document including the +// order of the VTIMEZONE blocks and the folding. +describe('serialised output', () => { + it('CAL-010: a trip with a zoned assignment, a note day and a flight serialises byte for byte', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { + title: 'Golden Trip', + description: 'Line one', + start_date: '2026-05-01', + end_date: '2026-05-01', + }); + const day = createDay(testDb, trip.id, { date: '2026-05-01' }); + const place = createPlace(testDb, trip.id, { name: 'Tokyo Tower', address: 'Minato', lat: 35.6586, lng: 139.7454 }); + const assignment = createDayAssignment(testDb, day.id, place.id); + testDb.prepare('UPDATE day_assignments SET assignment_time=?, assignment_end_time=? WHERE id=?') + .run('09:00', '10:30', assignment.id); + createDayNote(testDb, day.id, trip.id, { text: 'Bring the tickets', time: '08:00' }); + createReservation(testDb, trip.id, { + title: 'NH 203', + type: 'flight', + reservation_time: '2026-05-01T06:15', + confirmation_number: 'ABC123', + location: 'HND', + }); + + // DTSTAMP is the wall clock and the UID numbers are autoincrement state shared + // with every case above, so both are normalised: this pins the document, not + // the minute it ran in or its position in the file. + const ics = svc.exportICS(trip.id).ics + .replace(/DTSTAMP:\d{8}T\d{6}Z/g, 'DTSTAMP:') + .replace(/UID:trek-([a-z]+)-\d+@trek/g, 'UID:trek-$1-@trek'); + + expect(ics).toMatchInlineSnapshot(` + "BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//TREK//Travel Planner//EN + CALSCALE:GREGORIAN + METHOD:PUBLISH + X-WR-CALNAME:Golden Trip + BEGIN:VTIMEZONE + TZID:Asia/Tokyo + BEGIN:STANDARD + DTSTART:19700101T000000 + TZOFFSETFROM:+0900 + TZOFFSETTO:+0900 + TZNAME:Asia/Tokyo + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + UID:trek-trip-@trek + DTSTAMP: + DTSTART;VALUE=DATE:20260501 + DTEND;VALUE=DATE:20260502 + SUMMARY:Golden Trip + DESCRIPTION:Line one + END:VEVENT + BEGIN:VEVENT + UID:trek-assign-@trek + DTSTAMP: + DTSTART;TZID=Asia/Tokyo:20260501T090000 + DTEND;TZID=Asia/Tokyo:20260501T103000 + SUMMARY:Tokyo Tower + END:VEVENT + BEGIN:VEVENT + UID:trek-day-@trek + DTSTAMP: + DTSTART;VALUE=DATE:20260501 + DTEND;VALUE=DATE:20260502 + SUMMARY:Day 2 + DESCRIPTION:Notes:\\n08:00 — Bring the tickets + END:VEVENT + END:VCALENDAR + " + `); + }); +}); + describe('CalendarService wiring', () => { it('CAL-001: the module registers the service, so injection cannot silently fail', () => { expectRegisteredProvider(CalendarModule, CalendarService); diff --git a/server/tests/unit/nest/health.di.test.ts b/server/tests/unit/nest/health.di.test.ts deleted file mode 100644 index 33aec89aa..000000000 --- a/server/tests/unit/nest/health.di.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Test } from '@nestjs/testing'; -import { HealthController } from '../../../src/nest/health/health.controller'; -import { HealthService } from '../../../src/nest/health/health.service'; -import { DatabaseService } from '../../../src/nest/database/database.service'; - -describe('Nest dependency injection (vitest + swc)', () => { - it('injects HealthService + DatabaseService into HealthController by type', async () => { - const moduleRef = await Test.createTestingModule({ - controllers: [HealthController], - providers: [ - HealthService, - { provide: DatabaseService, useValue: { get: () => ({ n: 7 }) } }, - ], - }).compile(); - - const controller = moduleRef.get(HealthController); - expect(controller.getHealth()).toEqual({ - ok: true, - runtime: 'nestjs', - diInjected: true, - userCount: 7, - }); - }); -}); diff --git a/server/tests/unit/nest/wiring.test.ts b/server/tests/unit/nest/wiring.test.ts index bfb97a4de..8cb519459 100644 --- a/server/tests/unit/nest/wiring.test.ts +++ b/server/tests/unit/nest/wiring.test.ts @@ -3,7 +3,7 @@ import { HttpException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { db } from '../../../src/db/database'; import { AppModule } from '../../../src/nest/app.module'; -import { HealthController } from '../../../src/nest/health/health.controller'; +import { FeaturesController } from '../../../src/nest/health/features.controller'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { AdminGuard } from '../../../src/nest/auth/admin.guard'; @@ -17,7 +17,9 @@ describe('AppModule wiring', () => { .overrideProvider(DatabaseService) .useValue({ get: () => ({ n: 0 }) }) .compile(); - expect(moduleRef.get(HealthController)).toBeInstanceOf(HealthController); + // The one test that builds the whole AppModule: resolving a controller out of + // it proves every module in the graph compiled, not just this one. + expect(moduleRef.get(FeaturesController)).toBeInstanceOf(FeaturesController); }); });