mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
81292d4d2b
The audit over the 4.3.1 release found the seating rule reading a night by one value and its neighbours by another, so two nights on one day never agreed: the migration reported moves that changed nothing, flipped the pair on the next run and dropped the road drawn between them, and in the service every notes edit swapped them. One rule now lives in accommodations/night-seat.ts, measures every night by its own check-in, breaks a tie by the booking made first, and carries the vias; the service, the reseat migration and a trip date change all seat through it, the last of which used to append the night at the end of its day again. An account created by a first SSO login writes the user.register row the password path writes. Tests cover the equal and absent check-ins, the date change, the booking form path and a day that is nothing but its night.
1366 lines
80 KiB
TypeScript
1366 lines
80 KiB
TypeScript
/**
|
|
* Unit tests for the DI-native ReservationsService — RESV-SVC-001+ (real-SQL
|
|
* cases over the folded legacy services/reservationService.ts statements),
|
|
* RES-TRAV-001..005 (moved 1:1 from tests/unit/services/reservationTravelers.test.ts)
|
|
* and RESV-BRIDGE-001..009, which kept their assertions when the bridge went.
|
|
* Uses a real in-memory SQLite DB so the SQL logic — falsy-coercion defaults,
|
|
* COALESCE update semantics, day derivation, the accommodation/budget cascades
|
|
* and the TEXT accommodation_id normalization — is exercised faithfully.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
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: unknown, userId: number) => db.prepare(`
|
|
SELECT t.* 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: unknown, 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);
|
|
|
|
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
|
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
|
|
|
const checkPermission = vi.fn(() => true);
|
|
const permissionsStub = { checkPermission } as unknown as PermissionsService;
|
|
|
|
// Constructor-injected since the budget fold (was a path mock of the deleted
|
|
// services/budgetService).
|
|
const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() };
|
|
|
|
const { notif } = vi.hoisted(() => ({ notif: { send: vi.fn().mockResolvedValue(undefined) } }));
|
|
|
|
import { HttpException } from '@nestjs/common';
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createUser, createTrip, createReservation, createBudgetItem, createPlace, createDay, createDayAccommodation, createDayAssignment, addTripMember } from '../../helpers/factories';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
|
import { ReservationsReadRepository } from '../../../src/nest/reservations/reservations-read.repository';
|
|
import type { BudgetService } from '../../../src/nest/budget/budget.service';
|
|
// Was reservations.bridge, deleted along with the other three that had no
|
|
// consumer outside the container. The cases below kept their assertions and
|
|
// point at the service the bridge delegated to; only the delegation itself is
|
|
// gone, and there was nothing left to delegate for.
|
|
const bridge = {
|
|
listReservations: (tripId: string | number) => svc.list(tripId),
|
|
loadEndpointsByTrip: (tripId: string | number) => svc.loadEndpointsByTrip(tripId),
|
|
resyncReservationDays: (tripId: string | number) => svc.resyncReservationDays(tripId),
|
|
createReservation: (tripId: string | number, data: Parameters<ReservationsService['create']>[1]) => svc.create(tripId, data),
|
|
getReservation: (id: string | number, tripId: string | number) => svc.getReservation(id, tripId),
|
|
getReservationWithJoins: (id: string | number) => svc.getReservationWithJoins(id),
|
|
updateReservation: (
|
|
id: string | number,
|
|
tripId: string | number,
|
|
data: Parameters<ReservationsService['update']>[2],
|
|
current: Parameters<ReservationsService['update']>[3],
|
|
) => svc.update(id, tripId, data, current),
|
|
deleteReservation: (id: string | number, tripId: string | number) => svc.remove(id, tripId),
|
|
notifyBookingChange: (...a: Parameters<ReservationsService['notifyBookingChange']>) => svc.notifyBookingChange(...a),
|
|
};
|
|
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import { notificationsStub } from '../../helpers/notifications';
|
|
import { makeAccommodationsService } from '../../helpers/accommodations-service';
|
|
|
|
const svc = new ReservationsService(new DatabaseService(testDb), permissionsStub, budget as unknown as BudgetService, new RealtimeService(), notificationsStub(notif.send), new ReservationsReadRepository(new DatabaseService(testDb)), makeAccommodationsService(testDb));
|
|
|
|
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
vi.clearAllMocks();
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
afterAll(() => testDb.close());
|
|
|
|
/** Owner + trip helper for the common single-user cases. */
|
|
function ownerTrip(overrides: Parameters<typeof createTrip>[2] = {}) {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id, overrides);
|
|
return { user, trip };
|
|
}
|
|
|
|
describe('ReservationsService (DI-native, real SQL)', () => {
|
|
it('RESV-SVC-001: canEdit delegates to checkPermission with reservation_edit', () => {
|
|
svc.canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never);
|
|
expect(checkPermission).toHaveBeenCalledWith('reservation_edit', 'user', 2, 1, true);
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('RESV-SVC-002: applies the legacy falsy-coercion defaults and re-selects the joined row', () => {
|
|
const { trip } = ownerTrip();
|
|
const { reservation, accommodationCreated } = svc.create(String(trip.id), { title: 'Dinner', location: '' } as never);
|
|
expect(accommodationCreated).toBe(false);
|
|
expect(reservation).toMatchObject({
|
|
title: 'Dinner',
|
|
type: 'other', // type || 'other'
|
|
status: 'pending', // status || 'pending'
|
|
location: null, // '' || null
|
|
accommodation_id: null,
|
|
endpoints: [],
|
|
travelers: [],
|
|
});
|
|
});
|
|
|
|
it('RESV-SVC-003: derives day_id from reservation_time for non-hotel bookings', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
|
const day2 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2030-05-02') as { id: number };
|
|
const { reservation } = svc.create(String(trip.id), { title: 'Tour', type: 'tour', reservation_time: '2030-05-02T10:00:00' });
|
|
expect(reservation.day_id).toBe(day2.id);
|
|
// Out-of-range time clamps to the nearest day (create path clamps).
|
|
const day3 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2030-05-03') as { id: number };
|
|
const { reservation: clamped } = svc.create(String(trip.id), { title: 'Late', type: 'tour', reservation_time: '2030-06-20T10:00:00' });
|
|
expect(clamped.day_id).toBe(day3.id);
|
|
});
|
|
|
|
it('RESV-SVC-004: auto-creates the accommodation for a hotel with create_accommodation', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
|
const place = createPlace(testDb, trip.id);
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
const { reservation, accommodationCreated } = svc.create(String(trip.id), {
|
|
title: 'Hotel', type: 'hotel',
|
|
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[2].id, check_in: '15:00' },
|
|
confirmation_number: 'ABC123',
|
|
});
|
|
expect(accommodationCreated).toBe(true);
|
|
const acc = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(trip.id) as Record<string, unknown>;
|
|
expect(acc).toMatchObject({ place_id: place.id, check_in: '15:00', confirmation: 'ABC123' });
|
|
// TEXT accommodation_id column reads back normalized to an int.
|
|
expect(reservation.accommodation_id).toBe(Number(acc.id));
|
|
expect(reservation.accommodation_name).toBe(place.name);
|
|
});
|
|
|
|
it('RESV-SVC-005: saves endpoints, skipping null-coordinate rows; sequence defaults to the post-filter index', () => {
|
|
const { trip } = ownerTrip();
|
|
const endpoints = [
|
|
{ role: 'from', name: 'A', code: 'AAA', lat: 1, lng: 2, timezone: null, local_time: null, local_date: null },
|
|
{ role: 'to', name: 'NoGeo', code: null, lat: null, lng: null, timezone: null, local_time: null, local_date: null },
|
|
{ role: 'to', name: 'B', code: 'BBB', lat: 3, lng: 4, timezone: null, local_time: null, local_date: null },
|
|
];
|
|
const { reservation } = svc.create(String(trip.id), { title: 'Bus', type: 'other', endpoints } as never);
|
|
const rows = testDb.prepare('SELECT name, sequence FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence').all(reservation.id) as { name: string; sequence: number }[];
|
|
// The index fallback runs AFTER the null-coord filter, so 'B' (third on
|
|
// the wire, second surviving row) gets sequence 1.
|
|
expect(rows).toEqual([{ name: 'A', sequence: 0 }, { name: 'B', sequence: 1 }]);
|
|
});
|
|
|
|
it('RESV-SVC-006 (quirk fixed): metadata check-in sync keys off the resolved id, so an auto-created accommodation gets its times too', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
|
const place = createPlace(testDb, trip.id);
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
svc.create(String(trip.id), {
|
|
title: 'Hotel', type: 'hotel',
|
|
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[1].id },
|
|
metadata: { check_in_time: '16:00' },
|
|
});
|
|
// The legacy gate read the raw accommodation_id and left this NULL.
|
|
const acc = testDb.prepare('SELECT check_in FROM day_accommodations WHERE trip_id = ?').get(trip.id) as { check_in: string | null };
|
|
expect(acc.check_in).toBe('16:00');
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('RESV-SVC-007: an empty-string title silently keeps the old value (COALESCE + `|| null`)', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id, { title: 'Old title' });
|
|
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
const { reservation } = svc.update(String(res.id), String(trip.id), { title: '', notes: 'updated' }, current);
|
|
expect(reservation.title).toBe('Old title');
|
|
expect(reservation.notes).toBe('updated');
|
|
});
|
|
|
|
it('RESV-SVC-008: switching to hotel forces reservation_time/reservation_end_time to null', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id, { title: 'X', type: 'tour' });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2030-05-01T10:00:00', res.id);
|
|
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
const { reservation } = svc.update(String(res.id), String(trip.id), { type: 'hotel' }, current);
|
|
expect(reservation.reservation_time).toBeNull();
|
|
expect(reservation.reservation_end_time).toBeNull();
|
|
});
|
|
|
|
it('RESV-SVC-009: a stale accommodation_id pointing at a deleted row is nulled out', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
|
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run('999', res.id);
|
|
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
const { reservation } = svc.update(String(res.id), String(trip.id), { notes: 'n' }, current);
|
|
expect(reservation.accommodation_id).toBeNull();
|
|
});
|
|
|
|
it('RESV-SVC-033: a metadata payload without `price` keeps the linked expense price (and currency) from the stored metadata', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' });
|
|
const item = createBudgetItem(testDb, trip.id, { name: 'Flight', total_price: 2040 });
|
|
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id);
|
|
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?')
|
|
.run(JSON.stringify({ airline: 'CZ', seat: '72K', price: '2040', priceCurrency: 'CNY' }), res.id);
|
|
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
// What the booking form sends back: rebuilt from its fields, price not among them.
|
|
const { reservation } = svc.update(String(res.id), String(trip.id), { metadata: { airline: 'CZ', seat: '12A' } }, current);
|
|
expect(JSON.parse(reservation.metadata as string)).toEqual({ airline: 'CZ', seat: '12A', price: '2040', priceCurrency: 'CNY' });
|
|
});
|
|
|
|
it('RESV-SVC-034: a payload that names `price` is taken at face value, set or cleared', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id, { title: 'Linked', type: 'flight' });
|
|
const item = createBudgetItem(testDb, trip.id, { name: 'Linked', total_price: 100 });
|
|
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id);
|
|
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify({ price: '100' }), res.id);
|
|
|
|
let current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
let { reservation } = svc.update(String(res.id), String(trip.id), { metadata: { price: '150' } }, current);
|
|
expect(JSON.parse(reservation.metadata as string)).toEqual({ price: '150' });
|
|
|
|
// Naming the key with a null is how a caller removes the price on purpose;
|
|
// it must not be read as "the form did not mention it".
|
|
current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
({ reservation } = svc.update(String(res.id), String(trip.id), { metadata: { price: null, seat: '1A' } }, current));
|
|
expect(JSON.parse(reservation.metadata as string)).toEqual({ price: null, seat: '1A' });
|
|
});
|
|
|
|
it('RESV-SVC-036: a price with no linked expense is kept too, because the importer stamps one either way', () => {
|
|
// booking-import writes metadata.price unconditionally but only creates the
|
|
// linked cost when the Costs addon is on and the price is above zero, so an
|
|
// instance with that addon off has bookings carrying a price and no budget
|
|
// row at all. Keying the carry-over on a linked item would drop those.
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id, { title: 'Imported', type: 'flight' });
|
|
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?')
|
|
.run(JSON.stringify({ price: '999', priceCurrency: 'EUR' }), res.id);
|
|
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
|
|
const { reservation } = svc.update(String(res.id), String(trip.id), { metadata: { seat: '1A' } }, current);
|
|
expect(JSON.parse(reservation.metadata as string)).toEqual({ seat: '1A', price: '999', priceCurrency: 'EUR' });
|
|
});
|
|
|
|
it('RESV-SVC-035: metadata null still clears the stored metadata, linked expense or not', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' });
|
|
const item = createBudgetItem(testDb, trip.id, { name: 'Flight', total_price: 50 });
|
|
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id);
|
|
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify({ price: '50' }), res.id);
|
|
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
const { reservation } = svc.update(String(res.id), String(trip.id), { metadata: null }, current);
|
|
expect(reservation.metadata).toBeNull();
|
|
});
|
|
|
|
it('RESV-SVC-010: endpoints [] wipes stored endpoints; an absent field leaves them alone', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id, { title: 'Bus' });
|
|
testDb.prepare('INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, 0, ?, 1, 2)').run(res.id, 'from', 'A');
|
|
let current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
svc.update(String(res.id), String(trip.id), { notes: 'no endpoints field' }, current);
|
|
expect(testDb.prepare('SELECT COUNT(*) as c FROM reservation_endpoints WHERE reservation_id = ?').get(res.id)).toEqual({ c: 1 });
|
|
current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
svc.update(String(res.id), String(trip.id), { endpoints: [] }, current);
|
|
expect(testDb.prepare('SELECT COUNT(*) as c FROM reservation_endpoints WHERE reservation_id = ?').get(res.id)).toEqual({ c: 0 });
|
|
});
|
|
});
|
|
|
|
describe('remove', () => {
|
|
it('RESV-SVC-011: cascades to the linked accommodation and budget item, reporting both', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
|
const place = createPlace(testDb, trip.id);
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
|
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
|
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(String(acc.id), res.id);
|
|
const item = createBudgetItem(testDb, trip.id);
|
|
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id);
|
|
|
|
const result = svc.remove(String(res.id), String(trip.id));
|
|
expect(result.deleted).toMatchObject({ id: res.id, title: 'Hotel', type: 'hotel' });
|
|
expect(result.accommodationDeleted).toBe(true);
|
|
expect(result.deletedBudgetItemId).toBe(item.id);
|
|
expect(testDb.prepare('SELECT COUNT(*) as c FROM reservations WHERE id = ?').get(res.id)).toEqual({ c: 0 });
|
|
expect(testDb.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE id = ?').get(acc.id)).toEqual({ c: 0 });
|
|
expect(testDb.prepare('SELECT COUNT(*) as c FROM budget_items WHERE id = ?').get(item.id)).toEqual({ c: 0 });
|
|
});
|
|
|
|
it('RESV-SVC-012: returns the empty shape when the reservation is missing', () => {
|
|
const { trip } = ownerTrip();
|
|
expect(svc.remove('999', String(trip.id))).toEqual({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null });
|
|
});
|
|
});
|
|
|
|
describe('updatePositions', () => {
|
|
it('RESV-SVC-013: without dayId updates the global day_plan_position (legacy branch)', () => {
|
|
const { trip } = ownerTrip();
|
|
const r1 = createReservation(testDb, trip.id, { title: 'A' });
|
|
const r2 = createReservation(testDb, trip.id, { title: 'B' });
|
|
svc.updatePositions(String(trip.id), [{ id: r2.id, day_plan_position: 0 }, { id: r1.id, day_plan_position: 1 }]);
|
|
expect(testDb.prepare('SELECT day_plan_position FROM reservations WHERE id = ?').get(r2.id)).toEqual({ day_plan_position: 0 });
|
|
expect(testDb.prepare('SELECT day_plan_position FROM reservations WHERE id = ?').get(r1.id)).toEqual({ day_plan_position: 1 });
|
|
});
|
|
|
|
it('RESV-SVC-014: with dayId upserts per-day positions into reservation_day_positions', () => {
|
|
const { trip } = ownerTrip();
|
|
const day = createDay(testDb, trip.id, { date: '2030-05-01' });
|
|
const r1 = createReservation(testDb, trip.id, { title: 'A' });
|
|
svc.updatePositions(String(trip.id), [{ id: r1.id, day_plan_position: 3 }], day.id);
|
|
svc.updatePositions(String(trip.id), [{ id: r1.id, day_plan_position: 5 }], day.id); // OR REPLACE
|
|
expect(testDb.prepare('SELECT position FROM reservation_day_positions WHERE reservation_id = ? AND day_id = ?').get(r1.id, day.id)).toEqual({ position: 5 });
|
|
expect(testDb.prepare('SELECT day_plan_position FROM reservations WHERE id = ?').get(r1.id)).toEqual({ day_plan_position: null });
|
|
});
|
|
});
|
|
|
|
describe('list / getReservationWithJoins', () => {
|
|
it('RESV-SVC-015: attaches day_positions, endpoints, travelers and normalizes the TEXT accommodation_id', () => {
|
|
const { user, trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
|
const place = createPlace(testDb, trip.id);
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
|
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
|
// The TEXT column quirk: the FK reads back as a numeric string like "14.0".
|
|
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(`${acc.id}.0`, res.id);
|
|
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(res.id, days[0].id, 2);
|
|
svc.setReservationTravelers(res.id, trip.id, [user.id]);
|
|
|
|
const [row] = svc.list(String(trip.id));
|
|
expect(row.accommodation_id).toBe(acc.id);
|
|
expect(row.accommodation_name).toBe(place.name);
|
|
expect(row.day_positions).toEqual({ [days[0].id]: 2 });
|
|
expect(row.travelers).toHaveLength(1);
|
|
|
|
const single = svc.getReservationWithJoins(res.id)!;
|
|
expect(single.accommodation_id).toBe(acc.id);
|
|
expect(single.travelers).toHaveLength(1);
|
|
});
|
|
|
|
it('RESV-SVC-016: getReservationWithJoins returns undefined for a missing id', () => {
|
|
expect(svc.getReservationWithJoins(999)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('listUpcoming', () => {
|
|
it('RESV-SVC-017: returns future reservations across owned + member trips, skipping cancelled', () => {
|
|
const { user, trip } = ownerTrip();
|
|
const future = createReservation(testDb, trip.id, { title: 'Future' });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', future.id);
|
|
const past = createReservation(testDb, trip.id, { title: 'Past' });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2000-01-01T10:00:00', past.id);
|
|
const cancelled = createReservation(testDb, trip.id, { title: 'Cancelled' });
|
|
testDb.prepare("UPDATE reservations SET reservation_time = ?, status = 'cancelled' WHERE id = ?").run('2999-01-02T10:00:00', cancelled.id);
|
|
|
|
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
|
expect(rows.map(r => r.title)).toEqual(['Future']);
|
|
});
|
|
|
|
// #1934 — a stay covers a range, so it would hold a widget slot for its
|
|
// whole span. Both ways a hotel can be dated are kept out.
|
|
it('RESV-SVC-017b: leaves out a hotel dated through its own reservation_time', () => {
|
|
const { user, trip } = ownerTrip();
|
|
const hotel = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', hotel.id);
|
|
const flight = createReservation(testDb, trip.id, { title: 'Flight' });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-02T10:00:00', flight.id);
|
|
|
|
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
|
expect(rows.map(r => r.title)).toEqual(['Flight']);
|
|
});
|
|
|
|
it('RESV-SVC-017c: leaves out a hotel dated through its day', () => {
|
|
const { user, trip } = ownerTrip();
|
|
const day = createDay(testDb, trip.id, { date: '2999-06-01' });
|
|
createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel', day_id: day.id });
|
|
|
|
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
|
expect(rows.map(r => r.title)).toEqual([]);
|
|
});
|
|
|
|
// The type column defaults to 'other' but nothing stops a NULL, and a
|
|
// NULL-unsafe `type != 'hotel'` would silently drop those rows.
|
|
it('RESV-SVC-017d: a reservation with no type at all still shows', () => {
|
|
const { user, trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id, { title: 'Untyped' });
|
|
testDb.prepare('UPDATE reservations SET type = NULL, reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', res.id);
|
|
|
|
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
|
expect(rows.map(r => r.title)).toEqual(['Untyped']);
|
|
});
|
|
|
|
// The column holds two shapes: the booking form writes 'YYYY-MM-DDTHH:MM',
|
|
// day-anchored rows hold a bare 'HH:MM'. Compared as strings against an ISO
|
|
// timestamp, ':' sorts above '2', so a bare time was an arbitrary filter:
|
|
// everything from 20:00 on passed and everything before it vanished (#1934).
|
|
it('RESV-SVC-017e: a bare clock time falls back to its day instead of being read as a date', () => {
|
|
const { user, trip } = ownerTrip();
|
|
const day = createDay(testDb, trip.id, { date: '2999-06-01' });
|
|
const morning = createReservation(testDb, trip.id, { title: 'Ferry', day_id: day.id });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('08:30', morning.id);
|
|
const evening = createReservation(testDb, trip.id, { title: 'Show', day_id: day.id });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('20:00', evening.id);
|
|
|
|
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
|
expect(rows.map(r => r.title)).toEqual(['Ferry', 'Show']);
|
|
});
|
|
|
|
it('RESV-SVC-017f: orders by the day and the clock, not by the raw column', () => {
|
|
const { user, trip } = ownerTrip();
|
|
const early = createDay(testDb, trip.id, { date: '2999-06-01' });
|
|
const late = createDay(testDb, trip.id, { date: '2999-06-02' });
|
|
const bare = createReservation(testDb, trip.id, { title: 'Bare morning', day_id: early.id });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('08:30', bare.id);
|
|
const dated = createReservation(testDb, trip.id, { title: 'Dated day two' });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-06-02T09:00', dated.id);
|
|
const later = createReservation(testDb, trip.id, { title: 'Bare evening', day_id: early.id });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('20:00', later.id);
|
|
const dayOnly = createReservation(testDb, trip.id, { title: 'No time', day_id: late.id });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = NULL WHERE id = ?').run(dayOnly.id);
|
|
|
|
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
|
expect(rows.map(r => r.title)).toEqual(['Bare morning', 'Bare evening', 'No time', 'Dated day two']);
|
|
});
|
|
|
|
// The stay stays out, but arriving and leaving are moments, and they are the
|
|
// two the traveller needs reminding of.
|
|
it('RESV-SVC-017g: a stay contributes a check-in and a check-out named after its place', () => {
|
|
const { user, trip } = ownerTrip();
|
|
const start = createDay(testDb, trip.id, { date: '2999-06-01' });
|
|
const end = createDay(testDb, trip.id, { date: '2999-06-05' });
|
|
const place = createPlace(testDb, trip.id, { name: 'The Plaza' });
|
|
createDayAccommodation(testDb, trip.id, place.id, start.id, end.id, { check_in: '15:00', check_out: '11:00' });
|
|
|
|
const rows = svc.listUpcoming(user.id) as { title: string; type: string; day_date: string }[];
|
|
expect(rows).toEqual([
|
|
expect.objectContaining({ title: 'The Plaza', type: 'checkin', day_date: '2999-06-01' }),
|
|
expect.objectContaining({ title: 'The Plaza', type: 'checkout', day_date: '2999-06-05' }),
|
|
]);
|
|
});
|
|
|
|
it('RESV-SVC-017h: the two moments sort among the bookings rather than after them', () => {
|
|
const { user, trip } = ownerTrip();
|
|
const start = createDay(testDb, trip.id, { date: '2999-06-01' });
|
|
const end = createDay(testDb, trip.id, { date: '2999-06-03' });
|
|
const place = createPlace(testDb, trip.id, { name: 'Hostel' });
|
|
createDayAccommodation(testDb, trip.id, place.id, start.id, end.id, { check_in: '15:00', check_out: '10:00' });
|
|
const dinner = createReservation(testDb, trip.id, { title: 'Dinner' });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-06-01T19:00', dinner.id);
|
|
const museum = createReservation(testDb, trip.id, { title: 'Museum' });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-06-02T09:00', museum.id);
|
|
|
|
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
|
expect(rows.map(r => r.title)).toEqual(['Hostel', 'Dinner', 'Museum', 'Hostel']);
|
|
});
|
|
|
|
it('RESV-SVC-017i: a stay that is already over contributes nothing', () => {
|
|
const { user, trip } = ownerTrip();
|
|
const start = createDay(testDb, trip.id, { date: '2000-06-01' });
|
|
const end = createDay(testDb, trip.id, { date: '2000-06-05' });
|
|
const place = createPlace(testDb, trip.id, { name: 'Old Inn' });
|
|
createDayAccommodation(testDb, trip.id, place.id, start.id, end.id, { check_in: '15:00', check_out: '11:00' });
|
|
|
|
expect(svc.listUpcoming(user.id)).toEqual([]);
|
|
});
|
|
|
|
it('RESV-SVC-017j: a nameless stay falls back to its linked booking', () => {
|
|
const { user, trip } = ownerTrip();
|
|
const start = createDay(testDb, trip.id, { date: '2999-06-01' });
|
|
const end = createDay(testDb, trip.id, { date: '2999-06-02' });
|
|
const acc = testDb.prepare(
|
|
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out) VALUES (?, NULL, ?, ?, ?, ?)'
|
|
).run(trip.id, start.id, end.id, '15:00', '11:00');
|
|
const booking = createReservation(testDb, trip.id, { title: 'Hotel Ibis', type: 'hotel' });
|
|
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(String(acc.lastInsertRowid), booking.id);
|
|
|
|
const rows = svc.listUpcoming(user.id) as { title: string; type: string }[];
|
|
expect(rows.map(r => r.type + ':' + r.title)).toEqual(['checkin:Hotel Ibis', 'checkout:Hotel Ibis']);
|
|
});
|
|
});
|
|
|
|
describe('resyncReservationDays', () => {
|
|
it('RESV-SVC-018: re-anchors a dated booking to the day matching its time; out-of-range stays untouched', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
|
const days = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string }[];
|
|
const res = createReservation(testDb, trip.id, { title: 'Tour', type: 'tour', day_id: days[0].id });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2030-05-03T09:00:00', res.id);
|
|
const outside = createReservation(testDb, trip.id, { title: 'Outside', type: 'tour', day_id: days[0].id });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2031-01-01T09:00:00', outside.id);
|
|
|
|
svc.resyncReservationDays(String(trip.id));
|
|
expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[2].id });
|
|
expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(outside.id)).toEqual({ day_id: days[0].id });
|
|
});
|
|
});
|
|
|
|
describe('setTravelers (controller-facing composition)', () => {
|
|
it('returns null when the reservation is not on the trip (off-trip guard)', () => {
|
|
const { trip } = ownerTrip();
|
|
expect(svc.setTravelers('999', String(trip.id), [2])).toBeNull();
|
|
});
|
|
|
|
it('assigns travelers and returns the refreshed travelers + reservation', () => {
|
|
const { user, trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id);
|
|
const result = svc.setTravelers(String(res.id), String(trip.id), [user.id])!;
|
|
expect(result.travelers).toHaveLength(1);
|
|
expect(result.travelers[0]).toMatchObject({ user_id: user.id });
|
|
expect(result.reservation).toMatchObject({ id: res.id });
|
|
});
|
|
});
|
|
|
|
describe('syncBudgetOnCreate', () => {
|
|
it('does nothing without a positive price', () => {
|
|
svc.syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', undefined, 'sock');
|
|
svc.syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', { total_price: 0 }, 'sock');
|
|
expect(budget.linkBudgetItemToReservation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('links a budget item and broadcasts budget:created', () => {
|
|
budget.linkBudgetItemToReservation.mockReturnValue({ id: 7 });
|
|
svc.syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', { total_price: 200, category: 'Lodging' }, 'sock');
|
|
expect(budget.linkBudgetItemToReservation).toHaveBeenCalledWith('5', 9, { name: 'Hotel', category: 'Lodging', total_price: 200 });
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 7 } }, 'sock');
|
|
});
|
|
|
|
it('falls back to type then "Other" for the category and swallows errors', () => {
|
|
budget.linkBudgetItemToReservation.mockImplementation(() => { throw new Error('boom'); });
|
|
expect(() => svc.syncBudgetOnCreate('5', 9, 'Hotel', undefined, { total_price: 50 }, 'sock')).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('syncBudgetOnUpdate', () => {
|
|
function linkedItem(overrides: Parameters<typeof createBudgetItem>[2] = {}) {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id);
|
|
const item = createBudgetItem(testDb, trip.id, overrides);
|
|
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id);
|
|
return { trip, res, item };
|
|
}
|
|
|
|
it('deletes the linked item when the price is explicitly cleared (total_price 0)', () => {
|
|
const { trip, res, item } = linkedItem();
|
|
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'Hotel', 'lodging', 'Hotel', 'lodging', { total_price: 0 }, 'sock');
|
|
expect(budget.deleteBudgetItem).toHaveBeenCalledWith(item.id, String(trip.id));
|
|
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:deleted', { itemId: item.id }, 'sock');
|
|
});
|
|
|
|
it('leaves the linked item alone when no budget entry is on the payload (no wipe)', () => {
|
|
const { trip, res } = linkedItem();
|
|
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'Hotel', 'lodging', 'Hotel', 'lodging', undefined, 'sock');
|
|
expect(budget.deleteBudgetItem).not.toHaveBeenCalled();
|
|
expect(budget.updateBudgetItem).not.toHaveBeenCalled();
|
|
expect(budget.createBudgetItem).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('syncs the linked expense category when the booking type changes', () => {
|
|
const { trip, res, item } = linkedItem({ category: 'other' });
|
|
budget.updateBudgetItem.mockReturnValue({ id: item.id, category: 'flights' });
|
|
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'other', undefined, 'sock');
|
|
expect(budget.updateBudgetItem).toHaveBeenCalledWith(item.id, String(trip.id), { category: 'flights' });
|
|
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:updated', { item: { id: item.id, category: 'flights' } }, 'sock');
|
|
});
|
|
|
|
it('updates an existing linked item when a price is provided', () => {
|
|
const { trip, res, item } = linkedItem();
|
|
budget.updateBudgetItem.mockReturnValue({ id: item.id });
|
|
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'New', 'lodging', 'Old', 'lodging', { total_price: 80 }, 'sock');
|
|
expect(budget.updateBudgetItem).toHaveBeenCalledWith(item.id, String(trip.id), { name: 'New', category: 'lodging', total_price: 80 });
|
|
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:updated', { item: { id: item.id } }, 'sock');
|
|
});
|
|
|
|
it('creates + links a new item when none exists, using the current title fallback', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id);
|
|
const created = createBudgetItem(testDb, trip.id); // the row the mocked create "returns"
|
|
budget.createBudgetItem.mockReturnValue({ id: created.id });
|
|
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), '', undefined, 'Old title', 'flight', { total_price: 120 }, 'sock');
|
|
expect(budget.createBudgetItem).toHaveBeenCalledWith(String(trip.id), { name: 'Old title', category: 'flight', total_price: 120 });
|
|
// The service back-links the created item to the reservation itself.
|
|
expect(testDb.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(created.id)).toEqual({ reservation_id: res.id });
|
|
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'budget:created', { item: { id: created.id, reservation_id: res.id } }, 'sock');
|
|
});
|
|
});
|
|
|
|
describe('notifyBookingChange', () => {
|
|
it('sends the booking_change notification with the legacy payload (fire-and-forget)', async () => {
|
|
const { user, trip } = ownerTrip();
|
|
expect(() => svc.notifyBookingChange(String(trip.id), user.id, 'Hotel', 'lodging')).not.toThrow();
|
|
await vi.waitFor(() => expect(notif.send).toHaveBeenCalled());
|
|
expect(notif.send).toHaveBeenCalledWith({
|
|
event: 'booking_change',
|
|
actorId: user.id,
|
|
scope: 'trip',
|
|
targetId: trip.id,
|
|
params: { trip: trip.title, actor: user.email, booking: 'Hotel', type: 'lodging', tripId: String(trip.id) },
|
|
});
|
|
});
|
|
|
|
it('is a silent no-op when the actor does not exist', async () => {
|
|
const { trip } = ownerTrip();
|
|
svc.notifyBookingChange(String(trip.id), 999, 'Hotel', '');
|
|
await new Promise(r => setTimeout(r, 10));
|
|
expect(notif.send).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('setReservationTravelers / loadTravelers (#1517)', () => {
|
|
/** Create a named guest user (no credentials) and add it to the trip. */
|
|
function addGuest(tripId: number, displayName: string) {
|
|
const { user } = createUser(testDb);
|
|
testDb.prepare('UPDATE users SET is_guest = 1, display_name = ? WHERE id = ?').run(displayName, user.id);
|
|
addTripMember(testDb, tripId, user.id);
|
|
return user;
|
|
}
|
|
|
|
it('RES-TRAV-001: assigns trip members and returns them via loadTravelers', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: member } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
addTripMember(testDb, trip.id, member.id);
|
|
const res = createReservation(testDb, trip.id);
|
|
|
|
svc.setReservationTravelers(res.id, trip.id, [member.id]);
|
|
|
|
const travelers = svc.loadTravelers(res.id);
|
|
expect(travelers).toHaveLength(1);
|
|
expect(travelers[0]).toMatchObject({ user_id: member.id, username: member.username, is_guest: 0 });
|
|
});
|
|
|
|
it('RES-TRAV-002: silently drops a user id that is not on the trip (cross-trip guard)', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: member } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
addTripMember(testDb, trip.id, member.id);
|
|
const res = createReservation(testDb, trip.id);
|
|
|
|
// An outsider who belongs to a different trip only.
|
|
const { user: outsider } = createUser(testDb);
|
|
const otherTrip = createTrip(testDb, outsider.id);
|
|
addTripMember(testDb, otherTrip.id, outsider.id);
|
|
|
|
svc.setReservationTravelers(res.id, trip.id, [member.id, outsider.id]);
|
|
|
|
const ids = svc.loadTravelers(res.id).map(t => t.user_id);
|
|
expect(ids).toEqual([member.id]);
|
|
expect(ids).not.toContain(outsider.id);
|
|
});
|
|
|
|
it('RES-TRAV-003: assigns a named guest (users.is_guest = 1) joined via trip_members', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
const guest = addGuest(trip.id, 'Grandma');
|
|
const res = createReservation(testDb, trip.id);
|
|
|
|
svc.setReservationTravelers(res.id, trip.id, [guest.id]);
|
|
|
|
const travelers = svc.loadTravelers(res.id);
|
|
expect(travelers).toHaveLength(1);
|
|
expect(travelers[0]).toMatchObject({ user_id: guest.id, username: 'Grandma', is_guest: 1 });
|
|
});
|
|
|
|
it('RES-TRAV-004: re-setting replaces the previous set', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: a } = createUser(testDb);
|
|
const { user: b } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
addTripMember(testDb, trip.id, a.id);
|
|
addTripMember(testDb, trip.id, b.id);
|
|
const res = createReservation(testDb, trip.id);
|
|
|
|
svc.setReservationTravelers(res.id, trip.id, [a.id]);
|
|
expect(svc.loadTravelers(res.id).map(t => t.user_id)).toEqual([a.id]);
|
|
|
|
svc.setReservationTravelers(res.id, trip.id, [b.id]);
|
|
const ids = svc.loadTravelers(res.id).map(t => t.user_id);
|
|
expect(ids).toEqual([b.id]);
|
|
expect(ids).not.toContain(a.id);
|
|
|
|
// Clearing removes everyone.
|
|
svc.setReservationTravelers(res.id, trip.id, []);
|
|
expect(svc.loadTravelers(res.id)).toHaveLength(0);
|
|
});
|
|
|
|
it('RES-TRAV-005: list attaches travelers[] per reservation', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: member } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
addTripMember(testDb, trip.id, member.id);
|
|
const withTravelers = createReservation(testDb, trip.id, { title: 'Flight' });
|
|
const withoutTravelers = createReservation(testDb, trip.id, { title: 'Hotel' });
|
|
|
|
svc.setReservationTravelers(withTravelers.id, trip.id, [member.id]);
|
|
|
|
const list = svc.list(trip.id);
|
|
const assigned = list.find(r => r.id === withTravelers.id)!;
|
|
const empty = list.find(r => r.id === withoutTravelers.id)!;
|
|
|
|
expect(assigned.travelers).toHaveLength(1);
|
|
expect(assigned.travelers![0]).toMatchObject({ user_id: member.id, username: member.username });
|
|
expect(empty.travelers).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('ReservationsService — the surface the deleted bridge exposed', () => {
|
|
it('RESV-BRIDGE-001: listReservations delegates to ReservationsService.list', () => {
|
|
const { trip } = ownerTrip();
|
|
createReservation(testDb, trip.id, { title: 'A' });
|
|
expect(bridge.listReservations(trip.id).map(r => r.title)).toEqual(['A']);
|
|
});
|
|
|
|
it('RESV-BRIDGE-002: loadEndpointsByTrip groups endpoints per reservation', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id);
|
|
testDb.prepare('INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, 0, ?, 1, 2)').run(res.id, 'from', 'A');
|
|
const map = bridge.loadEndpointsByTrip(trip.id);
|
|
expect(map.get(res.id)).toHaveLength(1);
|
|
});
|
|
|
|
it('RESV-BRIDGE-003: resyncReservationDays re-anchors through the bridge', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour', day_id: days[0].id });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2030-05-02T09:00:00', res.id);
|
|
bridge.resyncReservationDays(trip.id);
|
|
expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[1].id });
|
|
});
|
|
|
|
it('RESV-BRIDGE-004: createReservation creates through the bridge', () => {
|
|
const { trip } = ownerTrip();
|
|
const { reservation } = bridge.createReservation(trip.id, { title: 'Bridge' });
|
|
expect(reservation).toMatchObject({ title: 'Bridge', type: 'other' });
|
|
});
|
|
|
|
it('RESV-BRIDGE-005: getReservation is trip-scoped', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id);
|
|
expect(bridge.getReservation(res.id, trip.id)).toMatchObject({ id: res.id });
|
|
expect(bridge.getReservation(res.id, trip.id + 1)).toBeUndefined();
|
|
});
|
|
|
|
it('RESV-BRIDGE-006: getReservationWithJoins attaches endpoints + travelers', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id);
|
|
expect(bridge.getReservationWithJoins(res.id)).toMatchObject({ id: res.id, endpoints: [], travelers: [] });
|
|
});
|
|
|
|
it('RESV-BRIDGE-007: updateReservation updates through the bridge', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id, { title: 'Old' });
|
|
const current = bridge.getReservation(res.id, trip.id)!;
|
|
const { reservation } = bridge.updateReservation(res.id, trip.id, { title: 'New' }, current);
|
|
expect(reservation.title).toBe('New');
|
|
});
|
|
|
|
it('RESV-BRIDGE-008: deleteReservation deletes through the bridge', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id);
|
|
const { deleted } = bridge.deleteReservation(res.id, trip.id);
|
|
expect(deleted).toMatchObject({ id: res.id });
|
|
expect(bridge.getReservation(res.id, trip.id)).toBeUndefined();
|
|
});
|
|
|
|
it('RESV-BRIDGE-009: notifyBookingChange fires the notification through the bridge', async () => {
|
|
const { user, trip } = ownerTrip();
|
|
bridge.notifyBookingChange(trip.id, user.id, 'Bridge booking', 'other');
|
|
await vi.waitFor(() => expect(notif.send).toHaveBeenCalled());
|
|
});
|
|
});
|
|
|
|
describe('ReservationsService — legacy branch parity (coverage of the folded conditionals)', () => {
|
|
it('RESV-SVC-019: create with a linked accommodation_id syncs metadata times + confirmation onto it', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
|
const place = createPlace(testDb, trip.id);
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
|
svc.create(String(trip.id), {
|
|
title: 'Hotel', type: 'hotel', accommodation_id: acc.id,
|
|
metadata: { check_in_time: '15:00', check_out_time: '11:00' },
|
|
confirmation_number: 'CN-1',
|
|
});
|
|
const row = testDb.prepare('SELECT check_in, check_out, confirmation FROM day_accommodations WHERE id = ?').get(acc.id);
|
|
expect(row).toEqual({ check_in: '15:00', check_out: '11:00', confirmation: 'CN-1' });
|
|
});
|
|
|
|
it('RESV-SVC-020: create with a malformed reservation_time derives no day', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
|
const { reservation } = svc.create(String(trip.id), { title: 'X', type: 'tour', reservation_time: 'not-a-date' });
|
|
expect(reservation.day_id).toBeNull();
|
|
});
|
|
|
|
it('RESV-SVC-021: hotel update with create_accommodation updates the linked accommodation in place', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
|
const place = createPlace(testDb, trip.id);
|
|
const place2 = createPlace(testDb, trip.id, { name: 'Other Hotel' });
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
|
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
|
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(String(acc.id), res.id);
|
|
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
const { accommodationChanged } = svc.update(String(res.id), String(trip.id), {
|
|
type: 'hotel',
|
|
create_accommodation: { place_id: place2.id, start_day_id: days[1].id, end_day_id: days[2].id, check_in: '14:00' },
|
|
confirmation_number: 'CN-2',
|
|
}, current);
|
|
expect(accommodationChanged).toBe(true);
|
|
const row = testDb.prepare('SELECT place_id, start_day_id, end_day_id, check_in, confirmation FROM day_accommodations WHERE id = ?').get(acc.id);
|
|
expect(row).toEqual({ place_id: place2.id, start_day_id: days[1].id, end_day_id: days[2].id, check_in: '14:00', confirmation: 'CN-2' });
|
|
});
|
|
|
|
it('RESV-SVC-022: hotel update inserts a new accommodation when none is linked and a place is given', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
|
const place = createPlace(testDb, trip.id);
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
|
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
const { reservation, accommodationChanged } = svc.update(String(res.id), String(trip.id), {
|
|
type: 'hotel',
|
|
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[1].id },
|
|
}, current);
|
|
expect(accommodationChanged).toBe(true);
|
|
expect(reservation.accommodation_id).not.toBeNull();
|
|
expect(testDb.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE trip_id = ?').get(trip.id)).toEqual({ c: 1 });
|
|
});
|
|
|
|
it('RESV-SVC-023: explicit update fields bind their values; empty strings null; absent fields keep current', () => {
|
|
const { trip } = ownerTrip();
|
|
const place = createPlace(testDb, trip.id);
|
|
const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour' });
|
|
testDb.prepare("UPDATE reservations SET location = 'Loc', notes = 'N', url = 'U', confirmation_number = 'C' WHERE id = ?").run(res.id);
|
|
let current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
let { reservation } = svc.update(String(res.id), String(trip.id), {
|
|
location: 'NewLoc', notes: '', url: 'https://x', confirmation_number: 'NEW',
|
|
status: 'confirmed', needs_review: true, metadata: { a: 1 }, place_id: place.id,
|
|
}, current);
|
|
expect(reservation).toMatchObject({
|
|
location: 'NewLoc', notes: null, url: 'https://x', confirmation_number: 'NEW',
|
|
status: 'confirmed', needs_review: 1, metadata: JSON.stringify({ a: 1 }), place_id: place.id,
|
|
});
|
|
current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
({ reservation } = svc.update(String(res.id), String(trip.id), { title: 'T2' }, current));
|
|
expect(reservation).toMatchObject({ title: 'T2', location: 'NewLoc', url: 'https://x', place_id: place.id });
|
|
});
|
|
|
|
it('RESV-SVC-024: explicit day_id / end_day_id updates win over derivation; explicit null end clears', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour' });
|
|
let current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
let { reservation } = svc.update(String(res.id), String(trip.id), { day_id: days[1].id, end_day_id: days[2].id }, current);
|
|
expect(reservation).toMatchObject({ day_id: days[1].id, end_day_id: days[2].id });
|
|
current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
({ reservation } = svc.update(String(res.id), String(trip.id), { end_day_id: null, reservation_end_time: '2030-05-02T18:00:00' }, current));
|
|
// explicit end_day_id null wins over the end-time derivation
|
|
expect(reservation.end_day_id).toBeNull();
|
|
current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
({ reservation } = svc.update(String(res.id), String(trip.id), { reservation_end_time: '2030-05-02T18:00:00' }, current));
|
|
// no explicit end day + an end time -> derived
|
|
expect(reservation.end_day_id).toBe(days[1].id);
|
|
});
|
|
|
|
it('RESV-SVC-025: with metadata absent, the stored metadata JSON drives the accommodation sync (confirmation falls back to current)', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
|
const place = createPlace(testDb, trip.id);
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
|
const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
|
testDb.prepare("UPDATE reservations SET accommodation_id = ?, metadata = ?, confirmation_number = 'KEEP' WHERE id = ?")
|
|
.run(String(acc.id), JSON.stringify({ check_in_time: '16:00' }), res.id);
|
|
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
svc.update(String(res.id), String(trip.id), { notes: 'touch' }, current);
|
|
const row = testDb.prepare('SELECT check_in, confirmation FROM day_accommodations WHERE id = ?').get(acc.id);
|
|
expect(row).toEqual({ check_in: '16:00', confirmation: 'KEEP' });
|
|
});
|
|
|
|
it('RESV-SVC-026: resync derives the end day too, keeping the stored one when the end time is out of range', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour', day_id: days[0].id });
|
|
testDb.prepare('UPDATE reservations SET reservation_time = ?, reservation_end_time = ?, end_day_id = ? WHERE id = ?')
|
|
.run('2030-05-02T09:00:00', '2031-01-01T09:00:00', days[0].id, res.id);
|
|
svc.resyncReservationDays(String(trip.id));
|
|
// start re-anchored to day 2; out-of-range end time keeps the stored end day
|
|
expect(testDb.prepare('SELECT day_id, end_day_id FROM reservations WHERE id = ?').get(res.id))
|
|
.toEqual({ day_id: days[1].id, end_day_id: days[0].id });
|
|
// A booking already on the right day is left untouched (no-change skip).
|
|
svc.resyncReservationDays(String(trip.id));
|
|
expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[1].id });
|
|
});
|
|
|
|
it('RESV-SVC-027: listUpcoming includes a timeless reservation whose day is in the future', () => {
|
|
const { user, trip } = ownerTrip();
|
|
const day = createDay(testDb, trip.id, { date: '2999-06-01' });
|
|
createReservation(testDb, trip.id, { title: 'Timeless', day_id: day.id });
|
|
const rows = svc.listUpcoming(user.id) as { title: string }[];
|
|
expect(rows.map(r => r.title)).toContain('Timeless');
|
|
});
|
|
|
|
it('RESV-SVC-028: list merges multiple per-day positions for one reservation', () => {
|
|
const { trip } = ownerTrip();
|
|
const d1 = createDay(testDb, trip.id, { date: '2030-05-01' });
|
|
const d2 = createDay(testDb, trip.id, { date: '2030-05-02' });
|
|
const res = createReservation(testDb, trip.id);
|
|
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(res.id, d1.id, 0);
|
|
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(res.id, d2.id, 3);
|
|
const [row] = svc.list(String(trip.id));
|
|
expect(row.day_positions).toEqual({ [d1.id]: 0, [d2.id]: 3 });
|
|
});
|
|
|
|
it('RESV-SVC-029: notifyBookingChange falls back to "Untitled" and type "booking"', async () => {
|
|
const { user } = createUser(testDb);
|
|
svc.notifyBookingChange('424242', user.id, 'B', '');
|
|
await vi.waitFor(() => expect(notif.send).toHaveBeenCalled());
|
|
expect(notif.send).toHaveBeenCalledWith(expect.objectContaining({
|
|
params: expect.objectContaining({ trip: 'Untitled', type: 'booking' }),
|
|
}));
|
|
});
|
|
|
|
it('RESV-SVC-030: syncBudgetOnUpdate type-change sync is a no-op without a linked item or when the category was hand-picked', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id);
|
|
// no linked item at all
|
|
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'other', undefined, undefined);
|
|
expect(budget.updateBudgetItem).not.toHaveBeenCalled();
|
|
// linked item whose category no longer matches the auto-derived one
|
|
const item = createBudgetItem(testDb, trip.id, { category: 'Hand picked' });
|
|
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id);
|
|
svc.syncBudgetOnUpdate(String(trip.id), String(res.id), 'X', 'flight', 'X', 'other', undefined, undefined);
|
|
expect(budget.updateBudgetItem).not.toHaveBeenCalled();
|
|
// explicit clear with no linked item deletes nothing
|
|
svc.syncBudgetOnUpdate(String(trip.id), '999999', 'X', 'flight', 'X', 'flight', { total_price: 0 }, undefined);
|
|
expect(budget.deleteBudgetItem).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('RESV-SVC-031: list returns staged bookings, the staging inbox has to see its own rows', () => {
|
|
const { trip } = ownerTrip();
|
|
createReservation(testDb, trip.id, { title: 'Live One' });
|
|
const staged = createReservation(testDb, trip.id, { title: 'Parked One' });
|
|
testDb.prepare("UPDATE reservations SET ingest_state = 'staged' WHERE id = ?").run(staged.id);
|
|
|
|
// The visibility predicate belongs to the anonymous exports (ICS feed,
|
|
// shared trip) and must never be pulled into the authenticated list, or
|
|
// nobody can confirm what the ingest parked.
|
|
expect(svc.list(String(trip.id)).map((r: any) => r.title).sort()).toEqual(['Live One', 'Parked One']);
|
|
});
|
|
});
|
|
|
|
describe('ReservationsService — quirk fixes (post-fold)', () => {
|
|
it('RESV-FIX-001: create is atomic — a failing endpoint save rolls back the reservation AND the auto-created accommodation', () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
|
|
const place = createPlace(testDb, trip.id);
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
// sequence is an unbindable object -> the endpoint INSERT throws mid-write.
|
|
const badEndpoints = [{ role: 'from', name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_time: null, local_date: null, sequence: {} }];
|
|
expect(() => svc.create(String(trip.id), {
|
|
title: 'Hotel', type: 'hotel',
|
|
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[1].id },
|
|
endpoints: badEndpoints,
|
|
} as never)).toThrow();
|
|
expect(testDb.prepare('SELECT COUNT(*) as c FROM reservations WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 });
|
|
expect(testDb.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 });
|
|
});
|
|
|
|
it('RESV-FIX-002: update is atomic — a failing endpoint save rolls back the field update', () => {
|
|
const { trip } = ownerTrip();
|
|
const res = createReservation(testDb, trip.id, { title: 'Old' });
|
|
const current = svc.getReservation(String(res.id), String(trip.id))!;
|
|
const badEndpoints = [{ role: 'from', name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_time: null, local_date: null, sequence: {} }];
|
|
expect(() => svc.update(String(res.id), String(trip.id), { title: 'New', endpoints: badEndpoints } as never, current)).toThrow();
|
|
expect(testDb.prepare('SELECT title FROM reservations WHERE id = ?').get(res.id)).toEqual({ title: 'Old' });
|
|
});
|
|
});
|
|
|
|
describe('ReservationsService — referenced ids stay inside the trip', () => {
|
|
/** Attacker's trip and a victim trip they are not a member of. */
|
|
function twoTrips() {
|
|
const { user: attacker } = createUser(testDb);
|
|
const { user: victim } = createUser(testDb, { email: 'victim@example.test' });
|
|
const mine = createTrip(testDb, attacker.id, { start_date: '2030-05-01', end_date: '2030-05-03' });
|
|
const theirs = createTrip(testDb, victim.id, { start_date: '2030-05-01', end_date: '2030-05-03' });
|
|
return { mine, theirs };
|
|
}
|
|
|
|
it('RESV-SCOPE-001: names every foreign id in the body', () => {
|
|
const { mine, theirs } = twoTrips();
|
|
const foreignPlace = createPlace(testDb, theirs.id);
|
|
const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number };
|
|
const foreignAcc = createDayAccommodation(testDb, theirs.id, foreignPlace.id, foreignDay.id, foreignDay.id);
|
|
|
|
expect(svc.referencesOutsideTrip(String(mine.id), {
|
|
title: 'x', day_id: foreignDay.id, place_id: foreignPlace.id, accommodation_id: foreignAcc.id,
|
|
})).toEqual(['day_id', 'place_id', 'accommodation_id']);
|
|
});
|
|
|
|
it('RESV-SCOPE-005: a dangling id is not an offender, so the booking stays editable', () => {
|
|
const { mine } = twoTrips();
|
|
const place = createPlace(testDb, mine.id);
|
|
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(mine.id) as { id: number };
|
|
const acc = createDayAccommodation(testDb, mine.id, place.id, day.id, day.id);
|
|
const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' });
|
|
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(acc.id, res.id);
|
|
// What shortening the trip's date range does: the day goes, the cascade
|
|
// takes the accommodation with it, and the reservation keeps the id.
|
|
testDb.prepare('DELETE FROM day_accommodations WHERE id = ?').run(acc.id);
|
|
|
|
expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x', accommodation_id: acc.id })).toEqual([]);
|
|
|
|
const current = svc.getReservation(String(res.id), String(mine.id))!;
|
|
svc.update(String(res.id), String(mine.id), { accommodation_id: acc.id, title: 'Hotel renamed' } as never, current);
|
|
expect(testDb.prepare('SELECT title, accommodation_id FROM reservations WHERE id = ?').get(res.id))
|
|
.toEqual({ title: 'Hotel renamed', accommodation_id: null });
|
|
});
|
|
|
|
it('RESV-SCOPE-002: passes a body whose ids all live in the trip', () => {
|
|
const { mine } = twoTrips();
|
|
const place = createPlace(testDb, mine.id);
|
|
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(mine.id) as { id: number };
|
|
|
|
expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x', day_id: day.id, place_id: place.id })).toEqual([]);
|
|
expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x' })).toEqual([]);
|
|
});
|
|
|
|
it('RESV-SCOPE-003: a stored foreign accommodation_id is not deleted with the reservation', () => {
|
|
const { mine, theirs } = twoTrips();
|
|
const foreignPlace = createPlace(testDb, theirs.id);
|
|
const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number };
|
|
const foreignAcc = createDayAccommodation(testDb, theirs.id, foreignPlace.id, foreignDay.id, foreignDay.id);
|
|
// A row from before the check existed: it already carries the foreign id.
|
|
const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' });
|
|
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(foreignAcc.id, res.id);
|
|
|
|
const { accommodationDeleted } = svc.remove(String(res.id), String(mine.id));
|
|
|
|
expect(accommodationDeleted).toBe(false);
|
|
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(foreignAcc.id)).toBeTruthy();
|
|
});
|
|
|
|
it('RESV-SCOPE-004: an update carrying a foreign accommodation_id does not write through to it', () => {
|
|
const { mine, theirs } = twoTrips();
|
|
const foreignPlace = createPlace(testDb, theirs.id);
|
|
const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number };
|
|
const foreignAcc = createDayAccommodation(testDb, theirs.id, foreignPlace.id, foreignDay.id, foreignDay.id, { check_in: '14:00' });
|
|
const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' });
|
|
const current = svc.getReservation(String(res.id), String(mine.id))!;
|
|
|
|
svc.update(String(res.id), String(mine.id), {
|
|
accommodation_id: foreignAcc.id, metadata: { check_in_time: '23:00' },
|
|
} as never, current);
|
|
|
|
expect(testDb.prepare('SELECT check_in FROM day_accommodations WHERE id = ?').get(foreignAcc.id)).toEqual({ check_in: '14:00' });
|
|
expect(testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ accommodation_id: null });
|
|
});
|
|
|
|
/** A row from before the foreign keys, or from a window where they were off:
|
|
* the only way to plant one today is the way it got there. */
|
|
function withForeignKeysOff(plant: () => void) {
|
|
testDb.exec('PRAGMA foreign_keys = OFF');
|
|
try { plant(); } finally { testDb.exec('PRAGMA foreign_keys = ON'); }
|
|
}
|
|
|
|
it('RESV-SCOPE-006: an id that resolves to nothing is named too, accommodation_id excepted', () => {
|
|
const { mine } = twoTrips();
|
|
const gone = 999999;
|
|
|
|
expect(svc.unresolvedReferences(String(mine.id), {
|
|
title: 'x', type: 'hotel',
|
|
day_id: gone, end_day_id: gone, place_id: gone, assignment_id: gone, accommodation_id: gone,
|
|
create_accommodation: { place_id: gone, start_day_id: gone, end_day_id: gone },
|
|
})).toEqual([
|
|
'day_id', 'end_day_id', 'place_id', 'assignment_id',
|
|
'create_accommodation.place_id', 'create_accommodation.start_day_id', 'create_accommodation.end_day_id',
|
|
]);
|
|
});
|
|
|
|
it('RESV-SCOPE-007: ids on the trip pass, and a falsy one is not a reference at all', () => {
|
|
const { mine } = twoTrips();
|
|
const place = createPlace(testDb, mine.id);
|
|
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(mine.id) as { id: number };
|
|
const assignment = createDayAssignment(testDb, day.id, place.id);
|
|
|
|
expect(svc.unresolvedReferences(String(mine.id), {
|
|
title: 'x', type: 'hotel',
|
|
day_id: day.id, end_day_id: day.id, place_id: place.id, assignment_id: assignment.id,
|
|
create_accommodation: { place_id: place.id, start_day_id: day.id, end_day_id: day.id },
|
|
})).toEqual([]);
|
|
// The write paths coerce these to NULL before they reach SQL, so naming
|
|
// them would 400 a body that stores a null today.
|
|
expect(svc.unresolvedReferences(String(mine.id), { title: 'x', day_id: 0, end_day_id: 0, place_id: 0, assignment_id: 0 })).toEqual([]);
|
|
// Only a hotel booking writes the stay row.
|
|
expect(svc.unresolvedReferences(String(mine.id), {
|
|
title: 'x', type: 'flight', create_accommodation: { place_id: 999999, start_day_id: 999999, end_day_id: 999999 },
|
|
})).toEqual([]);
|
|
// A stay without a place is a state the booking form writes.
|
|
expect(svc.unresolvedReferences(String(mine.id), {
|
|
title: 'x', type: 'hotel', create_accommodation: { start_day_id: day.id, end_day_id: day.id },
|
|
})).toEqual([]);
|
|
});
|
|
|
|
it('RESV-SCOPE-008: an id from another trip is named by both guards, so the older one answers first', () => {
|
|
const { mine, theirs } = twoTrips();
|
|
const foreignPlace = createPlace(testDb, theirs.id);
|
|
const body = { title: 'x', place_id: foreignPlace.id };
|
|
|
|
expect(svc.referencesOutsideTrip(String(mine.id), body)).toEqual(['place_id']);
|
|
expect(svc.unresolvedReferences(String(mine.id), body)).toEqual(['place_id']);
|
|
});
|
|
|
|
it('RESV-SCOPE-009: an update heals the stored ids whose rows are gone instead of failing on them', () => {
|
|
const { mine } = twoTrips();
|
|
const res = createReservation(testDb, mine.id, { title: 'Dinner' });
|
|
withForeignKeysOff(() => {
|
|
testDb.prepare('UPDATE reservations SET day_id = ?, end_day_id = ?, place_id = ?, assignment_id = ? WHERE id = ?')
|
|
.run(999999, 999998, 999997, 999996, res.id);
|
|
});
|
|
const current = svc.getReservation(String(res.id), String(mine.id))!;
|
|
|
|
// The body names none of them, so the statement rebinds what the row holds.
|
|
svc.update(String(res.id), String(mine.id), { title: 'Dinner, later' }, current);
|
|
|
|
expect(testDb.prepare('SELECT title, day_id, end_day_id, place_id, assignment_id FROM reservations WHERE id = ?').get(res.id))
|
|
.toEqual({ title: 'Dinner, later', day_id: null, end_day_id: null, place_id: null, assignment_id: null });
|
|
});
|
|
|
|
it('RESV-SCOPE-010: a supplied id that resolves to nothing is stored as NULL, on create and on update', () => {
|
|
const { mine } = twoTrips();
|
|
const { reservation } = svc.create(String(mine.id), {
|
|
title: 'Dinner', day_id: 999999, end_day_id: 999998, place_id: 999997, assignment_id: 999996,
|
|
});
|
|
expect(testDb.prepare('SELECT day_id, end_day_id, place_id, assignment_id FROM reservations WHERE id = ?').get(reservation.id))
|
|
.toEqual({ day_id: null, end_day_id: null, place_id: null, assignment_id: null });
|
|
|
|
const current = svc.getReservation(String(reservation.id), String(mine.id))!;
|
|
svc.update(String(reservation.id), String(mine.id), { place_id: 999997 }, current);
|
|
expect(testDb.prepare('SELECT place_id FROM reservations WHERE id = ?').get(reservation.id)).toEqual({ place_id: null });
|
|
});
|
|
|
|
it('RESV-SCOPE-011: a stay whose refs resolve to nothing is refused, never written and never dropped in silence', () => {
|
|
const { mine } = twoTrips();
|
|
const place = createPlace(testDb, mine.id);
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(mine.id) as { id: number }[];
|
|
|
|
expect(() => svc.create(String(mine.id), {
|
|
title: 'Hotel', type: 'hotel',
|
|
create_accommodation: { place_id: 999999, start_day_id: 999998, end_day_id: 999997 },
|
|
})).toThrow('Unknown reference: create_accommodation.place_id, create_accommodation.start_day_id, create_accommodation.end_day_id');
|
|
expect(testDb.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE trip_id = ?').get(mine.id)).toEqual({ c: 0 });
|
|
expect(testDb.prepare('SELECT COUNT(*) as c FROM reservations WHERE trip_id = ?').get(mine.id)).toEqual({ c: 0 });
|
|
|
|
// On an existing stay the edit is refused rather than skipped: dropping it
|
|
// would leave the caller with a 200 and the old dates.
|
|
const acc = createDayAccommodation(testDb, mine.id, place.id, days[0].id, days[1].id);
|
|
const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' });
|
|
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(acc.id, res.id);
|
|
const current = svc.getReservation(String(res.id), String(mine.id))!;
|
|
|
|
expect(() => svc.update(String(res.id), String(mine.id), {
|
|
type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: 999999 },
|
|
}, current)).toThrow('Unknown reference: create_accommodation.end_day_id');
|
|
expect(testDb.prepare('SELECT end_day_id FROM day_accommodations WHERE id = ?').get(acc.id)).toEqual({ end_day_id: days[1].id });
|
|
});
|
|
|
|
it('RESV-SCOPE-012: a stay booked without a place is still written', () => {
|
|
const { mine } = twoTrips();
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(mine.id) as { id: number }[];
|
|
|
|
const { reservation, accommodationCreated } = svc.create(String(mine.id), {
|
|
title: 'Hotel', type: 'hotel',
|
|
create_accommodation: { start_day_id: days[0].id, end_day_id: days[1].id, check_in: '15:00' },
|
|
});
|
|
|
|
expect(accommodationCreated).toBe(true);
|
|
expect(testDb.prepare('SELECT place_id, start_day_id FROM day_accommodations WHERE id = ?').get(Number(reservation.accommodation_id)))
|
|
.toEqual({ place_id: null, start_day_id: days[0].id });
|
|
});
|
|
|
|
it('RESV-SCOPE-013: the refusal carries the 400 the write surfaces send, not a status the filter has to guess', () => {
|
|
const { mine } = twoTrips();
|
|
|
|
// The write surfaces name the field before the service ever sees it, so the
|
|
// class only matters to whoever reaches this floor without one. It answers
|
|
// as they do — the filter passes a 400 through and keeps the message, where
|
|
// a class it cannot place would become 'Internal server error' (#2355).
|
|
let thrown: unknown;
|
|
try {
|
|
svc.create(String(mine.id), {
|
|
title: 'Hotel', type: 'hotel',
|
|
create_accommodation: { start_day_id: 999998, end_day_id: 999997 },
|
|
});
|
|
} catch (e) {
|
|
thrown = e;
|
|
}
|
|
|
|
expect(thrown).toBeInstanceOf(HttpException);
|
|
expect((thrown as HttpException).getStatus()).toBe(400);
|
|
expect((thrown as HttpException).message)
|
|
.toBe('Unknown reference: create_accommodation.start_day_id, create_accommodation.end_day_id');
|
|
});
|
|
});
|
|
|
|
describe('the day stop a hotel booking implies', () => {
|
|
// Booking a night on this form is booking a night: it shows in the day header
|
|
// exactly like one entered under Days, and road trip mode draws the same booking
|
|
// as a service stop instead. Before this the booking form was the one door that
|
|
// wrote the stay without the stop, so a hotel entered here was invisible to the
|
|
// drive and had to be added a second time as an ordinary place.
|
|
const stopsOn = (dayId: number) =>
|
|
testDb.prepare('SELECT id, place_id, accommodation_id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(dayId) as
|
|
{ id: number; place_id: number; accommodation_id: number | null }[];
|
|
const tripWithDays = () => {
|
|
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' });
|
|
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
|
return { trip, days };
|
|
};
|
|
|
|
it('RESV-STAY-001: a hotel booked on the form lands on its check-in day', () => {
|
|
const { trip, days } = tripWithDays();
|
|
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
|
|
|
const { reservation } = svc.create(String(trip.id), {
|
|
title: 'Hotel', type: 'hotel',
|
|
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[2].id },
|
|
});
|
|
|
|
expect(stopsOn(days[0].id)).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: Number(reservation.accommodation_id) })]);
|
|
// Typed as lodging, so the road trip draws it as the service stop it is.
|
|
expect(testDb.prepare('SELECT stop_type FROM places WHERE id = ?').get(place.id)).toMatchObject({ stop_type: 'hotel' });
|
|
// Only the check-in day, the same as every other door.
|
|
expect(stopsOn(days[2].id)).toEqual([]);
|
|
});
|
|
|
|
it('RESV-STAY-002: a booking that is not a hotel puts nothing on the plan', () => {
|
|
const { trip, days } = tripWithDays();
|
|
svc.create(String(trip.id), { title: 'Museum tour', type: 'tour', day_id: days[0].id });
|
|
expect(stopsOn(days[0].id)).toEqual([]);
|
|
});
|
|
|
|
it('RESV-STAY-003: moving the booking to another day takes its stop along', () => {
|
|
const { trip, days } = tripWithDays();
|
|
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
|
const { reservation } = svc.create(String(trip.id), {
|
|
title: 'Hotel', type: 'hotel',
|
|
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[0].id },
|
|
});
|
|
const current = svc.getReservation(String(reservation.id), String(trip.id))!;
|
|
|
|
svc.update(String(reservation.id), String(trip.id), {
|
|
type: 'hotel',
|
|
create_accommodation: { place_id: place.id, start_day_id: days[1].id, end_day_id: days[2].id },
|
|
} as never, current);
|
|
|
|
// Left behind it would sit on a day nobody sleeps there, hidden from the day
|
|
// list because it still carries this booking's id.
|
|
expect(stopsOn(days[0].id)).toEqual([]);
|
|
expect(stopsOn(days[1].id)).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: Number(reservation.accommodation_id) })]);
|
|
});
|
|
|
|
it('RESV-STAY-004: an edit that first creates the stay writes the stop too', () => {
|
|
// A booking saved without a date range, then given one. resolvedAccId is null on
|
|
// the way in, so this takes the insert branch rather than the update branch.
|
|
const { trip, days } = tripWithDays();
|
|
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
|
const { reservation } = svc.create(String(trip.id), { title: 'Hotel', type: 'hotel' });
|
|
const current = svc.getReservation(String(reservation.id), String(trip.id))!;
|
|
|
|
svc.update(String(reservation.id), String(trip.id), {
|
|
type: 'hotel',
|
|
create_accommodation: { place_id: place.id, start_day_id: days[1].id, end_day_id: days[1].id },
|
|
} as never, current);
|
|
|
|
const stay = testDb.prepare('SELECT id FROM day_accommodations WHERE trip_id = ?').get(trip.id) as { id: number };
|
|
expect(stopsOn(days[1].id)).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: stay.id })]);
|
|
});
|
|
|
|
it('RESV-STAY-005: deleting the booking takes the stop with the stay', () => {
|
|
const { trip, days } = tripWithDays();
|
|
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
|
const { reservation } = svc.create(String(trip.id), {
|
|
title: 'Hotel', type: 'hotel',
|
|
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[0].id },
|
|
});
|
|
|
|
const { accommodationDeleted } = svc.remove(String(reservation.id), String(trip.id));
|
|
|
|
expect(accommodationDeleted).toBe(true);
|
|
// A stop still carrying a dead booking's id is one the day list hides and
|
|
// nobody can reach to remove.
|
|
expect(stopsOn(days[0].id)).toEqual([]);
|
|
});
|
|
|
|
it('RESV-STAY-006: a place the traveller already planned that day keeps its own row', () => {
|
|
const { trip, days } = tripWithDays();
|
|
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
|
const own = testDb.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, 0)').run(days[0].id, place.id);
|
|
|
|
const { reservation } = svc.create(String(trip.id), {
|
|
title: 'Hotel', type: 'hotel',
|
|
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[0].id },
|
|
});
|
|
|
|
// No second row, and the booking claims neither: cancelling it must not delete
|
|
// a stop the traveller placed.
|
|
expect(stopsOn(days[0].id)).toEqual([{ id: Number(own.lastInsertRowid), place_id: place.id, accommodation_id: null }]);
|
|
svc.remove(String(reservation.id), String(trip.id));
|
|
expect(stopsOn(days[0].id)).toHaveLength(1);
|
|
});
|
|
|
|
it('RESV-STAY-008: a save from the booking form re-seats the night only when its check-in changed', () => {
|
|
// The form sends the whole stay on every save, check-in included, so this is the
|
|
// door a title edit on a hotel booking comes through. Its own reading of "the
|
|
// check-in changed" (the prior row, read before the write) decides whether a night
|
|
// the traveller dragged is left alone or seated afresh, the way ACC-022g pins it
|
|
// for the stay route.
|
|
const { trip, days } = tripWithDays();
|
|
const [harbour, market, hotel] = ['Hafen', 'Markt', 'Rostock'].map(name => createPlace(testDb, trip.id, { name }));
|
|
createDayAssignment(testDb, days[0].id, harbour.id);
|
|
createDayAssignment(testDb, days[0].id, market.id);
|
|
const stay = (check_in: string) => ({ place_id: hotel.id, start_day_id: days[0].id, end_day_id: days[0].id, check_in });
|
|
const { reservation } = svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', create_accommodation: stay('15:00') });
|
|
const order = () => stopsOn(days[0].id).map(s => s.place_id);
|
|
expect(order()).toEqual([hotel.id, harbour.id, market.id]);
|
|
|
|
// Dragged to the end of the day by hand.
|
|
const own = stopsOn(days[0].id).find(s => s.place_id === hotel.id)!;
|
|
testDb.prepare('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > 0').run(days[0].id);
|
|
testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id);
|
|
expect(order()).toEqual([harbour.id, market.id, hotel.id]);
|
|
|
|
vi.clearAllMocks();
|
|
let current = svc.getReservation(String(reservation.id), String(trip.id))!;
|
|
svc.update(String(reservation.id), String(trip.id), { title: 'Hotel, late arrival', type: 'hotel', create_accommodation: stay('15:00') } as never, current);
|
|
expect(order()).toEqual([harbour.id, market.id, hotel.id]);
|
|
expect(broadcast).not.toHaveBeenCalledWith(String(trip.id), 'assignment:moved', expect.anything());
|
|
|
|
current = svc.getReservation(String(reservation.id), String(trip.id))!;
|
|
svc.update(String(reservation.id), String(trip.id), { type: 'hotel', create_accommodation: stay('10:00') } as never, current);
|
|
expect(order()).toEqual([hotel.id, harbour.id, market.id]);
|
|
expect(stopsOn(days[0].id).map(s => s.id)).toContain(own.id);
|
|
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'assignment:moved', expect.objectContaining({ assignment: expect.objectContaining({ id: own.id }) }));
|
|
});
|
|
|
|
it('RESV-STAY-007: a foreign accommodation_id reaches no stop on the other trip', () => {
|
|
// The trip_id guard on the stay delete is what denies that reach; the stops go
|
|
// by accommodation id alone and would otherwise follow it straight over.
|
|
const { user: attacker } = createUser(testDb);
|
|
const { user: victim } = createUser(testDb, { email: 'victim@example.test' });
|
|
const mine = createTrip(testDb, attacker.id, { start_date: '2030-05-01', end_date: '2030-05-03' });
|
|
const theirs = createTrip(testDb, victim.id, { start_date: '2030-05-01', end_date: '2030-05-03' });
|
|
const foreignPlace = createPlace(testDb, theirs.id, { name: 'Their hotel' });
|
|
const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number };
|
|
const foreignAcc = createDayAccommodation(testDb, theirs.id, foreignPlace.id, foreignDay.id, foreignDay.id);
|
|
const theirStop = testDb.prepare('INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) VALUES (?, ?, 0, ?)')
|
|
.run(foreignDay.id, foreignPlace.id, foreignAcc.id);
|
|
const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' });
|
|
testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(foreignAcc.id, res.id);
|
|
|
|
svc.remove(String(res.id), String(mine.id));
|
|
|
|
expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(Number(theirStop.lastInsertRowid))).toBeTruthy();
|
|
});
|
|
});
|