mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user