Files
TREK/server/tests/unit/nest/trips.service.test.ts
T
Maurice 939452f9d8 refactor(server): consolidate trip access and fold the query helpers into the container (#1824)
* refactor(server): route every trip-access check through DatabaseService

Twelve Nest services carry the same verifyTripAccess/canEdit pair. Six already
asked DatabaseService.canAccessTrip; the other six went through
services/tripAccess, a nine-line re-export of the identical function. Same
query, same result, two ways to reach it.

All twelve now take the injected route, so services/tripAccess is gone.

The bigger duplication was in the types. Thirteen files declared their own
`Trip` for the same return value, in three different shapes:

  NonNullable<ReturnType<typeof verifyTripAccess>>   6x
  { user_id: number }                                4x
  TripAccess                                         3x

The narrow one is why assignments, days, places and accommodations all wrote
`as Trip | null | undefined` on the call — the cast existed to shrink
TripAccess down to a local type that was never meant to differ from it. All
thirteen now say TripAccess, and the four casts are gone with no other change.

middleware/tripAccess.ts goes too: it was already dead in production — nothing
under src/ imported requireTripAccess or requireTripOwner, only comments in the
services that had absorbed them. Its test went with it.

* refactor(server): fold the query helpers and trip joins into the container

Two more legacy files whose only reason for staying outside the container was
that they predated it.

services/queryHelpers.ts held five exports doing two different jobs. Three are
the batch loaders that keep the list endpoints off N+1 queries — they hit the
database and are now QueryHelpersService over the injected connection, imported
by assignments, days, places and share. The other two, formatAssignmentWithPlace
and ratingAggregate, touch no database at all; they sat next to the loaders only
because the loaders were free functions too. Those stay free functions, in
common/rowShape.ts.

The README note on the assignments service claimed the loaders had to stay in
services/ because the day, share and place services were unmigrated. All three
have since moved, so the reason is gone.

services/tripMembership.ts becomes TripMembershipService in its own module. It
could have folded into auth or trip-invite, but both call it — as do oidc and
the plugin RPC host — and putting it in either would have made AuthModule and
TripInviteModule import each other.

Neither module is @Global, matching PermissionsModule: e2e TestingModules
resolve them transitively through each consumer's explicit import.

Test seams changed shape accordingly. Two path mocks of services/tripMembership
became injected stubs; the trip-invite e2e overrides the provider in the
container instead. The oidc test keeps the real implementation, which is what
OIDC-SVC-045 was always asserting.

ratingAggregate moved out of the ungated services/ tree into the coverage-gated
one, so it gets the direct tests it never had — as does the tags/participants
pass-through, which two long-dead fixture variables in the moved test file had
been waiting for.

Also drops four stale comments that outlived their subject: the requireTripAccess
middleware they pointed at was deleted in the previous commit.
2026-08-07 16:00:12 +02:00

1253 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for the DI-native TripsService — TRIP-SVC-001 through TRIP-SVC-053
* (001–038 moved 1:1 from the legacy tests/unit/services/tripService.test.ts;
* the exportICS cases that duplicated the generateDays 010–012 numbering were
* renumbered to 024–026 with the post-fold quirk-fix commit; 040–041 pin the
* trips.bridge delegation; 042–050 cover the folded
* summary/list/create/delete/copy SQL; 051–053 pin the post-fold quirk fixes
* (transactional deletes, owner display_name)). Uses a real in-memory SQLite
* DB so SQL logic is exercised faithfully.
*/
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 }));
// notifyInvite fires a notification via a dynamic import — keep it out of unit scope
vi.mock('../../../src/nest/notifications/notifications.bridge', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
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, addTripMember } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { DaysService } from '../../../src/nest/days/days.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TodoService } from '../../../src/nest/todo/todo.service';
import { PackingService } from '../../../src/nest/packing/packing.service';
import { FilesService } from '../../../src/nest/files/files.service';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import { CollabService } from '../../../src/nest/collab/collab.service';
import { VacayService } from '../../../src/nest/vacay/vacay.service';
import { TripsService } from '../../../src/nest/trips/trips.service';
import { PlacesService } from '../../../src/nest/places/places.service';
import { MapsService } from '../../../src/nest/maps/maps.service';
import { getTripOwner, listMembers as bridgeListMembers } from '../../../src/nest/trips/trips.bridge';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import fs from 'fs';
// Real sibling services over the same in-memory DB — updateTrip's date-shift
// resyncs and the summary/bundle aggregation run their actual SQL.
const dbs = () => new DatabaseService(testDb);
const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs()));
const placesSvc = new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs()), new QueryHelpersService(dbs()));
const createAccommodation = daysSvc.createAccommodation.bind(daysSvc);
const svc = new TripsService(
dbs(),
new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService()),
daysSvc,
new PermissionsService(dbs()),
budgetSvc,
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new VacayService(dbs(), new RealtimeService()),
new RealtimeService(),
placesSvc,
);
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function getDays(tripId: number) {
return testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as {
id: number; trip_id: number; day_number: number; date: string | null;
}[];
}
function getAssignments(dayId: number) {
return testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[];
}
function getNotes(dayId: number) {
return testDb.prepare('SELECT * FROM day_notes WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[];
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('generateDays', () => {
it('TRIP-SVC-010: full range shift preserves day assignments and notes positionally', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(5);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, daysBefore[0].id, place.id);
const note = createDayNote(testDb, daysBefore[1].id, trip.id, { text: 'packed' });
// Shift forward 9 days — zero overlap with original dates
svc.generateDays(trip.id, '2025-06-10', '2025-06-14');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-06-10', '2025-06-11', '2025-06-12', '2025-06-13', '2025-06-14',
]);
// day_number 1 (formerly June 1) now has date June 10 — assignment still attached
const day1 = daysAfter[0];
const day2 = daysAfter[1];
expect(getAssignments(day1.id)).toHaveLength(1);
expect(getAssignments(day1.id)[0].id).toBe(assignment.id);
expect(getNotes(day2.id)).toHaveLength(1);
expect(getNotes(day2.id)[0].id).toBe(note.id);
});
it('TRIP-SVC-011: shrinking range deletes overflow days and their assignments (issue #909)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(5);
const place = createPlace(testDb, trip.id);
createDayAssignment(testDb, daysBefore[3].id, place.id);
createDayAssignment(testDb, daysBefore[4].id, place.id);
// Shrink from 5 to 3 days — surplus days and their content are removed
svc.generateDays(trip.id, '2025-07-01', '2025-07-03');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(3);
expect(daysAfter.map(d => d.date)).toEqual(['2025-07-01', '2025-07-02', '2025-07-03']);
});
it('TRIP-SVC-016: shrinking range deletes empty overflow days (issue #909)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-07' });
expect(getDays(trip.id)).toHaveLength(7);
// Shrink 7 → 5; days 6 and 7 have no content
svc.generateDays(trip.id, '2025-07-01', '2025-07-05');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05',
]);
});
it('TRIP-SVC-012: growing range keeps existing day content and appends new empty days', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-08-01', end_date: '2025-08-03' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(3);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, daysBefore[0].id, place.id);
// Grow to 5 days
svc.generateDays(trip.id, '2025-08-01', '2025-08-05');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-08-01', '2025-08-02', '2025-08-03', '2025-08-04', '2025-08-05',
]);
// Existing day 1 retains its assignment
expect(getAssignments(daysAfter[0].id)).toHaveLength(1);
expect(getAssignments(daysAfter[0].id)[0].id).toBe(assignment.id);
// New days 4 and 5 are empty
expect(getAssignments(daysAfter[3].id)).toHaveLength(0);
expect(getAssignments(daysAfter[4].id)).toHaveLength(0);
});
it('TRIP-SVC-013: clearing dates converts all days to dateless without destroying assignments', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-09-01', end_date: '2025-09-04' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(4);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, daysBefore[1].id, place.id);
// Clear both dates
svc.generateDays(trip.id, null, null);
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(4);
expect(daysAfter.every(d => d.date === null)).toBe(true);
// The assignment on the former day 2 still exists
const formerDay2 = daysAfter.find(d => d.id === daysBefore[1].id);
expect(formerDay2).toBeDefined();
expect(getAssignments(formerDay2!.id)).toHaveLength(1);
expect(getAssignments(formerDay2!.id)[0].id).toBe(assignment.id);
});
it('TRIP-SVC-014: partial overlap shift remaps by position (day 1→3 kept, 4-5 overflow)', () => {
// Original: Jun 1-5. New: Jun 3-7 (overlap on Jun 3-5, but we map by position)
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-10-01', end_date: '2025-10-05' });
const daysBefore = getDays(trip.id);
const place = createPlace(testDb, trip.id);
// Assign to each of the 5 days
for (const day of daysBefore) createDayAssignment(testDb, day.id, place.id);
// Shift forward 2 days (partial overlap with original range)
svc.generateDays(trip.id, '2025-10-03', '2025-10-07');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-10-03', '2025-10-04', '2025-10-05', '2025-10-06', '2025-10-07',
]);
// All 5 assignments survive
for (const day of daysAfter) {
expect(getAssignments(day.id)).toHaveLength(1);
}
});
it('TRIP-SVC-015: growing into dateless days reuses them; leftover dateless renumber without UNIQUE collision', () => {
// 3 dated days + 2 pre-existing dateless days. Resize to 4 dated days.
// Main loop: dated[0..2] → positions 1-3, dateless[0] → position 4 (consumed).
// Unused dateless: dateless[1] should land at position 5, NOT 4 (collision bug).
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-11-01', end_date: '2025-11-03' });
// Insert 2 dateless days directly
const daysBefore = getDays(trip.id);
testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 4);
testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 5);
const allDays = getDays(trip.id);
expect(allDays).toHaveLength(5);
const place = createPlace(testDb, trip.id);
// Put an assignment on the second dateless day (day_number=5) — it should survive
const assignment = createDayAssignment(testDb, allDays[4].id, place.id);
// Grow from 3 to 4 dated days — consumes dateless[0], leaves dateless[1] unused
// This is the scenario that triggered the UNIQUE collision bug
svc.generateDays(trip.id, '2025-11-01', '2025-11-04');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
const dated = daysAfter.filter(d => d.date !== null);
const dateless = daysAfter.filter(d => d.date === null);
expect(dated).toHaveLength(4);
expect(dateless).toHaveLength(1);
// The remaining dateless day still has its assignment
expect(getAssignments(dateless[0].id)).toHaveLength(1);
expect(getAssignments(dateless[0].id)[0].id).toBe(assignment.id);
// All day_numbers are unique 1..5
const nums = daysAfter.map(d => d.day_number).sort((a, b) => a - b);
expect(nums).toEqual([1, 2, 3, 4, 5]);
});
it('TRIP-SVC-017: switching a dateless trip to a shorter dated range drops empty leftover days but keeps ones with content (#1083)', () => {
const { user } = createUser(testDb);
// A 7-day trip, then cleared to dateless placeholders (day_count = 7).
const trip = createTrip(testDb, user.id, { start_date: '2025-12-01', end_date: '2025-12-07' });
svc.generateDays(trip.id, null, null);
const dateless = getDays(trip.id);
expect(dateless).toHaveLength(7);
expect(dateless.every(d => d.date === null)).toBe(true);
// Give the LAST dateless day real content so it must be preserved.
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, dateless[6].id, place.id);
// Now set an explicit 2-day range. The first two dateless days are reused for
// the dates; the four empty leftovers must be removed, the one with content kept.
svc.generateDays(trip.id, '2026-01-10', '2026-01-11');
const daysAfter = getDays(trip.id);
const dated = daysAfter.filter(d => d.date !== null);
const stillDateless = daysAfter.filter(d => d.date === null);
expect(dated.map(d => d.date)).toEqual(['2026-01-10', '2026-01-11']);
// day_count is COUNT(*) FROM days: 2 dated + 1 content-bearing dateless = 3 (not the stale 7)
expect(daysAfter).toHaveLength(3);
expect(stillDateless).toHaveLength(1);
expect(getAssignments(stillDateless[0].id)[0].id).toBe(assignment.id);
});
});
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/);
});
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-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');
});
});
// ── deleteOldCover — path containment ──────────────────────────────────────────
describe('deleteOldCover', () => {
it('TRIP-SVC-COVER-001: never unlinks outside uploads/covers for a crafted cover_image', () => {
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true);
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
try {
// Attacker-controlled values aimed at auth-gated sibling upload dirs.
svc.deleteOldCover('/uploads/files/victim.pdf');
svc.deleteOldCover('/uploads/covers/../files/secret.pdf');
svc.deleteOldCover('/uploads/avatars/someone.png');
for (const call of unlinkSpy.mock.calls) {
const target = String(call[0]);
expect(target).toMatch(/[\\/]uploads[\\/]covers[\\/]/); // stays in covers
expect(target).not.toMatch(/[\\/]files[\\/]/);
expect(target).not.toMatch(/[\\/]avatars[\\/]/);
}
} finally {
existsSpy.mockRestore();
unlinkSpy.mockRestore();
}
});
it('TRIP-SVC-COVER-002: deletes a legitimate cover file', () => {
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true);
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
try {
svc.deleteOldCover('/uploads/covers/abc123.jpg');
expect(unlinkSpy).toHaveBeenCalledTimes(1);
expect(String(unlinkSpy.mock.calls[0][0])).toMatch(/[\\/]covers[\\/]abc123\.jpg$/);
} finally {
existsSpy.mockRestore();
unlinkSpy.mockRestore();
}
});
});
describe('resyncReservationDays (#1288)', () => {
const dayFor = (tripId: number, date: string) =>
(testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id;
const insertDatedReservation = (tripId: number, dayId: number, time: string) =>
Number(testDb.prepare(
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', ?, 'restaurant', 'pending')",
).run(tripId, dayId, time).lastInsertRowid);
it('TRIP-SVC-018: changing the start date re-anchors a dated reservation to the day matching its time', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const resId = insertDatedReservation(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T19:00:00');
// Shift the whole range one day forward (days become 2025-06-02..06).
svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user');
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
// The booking stays on its absolute date (2025-06-02) instead of shifting with its old day row.
expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02'));
});
it('TRIP-SVC-019: a reservation whose date falls outside the new range keeps its day_id (not nulled)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const origDayId = dayFor(trip.id, '2025-06-02');
const resId = insertDatedReservation(trip.id, origDayId, '2025-06-02T19:00:00');
// Shift far forward so 2025-06-02 is no longer covered by any day.
svc.updateTrip(trip.id, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }, 'user');
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
expect(res.day_id).toBe(origDayId);
});
});
describe('resyncAccommodationDays (#1288)', () => {
const dayFor = (tripId: number, date: string) =>
(testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id;
const insertAccommodation = (tripId: number, startDayId: number, endDayId: number) => {
const place = createPlace(testDb, tripId, { name: 'Grand Hotel' });
const acc = createAccommodation(tripId, {
place_id: place.id, start_day_id: startDayId, end_day_id: endDayId,
}) as { id: number };
const linkedRes = testDb.prepare(
'SELECT id FROM reservations WHERE accommodation_id = ?',
).get(acc.id) as { id: number };
return { accId: acc.id, linkedResId: linkedRes.id };
};
const getAcc = (id: number) =>
testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(id) as
{ start_day_id: number; end_day_id: number };
const getRes = (id: number) =>
testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(id) as
{ day_id: number | null; reservation_time: string | null };
it('TRIP-SVC-035: extending the start keeps an accommodation on its absolute dates', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' });
const { accId, linkedResId } = insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13'));
// Add a day at the start: days re-date positionally (old 06-11 row becomes 06-10, …).
svc.updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user');
const acc = getAcc(accId);
expect(acc.start_day_id).toBe(dayFor(trip.id, '2025-06-11'));
expect(acc.end_day_id).toBe(dayFor(trip.id, '2025-06-13'));
const res = getRes(linkedResId);
expect(res.day_id).toBe(acc.start_day_id);
expect(res.reservation_time?.slice(0, 10)).toBe('2025-06-11');
});
it('TRIP-SVC-036: moving the whole trip out of the old range keeps the accommodation glued to its days', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const startDayId = dayFor(trip.id, '2025-06-02');
const endDayId = dayFor(trip.id, '2025-06-03');
const { accId, linkedResId } = insertAccommodation(trip.id, startDayId, endDayId);
svc.updateTrip(trip.id, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }, 'user');
const acc = getAcc(accId);
expect(acc.start_day_id).toBe(startDayId);
expect(acc.end_day_id).toBe(endDayId);
// The linked reservation follows the (re-dated) start day instead of keeping a stale date snapshot.
const res = getRes(linkedResId);
expect(res.day_id).toBe(startDayId);
expect(res.reservation_time?.slice(0, 10)).toBe('2025-07-02');
});
it("TRIP-SVC-038: date_shift_mode 'shift_all' glues bookings to their days and restamps their times", () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const origDayId = dayFor(trip.id, '2025-06-02');
const resId = Number(testDb.prepare(
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', '2025-06-02T19:00:00', 'restaurant', 'pending')",
).run(trip.id, origDayId).lastInsertRowid);
const { accId } = insertAccommodation(trip.id, origDayId, dayFor(trip.id, '2025-06-03'));
svc.updateTrip(trip.id, user.id, { start_date: '2025-06-03', end_date: '2025-06-07', date_shift_mode: 'shift_all' }, 'user');
// The booking stays on its day row (now 2025-06-04) and its time follows.
const res = testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(resId) as
{ day_id: number; reservation_time: string };
expect(res.day_id).toBe(origDayId);
expect(res.reservation_time).toBe('2025-06-04T19:00:00');
// The accommodation stays glued to its (re-dated) day rows too.
const acc = getAcc(accId);
expect(acc.start_day_id).toBe(origDayId);
});
it('TRIP-SVC-037: a dated hotel reservation without a linked accommodation is re-anchored like other bookings', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const resId = Number(testDb.prepare(
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Imported hotel', ?, 'hotel', 'pending')",
).run(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T15:00:00').lastInsertRowid);
svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user');
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02'));
});
});
describe('transferOwnership (#973)', () => {
it('TRIP-SVC-020: hands the trip to a member and demotes the former owner to a member', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const result = svc.transferOwnership(trip.id, member.id, owner.id);
expect(result.toEmail).toBe(member.email);
const updated = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number };
expect(updated.user_id).toBe(member.id);
// New owner no longer sits in trip_members, former owner now does.
const memberIds = (testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[]).map(r => r.user_id);
expect(memberIds).toContain(owner.id);
expect(memberIds).not.toContain(member.id);
});
it('TRIP-SVC-021: rejects a transfer from a non-owner', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
// member (not the owner) attempts the transfer
expect(() => svc.transferOwnership(trip.id, member.id, member.id)).toThrow();
});
it('TRIP-SVC-022: rejects a transfer to someone who is not a member', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(() => svc.transferOwnership(trip.id, stranger.id, owner.id)).toThrow('New owner must be a trip member');
});
it('TRIP-SVC-023: rejects transferring to yourself', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(() => svc.transferOwnership(trip.id, owner.id, owner.id)).toThrow('You already own this trip');
});
});
describe('guest members (#1362)', () => {
it('TRIP-SVC-030: createGuest adds a credential-less user joined into the trip', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member } = svc.createGuest(trip.id, ' Anna ', owner.id);
expect(member.username).toBe('Anna');
expect(member.is_guest).toBe(true);
const row = testDb.prepare('SELECT username, email, password_hash, is_guest, role FROM users WHERE id = ?').get(member.id) as any;
expect(row.is_guest).toBe(1);
expect(row.password_hash).toBe('');
expect(row.email).toMatch(/@guests\.invalid$/);
expect(row.role).toBe('user');
// Joined as a trip member.
const m = testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
expect(m).toBeTruthy();
// Surfaces in listMembers with is_guest=true and the typed display name.
const { members } = svc.listMembers(trip.id, owner.id) as any;
const guest = members.find((x: any) => x.id === member.id);
expect(guest.username).toBe('Anna');
expect(guest.is_guest).toBe(true);
});
it('TRIP-SVC-031: the same guest name is allowed, not suffixed (#1446)', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const a = svc.createGuest(trip.id, 'Sam', owner.id);
const b = svc.createGuest(trip.id, 'Sam', owner.id);
// both keep the plain display name; only the internal (uuid) username differs
expect(a.member.username).toBe('Sam');
expect(b.member.username).toBe('Sam');
expect(b.member.id).not.toBe(a.member.id);
const usernames = testDb.prepare('SELECT username FROM users WHERE id IN (?, ?)').all(a.member.id, b.member.id) as { username: string }[];
expect(usernames[0].username).not.toBe(usernames[1].username);
});
it('TRIP-SVC-032: renameGuest updates the display name (trip-scoped, guest-only)', () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const otherTrip = createTrip(testDb, other.id);
const trip = createTrip(testDb, owner.id);
const { member } = svc.createGuest(trip.id, 'Bob', owner.id);
expect(svc.renameGuest(trip.id, member.id, 'Robert')).toBe(true);
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(member.id) as any).display_name).toBe('Robert');
// A real user cannot be renamed through the guest path…
expect(svc.renameGuest(trip.id, owner.id, 'Hacked')).toBe(false);
// …and a guest cannot be renamed from a different trip.
expect(svc.renameGuest(otherTrip.id, member.id, 'Nope')).toBe(false);
});
it('TRIP-SVC-033: deleteGuest removes the user (cascading membership), guest-only + trip-scoped', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member } = svc.createGuest(trip.id, 'Carol', owner.id);
// Real members are not deletable via the guest path.
expect(svc.deleteGuest(trip.id, owner.id)).toBe(false);
expect(svc.deleteGuest(trip.id, member.id)).toBe(true);
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(member.id)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM trip_members WHERE user_id = ?').get(member.id)).toBeUndefined();
});
it('TRIP-SVC-034: a guest is never invitable (addMember) nor a transfer target', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member } = svc.createGuest(trip.id, 'Dora', owner.id);
// The synthetic username/email must not resolve through the invite box.
expect(() => svc.addMember(trip.id, 'Dora', owner.id, owner.id)).toThrow('User not found');
// Ownership can never be handed to a guest.
expect(() => svc.transferOwnership(trip.id, member.id, owner.id)).toThrow('Cannot transfer ownership to a guest');
});
});
// ── trips.bridge delegation (coverage gate) ───────────────────────────────────
describe('trips.bridge delegation', () => {
it('TRIP-SVC-040: getTripOwner delegates 1:1', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(getTripOwner(trip.id)).toEqual({ user_id: user.id });
expect(getTripOwner(99999)).toBeUndefined();
});
it('TRIP-SVC-041: listMembers delegates 1:1', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const result = bridgeListMembers(trip.id, owner.id);
expect(result.owner.id).toBe(owner.id);
expect(result.members.map(m => m.id)).toEqual([member.id]);
});
});
// ── Folded CRUD SQL (summary / list / create / delete / copy) ─────────────────
describe('folded trip CRUD', () => {
it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
addTripMember(testDb, trip.id, member.id);
testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'food', 'Dinner', 40)").run(trip.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id);
const summary = svc.getTripSummary(trip.id, owner.id)!;
expect(summary).toBeTruthy();
expect((summary.trip as any).id).toBe(trip.id);
expect(summary.members.owner.id).toBe(owner.id);
expect(summary.members.collaborators.map((m: any) => m.id)).toEqual([member.id]);
expect(summary.days).toHaveLength(2);
expect(summary.budget.item_count).toBe(1);
expect(summary.budget.total).toBe(40);
expect(summary.packing.total).toBe(1);
expect(summary.packing.checked).toBe(1);
expect(summary.reservations).toEqual([]);
expect(summary.collab_notes).toEqual([]);
// Missing trips return null instead of throwing.
expect(svc.getTripSummary(99999)).toBeNull();
});
it('TRIP-SVC-043: list returns owned + shared trips with is_owner, honoring the archived filter', () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const own = createTrip(testDb, owner.id, { title: 'Mine' });
const shared = createTrip(testDb, other.id, { title: 'Shared' });
addTripMember(testDb, shared.id, owner.id);
const archived = createTrip(testDb, owner.id, { title: 'Old' });
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
const active = svc.list(owner.id, 0) as any[];
expect(active.map(t => t.id).sort()).toEqual([own.id, shared.id].sort());
expect(active.find(t => t.id === own.id).is_owner).toBe(1);
expect(active.find(t => t.id === shared.id).is_owner).toBe(0);
const all = svc.list(owner.id, null) as any[];
expect(all.map(t => t.id).sort()).toEqual([own.id, shared.id, archived.id].sort());
});
it('TRIP-SVC-044: create applies || defaults, clamps reminder_days and generates days', () => {
const { user } = createUser(testDb);
const { trip, tripId, reminderDays } = svc.create(user.id, {
title: 'New Trip', start_date: '2025-06-01', end_date: '2025-06-03', reminder_days: 99,
});
expect(reminderDays).toBe(3); // out-of-range → default 3
expect((trip as any).currency).toBe('EUR'); // no currency → 'EUR'
expect(getDays(tripId)).toHaveLength(3);
});
it('TRIP-SVC-045: remove deletes the trip, cleans skeleton journey entries and detaches filled ones', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const journeyId = Number(testDb.prepare(
"INSERT INTO journeys (user_id, title, created_at, updated_at) VALUES (?, 'J', 0, 0)",
).run(user.id).lastInsertRowid);
testDb.prepare(
"INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'skeleton', 'S', '2025-06-01', 0, 0)",
).run(journeyId, trip.id, user.id);
const filledId = Number(testDb.prepare(
"INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'story', 'F', '2025-06-01', 0, 0)",
).run(journeyId, trip.id, user.id).lastInsertRowid);
const info = svc.remove(trip.id, user.id, 'user');
expect(info).toMatchObject({ tripId: trip.id, ownerId: user.id, isAdminDelete: false });
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeUndefined();
expect(testDb.prepare("SELECT id FROM journey_entries WHERE type = 'skeleton'").get()).toBeUndefined();
const filled = testDb.prepare('SELECT source_trip_id FROM journey_entries WHERE id = ?').get(filledId) as any;
expect(filled.source_trip_id).toBeNull();
// Missing trips throw the byte-identical error.
expect(() => svc.remove(99999, user.id, 'user')).toThrow('Trip not found');
});
it('TRIP-SVC-046: copy duplicates days/places/assignments and resets packing to unchecked', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' });
const days = getDays(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
createDayAssignment(testDb, days[0].id, place.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id);
const newTripId = svc.copy(trip.id, user.id, 'Clone');
const copied = testDb.prepare('SELECT title, is_archived FROM trips WHERE id = ?').get(newTripId) as any;
expect(copied.title).toBe('Clone');
expect(copied.is_archived).toBe(0);
expect(getDays(newTripId)).toHaveLength(2);
const newPlaces = testDb.prepare('SELECT id, name FROM places WHERE trip_id = ?').all(newTripId) as any[];
expect(newPlaces.map(p => p.name)).toEqual(['Louvre']);
expect(getAssignments(getDays(newTripId)[0].id)).toHaveLength(1);
const packing = testDb.prepare('SELECT checked FROM packing_items WHERE trip_id = ?').all(newTripId) as any[];
expect(packing).toEqual([{ checked: 0 }]);
// No title → source title (|| fallback).
const secondCopy = svc.copy(trip.id, user.id);
expect((testDb.prepare('SELECT title FROM trips WHERE id = ?').get(secondCopy) as any).title).toBe('Origin');
});
});
// ── Wrapper helpers (delegating members of the aggregate root) ────────────────
describe('TripsService wrapper helpers', () => {
it('re-anchors the budget before the trip row leaves its old currency (#1543)', async () => {
const order: string[] = [];
const rebaseSpy = vi.spyOn(budgetSvc, 'rebaseTripCurrency').mockImplementation(async () => { order.push('rebase'); });
const updateSpy = vi.spyOn(svc, 'updateTrip').mockImplementation(() => { order.push('update'); return {} as never; });
try {
await svc.update('9', 1, { currency: 'RUB' } as never, 'user');
// The rebase reads the outgoing currency off the trip row, so it has to run first.
expect(rebaseSpy).toHaveBeenCalledWith('9', 'RUB');
expect(order).toEqual(['rebase', 'update']);
} finally {
rebaseSpy.mockRestore();
updateSpy.mockRestore();
}
});
it('canAccessTrip delegates to the db helper; can() delegates to checkPermission; broadcast forwards', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(svc.canAccessTrip(String(trip.id), user.id)).toMatchObject({ user_id: user.id });
expect(svc.can('trip_edit', 'user', user.id, user.id, false)).toBe(true);
svc.broadcast('9', 'trip:updated', { a: 1 } as never, 'sock');
expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { a: 1 }, 'sock');
});
it('getCopiedTrip re-reads via the TRIP_SELECT query', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Copied' });
const row = svc.getCopiedTrip(trip.id, user.id) as any;
expect(row.id).toBe(trip.id);
expect(row.is_owner).toBe(1);
});
it('bundle aggregates every sub-collection + the member list, scoping packing to the viewer (#858)', () => {
const { user: owner } = createUser(testDb);
const { user: viewer } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
addTripMember(testDb, trip.id, viewer.id);
// A personal item of the OWNER must stay out of the other member's bundle.
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, owner.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Shared')").run(trip.id);
const result = svc.bundle(String(trip.id), { user_id: owner.id }, viewer.id) as any;
expect(result.days).toHaveLength(2);
expect(result.members.map((m: any) => m.id).sort()).toEqual([owner.id, viewer.id].sort());
expect(result.packingItems.map((p: any) => p.name)).toEqual(['Shared']);
});
it('notifyInvite is fire-and-forget (no throw)', () => {
expect(() => svc.notifyInvite('9', { id: 1, email: 'a@b.c' } as never, 2, 'T', 'b@x.y')).not.toThrow();
});
});
// ── Branch coverage for the folded update/export/copy quirks ─────────────────
describe('folded quirk branches', () => {
it('TRIP-SVC-047: updateTrip admin edit collects changes and the owner email; reminder 0 reads "none"', () => {
const { user: owner } = createUser(testDb);
const { user: admin } = createUser(testDb);
testDb.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(admin.id);
const trip = createTrip(testDb, owner.id, { title: 'Old' });
const result = svc.updateTrip(trip.id, admin.id, { title: 'New', is_archived: true, reminder_days: 0 }, 'admin');
expect(result.isAdminEdit).toBe(true);
expect(result.ownerEmail).toBe(owner.email);
expect(result.changes).toMatchObject({ title: 'New', archived: true, reminder_days: 'none' });
expect(result.newTitle).toBe('New');
// || coercion: an empty-string title falls back to the stored one.
const kept = svc.updateTrip(trip.id, owner.id, { title: '' }, 'user');
expect(kept.newTitle).toBe('New');
// Missing trips throw the byte-identical error; invalid ranges reject.
expect(() => svc.updateTrip(99999, owner.id, {}, 'user')).toThrow('Trip not found');
expect(() => svc.updateTrip(trip.id, owner.id, { start_date: '2025-06-10', end_date: '2025-06-01' }, 'user')).toThrow('End date must be after start date');
});
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');
});
it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Joinable' });
const result = svc.addMember(trip.id, invitee.email, owner.id, owner.id);
expect(result.member.id).toBe(invitee.id);
expect(result.tripTitle).toBe('Joinable');
expect(result.targetUserId).toBe(invitee.id);
// Duplicate + owner + missing identifier reject with the byte-identical errors.
expect(() => svc.addMember(trip.id, invitee.email, owner.id, owner.id)).toThrow('User already has access');
expect(() => svc.addMember(trip.id, owner.email, owner.id, owner.id)).toThrow('Trip owner is already a member');
expect(() => svc.addMember(trip.id, '', owner.id, owner.id)).toThrow('Email or username required');
svc.removeMember(trip.id, invitee.id);
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id)).toBeUndefined();
});
it('TRIP-SVC-050: copy remaps tags, accommodations, reservations, day notes, budget, bags and category order', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Deep', start_date: '2025-06-01', end_date: '2025-06-02' });
const days = getDays(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Hotel Zed' });
const assignment = createDayAssignment(testDb, days[0].id, place.id);
const tagId = Number(testDb.prepare("INSERT INTO tags (name, user_id) VALUES ('beach', ?)").run(user.id).lastInsertRowid);
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tagId);
const accomId = Number(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(trip.id, place.id, days[0].id, days[1].id).lastInsertRowid);
testDb.prepare(
'INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
).run(trip.id, days[0].id, days[1].id, place.id, assignment.id, accomId, 'Stay', 'hotel');
testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'stay', 'Hotel', 120)").run(trip.id);
const bagId = Number(testDb.prepare("INSERT INTO packing_bags (trip_id, name) VALUES (?, 'Backpack')").run(trip.id).lastInsertRowid);
testDb.prepare("INSERT INTO packing_items (trip_id, name, checked, bag_id) VALUES (?, 'Towel', 1, ?)").run(trip.id, bagId);
createDayNote(testDb, days[0].id, trip.id, { text: 'note' });
testDb.prepare("INSERT INTO todo_items (trip_id, name, checked) VALUES (?, 'Book', 1)").run(trip.id);
testDb.prepare("INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, 'stay', 2)").run(trip.id);
const newTripId = svc.copy(trip.id, user.id);
const newDays = getDays(newTripId);
expect(newDays).toHaveLength(2);
const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ?').get(newTripId) as { id: number };
expect(testDb.prepare('SELECT tag_id FROM place_tags WHERE place_id = ?').get(newPlace.id)).toEqual({ tag_id: tagId });
const newAccom = testDb.prepare('SELECT id, place_id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?').get(newTripId) as any;
expect(newAccom.place_id).toBe(newPlace.id);
expect(newAccom.start_day_id).toBe(newDays[0].id);
const newRes = testDb.prepare('SELECT day_id, end_day_id, place_id, accommodation_id FROM reservations WHERE trip_id = ?').get(newTripId) as any;
// Quirk preserved from the legacy copyTripById: reservations.accommodation_id
// is a TEXT column, so the number-keyed accomMap lookup never matches and the
// copied reservation loses its accommodation link (nulled, not remapped).
expect(newRes).toEqual({ day_id: newDays[0].id, end_day_id: newDays[1].id, place_id: newPlace.id, accommodation_id: null });
expect((testDb.prepare('SELECT COUNT(*) AS n FROM budget_items WHERE trip_id = ?').get(newTripId) as any).n).toBe(1);
const newItem = testDb.prepare('SELECT checked, bag_id FROM packing_items WHERE trip_id = ?').get(newTripId) as any;
expect(newItem.checked).toBe(0);
expect(newItem.bag_id).not.toBeNull();
expect((testDb.prepare('SELECT checked, assigned_user_id FROM todo_items WHERE trip_id = ?').get(newTripId) as any)).toEqual({ checked: 0, assigned_user_id: null });
expect((testDb.prepare('SELECT sort_order FROM budget_category_order WHERE trip_id = ?').get(newTripId) as any).sort_order).toBe(2);
expect((testDb.prepare('SELECT COUNT(*) AS n FROM day_notes WHERE trip_id = ?').get(newTripId) as any).n).toBe(1);
// Missing source throws the byte-identical error.
expect(() => svc.copy(99999, user.id)).toThrow('Trip not found');
});
});
// ── Post-fold quirk fixes (transactions, owner display name) ─────────────────
describe('quirk fixes', () => {
/** A TripsService whose connection throws when preparing SQL matching `match`. */
function failingTrips(match: string) {
const conn = new Proxy(testDb, {
get(target, prop) {
if (prop === 'prepare') {
return (sql: string) => {
if (sql.includes(match)) throw new Error('boom');
return target.prepare(sql);
};
}
const v = (target as any)[prop];
return typeof v === 'function' ? v.bind(target) : v;
},
});
const fdbs = { connection: conn, canAccessTrip: dbMock.canAccessTrip, isOwner: dbMock.isOwner } as unknown as import('../../../src/nest/database/database.service').DatabaseService;
return new TripsService(
fdbs,
new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService()),
daysSvc,
new PermissionsService(dbs()),
budgetSvc,
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new VacayService(dbs(), new RealtimeService()),
new RealtimeService(),
placesSvc,
);
}
it('TRIP-SVC-051: remove is atomic — a failed trip DELETE keeps the journey entries intact', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const journeyId = Number(testDb.prepare(
"INSERT INTO journeys (user_id, title, created_at, updated_at) VALUES (?, 'J', 0, 0)",
).run(user.id).lastInsertRowid);
testDb.prepare(
"INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'skeleton', 'S', '2025-06-01', 0, 0)",
).run(journeyId, trip.id, user.id);
const broken = failingTrips('DELETE FROM trips WHERE id = ?');
expect(() => broken.remove(trip.id, user.id, 'user')).toThrow('boom');
// The skeleton cleanup rolled back with the failed delete.
expect(testDb.prepare("SELECT id FROM journey_entries WHERE type = 'skeleton'").get()).toBeDefined();
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined();
});
it('TRIP-SVC-052: deleteGuest is atomic — a failed user DELETE rolls the budget re-split back', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member: guest } = svc.createGuest(trip.id, 'Gia', owner.id);
const item = budgetSvc.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, guest.id] });
const broken = failingTrips('DELETE FROM users WHERE id = ? AND is_guest = 1');
expect(() => broken.deleteGuest(trip.id, guest.id)).toThrow('boom');
// Neither the guest nor their split membership was touched.
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guest.id)).toBeDefined();
const row = testDb.prepare('SELECT persons FROM budget_items WHERE id = ?').get(item.id) as { persons: number | null };
expect(row.persons).toBe(2);
});
it('TRIP-SVC-053: listMembers prefers the owner display_name over the raw username (quirk fix)', () => {
const { user: owner } = createUser(testDb, { username: 'owner-handle' });
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Olive Displayed', owner.id);
const trip = createTrip(testDb, owner.id);
const { owner: row } = svc.listMembers(trip.id, owner.id);
expect(row.username).toBe('Olive Displayed');
});
});