mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
203d67c45f
Folds the 626-line services/reservationService.ts into the DI-native ReservationsService — byte-identical SQL, statuses, bodies, error strings and broadcasts; the legacy file is deleted. - reservations.mcp.ts: the 5 legacy tools (create/update/delete_reservation, reorder_reservations, link_hotel_accommodation) + the trip-reservations resource move to the decorator registry (identical names, schemas, annotations, error strings, broadcasts); the canWrite/canRead registration gates become declarative access markers. Legacy registrar and resource entries removed. - reservations.bridge.ts: 9 legacy-named exports + 3 type re-exports for the outside-container consumers (tripService, airtrail import/sync, the still-legacy transit + transports registrars, transitItineraryService's type-only import). - notifyBookingChange folds to the legacy (tripId, actorId) signature; the controller passes user.id and the plugin-host factory drops its last plain-function reservation import for a private method over the injected service (constructor unchanged). - TripsService and BookingImportService inject ReservationsService instead of importing legacy functions. - Tests: reservations.service.test.ts rewritten as a real-SQL suite (RESV-SVC-001..030, RES-TRAV-001..005 moved verbatim, RESV-BRIDGE-001..009 pinning the bridge); both e2e suites converted to the DI-native pattern; path mocks repointed to the bridge or constructor stubs; tools-reservations kept and extended with the moved resource cases. Quirks preserved on purpose (parity first — see the follow-up fix commit): the raw-accommodation_id metadata-sync gate, the un-transactioned multi-statement writes, the truthy updatePositions dayId check, the empty-string COALESCE keeps, and the TEXT accommodation_id normalization.
134 lines
7.8 KiB
TypeScript
134 lines
7.8 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { HttpException } from '@nestjs/common';
|
|
import { ReservationsController } from '../../../src/nest/reservations/reservations.controller';
|
|
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
|
import type { User } from '../../../src/types';
|
|
|
|
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
|
const trip = { id: 5, user_id: 1 };
|
|
|
|
function makeService(overrides: Partial<ReservationsService> = {}): ReservationsService {
|
|
return {
|
|
verifyTripAccess: vi.fn().mockReturnValue(trip),
|
|
canEdit: vi.fn().mockReturnValue(true),
|
|
broadcast: vi.fn(),
|
|
syncBudgetOnCreate: vi.fn(),
|
|
syncBudgetOnUpdate: vi.fn(),
|
|
notifyBookingChange: vi.fn(),
|
|
...overrides,
|
|
} as unknown as ReservationsService;
|
|
}
|
|
|
|
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
|
try { fn(); } catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
const e = err as HttpException;
|
|
return { status: e.getStatus(), body: e.getResponse() };
|
|
}
|
|
throw new Error('expected throw');
|
|
}
|
|
|
|
describe('ReservationsController (parity with the legacy /api/trips/:tripId/reservations route)', () => {
|
|
it('404 when trip not accessible', () => {
|
|
const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
|
expect(thrown(() => new ReservationsController(svc).list(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
});
|
|
|
|
it('GET / returns reservations', () => {
|
|
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<ReservationsService>);
|
|
expect(new ReservationsController(svc).list(user, '5')).toEqual({ reservations: [{ id: 1 }] });
|
|
});
|
|
|
|
describe('POST /', () => {
|
|
it('403 without permission', () => {
|
|
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
|
expect(thrown(() => new ReservationsController(svc).create(user, '5', { title: 'Hotel' }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
|
});
|
|
|
|
// The bespoke 'Title is required' 400 moved to the global ZodValidationPipe
|
|
// (ReservationCreateDto) — covered by the e2e suite.
|
|
|
|
it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', () => {
|
|
const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: true });
|
|
const broadcast = vi.fn(); const syncBudgetOnCreate = vi.fn(); const notifyBookingChange = vi.fn();
|
|
const svc = makeService({ create, broadcast, syncBudgetOnCreate, notifyBookingChange } as Partial<ReservationsService>);
|
|
const body = { title: 'Hotel', type: 'lodging', create_budget_entry: { total_price: 200 } };
|
|
expect(new ReservationsController(svc).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } });
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', {}, 'sock');
|
|
expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Hotel', 'lodging', { total_price: 200 }, 'sock');
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', { reservation: { id: 9 } }, 'sock');
|
|
expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging');
|
|
});
|
|
});
|
|
|
|
describe('PUT /positions', () => {
|
|
// The 'positions must be an array' 400 moved to the global
|
|
// ZodValidationPipe (ReservationPositionsDto).
|
|
|
|
it('updates positions and broadcasts', () => {
|
|
const updatePositions = vi.fn(); const broadcast = vi.fn();
|
|
const svc = makeService({ updatePositions, broadcast } as Partial<ReservationsService>);
|
|
const positions = [{ id: 1, day_plan_position: 0 }];
|
|
expect(new ReservationsController(svc).updatePositions(user, '5', { positions, day_id: 3 }, 'sock')).toEqual({ success: true });
|
|
expect(updatePositions).toHaveBeenCalledWith('5', positions, 3);
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:positions', { positions, day_id: 3 }, 'sock');
|
|
});
|
|
});
|
|
|
|
describe('PUT /:id', () => {
|
|
it('404 when the reservation is missing', () => {
|
|
const svc = makeService({ getReservation: vi.fn().mockReturnValue(undefined) } as Partial<ReservationsService>);
|
|
expect(thrown(() => new ReservationsController(svc).update(user, '5', '9', { title: 'X' }))).toEqual({ status: 404, body: { error: 'Reservation not found' } });
|
|
});
|
|
|
|
it('updates, syncs budget with current fallbacks, broadcasts + notifies', () => {
|
|
const getReservation = vi.fn().mockReturnValue({ title: 'Old', type: 'lodging' });
|
|
const update = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationChanged: true });
|
|
const broadcast = vi.fn(); const syncBudgetOnUpdate = vi.fn(); const notifyBookingChange = vi.fn();
|
|
const svc = makeService({ getReservation, update, broadcast, syncBudgetOnUpdate, notifyBookingChange } as Partial<ReservationsService>);
|
|
new ReservationsController(svc).update(user, '5', '9', { create_budget_entry: { total_price: 50 } }, 'sock');
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:updated', {}, 'sock');
|
|
expect(syncBudgetOnUpdate).toHaveBeenCalledWith('5', '9', '', undefined, 'Old', 'lodging', { total_price: 50 }, 'sock');
|
|
expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Old', 'lodging');
|
|
});
|
|
});
|
|
|
|
describe('PUT /:id/travelers', () => {
|
|
// The 'user_ids must be an array' 400 moved to the global
|
|
// ZodValidationPipe (ReservationTravelersDto).
|
|
|
|
it('404 when the reservation is off-trip / missing', () => {
|
|
const svc = makeService({ setTravelers: vi.fn().mockReturnValue(null) } as Partial<ReservationsService>);
|
|
expect(thrown(() => new ReservationsController(svc).updateTravelers(user, '5', '9', { user_ids: [1] }))).toEqual({ status: 404, body: { error: 'Reservation not found' } });
|
|
});
|
|
|
|
it('assigns travelers, broadcasts, and returns { travelers, reservation }', () => {
|
|
const travelers = [{ user_id: 2, username: 'Sam', avatar: null, is_guest: 0 }];
|
|
const reservation = { id: 9, travelers };
|
|
const setTravelers = vi.fn().mockReturnValue({ travelers, reservation });
|
|
const broadcast = vi.fn();
|
|
const svc = makeService({ setTravelers, broadcast } as Partial<ReservationsService>);
|
|
expect(new ReservationsController(svc).updateTravelers(user, '5', '9', { user_ids: [2] }, 'sock')).toEqual({ travelers, reservation });
|
|
expect(setTravelers).toHaveBeenCalledWith('9', '5', [2]);
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:travelers-updated', { reservationId: 9, travelers }, 'sock');
|
|
});
|
|
});
|
|
|
|
describe('DELETE /:id', () => {
|
|
it('404 when nothing deleted', () => {
|
|
const svc = makeService({ remove: vi.fn().mockReturnValue({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null }) } as Partial<ReservationsService>);
|
|
expect(thrown(() => new ReservationsController(svc).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Reservation not found' } });
|
|
});
|
|
|
|
it('broadcasts the accommodation + budget cascade then reservation:deleted', () => {
|
|
const remove = vi.fn().mockReturnValue({ deleted: { id: 9, title: 'Hotel', type: 'lodging', accommodation_id: 3 }, accommodationDeleted: true, deletedBudgetItemId: 7 });
|
|
const broadcast = vi.fn(); const notifyBookingChange = vi.fn();
|
|
const svc = makeService({ remove, broadcast, notifyBookingChange } as Partial<ReservationsService>);
|
|
expect(new ReservationsController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:deleted', { accommodationId: 3 }, 'sock');
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock');
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 9 }, 'sock');
|
|
});
|
|
});
|
|
});
|