Files
TREK/server/tests/unit/nest/accommodations.service.test.ts
T
Maurice 3d522be0e5 Give accommodations one home instead of two halves
The routes lived in reservations/ while the SQL lived on DaysService, so the
controller delegated every call across a domain boundary to reach its own
data, and neither module owned the thing outright. The seven statements move
into AccommodationsService unchanged and the domain gets its own directory,
module, DTO, RPC and MCP surface.

What stays in days/ is the reorder side: assertNoInvertedAccommodation and
resyncAccommodationDays are about re-dating day rows, and the stay is the
thing being carried, not the thing doing the carrying.

Two edges fall out of this. AccommodationsService drops its DaysService
dependency, and DaysMcp drops DatabaseService and PlacesService, because
create_place_accommodation was their only user - which lets DaysModule stop
importing PlacesModule and closes one leg of the latent
Days -> Places -> Assignments -> Days cycle.

DAY-SVC-016..026, 029 and 035 move to accommodations.service.test.ts with
their case ids intact. The reservations e2e container assembles both modules
now: it builds a single domain module on purpose, which is exactly what
catches a move like this, and it caught it.
2026-08-10 13:38:26 +02:00

280 lines
12 KiB
TypeScript

/**
* Unit tests for AccommodationsService. DAY-SVC-016..026, 029 and 035 moved here
* 1:1 with the SQL they cover when accommodations became their own domain; the
* case ids are unchanged so the diff reads as a move. Real in-memory SQLite.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
return {
testDb: db,
dbMock: {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
},
};
});
vi.mock('../../../src/db/database', () => dbMock);
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAccommodation } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
import { AccommodationsModule } from '../../../src/nest/accommodations/accommodations.module';
import { AccommodationsController } from '../../../src/nest/accommodations/accommodations.controller';
import { expectRegisteredProvider, expectRegisteredController } from '../../helpers/module-providers';
// Named `svc` so the moved cases read exactly as they did on DaysService.
const svc = new AccommodationsService(
new DatabaseService(testDb),
new PermissionsService(new DatabaseService(testDb)),
new RealtimeService(),
);
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
describe('validateAccommodationRefs', () => {
it('DAY-SVC-016 — returns no errors when all refs are valid', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const errors = svc.validateAccommodationRefs(trip.id, place.id, day.id, day.id);
expect(errors).toHaveLength(0);
});
it('DAY-SVC-017 — returns error when place does not exist in trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const errors = svc.validateAccommodationRefs(trip.id, 99999, day.id, day.id);
expect(errors.some((e: any) => e.field === 'place_id')).toBe(true);
});
it('DAY-SVC-018 — returns error when start_day_id is invalid', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const errors = svc.validateAccommodationRefs(trip.id, place.id, 99999, day.id);
expect(errors.some((e: any) => e.field === 'start_day_id')).toBe(true);
});
});
describe('createAccommodation', () => {
it('DAY-SVC-019 — creates accommodation and returns it with place info', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' }) as any;
const accom = svc.createAccommodation(trip.id, {
place_id: place.id,
start_day_id: day.id,
end_day_id: day.id,
check_in: '15:00',
check_out: '11:00',
}) as any;
expect(accom).toBeDefined();
expect(accom.place_name).toBe('Grand Hotel');
});
it('DAY-SVC-020 — auto-creates a linked reservation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'City Hotel' }) as any;
const accom = svc.createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
}) as any;
const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
expect(reservation).toBeDefined();
expect(reservation.type).toBe('hotel');
expect(reservation.status).toBe('confirmed');
});
});
describe('getAccommodation', () => {
it('DAY-SVC-021 — returns accommodation for valid id and trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any;
const found = svc.getAccommodation(accom.id, trip.id) as any;
expect(found).toBeDefined();
expect(found.id).toBe(accom.id);
});
it('DAY-SVC-022 — returns undefined for non-existent accommodation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(svc.getAccommodation(99999, trip.id)).toBeUndefined();
});
});
describe('updateAccommodation', () => {
it('DAY-SVC-023 — updates check-in and check-out times', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = svc.createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
}) as any;
const existing = svc.getAccommodation(accom.id, trip.id)!;
const updated = svc.updateAccommodation(accom.id, existing as any, { check_in: '16:00', check_out: '12:00' }) as any;
expect(updated).toBeDefined();
// Verify linked reservation metadata was synced
const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
expect(reservation).toBeDefined();
const meta = JSON.parse(reservation.metadata || '{}');
expect(meta.check_in_time).toBe('16:00');
expect(meta.check_out_time).toBe('12:00');
});
it('DAY-SVC-024 — preserves existing fields when not updated', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = svc.createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
confirmation: 'ABC123',
}) as any;
const existing = svc.getAccommodation(accom.id, trip.id)!;
svc.updateAccommodation(accom.id, existing as any, { check_in: '14:00' });
const row = svc.getAccommodation(accom.id, trip.id) as any;
expect(row.confirmation).toBe('ABC123');
});
});
describe('deleteAccommodation', () => {
it('DAY-SVC-025 — deletes accommodation and its linked reservation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = svc.createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
}) as any;
const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
const result = svc.deleteAccommodation(accom.id);
expect(result.linkedReservationId).toBe(reservation.id);
// Accommodation is gone
expect(svc.getAccommodation(accom.id, trip.id)).toBeUndefined();
// Reservation is gone
const deletedRes = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id);
expect(deletedRes).toBeUndefined();
});
it('DAY-SVC-026 — returns null linkedReservationId when no reservation was linked', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any;
// Remove the auto-created reservation so there's no linked one
testDb.prepare('DELETE FROM reservations WHERE accommodation_id = ?').run(accom.id);
const result = svc.deleteAccommodation(accom.id);
expect(result.linkedReservationId).toBeNull();
});
});
describe('listAccommodations', () => {
it('DAY-SVC-029 — listAccommodations returns the hydrated stays', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id, { name: 'Ryokan' });
createDayAccommodation(testDb, trip.id, place.id, day.id, day.id);
const rows = svc.listAccommodations(trip.id) as { place_name: string }[];
expect(rows).toHaveLength(1);
expect(rows[0].place_name).toBe('Ryokan');
});
});
describe('quirk fixes', () => {
it('DAY-SVC-035 — deleteAccommodation is atomic: a failed stay delete keeps the linked reservation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id, { name: 'Hotel' });
const accom = svc.createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
}) as { id: number };
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON day_accommodations BEGIN SELECT RAISE(ABORT, 'boom'); END");
try {
expect(() => svc.deleteAccommodation(accom.id)).toThrow();
// The earlier reservation delete inside the transaction rolled back.
expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 1 });
} finally {
testDb.exec('DROP TRIGGER boom');
}
});
});
describe('AccommodationsService wiring', () => {
it('ACC-001: the module carries the controller, the service and the RPC surface', () => {
expectRegisteredController(AccommodationsModule, AccommodationsController);
expectRegisteredProvider(AccommodationsModule, AccommodationsService);
const exports = Reflect.getMetadata('exports', AccommodationsModule) as unknown[];
expect(Array.isArray(exports)).toBe(true);
expect(exports).toEqual(expect.arrayContaining([AccommodationsService]));
});
it('ACC-002: it does not import the days or reservations modules', () => {
const imports = (Reflect.getMetadata('imports', AccommodationsModule) as { name?: string }[]) ?? [];
expect(Array.isArray(imports)).toBe(true);
const names = imports.map((m) => m?.name);
expect(names).not.toContain('DaysModule');
expect(names).not.toContain('ReservationsModule');
});
});