mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-08-14 08:26:39 +00:00
d952eaf4d4
Two verified defects carried through the parity fold: - The create-path accommodation metadata sync gated on the raw accommodation_id instead of the resolved one, so a hotel whose accommodation was just auto-created never received its metadata check-in/out times or confirmation. Now keyed off resolvedAccommodationId (RESV-SVC-006 re-pins the fixed behavior). - The multi-statement writes ran outside transactions: create (accommodation insert + reservation insert + endpoint save + metadata sync), update (accommodation upsert + update + endpoint replace + sync), remove (the 3-delete cascade), setReservationTravelers (delete + inserts) and resyncReservationDays now run in db.transaction() — a mid-write failure no longer leaves partial state (RESV-FIX-001/002 pin the rollbacks). Left as-is on purpose: the truthy updatePositions dayId check, the empty-string COALESCE keeps on title/status/type, the TEXT accommodation_id normalization, the optional day_plan_position wire tolerance and the swallowed notifyBookingChange catches — all contract or intentional behavior, not defects.
764 lines
44 KiB
TypeScript
764 lines
44 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 (one delegation case per reservations.bridge export).
|
|
* 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.hoisted(() => ({ checkPermission: vi.fn(() => true) }));
|
|
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
|
|
|
const { budget } = vi.hoisted(() => ({
|
|
budget: { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() },
|
|
}));
|
|
vi.mock('../../../src/services/budgetService', () => budget);
|
|
|
|
const { notif } = vi.hoisted(() => ({ notif: { send: vi.fn().mockResolvedValue(undefined) } }));
|
|
vi.mock('../../../src/services/notificationService', () => notif);
|
|
|
|
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, addTripMember } from '../../helpers/factories';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
|
import * as bridge from '../../../src/nest/reservations/reservations.bridge';
|
|
|
|
const svc = new ReservationsService(new DatabaseService(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-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']);
|
|
});
|
|
});
|
|
|
|
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('reservations.bridge (legacy-named delegation for outside-container consumers)', () => {
|
|
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();
|
|
});
|
|
});
|
|
|
|
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' });
|
|
});
|
|
});
|