Files
TREK/server/tests/unit/nest/calendar.service.test.ts
T
Maurice e3471e2dc8 fix(reservations): a night booked on the form belongs on the drive too
The booking form wrote the stay row itself, so it was the one door that left the
day stop out. A hotel entered there stayed invisible to road trip mode, which is
the same complaint as before with a different way in: enter the place twice or it
is not on the route.

It writes the stop now, through AccommodationsService rather than a second copy of
its SQL. The permission argument for not doing so does not hold: the night already
shows in the day header the moment the booking is saved, and the stop is that same
night in the other view, not a second edit to the plan.

Two holes go with it. Moving the booking left the stop on a day nobody sleeps there
any more, and deleting it left the stop behind entirely — in both cases still
carrying the booking's id, which is what the day list hides on, so nobody could
reach it to remove it. The stop now follows the stay and goes with it, and only
once the trip_id guard has confirmed the stay was this trip's to delete.
2026-09-13 12:38:21 +02:00

1719 lines
84 KiB
TypeScript

/**
* Unit tests for CalendarService. The cases moved 1:1 out of
* tests/unit/nest/trips.service.test.ts together with the code they cover
* (TRIP-SVC-001..002b, 020..027 and 048); the identifiers are unchanged so the
* diff shows a move rather than a rewrite, and the assertions still pin the
* emitted ICS byte for byte. Uses a real in-memory SQLite DB so the SQL runs.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { ReservationsReadRepository } from '../../../src/nest/reservations/reservations-read.repository';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import { CalendarService, foldICS } from '../../../src/nest/calendar/calendar.service';
import { CalendarModule } from '../../../src/nest/calendar/calendar.module';
import { expectRegisteredProvider } from '../../helpers/module-providers';
import { notificationsStub } from '../../helpers/notifications';
import { accommodationsOver } from '../../helpers/accommodations-service';
const dbs = () => new DatabaseService(testDb);
const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
// Named `svc` so the moved cases below read exactly as they did on TripsService.
const svc = new CalendarService(
dbs(),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsOver(dbs())),
);
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
describe('exportICS', () => {
it('TRIP-SVC-001: returns VCALENDAR wrapper', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'My Vacation',
start_date: '2025-06-01',
end_date: '2025-06-07',
});
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('BEGIN:VCALENDAR');
expect(ics).toContain('END:VCALENDAR');
});
it('TRIP-SVC-002: trip with start_date + end_date includes all-day VEVENT', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'Summer Holiday',
start_date: '2025-06-01',
end_date: '2025-06-07',
});
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20250601');
// DTEND is exclusive — the day *after* the last day, or the trip loses a day.
expect(ics).toContain('DTEND;VALUE=DATE:20250608');
expect(ics).toContain('SUMMARY:Summer Holiday');
});
describe('#1453 all-day DTEND is timezone-independent', () => {
const originalTz = process.env.TZ;
afterAll(() => {
process.env.TZ = originalTz;
});
// The old code did `new Date(date + 'T00:00:00')` — no Z, so parsed as *server-local*
// midnight — then setDate(+1) and .toISOString(). East of Greenwich that round-trip
// lands a day early, and since DTEND is exclusive the trip's last day was dropped.
// Only invisible in CI because containers default to TZ=UTC.
for (const tz of ['Europe/Berlin', 'Asia/Tokyo', 'Pacific/Kiritimati', 'America/New_York', 'UTC']) {
it(`TRIP-SVC-002b: DTEND is the day after the last day under TZ=${tz}`, () => {
process.env.TZ = tz;
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'TZ Trip',
start_date: '2026-03-28',
end_date: '2026-03-30',
});
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260328');
expect(ics).toContain('DTEND;VALUE=DATE:20260331');
});
}
it('TRIP-SVC-002c: a per-day all-day summary event has the same exclusive DTEND', () => {
process.env.TZ = 'Asia/Tokyo';
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Day Note Trip' });
const day = createDay(testDb, trip.id, { date: '2026-03-30', day_number: 1 });
createDayNote(testDb, day.id, trip.id, { text: 'Pack the bags' });
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260330');
expect(ics).toContain('DTEND;VALUE=DATE:20260331');
});
});
it('TRIP-SVC-003: reservation with full datetime (includes T) → DTSTART without VALUE=DATE', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Morning Flight',
type: 'flight',
});
testDb
.prepare('UPDATE reservations SET reservation_time=? WHERE id=?')
.run('2025-06-02T09:00', reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART:20250602T090000');
expect(ics).not.toContain('DTSTART;VALUE=DATE');
});
it('TRIP-SVC-004: reservation with date-only → DTSTART;VALUE=DATE', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Hotel Check-in',
type: 'hotel',
});
testDb
.prepare('UPDATE reservations SET reservation_time=? WHERE id=?')
.run('2025-06-02', reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20250602');
});
it('TRIP-SVC-005: reservation metadata with flight info appears in DESCRIPTION', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'CDG to JFK',
type: 'flight',
});
testDb
.prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?')
.run(
'2025-06-02T09:00',
JSON.stringify({
airline: 'Air Test',
flight_number: 'AT100',
departure_airport: 'CDG',
arrival_airport: 'JFK',
}),
reservation.id
);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('Airline: Air Test');
expect(ics).toContain('Flight: AT100');
});
it('TRIP-SVC-006: special characters in title are escaped', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip; First, Best' });
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('Trip\\; First\\, Best');
});
it('TRIP-SVC-007: throws NotFoundError for non-existent trip', () => {
expect(() => svc.exportICS(99999)).toThrow();
});
it('TRIP-SVC-008: returns a filename derived from trip title', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'My Trip 2025' });
const { filename } = svc.exportICS(trip.id);
expect(filename).toMatch(/My.Trip.2025\.ics/);
});
// JS \s admitted U+3000 (and \v/\f) into the filename, and Node's header
// validation then threw ERR_INVALID_CHAR on the export route (#2165).
it('TRIP-SVC-008b: ideographic whitespace in the title folds to _ in the filename', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: '沖縄 4泊5日' });
expect(svc.exportICS(trip.id).filename).toBe('___4_5_.ics');
});
it('TRIP-SVC-008c: vertical tab and form feed fold too', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'A\vB\fC' });
expect(svc.exportICS(trip.id).filename).toBe('A_B_C.ics');
});
it('TRIP-SVC-009: reservation with end time includes DTEND', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Afternoon Tour',
type: 'activity',
});
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2025-06-02T14:00', '2025-06-02T16:00', reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTEND:20250602T160000');
});
it('TRIP-SVC-024: flight with endpoint times but no reservation_time is included', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'CDG → JFK',
type: 'flight',
});
// Confirmed flights store times per endpoint, never as reservation_time.
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id);
const insertEp = testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02');
insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:CDG → JFK');
// Departure endpoint zone drives DTSTART, arrival zone drives DTEND, so the
// subscriber sees TREK's zones instead of their own (#1453).
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000');
expect(ics).toContain('DTEND;TZID=America/New_York:20250602T120000');
expect(ics).not.toContain('DTSTART:20250602T090000');
// Each referenced zone gets a VTIMEZONE definition.
expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris');
expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:America/New_York');
expect(ics).toContain('Route: CDG → JFK');
});
it('TRIP-SVC-024a: a flight that also carries reservation_time still uses per-side endpoint zones', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'CDG → JFK',
type: 'flight',
});
// TransportModal stamps reservation_time/_end_time (departure/arrival) alongside
// the endpoints. That branch's only zone is the linked place — a flight has none —
// so it floated both ends in the subscribed feed; endpoints must win (#1453).
testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2025-06-02T09:00', '2025-06-02T12:00', reservation.id);
const insertEp = testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02');
insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000');
expect(ics).toContain('DTEND;TZID=America/New_York:20250602T120000');
// The bug was a floating DTSTART/DTEND (no TZID) from the reservation_time branch.
expect(ics).not.toContain('DTSTART:20250602T090000');
expect(ics).not.toContain('DTEND:20250602T120000');
});
it('TRIP-SVC-024c: a one-sided transport keeps the multi-day DTEND from reservation_end_time', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rental Trip' });
const reservation = createReservation(testDb, trip.id, { title: 'Rental car', type: 'car_rental' });
// An imported rental geocodes the pickup only, so there is no second endpoint
// to carry the return. Before the endpoint branch took precedence, the return
// came from reservation_end_time; it still has to.
testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2025-06-02T10:00', '2025-06-09T10:00', reservation.id);
testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(reservation.id, 'from', 0, 'Berlin', null, 52.5, 13.4, 'Europe/Berlin', '10:00', '2025-06-02');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20250602T100000');
expect(ics).toContain('DTEND;TZID=Europe/Berlin:20250609T100000');
});
it('TRIP-SVC-024d: an untimed arrival endpoint still takes its DTEND from reservation_end_time', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Train Trip' });
const reservation = createReservation(testDb, trip.id, { title: 'ICE 1234', type: 'train' });
testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2025-06-02T08:00', '2025-06-02T14:30', reservation.id);
const insertEp = testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
insertEp.run(reservation.id, 'from', 0, 'Berlin Hbf', null, 52.5, 13.4, 'Europe/Berlin', '08:00', '2025-06-02');
// The destination was added without a clock — the arrival time only exists
// on the reservation itself.
insertEp.run(reservation.id, 'to', 1, 'Wien Hbf', null, 48.2, 16.4, 'Europe/Vienna', null, '2025-06-02');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20250602T080000');
expect(ics).toContain('DTEND;TZID=Europe/Berlin:20250602T143000');
});
it('TRIP-SVC-024e: a lone timed endpoint without reservation_end_time stays a DTSTART', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Ferry Trip' });
const reservation = createReservation(testDb, trip.id, { title: 'Ferry', type: 'ferry' });
testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=NULL WHERE id=?')
.run('2025-06-02T07:00', reservation.id);
testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(reservation.id, 'from', 0, 'Dover', null, 51.1, 1.3, 'Europe/London', '07:00', '2025-06-02');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;TZID=Europe/London:20250602T070000');
// Nothing to end it with, so no DTEND may be invented (the trip's own all-day
// DTEND is VALUE=DATE, hence the narrower match).
expect(ics).not.toContain('DTEND;TZID=');
});
it('TRIP-SVC-024b: an invalid endpoint timezone degrades to floating time instead of crashing the export', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Bad TZ Trip' });
const reservation = createReservation(testDb, trip.id, { title: 'CDG → JFK', type: 'flight' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id);
const insertEp = testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
// A stored/plugin-written timezone can be any string; it must never reach Intl.
// The bogus zone takes precedence over the coordinates (first.timezone || resolveZone).
insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Not/AZone', '09:00', '2025-06-02');
insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'garbage', '12:00', '2025-06-02');
let ics = '';
expect(() => { ics = svc.exportICS(trip.id).ics; }).not.toThrow();
// Falls back to a floating local time (no TZID) and never emits a bogus VTIMEZONE.
expect(ics).toContain('DTSTART:20250602T090000');
expect(ics).not.toContain('TZID=Not/AZone');
expect(ics).not.toContain('garbage');
});
it('TRIP-SVC-025: flight endpoint with no local_date is skipped (relative Day-N trips)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Relative Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Timeless Flight',
type: 'flight',
});
testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(reservation.id);
testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(reservation.id, 'from', 0, 'Origin', 'AAA', 1.0, 1.0, null, '09:00', null);
const { ics } = svc.exportICS(trip.id);
expect(ics).not.toContain('SUMMARY:Timeless Flight');
});
it('TRIP-SVC-026: timed assignment gets a TZID derived from the place coordinates', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip' });
const day = createDay(testDb, trip.id, { date: '2025-06-02' });
// Tokyo coordinates → Asia/Tokyo via tz-lookup.
const place = createPlace(testDb, trip.id, { name: 'Senso-ji', lat: 35.7148, lng: 139.7967 });
const assignment = createDayAssignment(testDb, day.id, place.id);
testDb
.prepare('UPDATE day_assignments SET assignment_time=? WHERE id=?')
.run('09:00', assignment.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;TZID=Asia/Tokyo:20250602T090000');
expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Asia/Tokyo');
expect(ics).not.toContain('DTSTART:20250602T090000');
});
it('CAL-011: a reservation_time with seconds is kept as is and a date-only end time emits no DTEND', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Second Precision' });
const reservation = createReservation(testDb, trip.id, { title: 'Guided Walk', type: 'activity' });
// Importers write both shapes: "…T14:00" (padded to seconds) and "…T14:00:00"
// (already 15 chars). Padding the second one again would produce a 17-char
// value that no client parses.
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2025-06-02T14:00:00', '2025-06-03', reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART:20250602T140000');
// The end value carries no clock time, so it cannot be a DTEND for a timed
// event; emitting the bare "20250603" would make the event span a nonsense
// range instead of being left open.
expect(ics).not.toContain('DTEND');
});
it('CAL-012: reservations with no placeable time are dropped instead of emitting a broken VEVENT', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Timeless' });
// Relative "Day N" trips store a clock time without a date; there is nothing
// to anchor it to, so the reservation must not reach the calendar at all.
const timeOnly = createReservation(testDb, trip.id, { title: 'Floating Dinner', type: 'restaurant' });
testDb.prepare('UPDATE reservations SET reservation_time=? WHERE id=?').run('19:30', timeOnly.id);
// A transport row whose endpoints were never imported has no fallback time either.
const noEndpoints = createReservation(testDb, trip.id, { title: 'Endpointless Train', type: 'transport' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(noEndpoints.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).not.toContain('SUMMARY:Floating Dinner');
expect(ics).not.toContain('SUMMARY:Endpointless Train');
// The trip itself has no dates and no days, so dropping both reservations
// leaves an event-free calendar rather than a partially filled one.
expect(ics).not.toContain('BEGIN:VEVENT');
});
it('CAL-013: endpoints without a stored timezone fall back to their coordinates', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'No Stored Zone' });
const reservation = createReservation(testDb, trip.id, { title: 'CDG to JFK', type: 'flight' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(reservation.id);
const insertEp = testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
// Endpoints created before the importer learned to store IANA zones have only
// coordinates. Without the lookup fallback both ends would go floating and the
// subscriber would see the flight in their own zone (#1453).
insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, null, '09:00', '2025-06-02');
insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, null, '12:00', '2025-06-02');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000');
expect(ics).toContain('DTEND;TZID=America/New_York:20250602T120000');
});
it('CAL-014: a single-endpoint transport emits DTSTART only', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'One Way' });
const reservation = createReservation(testDb, trip.id, { title: 'Airport Transfer', type: 'transport' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(reservation.id);
// Only the departure was imported. Using it for DTEND as well would emit a
// zero-length event; leaving DTEND out lets clients apply their default duration.
testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000');
expect(ics).not.toContain('DTEND');
});
it('CAL-015: a timed assignment carries its notes and address into DESCRIPTION and LOCATION', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Guided Day' });
const day = createDay(testDb, trip.id, { date: '2025-06-02' });
const place = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 });
testDb.prepare("UPDATE places SET address = 'Rue de Rivoli' WHERE id = ?").run(place.id);
const withNotes = createDayAssignment(testDb, day.id, place.id, { notes: 'meet the guide' });
testDb.prepare('UPDATE day_assignments SET assignment_time=? WHERE id=?').run('09:00', withNotes.id);
const withoutNotes = createDayAssignment(testDb, day.id, place.id);
testDb.prepare('UPDATE day_assignments SET assignment_time=? WHERE id=?').run('11:00', withoutNotes.id);
const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
// Notes come first and the address is appended on its own line; dropping the
// separator would glue them into one unreadable run.
expect(ics).toContain('DESCRIPTION:meet the guide\\nRue de Rivoli');
// Without notes the address must start the description rather than a stray newline.
expect(ics).toContain('DESCRIPTION:Rue de Rivoli\r\n');
expect(ics).toContain('LOCATION:Rue de Rivoli');
});
it('CAL-016: a day without a date is skipped and a titled day uses its own title', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Mixed Days' });
// Days of a relative "Day N" trip have no date; they cannot be placed on a
// calendar, and emitting them produced a VEVENT with an empty DTSTART.
const ghost = createDay(testDb, trip.id, { title: 'Ghost Day' });
createDayNote(testDb, ghost.id, trip.id, { text: 'never exported' });
const named = createDay(testDb, trip.id, { date: '2025-06-02', title: 'Museum Day' });
const place = createPlace(testDb, trip.id, { name: 'Bare Spot' });
createDayAssignment(testDb, named.id, place.id);
const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
expect(ics).not.toContain('Ghost Day');
expect(ics).not.toContain('never exported');
// A day with a title must not be renamed to "Day <n>".
expect(ics).toContain('SUMMARY:Museum Day');
// No address and no notes → the bullet stays bare, with no empty "()" or dash.
expect(ics).toContain('DESCRIPTION:• Bare Spot\r\n');
});
it('CAL-017: a reservation with no type, metadata or notes emits no empty DESCRIPTION', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Bare' });
const reservation = createReservation(testDb, trip.id, { title: 'Bare Booking', type: 'hotel' });
// Rows imported before `type` became mandatory still exist in prod. An empty
// DESCRIPTION line is invalid enough for some clients to reject the file.
testDb
.prepare('UPDATE reservations SET type=NULL, reservation_time=? WHERE id=?')
.run('2025-06-02', reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Bare Booking');
expect(ics).not.toContain('DESCRIPTION');
});
it('CAL-018: flight metadata with only one airport emits only that side of the route', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Half Routes' });
const inbound = createReservation(testDb, trip.id, { title: 'Inbound', type: 'flight' });
const outbound = createReservation(testDb, trip.id, { title: 'Outbound', type: 'flight' });
const setMeta = testDb.prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?');
// A hand-entered flight often has one airport only; the missing side must be
// left out instead of printing "To: undefined".
setMeta.run('2025-06-02T09:00', JSON.stringify({ arrival_airport: 'JFK' }), inbound.id);
setMeta.run('2025-06-09T09:00', JSON.stringify({ departure_airport: 'CDG' }), outbound.id);
const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
expect(ics).toContain('DESCRIPTION:Type: flight\\nTo: JFK\r\n');
expect(ics).toContain('DESCRIPTION:Type: flight\\nFrom: CDG\r\n');
});
it('CAL-019: legs and endpoints without names produce no Route line', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Nameless' });
const legs = createReservation(testDb, trip.id, { title: 'Codeless Legs', type: 'flight' });
// Multi-leg metadata whose legs carry no airports would otherwise render
// "Route: " with nothing after it.
testDb.prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?')
.run('2025-06-02T09:00', JSON.stringify({ legs: [{}, {}] }), legs.id);
const ferry = createReservation(testDb, trip.id, { title: 'Nameless Ferry', type: 'transport' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(ferry.id);
const insertEp = testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
// Same for endpoints with neither a code nor a name: the derived route would
// collapse to a single arrow between two blanks.
insertEp.run(ferry.id, 'from', 0, '', null, 1.0, 1.0, null, null, '2025-06-03');
insertEp.run(ferry.id, 'to', 1, '', null, 1.1, 1.1, null, null, '2025-06-03');
const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
expect(ics).toContain('SUMMARY:Codeless Legs');
expect(ics).toContain('SUMMARY:Nameless Ferry');
expect(ics).not.toContain('Route:');
});
it('CAL-043: a segment with its own booking reference gets its own Confirmation line (#1943)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Layover' });
const flight = createReservation(testDb, trip.id, { title: 'FRA to HND', type: 'flight' });
testDb.prepare('UPDATE reservations SET reservation_time=?, confirmation_number=?, metadata=? WHERE id=?').run(
'2025-06-02T09:00', 'BOOK1',
JSON.stringify({ legs: [
{ from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
{ from: 'BER', to: 'HND', confirmation_number: 'XYZ789' },
] }),
flight.id,
);
const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
expect(ics).toContain(
'DESCRIPTION:Type: flight\\nConfirmation: BOOK1\\nRoute: FRA → BER → HND'
+ '\\nConfirmation FRA-BER: ABC123\\nConfirmation BER-HND: XYZ789\r\n'
);
});
it('CAL-044: a multi-leg flight without per-segment references reads exactly as before', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Layover' });
const flight = createReservation(testDb, trip.id, { title: 'FRA to HND', type: 'flight' });
testDb.prepare('UPDATE reservations SET reservation_time=?, confirmation_number=?, metadata=? WHERE id=?').run(
'2025-06-02T09:00', 'BOOK1',
JSON.stringify({ legs: [{ from: 'FRA', to: 'BER' }, { from: 'BER', to: 'HND' }] }),
flight.id,
);
const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
// Byte parity for every feed that exists today: the description ends after
// the route line, so no subscriber sees a changed event.
expect(ics).toContain('DESCRIPTION:Type: flight\\nConfirmation: BOOK1\\nRoute: FRA → BER → HND\r\n');
});
it('CAL-020: an empty trip title falls back for SUMMARY, X-WR-CALNAME and the filename', () => {
const { user } = createUser(testDb);
// The title is only NOT NULL, not non-empty; an empty one used to produce
// "SUMMARY:" and a filename of ".ics".
const trip = createTrip(testDb, user.id, { title: '', start_date: '2025-06-01', end_date: '2025-06-02' });
const { ics, filename } = svc.exportICS(trip.id);
expect(ics).toContain('X-WR-CALNAME:TREK Trip');
expect(ics).toContain('SUMMARY:Trip');
expect(filename).toBe('trek-trip.ics');
});
it('CAL-021: a corrupt day date degrades the VTIMEZONE offset instead of throwing', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Corrupt Date' });
// days.date is free-form TEXT; a legacy/imported row can hold a date that no
// parser accepts. The representative date reaches Intl through the VTIMEZONE
// fallback, and Intl throws a RangeError on an invalid Date — which would take
// the whole export (and the trip's slot in the all-trips feed) down with it.
const day = createDay(testDb, trip.id, { date: '2025-13-45' });
const place = createPlace(testDb, trip.id, { name: 'Senso-ji', lat: 35.7148, lng: 139.7967 });
const assignment = createDayAssignment(testDb, day.id, place.id);
testDb.prepare('UPDATE day_assignments SET assignment_time=? WHERE id=?').run('09:00', assignment.id);
let ics = '';
expect(() => { ics = svc.exportICS(trip.id).ics; }).not.toThrow();
expect(ics).toContain('DTSTART;TZID=Asia/Tokyo:20251345T090000');
expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Asia/Tokyo');
expect(ics).toContain('TZOFFSETFROM:+0000');
expect(ics).toContain('TZOFFSETTO:+0000');
});
it('CAL-022: the timezone cache stays correct after it hits its 1000-entry bound', () => {
const { user } = createUser(testDb);
const bulkTrip = createTrip(testDb, user.id, { title: 'Cache Filler' });
const realTrip = createTrip(testDb, user.id, { title: 'After The Bound' });
const insertRes = testDb.prepare('INSERT INTO reservations (trip_id, title, type, reservation_time) VALUES (?, ?, ?, NULL)');
const insertEp = testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
// Endpoint timezones are free-form strings written by importers and plugins, so
// the validity cache is keyed by attacker-ish input and has to be bounded. Push
// it past the bound in one export.
testDb.transaction(() => {
for (let i = 0; i < 1010; i++) {
const resId = insertRes.run(bulkTrip.id, `Bulk ${i}`, 'transport').lastInsertRowid as number;
insertEp.run(resId, 'from', 0, `Stop ${i}`, null, 49.0, 2.5, `Bogus/Zone-${i}`, '09:00', '2025-06-02');
}
})();
const bulk = svc.exportICS(bulkTrip.id).ics;
// Every one of them has to reach the zone check — otherwise the bound is never
// approached and this case would pass without exercising anything.
expect(bulk).toContain('SUMMARY:Bulk 0');
expect(bulk).toContain('SUMMARY:Bulk 1009');
expect(bulk).toContain('DTSTART:20250602T090000');
expect(bulk).not.toContain('TZID=Bogus/Zone-0:');
const reservation = createReservation(testDb, realTrip.id, { title: 'CDG to JFK', type: 'flight' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(reservation.id);
insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02');
// Clearing the cache must make later zones be re-checked. A regression that
// evicted by writing `false` instead would silently strip the TZID from every
// export after the thousandth distinct zone string.
const { ics } = svc.exportICS(realTrip.id);
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000');
expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris');
});
it('CAL-032: an all-day booking with an end date spans the whole range', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Festival Trip' });
const reservation = createReservation(testDb, trip.id, { title: 'Music Festival', type: 'event' });
// A date-only start/end pair is what the booking import writes for an all-day
// multi-day event. Without a DTEND the RFC reads the event as a single day, so
// the booking collapsed onto its first day (#1869).
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2026-08-26', '2026-08-30', reservation.id);
const { ics } = svc.exportICS(trip.id);
// DTEND is exclusive, so the day after the last day of the booking.
expect(ics).toContain('DTSTART;VALUE=DATE:20260826\r\nDTEND;VALUE=DATE:20260831');
});
it('CAL-033: a timed end time on an all-day booking still yields a VALUE=DATE DTEND', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Mixed Precision' });
const reservation = createReservation(testDb, trip.id, { title: 'Cottage', type: 'other' });
// Mixing DTSTART;VALUE=DATE with a date-time DTEND violates RFC 5545 §3.8.2.2
// (both ends must share a value type), so only the date part may be used.
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2026-08-26', '2026-08-30T11:00', reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260826\r\nDTEND;VALUE=DATE:20260831');
expect(ics).not.toContain('DTEND:20260830T110000');
});
it('CAL-034: an end date before the start date emits no DTEND', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Backwards' });
const reservation = createReservation(testDb, trip.id, { title: 'Swapped Dates', type: 'event' });
// Clients either drop an event whose DTEND precedes its DTSTART or render it
// with zero duration; leaving the end out keeps the single-day fallback.
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2026-08-26', '2026-08-20', reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260826');
expect(ics).not.toContain('DTEND');
});
it('CAL-035: a clock-only end time on an all-day booking emits no DTEND', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Half Timed' });
const reservation = createReservation(testDb, trip.id, { title: 'Workshop', type: 'other' });
// Relative "Day N" trips store a bare clock time; there is no date to anchor it
// to, so it cannot become the end of an all-day range.
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2026-08-26', '11:00', reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260826');
expect(ics).not.toContain('DTEND');
});
});
// ── Accommodations in the feed (#1586) ───────────────────────────────────────
describe('accommodations', () => {
/** A stay with its linked hotel reservation, the shape createAccommodation writes. */
const createStay = (
tripId: number,
opts: {
start: string | null;
end?: string | null;
check_in?: string | null;
check_in_end?: string | null;
check_out?: string | null;
title?: string;
withReservation?: boolean;
lat?: number;
lng?: number;
address?: string | null;
confirmation?: string;
notes?: string;
location?: string;
},
) => {
const place = createPlace(testDb, tripId, {
name: opts.title ?? 'Hotel Bellevue',
lat: opts.lat ?? 48.8566,
lng: opts.lng ?? 2.3522,
});
testDb.prepare('UPDATE places SET address = ? WHERE id = ?')
.run(opts.address === undefined ? '1 Rue de Rivoli' : opts.address, place.id);
const startDay = createDay(testDb, tripId, { date: opts.start ?? undefined });
const endDay = opts.end === undefined
? startDay
: createDay(testDb, tripId, { date: opts.end ?? undefined });
const stayId = testDb.prepare(`
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
tripId, place.id, startDay.id, endDay.id,
opts.check_in ?? null, opts.check_in_end ?? null, opts.check_out ?? null,
).lastInsertRowid as number;
if (opts.withReservation !== false) {
testDb.prepare(`
INSERT INTO reservations (trip_id, day_id, title, reservation_time, status, type, accommodation_id,
confirmation_number, notes, location)
VALUES (?, ?, ?, ?, 'confirmed', 'hotel', ?, ?, ?, ?)
`).run(
tripId, startDay.id, opts.title ?? 'Hotel Bellevue', opts.start, String(stayId),
opts.confirmation ?? null, opts.notes ?? null, opts.location ?? null,
);
}
return { stayId, placeId: place.id };
};
it('CAL-025: a stay covers every night as one all-day event, not just the arrival day', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, { start: '2026-07-07', end: '2026-07-12' });
const { ics } = svc.exportICS(trip.id);
// DTEND is exclusive, so the day after checkout.
expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713');
expect(ics).toContain('SUMMARY:Hotel Bellevue');
});
it('CAL-025b: a stay that records both ends of its clock drops the all-day block (#2136)', () => {
// The two markers already say when to arrive and when to leave, which is the
// part a subscriber can act on. Keeping the block as well buries the week
// under a bar that repeats what the markers say.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, {
start: '2026-07-07', end: '2026-07-12',
check_in: '15:00', check_out: '11:00',
});
const { ics } = svc.exportICS(trip.id);
expect(ics).not.toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713');
expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue');
expect(ics).toContain('SUMMARY:Check-out: Hotel Bellevue');
// With the block gone the markers are the booking's whole representation, so
// they carry the hand-over default hour instead of being points.
expect(ics).toContain('DTEND;TZID=Europe/Paris:20260707T160000');
expect(ics).toContain('DTEND;TZID=Europe/Paris:20260712T120000');
});
it('CAL-025d: a second room on the same stay keeps its block, since no marker names it', () => {
// The markers are emitted once per stay and titled from its lowest-id
// booking, so dropping every block would leave the second one with nothing.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
const { stayId } = createStay(trip.id, {
start: '2026-07-07', end: '2026-07-12',
check_in: '15:00', check_out: '11:00',
});
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY id ASC LIMIT 1').get(trip.id) as { id: number };
testDb.prepare(`
INSERT INTO reservations (trip_id, day_id, title, reservation_time, status, type, accommodation_id)
VALUES (?, ?, 'Bellevue second room', NULL, 'confirmed', 'hotel', ?)
`).run(trip.id, day.id, String(stayId));
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Bellevue second room');
expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue');
});
it('CAL-025e: the markers of a fully timed stay carry what the dropped block said (#2136)', () => {
// Losing the block must not lose the confirmation number and the notes with
// it, the same handover the split window bookings do (#2068).
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, {
start: '2026-07-07', end: '2026-07-12',
check_in: '15:00', check_out: '11:00',
confirmation: 'HTL-77291', notes: 'Key box code 4711',
});
const { ics } = svc.exportICS(trip.id);
const unfolded = ics.replaceAll('\r\n ', '');
const descriptions = unfolded.split('\r\n').filter(l => l.startsWith('DESCRIPTION:Type: hotel'));
expect(descriptions).toHaveLength(2);
expect(descriptions[0]).toContain('Confirmation: HTL-77291');
expect(descriptions[0]).toContain('Key box code 4711');
});
it('CAL-025f: a stay whose place has no address takes the booking location instead', () => {
// place_id is nullable (ON DELETE SET NULL) and an address is optional, so
// without the fallback the only two events left name no address at all.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, {
start: '2026-07-07', end: '2026-07-12',
check_in: '15:00', check_out: '11:00',
address: null, location: '12 Hotel Street',
});
const { ics } = svc.exportICS(trip.id);
expect(ics.match(/LOCATION:12 Hotel Street/g)).toHaveLength(2);
});
it('CAL-025g: a stay that keeps its block does not repeat the description on its markers', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, {
start: '2026-07-07', end: '2026-07-12',
check_in: '15:00', confirmation: 'HTL-77291',
});
const { ics } = svc.exportICS(trip.id);
// The block is still emitted and still says it; saying it a second time on
// the check-in marker would change a feed that already went out.
const unfolded = ics.replaceAll('\r\n ', '');
expect(unfolded.split('\r\n').filter(l => l.startsWith('DESCRIPTION:Type: hotel'))).toHaveLength(1);
});
it('CAL-025c: knowing only one end keeps the block, since nothing else carries the other', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, { start: '2026-07-07', end: '2026-07-12', check_in: '15:00' });
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713');
expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue');
expect(ics).not.toContain('SUMMARY:Check-out');
});
it('CAL-026: check-in and check-out become their own timed events in the stay zone', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, {
start: '2026-07-07', end: '2026-07-12',
check_in: '15:00', check_in_end: '22:00', check_out: '11:00',
});
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue');
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T150000');
expect(ics).toContain('DTEND;TZID=Europe/Paris:20260707T220000');
expect(ics).toContain('SUMMARY:Check-out: Hotel Bellevue');
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260712T110000');
expect(ics).toContain('LOCATION:1 Rue de Rivoli');
expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris');
});
it('CAL-026b: a check-in without an until-clock reads as one hour, not a point (#2136)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, { start: '2026-07-07', end: '2026-07-12', check_in: '15:00' });
const { ics } = svc.exportICS(trip.id);
// Reporter variant 2: the lone timed end becomes a one-hour slot AND the
// all-day block stays, since it is the only carrier of the other end's date.
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T150000');
expect(ics).toContain('DTEND;TZID=Europe/Paris:20260707T160000');
expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713');
});
it('CAL-026c: the check-out marker carries the same default hour', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, { start: '2026-07-07', end: '2026-07-12', check_out: '11:00' });
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260712T110000');
expect(ics).toContain('DTEND;TZID=Europe/Paris:20260712T120000');
});
it('CAL-026d: a late check-out rolls the DTEND over midnight', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, { start: '2026-07-07', end: '2026-07-12', check_out: '23:30' });
const { ics } = svc.exportICS(trip.id);
// Without the day shift the DTEND would land before its DTSTART, and clients
// drop an event whose end precedes its start.
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260712T233000');
expect(ics).toContain('DTEND;TZID=Europe/Paris:20260713T003000');
});
it('CAL-026e: a check-in window that ends before it starts runs past midnight (#2136)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, {
start: '2026-07-07', end: '2026-07-12',
check_in: '22:00', check_in_end: '02:00', check_out: '11:00',
});
const { ics } = svc.exportICS(trip.id);
// A late-arrival window, and nothing on the way in (REST, MCP, plugin SDK)
// orders the two clocks. Emitted as recorded the DTEND precedes the DTSTART,
// clients drop the event, and since the block is gone the arrival is gone.
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T220000');
expect(ics).toContain('DTEND;TZID=Europe/Paris:20260708T020000');
});
it('CAL-027: a stay without times emits the all-day range and nothing else', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, { start: '2026-07-07', end: '2026-07-12' });
const { ics } = svc.exportICS(trip.id);
expect(ics).not.toContain('Check-in');
expect(ics).not.toContain('Check-out');
});
it('CAL-028: a stay whose end day lost its date falls back to the arrival day', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, { start: '2026-07-07', end: null, check_out: '11:00' });
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260708');
expect(ics).toContain('SUMMARY:Check-out: Hotel Bellevue');
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T110000');
});
it('CAL-029: a stay with no reservation still contributes its check-in event', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
createStay(trip.id, { start: '2026-07-07', end: '2026-07-09', check_in: '15:00', withReservation: false });
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue');
// No stay event without a reservation to carry it — the accommodation itself
// has no title, notes or confirmation of its own to show.
expect(ics).not.toContain('DTEND;VALUE=DATE:20260710');
});
it('CAL-030: a hotel reservation without a stay keeps its old single-day event', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
const reservation = createReservation(testDb, trip.id, { title: 'Airbnb', type: 'hotel' });
testDb.prepare('UPDATE reservations SET reservation_time=? WHERE id=?').run('2026-07-07', reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260707');
expect(ics).not.toContain('DTEND;VALUE=DATE');
});
it('CAL-031: a dateless stay is skipped instead of emitting a broken event', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Someday' });
createStay(trip.id, { start: null, end: null, check_in: '15:00', check_out: '11:00' });
const { ics } = svc.exportICS(trip.id);
expect(ics).not.toContain('Check-in');
expect(ics).not.toContain('Check-out');
expect(ics).not.toContain('DTSTART;VALUE=DATE:');
});
it('CAL-036: two bookings on one stay emit the check-in/check-out markers once', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris' });
const { stayId } = createStay(trip.id, {
start: '2026-07-07', end: '2026-07-12', check_in: '15:00', check_out: '11:00',
});
// Nothing stops a second booking from pointing at the same accommodation. The
// markers are keyed by the stay, so joining the reservations in fanned the stay
// out into two VEVENTs carrying the same UID, and clients then show whichever
// one they saw last (#1869).
testDb.prepare(`
INSERT INTO reservations (trip_id, title, reservation_time, status, type, accommodation_id)
VALUES (?, 'Bellevue second room', NULL, 'confirmed', 'hotel', ?)
`).run(trip.id, String(stayId));
const { ics } = svc.exportICS(trip.id);
expect(ics.match(/UID:trek-checkin-\d+@trek/g)).toHaveLength(1);
expect(ics.match(/UID:trek-checkout-\d+@trek/g)).toHaveLength(1);
// The lowest booking id names the markers, so the title cannot flip between
// exports. The second booking keeps its own event (its UID is its own).
expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue\r\n');
expect(ics).not.toContain('Check-in: Bellevue second room');
expect(ics).toContain('SUMMARY:Bellevue second room');
});
});
// ── Car rental pickup/drop-off in the feed (#1721) ──────────────────────────
describe('car rentals', () => {
const insertEndpoint = (
reservationId: number,
role: string,
sequence: number,
name: string,
lat: number,
lng: number,
timezone: string | null,
local_time: string | null,
local_date: string | null,
) => {
testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(reservationId, role, sequence, name, null, lat, lng, timezone, local_time, local_date);
};
it('CAL-037: a rental with from/to endpoints produces a pickup and a drop-off event at the right local times and zones', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const reservation = createReservation(testDb, trip.id, { title: 'Hertz Rental', type: 'car' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL, location=? WHERE id=?')
.run('Hertz Downtown', reservation.id);
insertEndpoint(reservation.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07');
insertEndpoint(reservation.id, 'to', 1, 'Berlin Office', 52.5, 13.4, 'Europe/Berlin', '10:30', '2026-07-14');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Pickup: Hertz Rental');
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000');
expect(ics).toContain('SUMMARY:Drop-off: Hertz Rental');
expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20260714T103000');
expect(ics).toContain('LOCATION:Hertz Downtown');
expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris');
expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin');
});
it('CAL-038: role-less endpoints fall back to first/last by sequence', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const reservation = createReservation(testDb, trip.id, { title: 'Avis Rental', type: 'car' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id);
// Neither endpoint carries a from/to role — an older import shape.
insertEndpoint(reservation.id, 'stop', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07');
insertEndpoint(reservation.id, 'stop', 1, 'Berlin Office', 52.5, 13.4, 'Europe/Berlin', '10:30', '2026-07-14');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Pickup: Avis Rental');
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000');
expect(ics).toContain('SUMMARY:Drop-off: Avis Rental');
expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20260714T103000');
});
it('CAL-039: a rental with only reservation_time/reservation_end_time still produces both events', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const place = createPlace(testDb, trip.id, { name: 'Rental Desk', lat: 48.8566, lng: 2.3522 });
const reservation = createReservation(testDb, trip.id, { title: 'Budget Rental', type: 'car' });
testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=?, place_id=? WHERE id=?')
.run('2026-07-07T09:00', '2026-07-14T10:30', place.id, reservation.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Pickup: Budget Rental');
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000');
expect(ics).toContain('SUMMARY:Drop-off: Budget Rental');
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260714T103000');
});
it('CAL-040: a rental with a single endpoint does not emit a bogus second event', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const reservation = createReservation(testDb, trip.id, { title: 'Sixt Rental', type: 'car' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id);
// Only the pickup was geocoded — the common shape for a partially-imported
// booking. The lone endpoint must not be reused as the drop-off too.
insertEndpoint(reservation.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Pickup: Sixt Rental');
expect(ics).not.toContain('Drop-off');
});
it('CAL-041: a rental with no usable clock emits no window events and its existing behaviour is unchanged', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const reservation = createReservation(testDb, trip.id, { title: 'Untimed Rental', type: 'car' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id);
// A date but no clock — the pre-existing "untimed transport" all-day
// fallback still applies and must be unaffected by the new marker events.
insertEndpoint(reservation.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', null, '2026-07-07');
const { ics } = svc.exportICS(trip.id);
expect(ics).not.toContain('Pickup');
expect(ics).not.toContain('Drop-off');
expect(ics).toContain('SUMMARY:Untimed Rental');
expect(ics).toContain('DTSTART;VALUE=DATE:20260707');
});
it('CAL-042: a clock that already carries seconds keeps its zone', () => {
// The booking import stores what KItinerary hands it, which is "10:00:00".
// fmtDateTime's time-only branch appended "00" regardless, so the value came
// out 17 characters long, dtLine refused to attach the TZID and the event
// went out floating — the #1453 regression by another route.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const reservation = createReservation(testDb, trip.id, { title: 'Seconds Rental', type: 'car' });
testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2026-07-07T09:00:00', '18:30:00', reservation.id);
insertEndpoint(reservation.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00:00', '2026-07-07');
insertEndpoint(reservation.id, 'to', 1, 'Lyon Office', 45.764, 4.8357, 'Europe/Paris', '18:30:00', '2026-07-07');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000');
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T183000');
// No 17-character value anywhere: that is what silently drops the zone.
expect(ics).not.toMatch(/DTSTART[^\r\n]*\d{8}T\d{8}/);
});
// ── Window bookings: two hand-overs, not one block (#2068) ─────────────────
it('CAL-045: multi-day parking becomes a drop-off and a pick-up instead of one block', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const parking = createReservation(testDb, trip.id, { title: 'Airport P4', type: 'parking' });
testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2026-07-01T06:30', '2026-07-10T22:15', parking.id);
const { ics } = svc.exportICS(trip.id);
// The two hand-overs, an hour each. Parking is dropped off first.
expect(ics).toContain('SUMMARY:Drop-off: Airport P4');
expect(ics).toContain('DTSTART:20260701T063000');
expect(ics).toContain('DTEND:20260701T073000');
expect(ics).toContain('SUMMARY:Pickup: Airport P4');
expect(ics).toContain('DTSTART:20260710T221500');
expect(ics).toContain('DTEND:20260710T231500');
// And the block across the ten days in between is gone.
expect(ics).not.toContain('SUMMARY:Airport P4\r\n');
expect(ics).not.toContain('DTEND:20260710T221500');
});
it('CAL-046: a same-day parking is one sitting and keeps its single event', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const parking = createReservation(testDb, trip.id, { title: 'Garage', type: 'parking' });
testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2026-07-01T08:00', '2026-07-01T18:30', parking.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Garage\r\n');
expect(ics).toContain('DTSTART:20260701T080000');
expect(ics).toContain('DTEND:20260701T183000');
expect(ics).not.toContain('Drop-off: Garage');
expect(ics).not.toContain('Pickup: Garage');
});
it('CAL-047: a rental typed with days but no clock finally reaches the feed', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const first = createDay(testDb, trip.id, { date: '2026-07-03' });
const last = createDay(testDb, trip.id, { date: '2026-07-08' });
const rental = createReservation(testDb, trip.id, { title: 'Sixt', type: 'car' });
// The planner writes reservation_time = NULL when the optional time pickers
// are left blank, which is what made this booking invisible.
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL, day_id=?, end_day_id=? WHERE id=?')
.run(first.id, last.id, rental.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Pickup: Sixt');
expect(ics).toContain('DTSTART;VALUE=DATE:20260703');
expect(ics).toContain('SUMMARY:Drop-off: Sixt');
expect(ics).toContain('DTSTART;VALUE=DATE:20260708');
});
it('CAL-048: a split rental keeps its zones and everything the block used to say', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const rental = createReservation(testDb, trip.id, { title: 'Hertz', type: 'car' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL, confirmation_number=?, location=? WHERE id=?')
.run('HZ-4471', 'Hertz Downtown', rental.id);
insertEndpoint(rental.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07');
insertEndpoint(rental.id, 'to', 1, 'Berlin Office', 52.5, 13.4, 'Europe/Berlin', '10:30', '2026-07-14');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000');
expect(ics).toContain('DTEND;TZID=Europe/Paris:20260707T100000');
expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20260714T103000');
expect(ics).toContain('DTEND;TZID=Europe/Berlin:20260714T113000');
// Dropping the block must not drop what it said: both hand-overs carry it.
// Long lines are folded at 75 octets, so unfold before reading them.
const unfolded = ics.replaceAll('\r\n ', '');
const descriptions = unfolded.split('\r\n').filter(l => l.startsWith('DESCRIPTION:Type: car'));
expect(descriptions).toHaveLength(2);
expect(descriptions[0]).toContain('Confirmation: HZ-4471');
expect(descriptions[0]).toContain('Route: Paris Office → Berlin Office');
});
// #1453 — the block is the only carrier of the return time when just one side
// was geocoded, so a one-sided rental must keep it.
it('CAL-049: a one-sided rental keeps its block rather than losing the return', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const place = createPlace(testDb, trip.id, { name: 'Rental Desk', lat: 48.8566, lng: 2.3522 });
const rental = createReservation(testDb, trip.id, { title: 'Avis', type: 'car' });
testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=?, place_id=? WHERE id=?')
.run('2026-07-02T10:00', '2026-07-09T10:00', place.id, rental.id);
const { ics } = svc.exportICS(trip.id);
// Both sides resolve off reservation_time/-_end_time, so this one does split,
// and the return keeps the pickup's zone instead of floating.
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260702T100000');
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260709T100000');
expect(ics).not.toMatch(/DTSTART:20260709T100000/);
});
it('CAL-050: a rental with only a pickup endpoint is left exactly as it was', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const rental = createReservation(testDb, trip.id, { title: 'Solo', type: 'car' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(rental.id);
insertEndpoint(rental.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Solo\r\n');
expect(ics).toContain('SUMMARY:Pickup: Solo');
expect(ics).not.toContain('Drop-off');
});
it('CAL-051: any transport pinned to days without a clock reaches the feed too', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const first = createDay(testDb, trip.id, { date: '2026-07-03' });
const last = createDay(testDb, trip.id, { date: '2026-07-04' });
const bus = createReservation(testDb, trip.id, { title: 'Night Bus', type: 'bus' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL, day_id=?, end_day_id=? WHERE id=?')
.run(first.id, last.id, bus.id);
const { ics } = svc.exportICS(trip.id);
// Not a window booking, so it stays one block — it just exists now.
expect(ics).toContain('SUMMARY:Night Bus');
expect(ics).toContain('DTSTART;VALUE=DATE:20260703');
expect(ics).toContain('DTEND;VALUE=DATE:20260705');
expect(ics).not.toContain('Pickup: Night Bus');
});
it('CAL-052: a split rental whose endpoints carry no zone falls back to their coordinates', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const rental = createReservation(testDb, trip.id, { title: 'Europcar', type: 'car' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(rental.id);
// An older import that geocoded both ends but stored no IANA zone.
insertEndpoint(rental.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, null, '09:00', '2026-07-07');
insertEndpoint(rental.id, 'to', 1, 'Berlin Office', 52.5, 13.4, null, '10:30', '2026-07-14');
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000');
expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20260714T103000');
});
// reservation_end_time is frequently a bare clock next to the booking's own
// date, which puts both hand-overs on the same day — so it is one sitting.
it('CAL-053: a bare end clock resolves against the start date rather than splitting', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const parking = createReservation(testDb, trip.id, { title: 'Street Bay', type: 'parking' });
testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2026-07-01T08:00', '18:30', parking.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Street Bay\r\n');
expect(ics).not.toContain('Drop-off: Street Bay');
expect(ics).not.toContain('Pickup: Street Bay');
});
it('CAL-054: an unsplit rental with a day but no clock emits no hand-over of its own', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Road Trip' });
const day = createDay(testDb, trip.id, { date: '2026-07-03' });
const rental = createReservation(testDb, trip.id, { title: 'Dayless', type: 'car' });
// A day but no end day and no clock: one side resolves, and it has no time,
// so there is nothing to place — the all-day block carries it instead.
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL, day_id=?, end_day_id=NULL WHERE id=?')
.run(day.id, rental.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Dayless\r\n');
expect(ics).toContain('DTSTART;VALUE=DATE:20260703');
expect(ics).not.toContain('Pickup: Dayless');
expect(ics).not.toContain('Drop-off: Dayless');
});
});
describe('staged bookings', () => {
/** A stay plus, optionally, the bookings that point at it. */
const stayWith = (tripId: number, states: Array<{ state: string; title: string }>) => {
const place = createPlace(testDb, tripId, { name: 'Hotel Bellevue', lat: 48.8566, lng: 2.3522 });
const startDay = createDay(testDb, tripId, { date: '2026-09-01' });
const endDay = createDay(testDb, tripId, { date: '2026-09-04' });
const stayId = testDb.prepare(`
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out)
VALUES (?, ?, ?, ?, '15:00', '11:00')
`).run(tripId, place.id, startDay.id, endDay.id).lastInsertRowid as number;
for (const s of states) {
testDb.prepare(`
INSERT INTO reservations (trip_id, day_id, title, reservation_time, status, type, accommodation_id, ingest_state)
VALUES (?, ?, ?, '2026-09-01T15:00', 'confirmed', 'hotel', ?, ?)
`).run(tripId, startDay.id, s.title, String(stayId), s.state);
}
return { stayId, placeId: place.id };
};
it('CAL-055: a staged booking is left out of the calendar, confirmation number included', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Kyoto' });
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
const staged = createReservation(testDb, trip.id, { title: 'Parked Flight', type: 'flight', day_id: day.id });
testDb.prepare(`UPDATE reservations SET ingest_state='staged', reservation_time='2026-09-01T08:00',
confirmation_number='ABC123' WHERE id=?`).run(staged.id);
const live = createReservation(testDb, trip.id, { title: 'Booked Flight', type: 'flight', day_id: day.id });
testDb.prepare("UPDATE reservations SET reservation_time='2026-09-01T12:00' WHERE id=?").run(live.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Booked Flight');
expect(ics).not.toContain('Parked Flight');
// The number rides in the DESCRIPTION, not the SUMMARY, so search the whole document.
expect(ics).not.toContain('ABC123');
});
it('CAL-056: a booking created before the column existed still exports', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Lisbon' });
const day = createDay(testDb, trip.id, { date: '2026-09-02' });
// No ingest_state named on the insert: exactly what every writer does today,
// and what the ALTER backfilled onto every pre-existing row.
const old = createReservation(testDb, trip.id, { title: 'Legacy Booking', type: 'flight', day_id: day.id });
testDb.prepare("UPDATE reservations SET reservation_time='2026-09-02T09:00' WHERE id=?").run(old.id);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('SUMMARY:Legacy Booking');
});
it('CAL-057: an accommodation whose only booking is staged emits no stay, check-in or check-out', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Osaka' });
stayWith(trip.id, [{ state: 'staged', title: 'Parked Hotel' }]);
const { ics } = svc.exportICS(trip.id);
expect(ics).not.toContain('Parked Hotel');
expect(ics).not.toMatch(/UID:trek-checkin-\d+@trek/);
expect(ics).not.toMatch(/UID:trek-checkout-\d+@trek/);
});
it('CAL-058: an accommodation with no linked booking at all still emits its stay', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Nara' });
// The regression this pins: a plain EXISTS(live) instead of
// NOT EXISTS(any) OR EXISTS(live) would drop every hand-added hotel out of
// trips that are shared today.
stayWith(trip.id, []);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('Hotel Bellevue');
expect(ics).toMatch(/UID:trek-checkin-\d+@trek/);
});
it('CAL-059: a stay with both a staged and a live booking takes its title from the live one', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Kobe' });
// The staged row gets the lower id, so the subquery's ORDER BY r.id ASC
// would pick it without the predicate.
stayWith(trip.id, [{ state: 'staged', title: 'Parked Name' }, { state: 'live', title: 'Real Name' }]);
const { ics } = svc.exportICS(trip.id);
expect(ics).toContain('Real Name');
expect(ics).not.toContain('Parked Name');
});
});
describe('folded quirk branches', () => {
it('TRIP-SVC-048: exportICS renders untimed/notes all-day summaries, multi-leg routes, endpoint routes and train/location fields', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Branchy' });
const day = createDay(testDb, trip.id, { date: '2025-06-02' });
const place = createPlace(testDb, trip.id, { name: 'Untimed Spot' });
testDb.prepare("UPDATE places SET address = '1 Rue Test' WHERE id = ?").run(place.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
testDb.prepare("UPDATE day_assignments SET notes = 'bring hat' WHERE id = ?").run(assignment.id);
createDayNote(testDb, day.id, trip.id, { text: 'timed note', time: '10:00' });
createDayNote(testDb, day.id, trip.id, { text: 'plain note' });
// Multi-leg flight metadata → Route: A → B → C, plus train + notes + location.
const flight = createReservation(testDb, trip.id, { title: 'Legs', type: 'flight' });
testDb.prepare('UPDATE reservations SET reservation_time=?, metadata=?, notes=?, location=?, confirmation_number=? WHERE id=?').run(
'2025-06-02T09:00',
JSON.stringify({ legs: [{ from: 'FRA', to: 'BER' }, { to: 'HND' }], train_number: 'ICE 100' }),
'window seat', 'Gate 4', 'ABC123', flight.id,
);
// Endpoint-derived route (no route metadata) with a date-only endpoint fallback.
const transport = createReservation(testDb, trip.id, { title: 'Ferry', type: 'transport' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(transport.id);
const insertEp = testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
insertEp.run(transport.id, 'from', 0, 'Pier A', null, 1.0, 1.0, null, null, '2025-06-03');
insertEp.run(transport.id, 'to', 1, 'Pier B', 'PB', 1.1, 1.1, null, null, '2025-06-03');
// Unfold the RFC 5545 75-octet folding so substring assertions see whole lines.
const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
expect(ics).toContain('SUMMARY:Day 1');
expect(ics).toContain('• Untimed Spot (1 Rue Test) — bring hat');
expect(ics).toContain('Notes:\\n10:00 — timed note\\n• plain note');
expect(ics).toContain('Route: FRA → BER → HND');
expect(ics).toContain('Train: ICE 100');
expect(ics).toContain('Confirmation: ABC123');
expect(ics).toContain('window seat');
expect(ics).toContain('LOCATION:Gate 4');
// Date-only endpoint → all-day DTSTART for the transport.
expect(ics).toContain('SUMMARY:Ferry');
expect(ics).toContain('DTSTART;VALUE=DATE:20250603');
});
});
// foldICS is the last thing that touches the bytes of both the download and the
// feed, and it is exported, so it is pinned directly instead of through a trip.
describe('foldICS', () => {
// Physical lines are measured in octets, so the split points below are byte
// offsets, not character offsets.
const octets = (s: string) => Buffer.from(s, 'utf8').length;
const unfold = (s: string) => s.replace(/\r\n /g, '');
it('CAL-023: lines up to 75 octets are untouched and longer ones fold at 75 then 74', () => {
const short = 'SUMMARY:' + 'a'.repeat(67); // exactly 75 octets
expect(foldICS(short)).toBe(short);
const long = 'SUMMARY:' + 'a'.repeat(200);
const physical = foldICS(long).split('\r\n');
expect(physical[0]).toHaveLength(75);
// Continuation lines spend one octet on the leading space, so they carry 74
// payload octets — budgeting 75 there produces 76-octet lines that strict
// validators reject.
expect(physical[1].startsWith(' ')).toBe(true);
expect(physical[1]).toHaveLength(75);
expect(unfold(foldICS(long))).toBe(long);
});
it('CAL-024: a two-byte codepoint straddling the 75th octet is not split', () => {
// The fold boundary lands inside "é": without the backoff the two halves are
// decoded separately and the title arrives as U+FFFD in every client.
const line = 'DESCRIPTION:' + 'é'.repeat(60);
expect(octets(line)).toBeGreaterThan(75);
const folded = foldICS(line);
expect(folded).not.toContain('\uFFFD');
expect(unfold(folded)).toBe(line);
for (const part of folded.split('\r\n')) expect(octets(part)).toBeLessThanOrEqual(75);
});
it('CAL-025: a four-byte codepoint backs the split off by more than one octet', () => {
// An emoji needs the backoff to run several times before it reaches a lead
// byte; stopping after a single step still cuts the sequence.
const line = 'SUMMARY:' + '🎌'.repeat(40);
const folded = foldICS(line);
expect(folded).not.toContain('\uFFFD');
expect(unfold(folded)).toBe(line);
for (const part of folded.split('\r\n')) expect(octets(part)).toBeLessThanOrEqual(75);
});
it('CAL-026: folding is applied per content line, so short lines around a long one survive', () => {
const ics = 'BEGIN:VEVENT\r\nSUMMARY:' + 'ü'.repeat(80) + '\r\nEND:VEVENT';
const folded = foldICS(ics);
expect(folded.startsWith('BEGIN:VEVENT\r\n')).toBe(true);
expect(folded.endsWith('\r\nEND:VEVENT')).toBe(true);
expect(unfold(folded)).toBe(ics);
});
});
// 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' });
// The factories insert only the columns they model; every other column is set
// afterwards with an explicit UPDATE, the way the cases above do it. This case
// sets none, so the place has no address and the flight no reservation_time,
// and the snapshot was recorded from exactly that data: the assignment carries
// no LOCATION, and the flight is dropped from the export for having no
// placeable time.
const place = createPlace(testDb, trip.id, { name: 'Tokyo Tower', 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' });
// 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', () => {
it('CAL-001: the module registers the service, so injection cannot silently fail', () => {
expectRegisteredProvider(CalendarModule, CalendarService);
const exports = Reflect.getMetadata('exports', CalendarModule) as unknown[];
expect(Array.isArray(exports)).toBe(true);
expect(exports).toEqual(expect.arrayContaining([CalendarService]));
});
});