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.
This commit is contained in:
Maurice
2026-08-10 09:05:20 +02:00
committed by Maurice
parent 5a9ded6676
commit 7f5c537ad6
6 changed files with 219 additions and 142 deletions
+77 -35
View File
@@ -5,6 +5,22 @@ import { addDays } from '../days/days.service';
import { resolveTimeZone } from '../common/timezoneService'; import { resolveTimeZone } from '../common/timezoneService';
import { NotFoundError } from '../common/domain-errors'; 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<string, string>;
/** One complete BEGIN:VEVENT…END:VEVENT block each, unfolded, in emission order. */
events: string[];
}
// ── ICS folding ───────────────────────────────────────────────────────────── // ── ICS folding ─────────────────────────────────────────────────────────────
// RFC 5545 §3.1: content lines longer than 75 octets must be folded with a CRLF // 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, // 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 // emoji) stay intact. Applied to the whole calendar, so both the one-time
// download and the subscribable feed emit spec-compliant output. // 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 foldLine = (line: string): string => {
const bytes = Buffer.from(line, 'utf8'); const bytes = Buffer.from(line, 'utf8');
if (bytes.length <= 75) return line; if (bytes.length <= 75) return line;
@@ -117,7 +133,17 @@ export class CalendarService {
// ── ICS export ──────────────────────────────────────────────────────────── // ── 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; const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any;
if (!trip) throw new NotFoundError('Trip not found'); if (!trip) throw new NotFoundError('Trip not found');
@@ -177,17 +203,17 @@ export class CalendarService {
return `${prop}:${val}\r\n`; 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'; const events: string[] = [];
ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`;
// Trip as all-day event. DTEND is exclusive, so it must be the day *after* the last // 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 // day. addDays() stays in UTC — building a local-time Date here dropped the trip's
// last day on any server east of Greenwich (#1453). // last day on any server east of Greenwich (#1453).
if (trip.start_date && trip.end_date) { if (trip.start_date && trip.end_date) {
const endStr = fmtDate(addDays(trip.end_date, 1)); 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`; 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) ics += `DESCRIPTION:${esc(trip.description)}\r\n`; if (trip.description) ev += `DESCRIPTION:${esc(trip.description)}\r\n`;
ics += `END:VEVENT\r\n`; ev += `END:VEVENT\r\n`;
events.push(ev);
} }
// Days with assignments and notes // Days with assignments and notes
@@ -216,18 +242,19 @@ export class CalendarService {
// Timed assignments → individual events // Timed assignments → individual events
for (const a of timed) { for (const a of timed) {
const zone = resolveTimeZone(a.place_lat, a.place_lng); const zone = resolveTimeZone(a.place_lat, a.place_lng);
ics += `BEGIN:VEVENT\r\nUID:${uid(a.id, 'assign')}\r\nDTSTAMP:${now}\r\n`; let ev = `BEGIN:VEVENT\r\nUID:${uid(a.id, 'assign')}\r\nDTSTAMP:${now}\r\n`;
ics += dtLine('DTSTART', a.effective_time, zone, day.date + 'T00:00'); ev += dtLine('DTSTART', a.effective_time, zone, day.date + 'T00:00');
if (a.effective_end_time) { 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 = ''; let desc = '';
if (a.notes) desc += a.notes; if (a.notes) desc += a.notes;
if (a.place_address) desc += (desc ? '\n' : '') + a.place_address; if (a.place_address) desc += (desc ? '\n' : '') + a.place_address;
if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`;
if (a.place_address) ics += `LOCATION:${esc(a.place_address)}\r\n`; if (a.place_address) ev += `LOCATION:${esc(a.place_address)}\r\n`;
ics += `END:VEVENT\r\n`; ev += `END:VEVENT\r\n`;
events.push(ev);
} }
// Build all-day summary event if there are untimed activities or notes // 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 dayTitle = day.title || `Day ${day.day_number}`;
const endStr = fmtDate(addDays(day.date, 1)); const endStr = fmtDate(addDays(day.date, 1));
ics += `BEGIN:VEVENT\r\nUID:${uid(day.id, 'day')}\r\nDTSTAMP:${now}\r\n`; let ev = `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`; ev += `DTSTART;VALUE=DATE:${fmtDate(day.date)}\r\nDTEND;VALUE=DATE:${endStr}\r\n`;
ics += `SUMMARY:${esc(dayTitle)}\r\n`; ev += `SUMMARY:${esc(dayTitle)}\r\n`;
let desc = ''; let desc = '';
if (untimed.length > 0) { if (untimed.length > 0) {
@@ -255,8 +282,9 @@ export class CalendarService {
return line; return line;
}).join('\n'); }).join('\n');
} }
if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`;
ics += `END:VEVENT\r\n`; ev += `END:VEVENT\r\n`;
events.push(ev);
} }
} }
@@ -312,9 +340,9 @@ export class CalendarService {
if (!timeLines) continue; if (!timeLines) continue;
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; 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`; let ev = `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`;
ics += timeLines; ev += timeLines;
ics += `SUMMARY:${esc(r.title)}\r\n`; ev += `SUMMARY:${esc(r.title)}\r\n`;
let desc = r.type ? `Type: ${r.type}` : ''; let desc = r.type ? `Type: ${r.type}` : '';
if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`; 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 (meta.train_number) desc += `\nTrain: ${meta.train_number}`;
if (r.notes) desc += `\n${r.notes}`; if (r.notes) desc += `\n${r.notes}`;
if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`;
if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`; if (r.location) ev += `LOCATION:${esc(r.location)}\r\n`;
ics += `END:VEVENT\r\n`; ev += `END:VEVENT\r\n`;
events.push(ev);
} }
ics += 'END:VCALENDAR\r\n'; // 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
// Define every referenced zone with a VTIMEZONE, inserted before the first // can define each one once.
// event so TZID references resolve. No-op when no timed event carried a zone. const timezones = new Map<string, string>();
if (usedZones.size > 0) { for (const [zone, yyyymmdd] of usedZones) timezones.set(zone, buildVTimezone(zone, yyyymmdd));
let vtz = '';
for (const [zone, yyyymmdd] of usedZones) vtz += buildVTimezone(zone, yyyymmdd);
ics = ics.replace('BEGIN:VEVENT', vtz + 'BEGIN:VEVENT');
}
const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_'); 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 };
} }
} }
+28 -57
View File
@@ -1,7 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { DatabaseService } from '../database/database.service'; 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 ninetyDaysAgo = () => {
const d = new Date(); const d = new Date();
@@ -95,15 +98,19 @@ export class FeedsService {
| undefined; | undefined;
if (!row) return null; if (!row) return null;
try { try {
const { ics, filename } = this.calendar.exportICS(row.id); const cal = this.calendar.buildTripCalendar(row.id);
// Inject calendar-subscription refresh hints into the VCALENDAR header so // Same document as the one-time download, plus the subscription refresh
// clients re-fetch hourly. The one-time download path (exportICS) is left // hints so clients re-fetch hourly. Assembled from the calendar's parts
// untouched; this is feed-only. // rather than string-surgeried into the finished text.
const withHints = ics.replace( const ics = foldICS(
'METHOD:PUBLISH\r\n', CALENDAR_HEADER +
'METHOD:PUBLISH\r\nREFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n', 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 { } catch {
return null; return null;
} }
@@ -133,68 +140,32 @@ export class FeedsService {
s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n'); s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
const calName = `${user.username} – All Trips`; const calName = `${user.username} – All Trips`;
let header = let header = CALENDAR_HEADER;
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
header += `X-WR-CALNAME:${esc(calName)}\r\n`; 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 // 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 // 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<string, string>(); const zones = new Map<string, string>();
let events = ''; let events = '';
for (const { id } of trips) { for (const { id } of trips) {
try { try {
const { ics } = this.calendar.exportICS(id); const cal = this.calendar.buildTripCalendar(id);
for (const vtz of extractVTimezones(ics)) { for (const [tzid, block] of cal.timezones) {
const tzid = vtz.match(/\r\nTZID:(.+)\r\n/)?.[1]; if (!zones.has(tzid)) zones.set(tzid, block);
if (tzid && !zones.has(tzid)) zones.set(tzid, vtz);
} }
events += extractVEvents(ics); events += cal.events.join('');
} catch { } catch {
// skip failed trips // 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 }; 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;
}
+30 -23
View File
@@ -8,8 +8,9 @@
* valid token → 200 text/calendar with the injected REFRESH-INTERVAL / X-PUBLISHED-TTL * 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 * 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 * buildTripCalendar is mocked so the test owns the calendar parts and can assert
* the all-trips feed pulled in without seeding the full trip/day/reservation schema. * 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 { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest'; import request from 'supertest';
@@ -35,16 +36,19 @@ const { db } = vi.hoisted(() => {
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
// Own the ICS payload so we control the events and can assert which trips were pulled. // Own the calendar parts so we control the events and can assert which trips were pulled.
const SAMPLE_ICS = const SAMPLE_EVENT =
'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' +
'BEGIN:VEVENT\r\nUID:trek-trip-x@trek\r\nDTSTAMP:20260101T000000Z\r\n' + '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' + 'DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\nSUMMARY:Sample\r\nEND:VEVENT\r\n';
'END:VCALENDAR\r\n'; const sampleCalendar = () => ({
calName: 'Sample',
filename: 'sample.ics',
timezones: new Map<string, string>(),
events: [SAMPLE_EVENT],
});
// FeedsService injects CalendarService — the mock is a spy on the container // FeedsService injects CalendarService — the mock is a spy on the container
// singleton (created in beforeAll, after build()). // singleton (created in beforeAll, after build()).
const exportICS = vi.fn(); const buildTripCalendar = vi.fn();
import { FeedsModule } from '../../src/nest/feeds/feeds.module'; import { FeedsModule } from '../../src/nest/feeds/feeds.module';
import { CalendarService } from '../../src/nest/calendar/calendar.service'; 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: 1, username: 'e2e-user' });
seedUser(db as never, { id: 2, username: 'other-user', email: 'other@example.test' }); seedUser(db as never, { id: 2, username: 'other-user', email: 'other@example.test' });
app = await build(); 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(); server = app.getHttpServer();
}); });
beforeEach(() => { beforeEach(() => {
exportICS.mockReset(); buildTripCalendar.mockReset();
exportICS.mockReturnValue({ ics: SAMPLE_ICS, filename: 'sample.ics' }); buildTripCalendar.mockImplementation(() => sampleCalendar());
// Reset feed tokens + trips between tests for isolation. // Reset feed tokens + trips between tests for isolation.
db.exec('DELETE FROM trips; DELETE FROM trip_members; UPDATE users SET feed_token = NULL;'); 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(); 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('REFRESH-INTERVAL;VALUE=DURATION:PT1H');
expect(res.text).toContain('X-PUBLISHED-TTL:PT1H'); expect(res.text).toContain('X-PUBLISHED-TTL:PT1H');
expect(res.text).toContain('BEGIN:VEVENT'); expect(res.text).toContain('BEGIN:VEVENT');
expect(exportICS).toHaveBeenCalledWith(5); expect(buildTripCalendar).toHaveBeenCalledWith(5);
}); });
it('public trip feed: 404 for an unknown token', async () => { 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('REFRESH-INTERVAL;VALUE=DURATION:PT1H');
expect(res.text).toContain('X-WR-CALNAME:e2e-user'); 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) 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`); const res = await request(server).get(`/api/feed/user/${token}.ics`);
expect(res.status).toBe(200); 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 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 () => { 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. // A per-trip calendar whose event references a zone via TZID and defines it.
const ZONED_ICS = const PARIS_VTIMEZONE =
'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' +
'BEGIN:VTIMEZONE\r\nTZID:Europe/Paris\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\n' + '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' + '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' + buildTripCalendar.mockImplementation(() => ({
'DTSTART;TZID=Europe/Paris:20260602T090000\r\nSUMMARY:Flight\r\nEND:VEVENT\r\n' + calName: 'Zoned',
'END:VCALENDAR\r\n'; filename: 'zoned.ics',
exportICS.mockReturnValue({ ics: ZONED_ICS, 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 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]; const token = gen.body.feed_url.match(/user\/([0-9a-f-]+)\.ics$/)![1];
@@ -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:<stamp>')
.replace(/UID:trek-([a-z]+)-\d+@trek/g, 'UID:trek-$1-<n>@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-<n>@trek
DTSTAMP:<stamp>
DTSTART;VALUE=DATE:20260501
DTEND;VALUE=DATE:20260502
SUMMARY:Golden Trip
DESCRIPTION:Line one
END:VEVENT
BEGIN:VEVENT
UID:trek-assign-<n>@trek
DTSTAMP:<stamp>
DTSTART;TZID=Asia/Tokyo:20260501T090000
DTEND;TZID=Asia/Tokyo:20260501T103000
SUMMARY:Tokyo Tower
END:VEVENT
BEGIN:VEVENT
UID:trek-day-<n>@trek
DTSTAMP:<stamp>
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', () => { describe('CalendarService wiring', () => {
it('CAL-001: the module registers the service, so injection cannot silently fail', () => { it('CAL-001: the module registers the service, so injection cannot silently fail', () => {
expectRegisteredProvider(CalendarModule, CalendarService); expectRegisteredProvider(CalendarModule, CalendarService);
-25
View File
@@ -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,
});
});
});
+4 -2
View File
@@ -3,7 +3,7 @@ import { HttpException } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { db } from '../../../src/db/database'; import { db } from '../../../src/db/database';
import { AppModule } from '../../../src/nest/app.module'; 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 { DatabaseService } from '../../../src/nest/database/database.service';
import { AdminGuard } from '../../../src/nest/auth/admin.guard'; import { AdminGuard } from '../../../src/nest/auth/admin.guard';
@@ -17,7 +17,9 @@ describe('AppModule wiring', () => {
.overrideProvider(DatabaseService) .overrideProvider(DatabaseService)
.useValue({ get: () => ({ n: 0 }) }) .useValue({ get: () => ({ n: 0 }) })
.compile(); .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);
}); });
}); });