mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
6763648c45
SettingsRepository (getForUser/upsertForUser/getOne/deleteForUser) replaces the per-user settings table's raw SQL; SettingsService drops DatabaseService entirely. instance-api-keys.ts's readInstanceApiKey/writeInstanceApiKey/ resolveApiKey take an explicit AppSettingsRepository/UsersRepository instead of a raw connection. An earlier version resolved those two repositories itself from the active MikroORM request context, so none of its six callers needed to change. That worked in production and e2e (always inside a real request) but broke service construction in plain unit tests across every calling domain (addons, auth x2, maps, transit, unsplash - 185 failures on a full run), since none of them wrap a request context. Each of those six services now injects its own AppSettingsRepository/UsersRepository and passes them through explicitly, matching how every other converted repository in this migration is consumed; their own SQL is untouched (outside Plan 3a).
1318 lines
74 KiB
TypeScript
1318 lines
74 KiB
TypeScript
/**
|
||
* Unit tests for the DI-native TripsService — TRIP-SVC-001 through TRIP-SVC-059
|
||
* (001–038 moved 1:1 from the legacy tests/unit/services/tripService.test.ts;
|
||
* the exportICS cases that duplicated the generateDays 010–012 numbering were
|
||
* renumbered to 024–026 with the post-fold quirk-fix commit; 040–041 pinned
|
||
* the deleted trips.bridge and died with it; 042–050 cover the folded
|
||
* summary/list/create/delete/copy SQL; 051–053 pin the post-fold quirk fixes
|
||
* (transactional deletes, owner display_name)). Uses a real in-memory SQLite
|
||
* DB so SQL logic is exercised faithfully.
|
||
*
|
||
* The membership and read-aggregate cases now drive TripMembersService and
|
||
* TripReadModelService, which is where that code went when the aggregate root
|
||
* was split. They stayed in this file, over the same in-memory DB, so the diff
|
||
* shows the move rather than a rewrite.
|
||
*/
|
||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
|
||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||
|
||
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');
|
||
const mock = {
|
||
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),
|
||
};
|
||
return { testDb: db, dbMock: mock };
|
||
});
|
||
|
||
vi.mock('../../../src/db/database', () => dbMock);
|
||
vi.mock('../../../src/config', () => ({
|
||
JWT_SECRET: 'test-secret',
|
||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||
updateJwtSecret: () => {},
|
||
}));
|
||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||
// notifyInvite fires a notification via a dynamic import — keep it out of unit scope
|
||
|
||
import { createTables } from '../../../src/db/schema';
|
||
import { runMigrations } from '../../../src/db/migrations';
|
||
import { resetTestDb } from '../../helpers/test-db';
|
||
import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote, addTripMember } from '../../helpers/factories';
|
||
import { MAX_TRIP_DAYS } from '@trek/shared';
|
||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||
import { DaysService } from '../../../src/nest/days/days.service';
|
||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||
import { TodoService } from '../../../src/nest/todo/todo.service';
|
||
import { PackingService } from '../../../src/nest/packing/packing.service';
|
||
import { FilesService } from '../../../src/nest/files/files.service';
|
||
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||
import { ReservationsReadRepository } from '../../../src/nest/reservations/reservations-read.repository';
|
||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||
import { CollabService } from '../../../src/nest/collab/collab.service';
|
||
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
|
||
import { VacayService } from '../../../src/nest/vacay/vacay.service';
|
||
import { TripsService } from '../../../src/nest/trips/trips.service';
|
||
import { PlacesService } from '../../../src/nest/places/places.service';
|
||
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
|
||
import { TripMembersService } from '../../../src/nest/trip-members/trip-members.service';
|
||
import { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service';
|
||
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
|
||
import { accommodationsOver, makeAccommodationsService } from '../../helpers/accommodations-service';
|
||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
||
import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { notificationsStub } from '../../helpers/notifications';
|
||
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
|
||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo } from '../../helpers/test-uow';
|
||
|
||
// Real sibling services over the same in-memory DB — updateTrip's date-shift
|
||
// resyncs and the summary/bundle aggregation run their actual SQL.
|
||
const dbs = () => new DatabaseService(testDb);
|
||
|
||
|
||
// Same collaborator set the container hands PlacesService (see places.service.test.ts).
|
||
// Only the read-model aggregation reaches into places here, but the photo cache,
|
||
// Unsplash and journey domain are real instances over the same in-memory DB
|
||
// rather than casts: the place hooks are fire-and-forget behind a catch, so a
|
||
// missing collaborator would look like a pass while swallowing a TypeError.
|
||
// One PlacePhotoCacheService for both PlacesService and MapsService, matching
|
||
// production, where the in-flight dedup only works on a shared instance.
|
||
const photoCache = new PlacePhotoCacheService(dbs(), makeStorageFixture('photos/google/').storage);
|
||
const coversFx = makeStorageFixture('covers/');
|
||
|
||
|
||
let accommodationsSvc: Awaited<ReturnType<typeof makeAccommodationsService>>;
|
||
let createAccommodation: (...args: Parameters<typeof accommodationsSvc.createAccommodation>) => ReturnType<typeof accommodationsSvc.createAccommodation>;
|
||
beforeAll(async () => {
|
||
accommodationsSvc = await makeAccommodationsService(testDb);
|
||
createAccommodation = (...args) => accommodationsSvc.createAccommodation(...args);
|
||
});
|
||
|
||
|
||
|
||
let budgetSvc: BudgetService;
|
||
let daysSvc: DaysService;
|
||
let placesSvc: PlacesService;
|
||
let svc: TripsService;
|
||
let membersSvc: TripMembersService;
|
||
let readModelSvc: TripReadModelService;
|
||
beforeAll(async () => {
|
||
budgetSvc = new BudgetService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs().connection));
|
||
daysSvc = new DaysService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new QueryHelpersService(dbs()), await createTestUnitOfWork(dbs().connection));
|
||
placesSvc = new PlacesService(
|
||
dbs(),
|
||
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
|
||
new RealtimeService(),
|
||
new MapsService(dbs(), photoCache, await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection)),
|
||
new QueryHelpersService(dbs()),
|
||
new UnsplashService(await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection), new RuntimeEnvService(), coversFx.storage),
|
||
photoCache,
|
||
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs()), await createTestUnitOfWork(dbs().connection)),
|
||
makeStorageFixture('').storage,
|
||
await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection),
|
||
);
|
||
svc = new TripsService(
|
||
dbs(),
|
||
new ReservationsService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)),
|
||
daysSvc,
|
||
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
|
||
budgetSvc,
|
||
new VacayService(dbs(), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
|
||
new RealtimeService(),
|
||
undefined as never, // unsplash — not exercised here
|
||
coversFx.storage,
|
||
await createTestUnitOfWork(dbs().connection),
|
||
);
|
||
membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection));
|
||
readModelSvc = new TripReadModelService(
|
||
dbs(), membersSvc, daysSvc, accommodationsSvc, budgetSvc,
|
||
new PackingService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
|
||
new ReservationsService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)),
|
||
new CollabService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), coversFx.storage, new RateLimitService(), await createTestUnitOfWork(dbs().connection)),
|
||
placesSvc,
|
||
new TodoService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), await createTestUnitOfWork(dbs().connection)),
|
||
new FilesService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new EphemeralTokenService(), coversFx.storage),
|
||
);
|
||
});
|
||
|
||
|
||
beforeAll(() => {
|
||
createTables(testDb);
|
||
runMigrations(testDb);
|
||
});
|
||
|
||
beforeEach(() => {
|
||
resetTestDb(testDb);
|
||
});
|
||
|
||
afterAll(() => {
|
||
testDb.close();
|
||
});
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function getDays(tripId: number) {
|
||
return testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as {
|
||
id: number; trip_id: number; day_number: number; date: string | null;
|
||
}[];
|
||
}
|
||
|
||
function getAssignments(dayId: number) {
|
||
return testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[];
|
||
}
|
||
|
||
function getNotes(dayId: number) {
|
||
return testDb.prepare('SELECT * FROM day_notes WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[];
|
||
}
|
||
|
||
function addDaysIso(date: string, n: number) {
|
||
return new Date(Date.parse(date + 'T00:00:00Z') + n * 86400000).toISOString().slice(0, 10);
|
||
}
|
||
|
||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||
|
||
describe('generateDays', () => {
|
||
it('TRIP-SVC-010: full range shift preserves day assignments and notes positionally', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
|
||
const daysBefore = getDays(trip.id);
|
||
expect(daysBefore).toHaveLength(5);
|
||
|
||
const place = createPlace(testDb, trip.id);
|
||
const assignment = createDayAssignment(testDb, daysBefore[0].id, place.id);
|
||
const note = createDayNote(testDb, daysBefore[1].id, trip.id, { text: 'packed' });
|
||
|
||
// Shift forward 9 days — zero overlap with original dates
|
||
await svc.generateDays(trip.id, '2025-06-10', '2025-06-14');
|
||
|
||
const daysAfter = getDays(trip.id);
|
||
expect(daysAfter).toHaveLength(5);
|
||
expect(daysAfter.map(d => d.date)).toEqual([
|
||
'2025-06-10', '2025-06-11', '2025-06-12', '2025-06-13', '2025-06-14',
|
||
]);
|
||
|
||
// day_number 1 (formerly June 1) now has date June 10 — assignment still attached
|
||
const day1 = daysAfter[0];
|
||
const day2 = daysAfter[1];
|
||
expect(getAssignments(day1.id)).toHaveLength(1);
|
||
expect(getAssignments(day1.id)[0].id).toBe(assignment.id);
|
||
expect(getNotes(day2.id)).toHaveLength(1);
|
||
expect(getNotes(day2.id)[0].id).toBe(note.id);
|
||
});
|
||
|
||
it('TRIP-SVC-011: shrinking range deletes overflow days and their assignments (issue #909)', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' });
|
||
const daysBefore = getDays(trip.id);
|
||
expect(daysBefore).toHaveLength(5);
|
||
|
||
const place = createPlace(testDb, trip.id);
|
||
createDayAssignment(testDb, daysBefore[3].id, place.id);
|
||
createDayAssignment(testDb, daysBefore[4].id, place.id);
|
||
|
||
// Shrink from 5 to 3 days — surplus days and their content are removed
|
||
await svc.generateDays(trip.id, '2025-07-01', '2025-07-03');
|
||
|
||
const daysAfter = getDays(trip.id);
|
||
expect(daysAfter).toHaveLength(3);
|
||
expect(daysAfter.map(d => d.date)).toEqual(['2025-07-01', '2025-07-02', '2025-07-03']);
|
||
});
|
||
|
||
it('TRIP-SVC-016: shrinking range deletes empty overflow days (issue #909)', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-07' });
|
||
expect(getDays(trip.id)).toHaveLength(7);
|
||
|
||
// Shrink 7 → 5; days 6 and 7 have no content
|
||
await svc.generateDays(trip.id, '2025-07-01', '2025-07-05');
|
||
|
||
const daysAfter = getDays(trip.id);
|
||
expect(daysAfter).toHaveLength(5);
|
||
expect(daysAfter.map(d => d.date)).toEqual([
|
||
'2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05',
|
||
]);
|
||
});
|
||
|
||
it('TRIP-SVC-012: growing range keeps existing day content and appends new empty days', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-08-01', end_date: '2025-08-03' });
|
||
const daysBefore = getDays(trip.id);
|
||
expect(daysBefore).toHaveLength(3);
|
||
|
||
const place = createPlace(testDb, trip.id);
|
||
const assignment = createDayAssignment(testDb, daysBefore[0].id, place.id);
|
||
|
||
// Grow to 5 days
|
||
await svc.generateDays(trip.id, '2025-08-01', '2025-08-05');
|
||
|
||
const daysAfter = getDays(trip.id);
|
||
expect(daysAfter).toHaveLength(5);
|
||
expect(daysAfter.map(d => d.date)).toEqual([
|
||
'2025-08-01', '2025-08-02', '2025-08-03', '2025-08-04', '2025-08-05',
|
||
]);
|
||
|
||
// Existing day 1 retains its assignment
|
||
expect(getAssignments(daysAfter[0].id)).toHaveLength(1);
|
||
expect(getAssignments(daysAfter[0].id)[0].id).toBe(assignment.id);
|
||
|
||
// New days 4 and 5 are empty
|
||
expect(getAssignments(daysAfter[3].id)).toHaveLength(0);
|
||
expect(getAssignments(daysAfter[4].id)).toHaveLength(0);
|
||
});
|
||
|
||
it('TRIP-SVC-062: a range longer than a year gets every one of its days (#2403)', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-01-26', end_date: '2025-01-28' });
|
||
// The reporter's range: 368 days, and the days used to stop at 365.
|
||
await svc.generateDays(trip.id, '2025-01-26', '2026-01-28');
|
||
const days = getDays(trip.id);
|
||
expect(days).toHaveLength(368);
|
||
expect(days[364].date).toBe('2026-01-25');
|
||
expect(days[367]).toMatchObject({ day_number: 368, date: '2026-01-28' });
|
||
});
|
||
|
||
it('TRIP-SVC-063: a dateless day_count is clamped to MAX_TRIP_DAYS', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
await svc.generateDays(trip.id, null, null, MAX_TRIP_DAYS + 50);
|
||
expect(getDays(trip.id)).toHaveLength(MAX_TRIP_DAYS);
|
||
});
|
||
|
||
it('TRIP-SVC-013: clearing dates converts all days to dateless without destroying assignments', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-09-01', end_date: '2025-09-04' });
|
||
const daysBefore = getDays(trip.id);
|
||
expect(daysBefore).toHaveLength(4);
|
||
|
||
const place = createPlace(testDb, trip.id);
|
||
const assignment = createDayAssignment(testDb, daysBefore[1].id, place.id);
|
||
|
||
// Clear both dates
|
||
await svc.generateDays(trip.id, null, null);
|
||
|
||
const daysAfter = getDays(trip.id);
|
||
expect(daysAfter).toHaveLength(4);
|
||
expect(daysAfter.every(d => d.date === null)).toBe(true);
|
||
|
||
// The assignment on the former day 2 still exists
|
||
const formerDay2 = daysAfter.find(d => d.id === daysBefore[1].id);
|
||
expect(formerDay2).toBeDefined();
|
||
expect(getAssignments(formerDay2!.id)).toHaveLength(1);
|
||
expect(getAssignments(formerDay2!.id)[0].id).toBe(assignment.id);
|
||
});
|
||
|
||
it('TRIP-SVC-014: partial overlap shift remaps by position (day 1→3 kept, 4-5 overflow)', async () => {
|
||
// Original: Jun 1-5. New: Jun 3-7 (overlap on Jun 3-5, but we map by position)
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-10-01', end_date: '2025-10-05' });
|
||
const daysBefore = getDays(trip.id);
|
||
const place = createPlace(testDb, trip.id);
|
||
// Assign to each of the 5 days
|
||
for (const day of daysBefore) createDayAssignment(testDb, day.id, place.id);
|
||
|
||
// Shift forward 2 days (partial overlap with original range)
|
||
await svc.generateDays(trip.id, '2025-10-03', '2025-10-07');
|
||
|
||
const daysAfter = getDays(trip.id);
|
||
expect(daysAfter).toHaveLength(5);
|
||
expect(daysAfter.map(d => d.date)).toEqual([
|
||
'2025-10-03', '2025-10-04', '2025-10-05', '2025-10-06', '2025-10-07',
|
||
]);
|
||
|
||
// All 5 assignments survive
|
||
for (const day of daysAfter) {
|
||
expect(getAssignments(day.id)).toHaveLength(1);
|
||
}
|
||
});
|
||
|
||
it('TRIP-SVC-015: growing into dateless days reuses them; leftover dateless renumber without UNIQUE collision', async () => {
|
||
// 3 dated days + 2 pre-existing dateless days. Resize to 4 dated days.
|
||
// Main loop: dated[0..2] → positions 1-3, dateless[0] → position 4 (consumed).
|
||
// Unused dateless: dateless[1] should land at position 5, NOT 4 (collision bug).
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-11-01', end_date: '2025-11-03' });
|
||
|
||
// Insert 2 dateless days directly
|
||
const daysBefore = getDays(trip.id);
|
||
testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 4);
|
||
testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 5);
|
||
|
||
const allDays = getDays(trip.id);
|
||
expect(allDays).toHaveLength(5);
|
||
|
||
const place = createPlace(testDb, trip.id);
|
||
// Put an assignment on the second dateless day (day_number=5) — it should survive
|
||
const assignment = createDayAssignment(testDb, allDays[4].id, place.id);
|
||
|
||
// Grow from 3 to 4 dated days — consumes dateless[0], leaves dateless[1] unused
|
||
// This is the scenario that triggered the UNIQUE collision bug
|
||
await svc.generateDays(trip.id, '2025-11-01', '2025-11-04');
|
||
|
||
const daysAfter = getDays(trip.id);
|
||
expect(daysAfter).toHaveLength(5);
|
||
|
||
const dated = daysAfter.filter(d => d.date !== null);
|
||
const dateless = daysAfter.filter(d => d.date === null);
|
||
expect(dated).toHaveLength(4);
|
||
expect(dateless).toHaveLength(1);
|
||
|
||
// The remaining dateless day still has its assignment
|
||
expect(getAssignments(dateless[0].id)).toHaveLength(1);
|
||
expect(getAssignments(dateless[0].id)[0].id).toBe(assignment.id);
|
||
|
||
// All day_numbers are unique 1..5
|
||
const nums = daysAfter.map(d => d.day_number).sort((a, b) => a - b);
|
||
expect(nums).toEqual([1, 2, 3, 4, 5]);
|
||
});
|
||
|
||
it('TRIP-SVC-017: switching a dateless trip to a shorter dated range drops empty leftover days but keeps ones with content (#1083)', async () => {
|
||
const { user } = createUser(testDb);
|
||
// A 7-day trip, then cleared to dateless placeholders (day_count = 7).
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-12-01', end_date: '2025-12-07' });
|
||
await svc.generateDays(trip.id, null, null);
|
||
const dateless = getDays(trip.id);
|
||
expect(dateless).toHaveLength(7);
|
||
expect(dateless.every(d => d.date === null)).toBe(true);
|
||
|
||
// Give the LAST dateless day real content so it must be preserved.
|
||
const place = createPlace(testDb, trip.id);
|
||
const assignment = createDayAssignment(testDb, dateless[6].id, place.id);
|
||
|
||
// Now set an explicit 2-day range. The first two dateless days are reused for
|
||
// the dates; the four empty leftovers must be removed, the one with content kept.
|
||
await svc.generateDays(trip.id, '2026-01-10', '2026-01-11');
|
||
|
||
const daysAfter = getDays(trip.id);
|
||
const dated = daysAfter.filter(d => d.date !== null);
|
||
const stillDateless = daysAfter.filter(d => d.date === null);
|
||
expect(dated.map(d => d.date)).toEqual(['2026-01-10', '2026-01-11']);
|
||
// day_count is COUNT(*) FROM days: 2 dated + 1 content-bearing dateless = 3 (not the stale 7)
|
||
expect(daysAfter).toHaveLength(3);
|
||
expect(stillDateless).toHaveLength(1);
|
||
expect(getAssignments(stillDateless[0].id)[0].id).toBe(assignment.id);
|
||
});
|
||
});
|
||
|
||
// ── deleteOldCover — path containment ──────────────────────────────────────────
|
||
|
||
describe('deleteOldCover', () => {
|
||
it('TRIP-SVC-COVER-001: never deletes outside the covers category for a crafted cover_image', async () => {
|
||
// Attacker-controlled values aimed at auth-gated sibling upload dirs — the
|
||
// basename + category addressing keeps every delete inside covers/.
|
||
const filesDir = path.join(coversFx.root, 'files');
|
||
const avatarsDir = path.join(coversFx.root, 'avatars');
|
||
fs.mkdirSync(filesDir, { recursive: true });
|
||
fs.mkdirSync(avatarsDir, { recursive: true });
|
||
const secret = path.join(filesDir, 'secret.pdf');
|
||
const someone = path.join(avatarsDir, 'someone.png');
|
||
fs.writeFileSync(secret, 'pdf');
|
||
fs.writeFileSync(someone, 'png');
|
||
|
||
await svc.deleteOldCover('/uploads/files/secret.pdf');
|
||
await svc.deleteOldCover('/uploads/covers/../files/secret.pdf');
|
||
await svc.deleteOldCover('/uploads/avatars/someone.png');
|
||
|
||
expect(fs.existsSync(secret)).toBe(true);
|
||
expect(fs.existsSync(someone)).toBe(true);
|
||
});
|
||
|
||
it('TRIP-SVC-COVER-002: deletes a legitimate cover file', async () => {
|
||
const coversDir = path.join(coversFx.root, 'covers');
|
||
fs.mkdirSync(coversDir, { recursive: true });
|
||
const cover = path.join(coversDir, 'abc123.jpg');
|
||
fs.writeFileSync(cover, 'jpeg');
|
||
|
||
await svc.deleteOldCover('/uploads/covers/abc123.jpg');
|
||
expect(fs.existsSync(cover)).toBe(false);
|
||
});
|
||
|
||
it('TRIP-SVC-COVER-003: an external https cover URL is tolerated (no throw)', async () => {
|
||
await expect(svc.deleteOldCover('https://example.com/some/pic.jpg')).resolves.toBeUndefined();
|
||
await expect(svc.deleteOldCover(null)).resolves.toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('resyncReservationDays (#1288)', () => {
|
||
const dayFor = (tripId: number, date: string) =>
|
||
(testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id;
|
||
const insertDatedReservation = (tripId: number, dayId: number, time: string) =>
|
||
Number(testDb.prepare(
|
||
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', ?, 'restaurant', 'pending')",
|
||
).run(tripId, dayId, time).lastInsertRowid);
|
||
|
||
it('TRIP-SVC-018: changing the start date re-anchors a dated reservation to the day matching its time', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
|
||
const resId = insertDatedReservation(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T19:00:00');
|
||
// Shift the whole range one day forward (days become 2025-06-02..06).
|
||
await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user');
|
||
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
|
||
// The booking stays on its absolute date (2025-06-02) instead of shifting with its old day row.
|
||
expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02'));
|
||
});
|
||
|
||
it('TRIP-SVC-019: a reservation whose date falls outside the new range keeps its day_id (not nulled)', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
|
||
const origDayId = dayFor(trip.id, '2025-06-02');
|
||
const resId = insertDatedReservation(trip.id, origDayId, '2025-06-02T19:00:00');
|
||
// Shift far forward so 2025-06-02 is no longer covered by any day.
|
||
await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }, 'user');
|
||
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
|
||
expect(res.day_id).toBe(origDayId);
|
||
});
|
||
});
|
||
|
||
describe('resyncAccommodationDays (#1288)', () => {
|
||
const dayFor = (tripId: number, date: string) =>
|
||
(testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id;
|
||
|
||
const insertAccommodation = async (tripId: number, startDayId: number, endDayId: number) => {
|
||
const place = createPlace(testDb, tripId, { name: 'Grand Hotel' });
|
||
const { accommodation: acc } = (await createAccommodation(tripId, {
|
||
place_id: place.id, start_day_id: startDayId, end_day_id: endDayId,
|
||
})) as { accommodation: { id: number } };
|
||
const linkedRes = testDb.prepare(
|
||
'SELECT id FROM reservations WHERE accommodation_id = ?',
|
||
).get(acc.id) as { id: number };
|
||
return { accId: acc.id, linkedResId: linkedRes.id };
|
||
};
|
||
|
||
const getAcc = (id: number) =>
|
||
testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(id) as
|
||
{ start_day_id: number; end_day_id: number };
|
||
const getRes = (id: number) =>
|
||
testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(id) as
|
||
{ day_id: number | null; reservation_time: string | null };
|
||
|
||
it('TRIP-SVC-035: extending the start keeps an accommodation on its absolute dates', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' });
|
||
const { accId, linkedResId } = await insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13'));
|
||
// Add a day at the start: days re-date positionally (old 06-11 row becomes 06-10, …).
|
||
await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user');
|
||
const acc = getAcc(accId);
|
||
expect(acc.start_day_id).toBe(dayFor(trip.id, '2025-06-11'));
|
||
expect(acc.end_day_id).toBe(dayFor(trip.id, '2025-06-13'));
|
||
const res = getRes(linkedResId);
|
||
expect(res.day_id).toBe(acc.start_day_id);
|
||
expect(res.reservation_time?.slice(0, 10)).toBe('2025-06-11');
|
||
});
|
||
|
||
it('TRIP-SVC-059: the day stop a booking wrote follows it when the trip is re-dated', async () => {
|
||
// Booking a night also puts its place on the check-in day. Re-dating the trip moves
|
||
// the stay to whichever day row now carries its date, and the stop has to go with
|
||
// it, or the route runs through a day the traveller is no longer staying on.
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' });
|
||
const { accId } = await insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13'));
|
||
const stopOf = () => testDb.prepare('SELECT day_id FROM day_assignments WHERE accommodation_id = ?').get(accId) as { day_id: number };
|
||
expect(stopOf().day_id).toBe(dayFor(trip.id, '2025-06-11'));
|
||
|
||
await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user');
|
||
|
||
expect(stopOf().day_id).toBe(getAcc(accId).start_day_id);
|
||
expect(stopOf().day_id).toBe(dayFor(trip.id, '2025-06-11'));
|
||
});
|
||
|
||
it('TRIP-SVC-036: moving the whole trip out of the old range keeps the accommodation glued to its days', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
|
||
const startDayId = dayFor(trip.id, '2025-06-02');
|
||
const endDayId = dayFor(trip.id, '2025-06-03');
|
||
const { accId, linkedResId } = await insertAccommodation(trip.id, startDayId, endDayId);
|
||
await svc.updateTrip(trip.id, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }, 'user');
|
||
const acc = getAcc(accId);
|
||
expect(acc.start_day_id).toBe(startDayId);
|
||
expect(acc.end_day_id).toBe(endDayId);
|
||
// The linked reservation follows the (re-dated) start day instead of keeping a stale date snapshot.
|
||
const res = getRes(linkedResId);
|
||
expect(res.day_id).toBe(startDayId);
|
||
expect(res.reservation_time?.slice(0, 10)).toBe('2025-07-02');
|
||
});
|
||
|
||
it("TRIP-SVC-038: date_shift_mode 'shift_all' glues bookings to their days and restamps their times", async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
|
||
const origDayId = dayFor(trip.id, '2025-06-02');
|
||
const resId = Number(testDb.prepare(
|
||
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', '2025-06-02T19:00:00', 'restaurant', 'pending')",
|
||
).run(trip.id, origDayId).lastInsertRowid);
|
||
const { accId } = await insertAccommodation(trip.id, origDayId, dayFor(trip.id, '2025-06-03'));
|
||
await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-03', end_date: '2025-06-07', date_shift_mode: 'shift_all' }, 'user');
|
||
// The booking stays on its day row (now 2025-06-04) and its time follows.
|
||
const res = testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(resId) as
|
||
{ day_id: number; reservation_time: string };
|
||
expect(res.day_id).toBe(origDayId);
|
||
expect(res.reservation_time).toBe('2025-06-04T19:00:00');
|
||
// The accommodation stays glued to its (re-dated) day rows too.
|
||
const acc = getAcc(accId);
|
||
expect(acc.start_day_id).toBe(origDayId);
|
||
});
|
||
|
||
it('TRIP-SVC-037: a dated hotel reservation without a linked accommodation is re-anchored like other bookings', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
|
||
const resId = Number(testDb.prepare(
|
||
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Imported hotel', ?, 'hotel', 'pending')",
|
||
).run(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T15:00:00').lastInsertRowid);
|
||
await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user');
|
||
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
|
||
expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02'));
|
||
});
|
||
});
|
||
|
||
describe('transferOwnership (#973)', () => {
|
||
it('TRIP-SVC-020: hands the trip to a member and demotes the former owner to a member', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: member } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
addTripMember(testDb, trip.id, member.id);
|
||
|
||
const result = await membersSvc.transferOwnership(trip.id, member.id, owner.id);
|
||
expect(result.toEmail).toBe(member.email);
|
||
|
||
const updated = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number };
|
||
expect(updated.user_id).toBe(member.id);
|
||
|
||
// New owner no longer sits in trip_members, former owner now does.
|
||
const memberIds = (testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[]).map(r => r.user_id);
|
||
expect(memberIds).toContain(owner.id);
|
||
expect(memberIds).not.toContain(member.id);
|
||
});
|
||
|
||
it('TRIP-SVC-021: rejects a transfer from a non-owner', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: member } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
addTripMember(testDb, trip.id, member.id);
|
||
// member (not the owner) attempts the transfer
|
||
await expect(membersSvc.transferOwnership(trip.id, member.id, member.id)).rejects.toThrow();
|
||
});
|
||
|
||
it('TRIP-SVC-022: rejects a transfer to someone who is not a member', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: stranger } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
await expect(membersSvc.transferOwnership(trip.id, stranger.id, owner.id)).rejects.toThrow('New owner must be a trip member');
|
||
});
|
||
|
||
it('TRIP-SVC-023: rejects transferring to yourself', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
await expect(membersSvc.transferOwnership(trip.id, owner.id, owner.id)).rejects.toThrow('You already own this trip');
|
||
});
|
||
});
|
||
|
||
describe('guest members (#1362)', () => {
|
||
it('TRIP-SVC-030: createGuest adds a credential-less user joined into the trip', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
|
||
const { member } = await membersSvc.createGuest(trip.id, ' Anna ', owner.id);
|
||
expect(member.username).toBe('Anna');
|
||
expect(member.is_guest).toBe(true);
|
||
|
||
const row = testDb.prepare('SELECT username, email, password_hash, is_guest, role FROM users WHERE id = ?').get(member.id) as any;
|
||
expect(row.is_guest).toBe(1);
|
||
expect(row.password_hash).toBe('');
|
||
expect(row.email).toMatch(/@guests\.invalid$/);
|
||
expect(row.role).toBe('user');
|
||
|
||
// Joined as a trip member.
|
||
const m = testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
|
||
expect(m).toBeTruthy();
|
||
|
||
// Surfaces in listMembers with is_guest=true and the typed display name.
|
||
const { members } = await membersSvc.listMembers(trip.id, owner.id) as any;
|
||
const guest = members.find((x: any) => x.id === member.id);
|
||
expect(guest.username).toBe('Anna');
|
||
expect(guest.is_guest).toBe(true);
|
||
});
|
||
|
||
it('TRIP-SVC-031: the same guest name is allowed, not suffixed (#1446)', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
const a = await membersSvc.createGuest(trip.id, 'Sam', owner.id);
|
||
const b = await membersSvc.createGuest(trip.id, 'Sam', owner.id);
|
||
// both keep the plain display name; only the internal (uuid) username differs
|
||
expect(a.member.username).toBe('Sam');
|
||
expect(b.member.username).toBe('Sam');
|
||
expect(b.member.id).not.toBe(a.member.id);
|
||
const usernames = testDb.prepare('SELECT username FROM users WHERE id IN (?, ?)').all(a.member.id, b.member.id) as { username: string }[];
|
||
expect(usernames[0].username).not.toBe(usernames[1].username);
|
||
});
|
||
|
||
it('TRIP-SVC-032: renameGuest updates the display name (trip-scoped, guest-only)', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: other } = createUser(testDb);
|
||
const otherTrip = createTrip(testDb, other.id);
|
||
const trip = createTrip(testDb, owner.id);
|
||
const { member } = await membersSvc.createGuest(trip.id, 'Bob', owner.id);
|
||
|
||
expect(await membersSvc.renameGuest(trip.id, member.id, 'Robert')).toBe(true);
|
||
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(member.id) as any).display_name).toBe('Robert');
|
||
|
||
// A real user cannot be renamed through the guest path…
|
||
expect(await membersSvc.renameGuest(trip.id, owner.id, 'Hacked')).toBe(false);
|
||
// …and a guest cannot be renamed from a different trip.
|
||
expect(await membersSvc.renameGuest(otherTrip.id, member.id, 'Nope')).toBe(false);
|
||
});
|
||
|
||
it('TRIP-SVC-033: deleteGuest removes the user (cascading membership), guest-only + trip-scoped', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
const { member } = await membersSvc.createGuest(trip.id, 'Carol', owner.id);
|
||
|
||
// Real members are not deletable via the guest path.
|
||
expect(await membersSvc.deleteGuest(trip.id, owner.id)).toBe(false);
|
||
|
||
expect(await membersSvc.deleteGuest(trip.id, member.id)).toBe(true);
|
||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(member.id)).toBeUndefined();
|
||
expect(testDb.prepare('SELECT id FROM trip_members WHERE user_id = ?').get(member.id)).toBeUndefined();
|
||
});
|
||
|
||
it('TRIP-SVC-034: a guest is never invitable (addMember) nor a transfer target', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
const { member } = await membersSvc.createGuest(trip.id, 'Dora', owner.id);
|
||
|
||
// The synthetic username/email must not resolve through the invite box.
|
||
await expect(membersSvc.addMember(trip.id, 'Dora', owner.id, owner.id)).rejects.toThrow('User not found');
|
||
// Ownership can never be handed to a guest.
|
||
await expect(membersSvc.transferOwnership(trip.id, member.id, owner.id)).rejects.toThrow('Cannot transfer ownership to a guest');
|
||
});
|
||
});
|
||
|
||
// ── Folded CRUD SQL (summary / list / create / delete / copy) ─────────────────
|
||
|
||
describe('folded trip CRUD', () => {
|
||
it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: member } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
|
||
addTripMember(testDb, trip.id, member.id);
|
||
testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'food', 'Dinner', 40)").run(trip.id);
|
||
testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id);
|
||
|
||
const summary = await (await readModelSvc.getTripSummary(trip.id, owner.id))!;
|
||
expect(summary).toBeTruthy();
|
||
expect((summary.trip as any).id).toBe(trip.id);
|
||
expect(summary.members.owner.id).toBe(owner.id);
|
||
expect(summary.members.collaborators.map((m: any) => m.id)).toEqual([member.id]);
|
||
expect(summary.days).toHaveLength(2);
|
||
expect(summary.budget.item_count).toBe(1);
|
||
expect(summary.budget.total).toBe(40);
|
||
expect(summary.packing.total).toBe(1);
|
||
expect(summary.packing.checked).toBe(1);
|
||
expect(summary.reservations).toEqual([]);
|
||
expect(summary.collab_notes).toEqual([]);
|
||
|
||
// Missing trips return null instead of throwing.
|
||
expect(await readModelSvc.getTripSummary(99999)).toBeNull();
|
||
});
|
||
|
||
it('TRIP-SVC-043: list returns owned + shared trips with is_owner, honoring the archived filter', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: other } = createUser(testDb);
|
||
const own = createTrip(testDb, owner.id, { title: 'Mine' });
|
||
const shared = createTrip(testDb, other.id, { title: 'Shared' });
|
||
addTripMember(testDb, shared.id, owner.id);
|
||
const archived = createTrip(testDb, owner.id, { title: 'Old' });
|
||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
|
||
|
||
const active = (await svc.list(owner.id, 0)) as any[];
|
||
expect(active.map(t => t.id).sort()).toEqual([own.id, shared.id].sort());
|
||
expect(active.find(t => t.id === own.id).is_owner).toBe(1);
|
||
expect(active.find(t => t.id === shared.id).is_owner).toBe(0);
|
||
|
||
const all = (await svc.list(owner.id, null)) as any[];
|
||
expect(all.map(t => t.id).sort()).toEqual([own.id, shared.id, archived.id].sort());
|
||
});
|
||
|
||
it('TRIP-SVC-044: create applies || defaults, clamps reminder_days and generates days', async () => {
|
||
const { user } = createUser(testDb);
|
||
const { trip, tripId, reminderDays } = await svc.create(user.id, {
|
||
title: 'New Trip', start_date: '2025-06-01', end_date: '2025-06-03', reminder_days: 99,
|
||
});
|
||
expect(reminderDays).toBe(3); // out-of-range → default 3
|
||
expect((trip as any).currency).toBe('EUR'); // no currency → 'EUR'
|
||
expect(getDays(tripId)).toHaveLength(3);
|
||
});
|
||
|
||
it('TRIP-SVC-064: create refuses a range past MAX_TRIP_DAYS and writes nothing', async () => {
|
||
const { user } = createUser(testDb);
|
||
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n;
|
||
await expect(svc.create(user.id, { title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' }))
|
||
.rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||
expect((testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n).toBe(before);
|
||
// The longest allowed range goes through in full.
|
||
const { tripId } = await svc.create(user.id, { title: 'Longest', start_date: '2026-01-01', end_date: addDaysIso('2026-01-01', MAX_TRIP_DAYS - 1) });
|
||
expect(getDays(tripId)).toHaveLength(MAX_TRIP_DAYS);
|
||
});
|
||
|
||
it('TRIP-SVC-045: remove deletes the trip, cleans skeleton journey entries and detaches filled ones', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
const journeyId = Number(testDb.prepare(
|
||
"INSERT INTO journeys (user_id, title, created_at, updated_at) VALUES (?, 'J', 0, 0)",
|
||
).run(user.id).lastInsertRowid);
|
||
testDb.prepare(
|
||
"INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'skeleton', 'S', '2025-06-01', 0, 0)",
|
||
).run(journeyId, trip.id, user.id);
|
||
const filledId = Number(testDb.prepare(
|
||
"INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'story', 'F', '2025-06-01', 0, 0)",
|
||
).run(journeyId, trip.id, user.id).lastInsertRowid);
|
||
|
||
const info = await svc.remove(trip.id, user.id, 'user');
|
||
expect(info).toMatchObject({ tripId: trip.id, ownerId: user.id, isAdminDelete: false });
|
||
|
||
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeUndefined();
|
||
expect(testDb.prepare("SELECT id FROM journey_entries WHERE type = 'skeleton'").get()).toBeUndefined();
|
||
const filled = testDb.prepare('SELECT source_trip_id FROM journey_entries WHERE id = ?').get(filledId) as any;
|
||
expect(filled.source_trip_id).toBeNull();
|
||
|
||
// Missing trips throw the byte-identical error.
|
||
await expect(svc.remove(99999, user.id, 'user')).rejects.toThrow('Trip not found');
|
||
});
|
||
|
||
it('TRIP-SVC-046: copy duplicates days/places/assignments and resets packing to unchecked', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' });
|
||
const days = getDays(trip.id);
|
||
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
|
||
createDayAssignment(testDb, days[0].id, place.id);
|
||
testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id);
|
||
|
||
const newTripId = await svc.copy(trip.id, user.id, 'Clone');
|
||
|
||
const copied = testDb.prepare('SELECT title, is_archived FROM trips WHERE id = ?').get(newTripId) as any;
|
||
expect(copied.title).toBe('Clone');
|
||
expect(copied.is_archived).toBe(0);
|
||
expect(getDays(newTripId)).toHaveLength(2);
|
||
const newPlaces = testDb.prepare('SELECT id, name FROM places WHERE trip_id = ?').all(newTripId) as any[];
|
||
expect(newPlaces.map(p => p.name)).toEqual(['Louvre']);
|
||
expect(getAssignments(getDays(newTripId)[0].id)).toHaveLength(1);
|
||
const packing = testDb.prepare('SELECT checked FROM packing_items WHERE trip_id = ?').all(newTripId) as any[];
|
||
expect(packing).toEqual([{ checked: 0 }]);
|
||
|
||
// No title → source title (|| fallback).
|
||
const secondCopy = await svc.copy(trip.id, user.id);
|
||
expect((testDb.prepare('SELECT title FROM trips WHERE id = ?').get(secondCopy) as any).title).toBe('Origin');
|
||
});
|
||
|
||
it('TRIP-SVC-061: copy carries the road-trip shaping, not just the places', async () => {
|
||
// A via is the road the traveller chose over the one the router prefers, and
|
||
// a day track is the line a day was fitted to. Leaving them behind gave back
|
||
// a trip that looks complete and quietly drives somewhere else — noticed
|
||
// only once somebody edits the copy, with nothing left to recover from.
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { title: 'Norway', start_date: '2025-06-01', end_date: '2025-06-02' });
|
||
const days = getDays(trip.id);
|
||
const stop = createPlace(testDb, trip.id, { name: 'Geiranger' });
|
||
const track = createPlace(testDb, trip.id, { name: 'Scenic route' });
|
||
testDb.prepare("UPDATE places SET stop_type = 'fuel' WHERE id = ?").run(stop.id);
|
||
testDb.prepare("UPDATE places SET route_geometry = '[[1,2],[3,4]]' WHERE id = ?").run(track.id);
|
||
createDayAssignment(testDb, days[0].id, stop.id);
|
||
testDb.prepare(
|
||
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 0, 0, 62.1, 7.2), (?, 0, 1, 62.2, 7.3)',
|
||
).run(days[0].id, days[0].id);
|
||
testDb.prepare('INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) VALUES (?, ?, 1.5)')
|
||
.run(days[0].id, track.id);
|
||
|
||
const newTripId = await svc.copy(trip.id, user.id, 'Clone');
|
||
const newDays = getDays(newTripId);
|
||
|
||
// The kind of stop each place is survives the copy.
|
||
const copiedStop = testDb.prepare("SELECT stop_type FROM places WHERE trip_id = ? AND name = 'Geiranger'")
|
||
.get(newTripId) as { stop_type: string | null };
|
||
expect(copiedStop.stop_type).toBe('fuel');
|
||
|
||
const vias = testDb.prepare('SELECT after_order_index, sequence, lat, lng FROM roadtrip_vias WHERE day_id = ? ORDER BY sequence')
|
||
.all(newDays[0].id) as { after_order_index: number; sequence: number; lat: number; lng: number }[];
|
||
expect(vias).toEqual([
|
||
{ after_order_index: 0, sequence: 0, lat: 62.1, lng: 7.2 },
|
||
{ after_order_index: 0, sequence: 1, lat: 62.2, lng: 7.3 },
|
||
]);
|
||
|
||
// The track points at the COPY's place, never back at the original.
|
||
const copiedTrack = testDb.prepare('SELECT place_id, stray_km FROM roadtrip_day_tracks WHERE day_id = ?')
|
||
.get(newDays[0].id) as { place_id: number; stray_km: number };
|
||
const copiedTrackPlace = testDb.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Scenic route'")
|
||
.get(newTripId) as { id: number };
|
||
expect(copiedTrack.place_id).toBe(copiedTrackPlace.id);
|
||
expect(copiedTrack.stray_km).toBe(1.5);
|
||
|
||
// And the original keeps exactly what it had.
|
||
expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias WHERE day_id = ?').get(days[0].id)).toEqual({ c: 2 });
|
||
});
|
||
|
||
it('TRIP-SVC-060: copying a trip keeps a staged booking staged', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' });
|
||
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, ingest_state)
|
||
VALUES (?, 'Parked', 'flight', 'confirmed', 'staged')`).run(trip.id);
|
||
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status)
|
||
VALUES (?, 'Booked', 'flight', 'confirmed')`).run(trip.id);
|
||
|
||
const newTripId = await svc.copy(trip.id, user.id, 'Clone');
|
||
|
||
// Without ingest_state on the duplicate INSERT the staged row falls back to
|
||
// the column default and shows up in the copy's public feed.
|
||
const rows = testDb.prepare('SELECT title, ingest_state FROM reservations WHERE trip_id = ? ORDER BY title')
|
||
.all(newTripId) as any[];
|
||
expect(rows).toEqual([
|
||
{ title: 'Booked', ingest_state: 'live' },
|
||
{ title: 'Parked', ingest_state: 'staged' },
|
||
]);
|
||
});
|
||
|
||
/**
|
||
* Copying a trip used to take every packing row and re-insert it without
|
||
* is_private/owner_id, so both fell back to the column defaults and another
|
||
* member's Personal or Shared item reappeared in the copy as a Common item
|
||
* that everyone on the new trip could read (GHSA-vh2h-288v-ggch).
|
||
*/
|
||
it("TRIP-SVC-046b: copy leaves other members' restricted packing items behind", async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: member } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' });
|
||
const ins = testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, is_private, owner_id) VALUES (?, ?, 0, ?, ?)');
|
||
ins.run(trip.id, 'Shared tent', 0, null); // Common
|
||
ins.run(trip.id, "Owner's diary", 1, owner.id); // the owner's Personal
|
||
ins.run(trip.id, "Member's meds", 1, member.id); // the copier's own Personal
|
||
|
||
const newTripId = await svc.copy(trip.id, member.id, 'Copy');
|
||
const rows = testDb.prepare('SELECT name, is_private, owner_id FROM packing_items WHERE trip_id = ? ORDER BY name').all(newTripId) as any[];
|
||
|
||
// The owner's private row is gone, not relabelled as Common.
|
||
expect(rows.map(r => r.name)).toEqual(["Member's meds", 'Shared tent']);
|
||
expect(rows.find(r => r.name === 'Shared tent')).toMatchObject({ is_private: 0, owner_id: null });
|
||
// The copier's own item stays restricted and belongs to them in the copy.
|
||
expect(rows.find(r => r.name === "Member's meds")).toMatchObject({ is_private: 1, owner_id: member.id });
|
||
});
|
||
|
||
it('TRIP-SVC-059: copy remaps cross-links and carries splits/participants (smoke-test I-01)', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: friend } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id, { title: 'Linked', start_date: '2025-06-01', end_date: '2025-06-02' });
|
||
const days = getDays(trip.id);
|
||
const place = createPlace(testDb, trip.id, { name: 'Hotel Le Test' });
|
||
const assignment = createDayAssignment(testDb, days[0].id, place.id);
|
||
testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignment.id, owner.id);
|
||
|
||
const accomId = Number(testDb.prepare(`
|
||
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out)
|
||
VALUES (?, ?, ?, ?, '15:00', '11:00')
|
||
`).run(trip.id, place.id, days[0].id, days[1].id).lastInsertRowid);
|
||
|
||
const resId = Number(testDb.prepare(`
|
||
INSERT INTO reservations (trip_id, day_id, assignment_id, accommodation_id, title, type, url)
|
||
VALUES (?, ?, ?, ?, 'Hotel booking', 'hotel', 'https://example.test/booking')
|
||
`).run(trip.id, days[0].id, assignment.id, accomId).lastInsertRowid);
|
||
|
||
const itemId = Number(testDb.prepare(`
|
||
INSERT INTO budget_items (trip_id, category, name, total_price, persons, reservation_id, currency, exchange_rate, expense_date)
|
||
VALUES (?, 'Accommodation', 'Hotel', 240, 2, ?, 'JPY', 0.0062, '2025-06-01')
|
||
`).run(trip.id, resId).lastInsertRowid);
|
||
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 1, 120)').run(itemId, owner.id);
|
||
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, 120)').run(itemId, friend.id);
|
||
testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, 240)').run(itemId, owner.id);
|
||
testDb.prepare("INSERT INTO todo_items (trip_id, name, checked) VALUES (?, 'Book transfer', 1)").run(trip.id);
|
||
|
||
const newTripId = await svc.copy(trip.id, owner.id, 'Linked copy');
|
||
|
||
// Budget → reservation link points at the copied reservation, not null / not the old id.
|
||
const newItem = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').get(newTripId) as any;
|
||
const newRes = testDb.prepare('SELECT * FROM reservations WHERE trip_id = ?').get(newTripId) as any;
|
||
expect(newRes.id).not.toBe(resId);
|
||
expect(newItem.reservation_id).toBe(newRes.id);
|
||
expect(newItem).toMatchObject({ currency: 'JPY', exchange_rate: 0.0062, expense_date: '2025-06-01' });
|
||
|
||
// Reservation → accommodation resolves to the copied accommodation (accommodation_id is TEXT).
|
||
const newAccom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(newTripId) as any;
|
||
expect(newAccom.id).not.toBe(accomId);
|
||
expect(Number(newRes.accommodation_id)).toBe(newAccom.id);
|
||
expect(newRes.url).toBe('https://example.test/booking');
|
||
|
||
// Splits carried over with per-member paid flags and amounts.
|
||
const members = testDb.prepare('SELECT user_id, paid, amount FROM budget_item_members WHERE budget_item_id = ? ORDER BY user_id').all(newItem.id) as any[];
|
||
expect(members).toEqual([
|
||
{ user_id: owner.id, paid: 1, amount: 120 },
|
||
{ user_id: friend.id, paid: 0, amount: 120 },
|
||
]);
|
||
const payers = testDb.prepare('SELECT user_id, amount FROM budget_item_payers WHERE budget_item_id = ?').all(newItem.id) as any[];
|
||
expect(payers).toEqual([{ user_id: owner.id, amount: 240 }]);
|
||
|
||
// Assignment participants copied onto the remapped assignment.
|
||
const newAssignment = getAssignments(getDays(newTripId)[0].id)[0];
|
||
const participants = testDb.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?').all(newAssignment.id) as any[];
|
||
expect(participants).toEqual([{ user_id: owner.id }]);
|
||
|
||
// To-dos come across but reset to unchecked (documented behaviour).
|
||
const todos = testDb.prepare('SELECT name, checked FROM todo_items WHERE trip_id = ?').all(newTripId) as any[];
|
||
expect(todos).toEqual([{ name: 'Book transfer', checked: 0 }]);
|
||
});
|
||
});
|
||
|
||
// ── Wrapper helpers (delegating members of the aggregate root) ────────────────
|
||
|
||
describe('TripsService wrapper helpers', () => {
|
||
it('re-anchors the budget before the trip row leaves its old currency (#1543)', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
const order: string[] = [];
|
||
const rebaseSpy = vi.spyOn(budgetSvc, 'rebaseTripCurrency').mockImplementation(async () => { order.push('rebase'); });
|
||
const updateSpy = vi.spyOn(svc, 'updateTrip').mockImplementation(() => { order.push('update'); return {} as never; });
|
||
try {
|
||
await svc.update(trip.id, user.id, { currency: 'RUB' } as never, 'user');
|
||
// The rebase reads the outgoing currency off the trip row, so it has to run first.
|
||
expect(rebaseSpy).toHaveBeenCalledWith(trip.id, 'RUB');
|
||
expect(order).toEqual(['rebase', 'update']);
|
||
} finally {
|
||
rebaseSpy.mockRestore();
|
||
updateSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it('TRIP-SVC-068: update refuses a bad range before the budget is rebased (#2403)', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-07' });
|
||
const rebaseSpy = vi.spyOn(budgetSvc, 'rebaseTripCurrency').mockResolvedValue();
|
||
try {
|
||
await expect(svc.update(trip.id, user.id, { currency: 'USD', end_date: '2036-07-01' }, 'user'))
|
||
.rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||
await expect(svc.update(trip.id, user.id, { currency: 'USD', start_date: '2026-07-10' }, 'user'))
|
||
.rejects.toThrow('End date must be after start date');
|
||
expect(rebaseSpy).not.toHaveBeenCalled();
|
||
await expect(svc.update(99999, user.id, { currency: 'USD' }, 'user')).rejects.toThrow('Trip not found');
|
||
} finally {
|
||
rebaseSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it('canAccessTrip delegates to the db helper; can() delegates to checkPermission; broadcast forwards', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
expect(await svc.canAccessTrip(String(trip.id), user.id)).toMatchObject({ user_id: user.id });
|
||
|
||
expect(await svc.can('trip_edit', 'user', user.id, user.id, false)).toBe(true);
|
||
|
||
svc.broadcast('9', 'trip:updated', { a: 1 } as never, 'sock');
|
||
expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { a: 1 }, 'sock');
|
||
});
|
||
|
||
it('getCopiedTrip re-reads via the TRIP_SELECT query', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { title: 'Copied' });
|
||
const row = (await svc.getCopiedTrip(trip.id, user.id)) as any;
|
||
expect(row.id).toBe(trip.id);
|
||
expect(row.is_owner).toBe(1);
|
||
});
|
||
|
||
it('bundle aggregates every sub-collection + the member list, scoping packing to the viewer (#858)', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: viewer } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
|
||
addTripMember(testDb, trip.id, viewer.id);
|
||
// A personal item of the OWNER must stay out of the other member's bundle.
|
||
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, owner.id);
|
||
testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Shared')").run(trip.id);
|
||
|
||
const result = (await readModelSvc.bundle(String(trip.id), { user_id: owner.id }, viewer.id)) as any;
|
||
expect(result.days).toHaveLength(2);
|
||
expect(result.members.map((m: any) => m.id).sort()).toEqual([owner.id, viewer.id].sort());
|
||
expect(result.packingItems.map((p: any) => p.name)).toEqual(['Shared']);
|
||
});
|
||
|
||
it('notifyInvite is fire-and-forget (no throw)', () => {
|
||
expect(() => membersSvc.notifyInvite('9', { id: 1, email: 'a@b.c' } as never, 2, 'T', 'b@x.y')).not.toThrow();
|
||
});
|
||
});
|
||
|
||
// ── Branch coverage for the folded update/export/copy quirks ─────────────────
|
||
|
||
describe('folded quirk branches', () => {
|
||
it('TRIP-SVC-047: updateTrip admin edit collects changes and the owner email; reminder 0 reads "none"', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: admin } = createUser(testDb);
|
||
testDb.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(admin.id);
|
||
const trip = createTrip(testDb, owner.id, { title: 'Old' });
|
||
|
||
const result = await svc.updateTrip(trip.id, admin.id, { title: 'New', is_archived: true, reminder_days: 0 }, 'admin');
|
||
|
||
expect(result.isAdminEdit).toBe(true);
|
||
expect(result.ownerEmail).toBe(owner.email);
|
||
expect(result.changes).toMatchObject({ title: 'New', archived: true, reminder_days: 'none' });
|
||
expect(result.newTitle).toBe('New');
|
||
// || coercion: an empty-string title falls back to the stored one.
|
||
const kept = await svc.updateTrip(trip.id, owner.id, { title: '' }, 'user');
|
||
expect(kept.newTitle).toBe('New');
|
||
// Missing trips throw the byte-identical error; invalid ranges reject.
|
||
await expect(svc.updateTrip(99999, owner.id, {}, 'user')).rejects.toThrow('Trip not found');
|
||
await expect(svc.updateTrip(trip.id, owner.id, { start_date: '2025-06-10', end_date: '2025-06-01' }, 'user')).rejects.toThrow('End date must be after start date');
|
||
});
|
||
|
||
it('TRIP-SVC-065: updateTrip refuses a range past MAX_TRIP_DAYS before touching the row', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' });
|
||
await expect(svc.updateTrip(trip.id, user.id, { title: 'Decade', end_date: '2036-07-01' }, 'user'))
|
||
.rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||
expect(testDb.prepare('SELECT title, end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ title: 'Week', end_date: '2026-07-07' });
|
||
expect(getDays(trip.id)).toHaveLength(7);
|
||
// Moving only the start keeps the stored end and is measured against it.
|
||
await expect(svc.updateTrip(trip.id, user.id, { start_date: '2020-01-01' }, 'user'))
|
||
.rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||
});
|
||
|
||
it('TRIP-SVC-066: a trip whose stored range already exceeds the limit can still be renamed', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { title: 'Legacy' });
|
||
testDb.prepare("UPDATE trips SET start_date = '2020-01-01', end_date = '2030-01-01' WHERE id = ?").run(trip.id);
|
||
const result = await svc.updateTrip(trip.id, user.id, { title: 'Renamed' }, 'user');
|
||
expect(result.newTitle).toBe('Renamed');
|
||
expect(result.changes).toEqual({ title: 'Renamed' });
|
||
// A day_count would rebuild the grid over the whole stored range, so it is held to the limit too.
|
||
await expect(svc.updateTrip(trip.id, user.id, { day_count: 5 }, 'user'))
|
||
.rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
||
expect(getDays(trip.id)).toHaveLength(0);
|
||
});
|
||
|
||
it('TRIP-SVC-067: a start date moved past the stored end is refused instead of emptying the trip', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' });
|
||
await expect(svc.updateTrip(trip.id, user.id, { start_date: '2026-07-10' }, 'user'))
|
||
.rejects.toThrow('End date must be after start date');
|
||
expect(testDb.prepare('SELECT start_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ start_date: '2026-07-01' });
|
||
expect(getDays(trip.id)).toHaveLength(7);
|
||
});
|
||
|
||
it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: invitee } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id, { title: 'Joinable' });
|
||
|
||
const result = await membersSvc.addMember(trip.id, invitee.email, owner.id, owner.id);
|
||
expect(result.member.id).toBe(invitee.id);
|
||
expect(result.tripTitle).toBe('Joinable');
|
||
expect(result.targetUserId).toBe(invitee.id);
|
||
|
||
// Duplicate + owner + missing identifier reject with the byte-identical errors.
|
||
await expect(membersSvc.addMember(trip.id, invitee.email, owner.id, owner.id)).rejects.toThrow('User already has access');
|
||
await expect(membersSvc.addMember(trip.id, owner.email, owner.id, owner.id)).rejects.toThrow('Trip owner is already a member');
|
||
await expect(membersSvc.addMember(trip.id, '', owner.id, owner.id)).rejects.toThrow('Email or username required');
|
||
|
||
await membersSvc.removeMember(trip.id, invitee.id);
|
||
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id)).toBeUndefined();
|
||
});
|
||
|
||
it('TRIP-SVC-050: copy remaps tags, accommodations, reservations, day notes, budget, bags and category order', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { title: 'Deep', start_date: '2025-06-01', end_date: '2025-06-02' });
|
||
const days = getDays(trip.id);
|
||
const place = createPlace(testDb, trip.id, { name: 'Hotel Zed' });
|
||
const assignment = createDayAssignment(testDb, days[0].id, place.id);
|
||
const tagId = Number(testDb.prepare("INSERT INTO tags (name, user_id) VALUES ('beach', ?)").run(user.id).lastInsertRowid);
|
||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tagId);
|
||
const accomId = Number(testDb.prepare(
|
||
"INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out) VALUES (?, ?, ?, ?, '15:00', '11:00')",
|
||
).run(trip.id, place.id, days[0].id, days[1].id).lastInsertRowid);
|
||
testDb.prepare(
|
||
'INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||
).run(trip.id, days[0].id, days[1].id, place.id, assignment.id, accomId, 'Stay', 'hotel');
|
||
testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'stay', 'Hotel', 120)").run(trip.id);
|
||
const bagId = Number(testDb.prepare("INSERT INTO packing_bags (trip_id, name) VALUES (?, 'Backpack')").run(trip.id).lastInsertRowid);
|
||
testDb.prepare("INSERT INTO packing_items (trip_id, name, checked, bag_id) VALUES (?, 'Towel', 1, ?)").run(trip.id, bagId);
|
||
createDayNote(testDb, days[0].id, trip.id, { text: 'note' });
|
||
testDb.prepare("INSERT INTO todo_items (trip_id, name, checked) VALUES (?, 'Book', 1)").run(trip.id);
|
||
testDb.prepare("INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, 'stay', 2)").run(trip.id);
|
||
|
||
const newTripId = await svc.copy(trip.id, user.id);
|
||
|
||
const newDays = getDays(newTripId);
|
||
expect(newDays).toHaveLength(2);
|
||
const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ?').get(newTripId) as { id: number };
|
||
expect(testDb.prepare('SELECT tag_id FROM place_tags WHERE place_id = ?').get(newPlace.id)).toEqual({ tag_id: tagId });
|
||
const newAccom = testDb.prepare('SELECT id, place_id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?').get(newTripId) as any;
|
||
expect(newAccom.place_id).toBe(newPlace.id);
|
||
expect(newAccom.start_day_id).toBe(newDays[0].id);
|
||
const newRes = testDb.prepare('SELECT day_id, end_day_id, place_id, accommodation_id FROM reservations WHERE trip_id = ?').get(newTripId) as any;
|
||
// The legacy copyTripById nulled this link (TEXT column vs number-keyed map);
|
||
// fixed with smoke-test I-01 — the copy now coerces and remaps it.
|
||
expect(newRes.day_id).toBe(newDays[0].id);
|
||
expect(newRes.end_day_id).toBe(newDays[1].id);
|
||
expect(newRes.place_id).toBe(newPlace.id);
|
||
expect(Number(newRes.accommodation_id)).toBe(newAccom.id);
|
||
expect((testDb.prepare('SELECT COUNT(*) AS n FROM budget_items WHERE trip_id = ?').get(newTripId) as any).n).toBe(1);
|
||
const newItem = testDb.prepare('SELECT checked, bag_id FROM packing_items WHERE trip_id = ?').get(newTripId) as any;
|
||
expect(newItem.checked).toBe(0);
|
||
expect(newItem.bag_id).not.toBeNull();
|
||
expect((testDb.prepare('SELECT checked, assigned_user_id FROM todo_items WHERE trip_id = ?').get(newTripId) as any)).toEqual({ checked: 0, assigned_user_id: null });
|
||
expect((testDb.prepare('SELECT sort_order FROM budget_category_order WHERE trip_id = ?').get(newTripId) as any).sort_order).toBe(2);
|
||
expect((testDb.prepare('SELECT COUNT(*) AS n FROM day_notes WHERE trip_id = ?').get(newTripId) as any).n).toBe(1);
|
||
|
||
// Missing source throws the byte-identical error.
|
||
await expect(svc.copy(99999, user.id)).rejects.toThrow('Trip not found');
|
||
});
|
||
});
|
||
|
||
// ── Post-fold quirk fixes (transactions, owner display name) ─────────────────
|
||
|
||
describe('quirk fixes', () => {
|
||
/** A TripsService whose connection throws when preparing SQL matching `match`. */
|
||
function failingConnection(match: string) {
|
||
const conn = new Proxy(testDb, {
|
||
get(target, prop) {
|
||
if (prop === 'prepare') {
|
||
return (sql: string) => {
|
||
if (sql.includes(match)) throw new Error('boom');
|
||
return target.prepare(sql);
|
||
};
|
||
}
|
||
const v = (target as any)[prop];
|
||
return typeof v === 'function' ? v.bind(target) : v;
|
||
},
|
||
});
|
||
return { connection: conn, canAccessTrip: dbMock.canAccessTrip, isOwner: dbMock.isOwner } as unknown as import('../../../src/nest/database/database.service').DatabaseService;
|
||
}
|
||
|
||
async function failingTrips(match: string) {
|
||
const fdbs = failingConnection(match);
|
||
return new TripsService(
|
||
fdbs,
|
||
new ReservationsService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)),
|
||
daysSvc,
|
||
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
|
||
budgetSvc,
|
||
new VacayService(dbs(), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
|
||
new RealtimeService(),
|
||
undefined as never,
|
||
coversFx.storage,
|
||
await createTestUnitOfWork(dbs().connection),
|
||
);
|
||
}
|
||
|
||
/** Same frozen connection, for the guest deletion that now lives on the roster. */
|
||
async function failingMembers(match: string) {
|
||
return new TripMembersService(
|
||
failingConnection(match), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)),
|
||
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(),
|
||
notificationsStub(), await createTestUnitOfWork(dbs().connection),
|
||
);
|
||
}
|
||
|
||
it('TRIP-SVC-051: remove is atomic — a failed trip DELETE keeps the journey entries intact', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
const journeyId = Number(testDb.prepare(
|
||
"INSERT INTO journeys (user_id, title, created_at, updated_at) VALUES (?, 'J', 0, 0)",
|
||
).run(user.id).lastInsertRowid);
|
||
testDb.prepare(
|
||
"INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'skeleton', 'S', '2025-06-01', 0, 0)",
|
||
).run(journeyId, trip.id, user.id);
|
||
|
||
const broken = await failingTrips('DELETE FROM trips WHERE id = ?');
|
||
await expect(broken.remove(trip.id, user.id, 'user')).rejects.toThrow('boom');
|
||
|
||
// The skeleton cleanup rolled back with the failed delete.
|
||
expect(testDb.prepare("SELECT id FROM journey_entries WHERE type = 'skeleton'").get()).toBeDefined();
|
||
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined();
|
||
});
|
||
|
||
it('TRIP-SVC-052: deleteGuest is atomic — a failed user DELETE rolls the budget re-split back', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id);
|
||
const { member: guest } = await membersSvc.createGuest(trip.id, 'Gia', owner.id);
|
||
const item = await budgetSvc.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, guest.id] });
|
||
|
||
const broken = await failingMembers('DELETE FROM users WHERE id = ? AND is_guest = 1');
|
||
await expect(broken.deleteGuest(trip.id, guest.id)).rejects.toThrow('boom');
|
||
|
||
// Neither the guest nor their split membership was touched.
|
||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guest.id)).toBeDefined();
|
||
const row = testDb.prepare('SELECT persons FROM budget_items WHERE id = ?').get(item.id) as { persons: number | null };
|
||
expect(row.persons).toBe(2);
|
||
});
|
||
|
||
it('TRIP-SVC-053: listMembers prefers the owner display_name over the raw username (quirk fix)', async () => {
|
||
const { user: owner } = createUser(testDb, { username: 'owner-handle' });
|
||
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Olive Displayed', owner.id);
|
||
const trip = createTrip(testDb, owner.id);
|
||
const { owner: row } = await membersSvc.listMembers(trip.id, owner.id);
|
||
expect(row.username).toBe('Olive Displayed');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* activeTrip powers the startup redirect, so its order has to stay identical to
|
||
* the dashboard's sortTrips (client/src/pages/dashboard/dashboardModel.ts):
|
||
* running today → next one starting (earliest first) → most recently started →
|
||
* undated last. If these drift, "open my trip" and the hero show different trips.
|
||
*/
|
||
describe('activeTrip (startup destination)', () => {
|
||
const TODAY = '2026-08-08';
|
||
|
||
it('TRIP-SVC-054: prefers the trip running today over anything upcoming', async () => {
|
||
const { user } = createUser(testDb);
|
||
createTrip(testDb, user.id, { title: 'soon', start_date: '2026-08-20', end_date: '2026-08-25' });
|
||
createTrip(testDb, user.id, { title: 'running', start_date: '2026-08-05', end_date: '2026-08-12' });
|
||
expect((await svc.activeTrip(user.id, TODAY))?.title).toBe('running');
|
||
});
|
||
|
||
it('TRIP-SVC-055: without a running trip it takes the next one starting, earliest first', async () => {
|
||
const { user } = createUser(testDb);
|
||
createTrip(testDb, user.id, { title: 'late', start_date: '2026-12-01', end_date: '2026-12-10' });
|
||
createTrip(testDb, user.id, { title: 'soon', start_date: '2026-09-01', end_date: '2026-09-10' });
|
||
expect((await svc.activeTrip(user.id, TODAY))?.title).toBe('soon');
|
||
});
|
||
|
||
it('TRIP-SVC-056: falls back to the most recently started trip, undated ones last', async () => {
|
||
const { user } = createUser(testDb);
|
||
createTrip(testDb, user.id, { title: 'undated' });
|
||
createTrip(testDb, user.id, { title: 'old', start_date: '2020-01-01', end_date: '2020-01-10' });
|
||
createTrip(testDb, user.id, { title: 'recent', start_date: '2026-07-01', end_date: '2026-07-10' });
|
||
expect((await svc.activeTrip(user.id, TODAY))?.title).toBe('recent');
|
||
});
|
||
|
||
it('TRIP-SVC-057: skips archived trips and returns undefined when nothing is left', async () => {
|
||
const { user } = createUser(testDb);
|
||
const archived = createTrip(testDb, user.id, { title: 'archived', start_date: '2026-08-05', end_date: '2026-08-12' });
|
||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
|
||
expect(await svc.activeTrip(user.id, TODAY)).toBeUndefined();
|
||
});
|
||
|
||
it('TRIP-SVC-058: sees shared trips but never another user\'s private ones', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: guest } = createUser(testDb);
|
||
createTrip(testDb, owner.id, { title: 'private', start_date: '2026-08-05', end_date: '2026-08-12' });
|
||
const shared = createTrip(testDb, owner.id, { title: 'shared', start_date: '2026-09-01', end_date: '2026-09-10' });
|
||
addTripMember(testDb, shared.id, guest.id);
|
||
expect((await svc.activeTrip(guest.id, TODAY))?.title).toBe('shared');
|
||
});
|
||
});
|