Files
TREK/server/tests/unit/nest/trips.service.test.ts
T
2026-07-27 23:48:05 +02:00

124 lines
7.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
const { dbMock } = vi.hoisted(() => {
const stmt = { get: vi.fn(() => ({ id: 42 })), all: vi.fn(() => []), run: vi.fn() };
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn(() => ({ user_id: 1 })) }));
vi.mock('../../../src/db/database', () => ({ db: dbMock, canAccessTrip, closeDb: () => {}, reinitialize: () => {} }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast }));
const checkPermission = vi.fn(() => true);
const permissionsStub = { checkPermission } as unknown as PermissionsService;
const { tripSvc } = vi.hoisted(() => ({
tripSvc: {
listTrips: vi.fn(), createTrip: vi.fn(), getTrip: vi.fn(), updateTrip: vi.fn(), deleteTrip: vi.fn(),
getTripRaw: vi.fn(), getTripOwner: vi.fn(), deleteOldCover: vi.fn(), updateCoverImage: vi.fn(),
listMembers: vi.fn(() => ({ owner: { id: 1 }, members: [] })), addMember: vi.fn(), removeMember: vi.fn(),
transferOwnership: vi.fn(),
createGuest: vi.fn(), renameGuest: vi.fn(), deleteGuest: vi.fn(),
exportICS: vi.fn(), copyTripById: vi.fn(), TRIP_SELECT: 'SELECT * FROM trips t',
},
}));
vi.mock('../../../src/services/tripService', () => tripSvc);
vi.mock('../../../src/services/placeService', () => ({ listPlaces: () => [] }));
import { TripsService } from '../../../src/nest/trips/trips.service';
import type { TodoService } from '../../../src/nest/todo/todo.service';
import type { PackingService } from '../../../src/nest/packing/packing.service';
import type { FilesService } from '../../../src/nest/files/files.service';
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import type { DaysService } from '../../../src/nest/days/days.service';
import type { BudgetService } from '../../../src/nest/budget/budget.service';
// Constructor-injected since the budget fold (was a path mock of the deleted
// services/budgetService).
const rebaseTripCurrency = vi.fn(async () => {});
const budgetStub = { listBudgetItems: () => [], rebaseTripCurrency } as unknown as BudgetService;
const todoStub = { listItems: () => [] } as unknown as TodoService;
const packingListItems = vi.fn(() => []);
const packingStub = { listItems: packingListItems } as unknown as PackingService;
const filesStub = { listFiles: () => [] } as unknown as FilesService;
const reservationsStub = { list: () => [] } as unknown as ReservationsService;
const daysStub = { list: () => ({ days: [1] }), listAccommodations: () => [] } as unknown as DaysService;
function svc() { return new TripsService(new DatabaseService(dbConn), todoStub, packingStub, filesStub, reservationsStub, daysStub, permissionsStub, budgetStub); }
beforeEach(() => vi.clearAllMocks());
describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => {
it('delegates the simple wrappers to tripService', async () => {
const s = svc();
s.list(1, 0); expect(tripSvc.listTrips).toHaveBeenCalledWith(1, 0);
s.create(1, { title: 'T' } as never); expect(tripSvc.createTrip).toHaveBeenCalledWith(1, { title: 'T' });
s.get('9', 1); expect(tripSvc.getTrip).toHaveBeenCalledWith('9', 1);
s.getRaw('9'); expect(tripSvc.getTripRaw).toHaveBeenCalledWith('9');
s.getOwner('9'); expect(tripSvc.getTripOwner).toHaveBeenCalledWith('9');
// update() first re-anchors the budget to any incoming currency, so it is async now.
await s.update('9', 1, {} as never, 'user'); expect(tripSvc.updateTrip).toHaveBeenCalledWith('9', 1, {}, 'user');
expect(rebaseTripCurrency).toHaveBeenCalledWith('9', undefined);
s.remove('9', 1, 'user'); expect(tripSvc.deleteTrip).toHaveBeenCalledWith('9', 1, 'user');
s.deleteOldCover('/old.jpg'); expect(tripSvc.deleteOldCover).toHaveBeenCalledWith('/old.jpg');
s.updateCoverImage('9', '/n.jpg'); expect(tripSvc.updateCoverImage).toHaveBeenCalledWith('9', '/n.jpg');
s.copy('9', 1, 'C'); expect(tripSvc.copyTripById).toHaveBeenCalledWith('9', 1, 'C');
s.listMembers('9', 1); expect(tripSvc.listMembers).toHaveBeenCalledWith('9', 1);
s.addMember('9', 'b@x.y', 1, 1); expect(tripSvc.addMember).toHaveBeenCalledWith('9', 'b@x.y', 1, 1);
s.removeMember('9', 2); expect(tripSvc.removeMember).toHaveBeenCalledWith('9', 2);
s.transferOwnership('9', 2, 1); expect(tripSvc.transferOwnership).toHaveBeenCalledWith('9', 2, 1);
s.createGuest('9', 'Anna', 1); expect(tripSvc.createGuest).toHaveBeenCalledWith('9', 'Anna', 1);
s.renameGuest('9', 7, 'Bob'); expect(tripSvc.renameGuest).toHaveBeenCalledWith('9', 7, 'Bob');
s.deleteGuest('9', 7); expect(tripSvc.deleteGuest).toHaveBeenCalledWith('9', 7);
s.exportICS('9'); expect(tripSvc.exportICS).toHaveBeenCalledWith('9');
});
it('re-anchors the budget before the trip row leaves its old currency (#1543)', async () => {
const order: string[] = [];
(rebaseTripCurrency as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => { order.push('rebase'); });
(tripSvc.updateTrip as ReturnType<typeof vi.fn>).mockImplementationOnce(() => { order.push('update'); return {} as never; });
await svc().update('9', 1, { currency: 'RUB' } as never, 'user');
// The rebase reads the outgoing currency off the trip row, so it has to run first.
expect(rebaseTripCurrency).toHaveBeenCalledWith('9', 'RUB');
expect(order).toEqual(['rebase', 'update']);
});
it('canAccessTrip delegates to the db helper', () => {
canAccessTrip.mockReturnValueOnce({ user_id: 7 });
expect(svc().canAccessTrip('9', 7)).toEqual({ user_id: 7 });
expect(canAccessTrip).toHaveBeenCalledWith('9', 7);
});
it('can() delegates to checkPermission; broadcast forwards', () => {
svc().can('trip_edit', 'user', 1, 1, false);
expect(checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 1, 1, false);
svc().broadcast('9', 'trip:updated', { a: 1 }, 'sock');
expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { a: 1 }, 'sock');
});
it('getCopiedTrip re-reads via the TRIP_SELECT query', () => {
expect(svc().getCopiedTrip(42, 1)).toEqual({ id: 42 });
expect(dbMock.prepare).toHaveBeenCalledWith(expect.stringContaining('SELECT * FROM trips t'));
});
it('bundle aggregates every sub-collection + the member list, scoping packing to the viewer (#858)', () => {
const result = svc().bundle('9', { user_id: 1 }, 7);
expect(result).toMatchObject({ trip: { user_id: 1 }, days: [1], places: [], members: [{ id: 1 }] });
// The viewer id is forwarded so other members' private items stay out of the offline cache.
expect(packingListItems).toHaveBeenCalledWith('9', 7);
});
it('bundle tolerates a null member list', () => {
tripSvc.listMembers.mockReturnValueOnce({ owner: { id: 1 }, members: null });
const result = svc().bundle('9', { user_id: 1 }, 1);
expect(result).toMatchObject({ members: [{ id: 1 }] });
});
it('notifyInvite is fire-and-forget (no throw)', () => {
expect(() => svc().notifyInvite('9', { id: 1, email: 'a@b.c' } as never, 2, 'T', 'b@x.y')).not.toThrow();
});
});