mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
7ca7dfd555
Brings in the Tours addon and the MikroORM follow-ups. Conflicts were resolved to dev's content, then every file dev brought in was run through prettier and eslint --fix so the branch stays uniformly formatted.
2868 lines
130 KiB
TypeScript
2868 lines
130 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.
|
||
*/
|
||
// notifyInvite fires a notification via a dynamic import — keep it out of unit scope
|
||
import { db as testDb } from '../../../src/db/database';
|
||
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
|
||
import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity';
|
||
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
||
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
|
||
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
|
||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
|
||
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
|
||
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 { DaysService } from '../../../src/nest/days/days.service';
|
||
import { FilesService } from '../../../src/nest/files/files.service';
|
||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||
import { PackingService } from '../../../src/nest/packing/packing.service';
|
||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
|
||
import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||
import { PlacesService } from '../../../src/nest/places/places.service';
|
||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||
import { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service';
|
||
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||
import { SettingsService } from '../../../src/nest/settings/settings.service';
|
||
import { TodoService } from '../../../src/nest/todo/todo.service';
|
||
import { TripMembersService } from '../../../src/nest/trip-members/trip-members.service';
|
||
import { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service';
|
||
import { TripsService } from '../../../src/nest/trips/trips.service';
|
||
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
||
import { VacayService } from '../../../src/nest/vacay/vacay.service';
|
||
import { accommodationsOver, makeAccommodationsService } from '../../helpers/accommodations-service';
|
||
import { budgetRepoArgs } from '../../helpers/budget-repos';
|
||
import {
|
||
createTestCollabMessageReactionsRepo,
|
||
createTestCollabNotesRepo,
|
||
createTestCollabPollsRepo,
|
||
createTestCollabPollVotesRepo,
|
||
createTestCollabLinksRepo,
|
||
createTestCollabMessagesRepo,
|
||
} from '../../helpers/collab-repos';
|
||
import {
|
||
createUser,
|
||
createTrip,
|
||
createReservation,
|
||
createPlace,
|
||
createDay,
|
||
createDayAssignment,
|
||
createDayNote,
|
||
addTripMember,
|
||
} from '../../helpers/factories';
|
||
import { createTestTripFilesRepo, createTestFileLinksRepo, createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||
import { noGoogleQuota } from '../../helpers/google-quota';
|
||
import {
|
||
createTestJourneysRepo,
|
||
createTestJourneyContributorsRepo,
|
||
createTestJourneyTripsRepo,
|
||
createTestJourneyEntriesRepo,
|
||
createTestJourneyPhotosRepo,
|
||
createTestJourneyEntryPhotosRepo,
|
||
} from '../../helpers/journey-repos';
|
||
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
|
||
import { notificationsStub } from '../../helpers/notifications';
|
||
import {
|
||
createTestPackingItemsRepo,
|
||
createTestPackingItemContributorsRepo,
|
||
createTestPackingBagsRepo,
|
||
createTestPackingCategoryAssigneesRepo,
|
||
createTestPackingTemplatesRepo,
|
||
createTestPackingTemplateCategoriesRepo,
|
||
createTestPackingTemplateItemsRepo,
|
||
} from '../../helpers/packing-repos';
|
||
import {
|
||
createTestVacayHolidayCalendarsRepo,
|
||
createTestSchoolHolidayRegionsRepo,
|
||
} from '../../helpers/school-holidays-repos';
|
||
import {
|
||
createTestShareTokensRepo,
|
||
createTestPluginsRepo,
|
||
createTestPluginUserErasureQueueRepo,
|
||
} from '../../helpers/share-repos';
|
||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||
import { resetTestDb } from '../../helpers/test-db';
|
||
import {
|
||
createTestUnitOfWork,
|
||
createTestAppSettingsRepo,
|
||
createTestUsersRepo,
|
||
sharedTestOrm,
|
||
createTestDaysRepo,
|
||
createTestDayAssignmentsRepo,
|
||
createTestDayNotesRepo,
|
||
createTestTripsRepo,
|
||
createTestTripMembersRepo,
|
||
createTestTagsRepo,
|
||
createTestPlaceRatingsRepo,
|
||
createTestAssignmentParticipantsRepo,
|
||
createTestGooglePlacePhotoMetaRepo,
|
||
createTestPlacesRepo,
|
||
createTestCategoriesRepo,
|
||
createTestPlaceDetailsCacheRepo,
|
||
createTestReservationsRepo,
|
||
createTestReservationEndpointsRepo,
|
||
createTestReservationTravelersRepo,
|
||
createTestReservationDayPositionsRepo,
|
||
createTestDayAccommodationsRepo,
|
||
createTestSettingsRepo,
|
||
createTestRoadtripViasRepo,
|
||
createTestRoadtripDayBoundariesRepo,
|
||
} from '../../helpers/test-uow';
|
||
import { createTestCollectionPlacesRepo } from '../../helpers/test-uow';
|
||
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from '../../helpers/todo-repos';
|
||
import { createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos';
|
||
import {
|
||
createTestVacayPlansRepo,
|
||
createTestVacayPlanMembersRepo,
|
||
createTestVacayYearsRepo,
|
||
createTestVacayUserYearsRepo,
|
||
createTestVacayUserColorsRepo,
|
||
createTestVacayEntriesRepo,
|
||
createTestVacayCompanyHolidaysRepo,
|
||
createTestVacaySharesRepo,
|
||
createTestVacayUserSettingsRepo,
|
||
} from '../../helpers/vacay-repos';
|
||
import { MAX_TRIP_DAYS, resolveDayGridRange, tripSpanDays } from '@trek/shared';
|
||
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
|
||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||
|
||
vi.mock('../../../src/db/database', async () => {
|
||
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
||
const db = createSnapshotTestDb();
|
||
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 mock;
|
||
});
|
||
|
||
vi.mock('../../../src/config', () => ({
|
||
JWT_SECRET: 'test-secret',
|
||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||
updateJwtSecret: () => {},
|
||
}));
|
||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||
|
||
// Real sibling services over the same in-memory DB — updateTrip's date-shift
|
||
// resyncs and the summary/bundle aggregation run their actual SQL.
|
||
//
|
||
// Plan 3c Task 0b: `dbsEm` is resolved once, at the top of the first
|
||
// `beforeAll` below — `canAccessTrip`/`isOwner`/`rosterUserIds`/
|
||
// `getPlaceWithTags` resolve `TripsRepository`/`TripMembersRepository`/
|
||
// `PlacesRepository` through it. Plan 4 Task 4: `DatabaseService` itself is
|
||
// gone — `UserCleanupService` now takes this `EntityManager` directly.
|
||
let dbsEm: import('@mikro-orm/core').EntityManager | undefined;
|
||
|
||
// 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.
|
||
// Plan 3c Task 1: built inside the async `beforeAll` below now — the
|
||
// constructor needs two repositories, resolved through `createTestOrm`.
|
||
let photoCache: PlacePhotoCacheService;
|
||
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 () => {
|
||
dbsEm = (await sharedTestOrm(testDb)).em;
|
||
photoCache = new PlacePhotoCacheService(
|
||
makeStorageFixture('photos/google/').storage,
|
||
await createTestGooglePlacePhotoMetaRepo(testDb),
|
||
await createTestPlacesRepo(testDb),
|
||
await createTestCollectionPlacesRepo(testDb),
|
||
);
|
||
budgetSvc = new BudgetService(
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
new ExchangeRatesService(),
|
||
new RealtimeService(),
|
||
await createTestUnitOfWork(testDb),
|
||
...(await budgetRepoArgs(testDb)),
|
||
);
|
||
daysSvc = new DaysService(
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
new RealtimeService(),
|
||
new QueryHelpersService(
|
||
await createTestTagsRepo(testDb),
|
||
await createTestPlaceRatingsRepo(testDb),
|
||
await createTestAssignmentParticipantsRepo(testDb),
|
||
),
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestDaysRepo(testDb),
|
||
await createTestDayAssignmentsRepo(testDb),
|
||
await createTestDayNotesRepo(testDb),
|
||
await createTestTripsRepo(testDb),
|
||
await createTestReservationsRepo(testDb),
|
||
await createTestReservationEndpointsRepo(testDb),
|
||
await createTestDayAccommodationsRepo(testDb),
|
||
await createTestRoadtripViasRepo(testDb),
|
||
await createTestRoadtripDayBoundariesRepo(testDb),
|
||
);
|
||
placesSvc = new PlacesService(
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
new RealtimeService(),
|
||
new MapsService(
|
||
photoCache,
|
||
await createTestAppSettingsRepo(testDb),
|
||
await createTestUsersRepo(testDb),
|
||
await createTestPlaceDetailsCacheRepo(testDb),
|
||
await createTestPlacesRepo(testDb),
|
||
noGoogleQuota,
|
||
),
|
||
new QueryHelpersService(
|
||
await createTestTagsRepo(testDb),
|
||
await createTestPlaceRatingsRepo(testDb),
|
||
await createTestAssignmentParticipantsRepo(testDb),
|
||
),
|
||
new UnsplashService(
|
||
await createTestAppSettingsRepo(testDb),
|
||
await createTestUsersRepo(testDb),
|
||
new RuntimeEnvService(),
|
||
coversFx.storage,
|
||
),
|
||
photoCache,
|
||
new JourneyDomainService(
|
||
new RealtimeService(),
|
||
new TrekPhotoRegistrationService(
|
||
dbsEm!.getRepository(TrekPhotos),
|
||
dbsEm!.getRepository(TripPhotos),
|
||
dbsEm!.getRepository(JourneyPhotos),
|
||
),
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestJourneysRepo(testDb),
|
||
await createTestJourneyContributorsRepo(testDb),
|
||
await createTestJourneyTripsRepo(testDb),
|
||
await createTestJourneyEntriesRepo(testDb),
|
||
await createTestTripsRepo(testDb),
|
||
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
|
||
await createTestJourneyPhotosRepo(testDb),
|
||
await createTestJourneyEntryPhotosRepo(testDb),
|
||
await createTestPlacesRepo(testDb),
|
||
),
|
||
makeStorageFixture('').storage,
|
||
await accommodationsOver(testDb),
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestPlacesRepo(testDb),
|
||
await createTestTagsRepo(testDb),
|
||
await createTestPlaceRatingsRepo(testDb),
|
||
await createTestTripMembersRepo(testDb),
|
||
await createTestDayAssignmentsRepo(testDb),
|
||
await createTestCategoriesRepo(testDb),
|
||
await createTestTripsRepo(testDb),
|
||
await createTestBudgetItemsRepo(testDb),
|
||
await createTestCollectionPlacesRepo(testDb),
|
||
);
|
||
svc = new TripsService(
|
||
new ReservationsService(
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
budgetSvc,
|
||
new RealtimeService(),
|
||
notificationsStub(),
|
||
new ReservationsReadService(
|
||
await createTestReservationsRepo(testDb),
|
||
await createTestReservationEndpointsRepo(testDb),
|
||
await createTestReservationTravelersRepo(testDb),
|
||
),
|
||
accommodationsSvc,
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestReservationsRepo(testDb),
|
||
await createTestReservationEndpointsRepo(testDb),
|
||
await createTestReservationTravelersRepo(testDb),
|
||
await createTestReservationDayPositionsRepo(testDb),
|
||
await createTestDayAccommodationsRepo(testDb),
|
||
await createTestDaysRepo(testDb),
|
||
await createTestPlacesRepo(testDb),
|
||
await createTestDayAssignmentsRepo(testDb),
|
||
await createTestTripMembersRepo(testDb),
|
||
await createTestUsersRepo(testDb),
|
||
await createTestTripsRepo(testDb),
|
||
await createTestBudgetItemsRepo(testDb),
|
||
),
|
||
daysSvc,
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
budgetSvc,
|
||
new VacayService(
|
||
await createTestVacayPlansRepo(testDb),
|
||
await createTestVacayPlanMembersRepo(testDb),
|
||
await createTestVacayYearsRepo(testDb),
|
||
await createTestVacayUserYearsRepo(testDb),
|
||
await createTestVacayUserColorsRepo(testDb),
|
||
await createTestVacayEntriesRepo(testDb),
|
||
await createTestVacayCompanyHolidaysRepo(testDb),
|
||
await createTestVacayHolidayCalendarsRepo(testDb),
|
||
await createTestVacaySharesRepo(testDb),
|
||
await createTestVacayUserSettingsRepo(testDb),
|
||
await createTestSchoolHolidayRegionsRepo(testDb),
|
||
new RealtimeService(),
|
||
notificationsStub(),
|
||
await createTestUnitOfWork(testDb),
|
||
),
|
||
new RealtimeService(),
|
||
undefined as never, // unsplash — not exercised here
|
||
coversFx.storage,
|
||
await createTestUnitOfWork(testDb),
|
||
(await sharedTestOrm(testDb)).em,
|
||
new SettingsService(
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestAppSettingsRepo(testDb),
|
||
await createTestSettingsRepo(testDb),
|
||
),
|
||
);
|
||
membersSvc = new TripMembersService(
|
||
budgetSvc,
|
||
new UserCleanupService(
|
||
dbsEm!,
|
||
budgetSvc,
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestUsersRepo(testDb),
|
||
await createTestTripMembersRepo(testDb),
|
||
await createTestBudgetItemsRepo(testDb),
|
||
await createTestJourneyShareTokensRepo(testDb),
|
||
await createTestJourneysRepo(testDb),
|
||
await createTestJourneyEntriesRepo(testDb),
|
||
await createTestJourneyContributorsRepo(testDb),
|
||
await createTestShareTokensRepo(testDb),
|
||
await createTestPluginsRepo(testDb),
|
||
await createTestPluginUserErasureQueueRepo(testDb),
|
||
),
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
new RealtimeService(),
|
||
notificationsStub(),
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestTripsRepo(testDb),
|
||
await createTestTripMembersRepo(testDb),
|
||
await createTestUsersRepo(testDb),
|
||
);
|
||
readModelSvc = new TripReadModelService(
|
||
await createTestTripsRepo(testDb),
|
||
membersSvc,
|
||
daysSvc,
|
||
accommodationsSvc,
|
||
budgetSvc,
|
||
new PackingService(
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
new RealtimeService(),
|
||
notificationsStub(),
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestPackingItemsRepo(testDb),
|
||
await createTestPackingItemContributorsRepo(testDb),
|
||
await createTestPackingBagsRepo(testDb),
|
||
await createTestPackingCategoryAssigneesRepo(testDb),
|
||
await createTestPackingTemplatesRepo(testDb),
|
||
await createTestPackingTemplateCategoriesRepo(testDb),
|
||
await createTestPackingTemplateItemsRepo(testDb),
|
||
await createTestTripsRepo(testDb),
|
||
await createTestTripMembersRepo(testDb),
|
||
),
|
||
new ReservationsService(
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
budgetSvc,
|
||
new RealtimeService(),
|
||
notificationsStub(),
|
||
new ReservationsReadService(
|
||
await createTestReservationsRepo(testDb),
|
||
await createTestReservationEndpointsRepo(testDb),
|
||
await createTestReservationTravelersRepo(testDb),
|
||
),
|
||
accommodationsSvc,
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestReservationsRepo(testDb),
|
||
await createTestReservationEndpointsRepo(testDb),
|
||
await createTestReservationTravelersRepo(testDb),
|
||
await createTestReservationDayPositionsRepo(testDb),
|
||
await createTestDayAccommodationsRepo(testDb),
|
||
await createTestDaysRepo(testDb),
|
||
await createTestPlacesRepo(testDb),
|
||
await createTestDayAssignmentsRepo(testDb),
|
||
await createTestTripMembersRepo(testDb),
|
||
await createTestUsersRepo(testDb),
|
||
await createTestTripsRepo(testDb),
|
||
await createTestBudgetItemsRepo(testDb),
|
||
),
|
||
new CollabService(
|
||
// Plan 4 Task 2 — CollabService's own DatabaseService param is gone:
|
||
// canAccessTrip now reads through the TripsRepository at the end.
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
new RealtimeService(),
|
||
notificationsStub(),
|
||
coversFx.storage,
|
||
new RateLimitService(),
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestCollabMessageReactionsRepo(testDb),
|
||
await createTestCollabNotesRepo(testDb),
|
||
await createTestCollabPollsRepo(testDb),
|
||
await createTestCollabPollVotesRepo(testDb),
|
||
await createTestCollabLinksRepo(testDb),
|
||
await createTestCollabMessagesRepo(testDb),
|
||
await createTestTripsRepo(testDb),
|
||
),
|
||
placesSvc,
|
||
new TodoService(
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
new RealtimeService(),
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestTodoItemsRepo(testDb),
|
||
await createTestTodoCategoryAssigneesRepo(testDb),
|
||
await createTestTripsRepo(testDb),
|
||
await createTestTripMembersRepo(testDb),
|
||
),
|
||
new FilesService(
|
||
// Plan 4 Task 2 — FilesService's own canAccessTrip delegate is now
|
||
// TripsRepository.findAccessible, in the same constructor slot.
|
||
await createTestTripsRepo(testDb),
|
||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||
new RealtimeService(),
|
||
new EphemeralTokenService(),
|
||
coversFx.storage,
|
||
(await sharedTestOrm(testDb)).em,
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestTripFilesRepo(testDb),
|
||
await createTestFileLinksRepo(testDb),
|
||
await createTestReservationsRepo(testDb),
|
||
await createTestPlacesRepo(testDb),
|
||
await createTestDayAssignmentsRepo(testDb),
|
||
await createTestBudgetItemsRepo(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);
|
||
});
|
||
|
||
// ── generateDays carries out the shared planDayGrid plan ──────────────────
|
||
// The trip dialog warns about lost days by the same plan, so the plan has to
|
||
// be exactly what the rebuild did before it was written down in shared.
|
||
|
||
function addUndatedDay(tripId: number) {
|
||
const next = (
|
||
testDb.prepare('SELECT COALESCE(MAX(day_number), 0) + 1 AS n FROM days WHERE trip_id = ?').get(tripId) as {
|
||
n: number;
|
||
}
|
||
).n;
|
||
const id = Number(
|
||
testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(tripId, next)
|
||
.lastInsertRowid,
|
||
);
|
||
return id;
|
||
}
|
||
|
||
function addStay(tripId: number, placeId: number, startDayId: number, endDayId: number) {
|
||
return Number(
|
||
testDb
|
||
.prepare('INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)')
|
||
.run(tripId, placeId, startDayId, endDayId).lastInsertRowid,
|
||
);
|
||
}
|
||
|
||
const stayExists = (id: number) => !!testDb.prepare('SELECT 1 FROM day_accommodations WHERE id = ?').get(id);
|
||
|
||
it('TRIP-SVC-074: a stay from a removed day to a spare day goes, and the spare day it left empty goes too', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' });
|
||
const days = getDays(trip.id);
|
||
const spare = addUndatedDay(trip.id);
|
||
const place = createPlace(testDb, trip.id);
|
||
const stay = addStay(trip.id, place.id, days[4].id, spare);
|
||
|
||
const plan = await svc.generateDays(trip.id, '2025-07-01', '2025-07-04');
|
||
|
||
expect(plan.removed.map((r) => [r.id, r.reason])).toEqual([
|
||
[days[4].id, 'overflow'],
|
||
[spare, 'spare'],
|
||
]);
|
||
expect(stayExists(stay)).toBe(false);
|
||
expect(getDays(trip.id).map((d) => d.id)).toEqual(days.slice(0, 4).map((d) => d.id));
|
||
});
|
||
|
||
it('TRIP-SVC-075: only moving the dates drops an empty spare day and keeps one with a note', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-03' });
|
||
const empty = addUndatedDay(trip.id);
|
||
const noted = addUndatedDay(trip.id);
|
||
createDayNote(testDb, noted, trip.id, { text: 'Buffer' });
|
||
|
||
const plan = await svc.generateDays(trip.id, '2025-07-11', '2025-07-13');
|
||
|
||
expect(plan.removed).toEqual([{ id: empty, day_number: 4, date: null, reason: 'spare' }]);
|
||
const after = getDays(trip.id);
|
||
expect(after.map((d) => d.date)).toEqual(['2025-07-11', '2025-07-12', '2025-07-13', null]);
|
||
expect(after[3]).toMatchObject({ id: noted, day_number: 4 });
|
||
expect(getNotes(noted)).toHaveLength(1);
|
||
});
|
||
|
||
it('TRIP-SVC-076: without dates only empty days are trimmed, the highest numbers first', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
const ids = Array.from({ length: 6 }, () => addUndatedDay(trip.id));
|
||
const place = createPlace(testDb, trip.id);
|
||
createDayAssignment(testDb, ids[1], place.id);
|
||
createDayAssignment(testDb, ids[5], place.id);
|
||
|
||
const plan = await svc.generateDays(trip.id, null, null, 3);
|
||
|
||
expect(plan.removed.map((r) => r.id)).toEqual([ids[4], ids[3], ids[2]]);
|
||
expect(getDays(trip.id).map((d) => [d.id, d.day_number])).toEqual([
|
||
[ids[0], 1],
|
||
[ids[1], 2],
|
||
[ids[5], 3],
|
||
]);
|
||
});
|
||
|
||
it('TRIP-SVC-077: generateDays returns the plan it carried out', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-03' });
|
||
const before = getDays(trip.id);
|
||
const spare = addUndatedDay(trip.id);
|
||
|
||
const plan = await svc.generateDays(trip.id, '2025-07-01', '2025-07-06');
|
||
|
||
const after = getDays(trip.id);
|
||
expect(plan.removed).toEqual([]);
|
||
expect(plan.rows.map((r) => r.date)).toEqual(after.map((d) => d.date));
|
||
expect(plan.rows.slice(0, 4).map((r) => r.id)).toEqual([...before.map((d) => d.id), spare]);
|
||
// New rows come back without an id and are the rows the insert created.
|
||
expect(plan.rows.slice(4).map((r) => r.id)).toEqual([null, null]);
|
||
expect(after.slice(4).every((d) => !before.some((b) => b.id === d.id) && d.id !== spare)).toBe(true);
|
||
|
||
const shrink = await svc.generateDays(trip.id, '2025-07-01', '2025-07-02');
|
||
expect(shrink.removed.map((r) => r.id)).toEqual(after.slice(2).map((d) => d.id));
|
||
expect(getDays(trip.id).map((d) => d.id)).toEqual(shrink.rows.map((r) => r.id));
|
||
});
|
||
|
||
it('TRIP-SVC-078: fuzz, 300 random day grids end exactly where the rebuild before the shared plan left them', async () => {
|
||
// The rebuild as it stood before planDayGrid, kept here as the oracle.
|
||
function legacyGenerateDays(tripId: number, startDate: string | null, endDate: string | null, dayCount?: number) {
|
||
const existing = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as {
|
||
id: number;
|
||
day_number: number;
|
||
date: string | null;
|
||
}[];
|
||
const setDayNumber = testDb.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||
const renumber = (list: { id: number }[]) => {
|
||
list.forEach((d, i) => setDayNumber.run(-(i + 1), d.id));
|
||
list.forEach((d, i) => setDayNumber.run(i + 1, d.id));
|
||
};
|
||
if (!startDate || !endDate) {
|
||
for (const d of existing.filter((d) => d.date))
|
||
testDb.prepare('UPDATE days SET date = NULL WHERE id = ?').run(d.id);
|
||
const all = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as {
|
||
id: number;
|
||
}[];
|
||
const target = Math.min(Math.max(dayCount ?? (all.length || 7), 1), MAX_TRIP_DAYS);
|
||
const needed = target - all.length;
|
||
if (needed > 0) {
|
||
for (let i = 0; i < needed; i++)
|
||
testDb
|
||
.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)')
|
||
.run(tripId, all.length + i + 1);
|
||
} else if (needed < 0) {
|
||
const candidates = testDb
|
||
.prepare(
|
||
`SELECT d.id FROM days d WHERE d.trip_id = ?
|
||
AND NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = d.id)
|
||
AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = d.id)
|
||
AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = d.id OR dac.end_day_id = d.id)
|
||
ORDER BY d.day_number DESC LIMIT ?`,
|
||
)
|
||
.all(tripId, -needed) as { id: number }[];
|
||
for (const d of candidates) testDb.prepare('DELETE FROM days WHERE id = ?').run(d.id);
|
||
}
|
||
renumber(
|
||
testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[],
|
||
);
|
||
return;
|
||
}
|
||
const numDays = tripSpanDays(startDate, endDate);
|
||
const targetDates = Array.from({ length: Math.max(numDays, 0) }, (_, i) => addDaysIso(startDate, i));
|
||
const dated = existing.filter((d) => d.date).sort((a, b) => a.day_number - b.day_number);
|
||
const dateless = existing.filter((d) => !d.date).sort((a, b) => a.day_number - b.day_number);
|
||
[...dated, ...dateless].forEach((d, i) => setDayNumber.run(-(i + 1), d.id));
|
||
const assignDay = testDb.prepare('UPDATE days SET date = ?, day_number = ? WHERE id = ?');
|
||
let datelessIdx = 0;
|
||
targetDates.forEach((date, i) => {
|
||
if (i < dated.length) assignDay.run(date, i + 1, dated[i].id);
|
||
else if (datelessIdx < dateless.length) assignDay.run(date, i + 1, dateless[datelessIdx++].id);
|
||
else testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, i + 1, date);
|
||
});
|
||
for (let i = targetDates.length; i < dated.length; i++)
|
||
testDb.prepare('DELETE FROM days WHERE id = ?').run(dated[i].id);
|
||
const isEmpty = testDb.prepare(
|
||
`SELECT NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = @id)
|
||
AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = @id)
|
||
AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = @id OR dac.end_day_id = @id) AS empty`,
|
||
);
|
||
const maxAssigned = Math.max(targetDates.length, dated.length);
|
||
let kept = 0;
|
||
for (let i = datelessIdx; i < dateless.length; i++) {
|
||
if ((isEmpty.get({ id: dateless[i].id }) as { empty: number }).empty)
|
||
testDb.prepare('DELETE FROM days WHERE id = ?').run(dateless[i].id);
|
||
else setDayNumber.run(maxAssigned + ++kept, dateless[i].id);
|
||
}
|
||
renumber(
|
||
testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[],
|
||
);
|
||
}
|
||
|
||
// A seeded generator, so a failure names a grid that can be replayed.
|
||
let seed = 20260923;
|
||
const rand = () => {
|
||
seed = (seed + 0x6d2b79f5) | 0;
|
||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
const int = (lo: number, hi: number) => lo + Math.floor(rand() * (hi - lo + 1));
|
||
|
||
const snapshot = (tripId: number) => ({
|
||
days: testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId),
|
||
stays: testDb.prepare('SELECT id FROM day_accommodations WHERE trip_id = ? ORDER BY id').all(tripId),
|
||
assignments: testDb
|
||
.prepare(
|
||
'SELECT da.id, da.day_id FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ? ORDER BY da.id',
|
||
)
|
||
.all(tripId),
|
||
notes: testDb.prepare('SELECT id, day_id FROM day_notes WHERE trip_id = ? ORDER BY id').all(tripId),
|
||
});
|
||
const ROLLBACK = new Error('rollback');
|
||
|
||
const { user } = createUser(testDb);
|
||
let removing = 0;
|
||
for (let round = 0; round < 300; round++) {
|
||
const trip = createTrip(testDb, user.id);
|
||
const place = createPlace(testDb, trip.id);
|
||
const dayIds: number[] = [];
|
||
const count = int(0, 9);
|
||
for (let n = 1; n <= count; n++) {
|
||
const date = rand() < 0.7 ? addDaysIso('2026-03-20', int(0, 20)) : null;
|
||
const id = Number(
|
||
testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(trip.id, n, date)
|
||
.lastInsertRowid,
|
||
);
|
||
dayIds.push(id);
|
||
if (rand() < 0.3) createDayAssignment(testDb, id, place.id);
|
||
if (rand() < 0.2) createDayNote(testDb, id, trip.id);
|
||
}
|
||
if (dayIds.length > 0) {
|
||
for (let k = int(0, 3); k > 0; k--)
|
||
addStay(trip.id, place.id, dayIds[int(0, dayIds.length - 1)], dayIds[int(0, dayIds.length - 1)]);
|
||
}
|
||
let range: [string | null, string | null, number | undefined];
|
||
if (rand() < 0.25) {
|
||
range = [null, null, rand() < 0.4 ? undefined : int(1, 12)];
|
||
} else {
|
||
const start = addDaysIso('2026-03-15', int(0, 20));
|
||
range = [start, addDaysIso(start, int(0, 11)), rand() < 0.8 ? undefined : int(1, 12)];
|
||
}
|
||
|
||
const before = snapshot(trip.id);
|
||
let expected: ReturnType<typeof snapshot> | undefined;
|
||
try {
|
||
testDb.transaction(() => {
|
||
legacyGenerateDays(trip.id, ...range);
|
||
expected = snapshot(trip.id);
|
||
throw ROLLBACK;
|
||
})();
|
||
} catch (err) {
|
||
if (err !== ROLLBACK) throw err;
|
||
}
|
||
expect(snapshot(trip.id), `round ${round}: the oracle must leave no trace`).toEqual(before);
|
||
|
||
const plan = await svc.generateDays(trip.id, ...range);
|
||
const actual = snapshot(trip.id);
|
||
expect(actual, `round ${round}: ${JSON.stringify({ range, before: before.days })}`).toEqual(expected);
|
||
const survivors = new Set((actual.days as { id: number }[]).map((d) => d.id));
|
||
expect(
|
||
plan.removed.map((r) => r.id).sort((a, b) => a - b),
|
||
`round ${round}: removed`,
|
||
).toEqual(
|
||
(before.days as { id: number }[])
|
||
.map((d) => d.id)
|
||
.filter((id) => !survivors.has(id))
|
||
.sort((a, b) => a - b),
|
||
);
|
||
if (plan.removed.length > 0) removing++;
|
||
}
|
||
// The grids are random, not easy: plenty of them lose days.
|
||
expect(removing).toBeGreaterThan(60);
|
||
});
|
||
|
||
it('TRIP-SVC-079: resolveRange reads a range the way the shared resolveDayGridRange does, a day_count of 0 included', async () => {
|
||
const { user } = createUser(testDb);
|
||
const dated = (await svc.getRaw(
|
||
createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }).id,
|
||
))!;
|
||
const undated = (await svc.getRaw(createTrip(testDb, user.id).id))!;
|
||
const resolve = (trip: typeof dated, data: Parameters<typeof svc.updateTrip>[2]) => svc['resolveRange'](trip, data);
|
||
const cases: [typeof dated, Parameters<typeof svc.updateTrip>[2]][] = [
|
||
[dated, {}],
|
||
[dated, { title: 'Renamed' }],
|
||
[dated, { end_date: '2025-07-03' }],
|
||
[dated, { start_date: '2025-07-03', end_date: '2025-07-05' }],
|
||
[dated, { start_date: null, end_date: null }],
|
||
[dated, { start_date: null, end_date: null, day_count: 3 }],
|
||
[undated, { day_count: 0 }],
|
||
[undated, { day_count: 4 }],
|
||
[undated, { day_count: MAX_TRIP_DAYS + 1 }],
|
||
[undated, { start_date: '2025-07-01', end_date: '2025-07-02' }],
|
||
];
|
||
for (const [trip, data] of cases)
|
||
expect(resolve(trip, data), JSON.stringify(data)).toEqual(resolveDayGridRange(trip, data));
|
||
expect(resolve(undated, { day_count: 0 }).regenerate).toBe(false);
|
||
// The refusals stay on the server side of the rule.
|
||
expect(() => resolve(dated, { start_date: '2025-07-05', end_date: '2025-07-01' })).toThrow(
|
||
'End date must be after start date',
|
||
);
|
||
expect(() => resolve(dated, { end_date: '2025-06-30' })).toThrow('End date must be after start date');
|
||
});
|
||
});
|
||
|
||
// ── 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 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-080: create without a currency takes the display currency, admin default included, else EUR', async () => {
|
||
const { user } = createUser(testDb);
|
||
const setUser = (value: string) =>
|
||
testDb
|
||
.prepare(
|
||
"INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value",
|
||
)
|
||
.run(user.id, JSON.stringify(value));
|
||
const setAdmin = (value: string) =>
|
||
testDb
|
||
.prepare(
|
||
"INSERT INTO app_settings (key, value) VALUES ('default_user_setting_default_currency', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||
)
|
||
.run(JSON.stringify(value));
|
||
const currencyOf = async (data: Parameters<typeof svc.create>[1]) =>
|
||
((await svc.create(user.id, data)).trip as any).currency;
|
||
|
||
expect(await currencyOf({ title: 'Nothing set' })).toBe('EUR');
|
||
// "Trip currency" in the settings stores an empty string, which counts as unset.
|
||
setUser('');
|
||
expect(await currencyOf({ title: 'Cleared, no admin default' })).toBe('EUR');
|
||
setUser(' ');
|
||
expect(await currencyOf({ title: 'Blank, no admin default' })).toBe('EUR');
|
||
setUser('');
|
||
setAdmin('CHF');
|
||
expect(await currencyOf({ title: 'Admin default' })).toBe('CHF');
|
||
setUser('USD');
|
||
expect(await currencyOf({ title: 'Own display currency' })).toBe('USD');
|
||
setUser('');
|
||
expect(await currencyOf({ title: 'Back on the admin default' })).toBe('CHF');
|
||
// An explicit currency always wins.
|
||
setUser('USD');
|
||
expect(await currencyOf({ title: 'Explicit', currency: 'JPY' })).toBe('JPY');
|
||
});
|
||
|
||
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-071 (Task 7 security review L1, absorbed): a stored NULL is_archived pre-image stays NULL through an unrelated update, never folded to 0', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { title: 'Legacy row' });
|
||
// A pre-migration row (or any row the `is_archived` column default never
|
||
// touched) can genuinely hold NULL — the legacy statement wrote it back
|
||
// unfolded whenever the request itself never sent `is_archived`.
|
||
testDb.prepare('UPDATE trips SET is_archived = NULL WHERE id = ?').run(trip.id);
|
||
|
||
await svc.updateTrip(trip.id, user.id, { title: 'Renamed' }, 'user');
|
||
|
||
expect(
|
||
(testDb.prepare('SELECT is_archived FROM trips WHERE id = ?').get(trip.id) as { is_archived: number | null })
|
||
.is_archived,
|
||
).toBeNull();
|
||
|
||
// Explicitly archiving still writes 1/0 as before — only the "untouched, was NULL" path is preserved.
|
||
await svc.updateTrip(trip.id, user.id, { is_archived: true }, 'user');
|
||
expect(
|
||
(testDb.prepare('SELECT is_archived FROM trips WHERE id = ?').get(trip.id) as { is_archived: number | null })
|
||
.is_archived,
|
||
).toBe(1);
|
||
});
|
||
|
||
it('TRIP-SVC-072 (Task 7 security review L2, absorbed): the trip UPDATE (TP25) commits before the days-regeneration transaction — a failed regen leaves the new dates in place', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { title: 'Ordering', start_date: '2025-06-01', end_date: '2025-06-03' });
|
||
|
||
const daysRepo = await createTestDaysRepo(testDb);
|
||
const spy = vi.spyOn(daysRepo, 'listOrderedForReorder').mockRejectedValueOnce(new Error('boom'));
|
||
try {
|
||
await expect(
|
||
svc.updateTrip(trip.id, user.id, { start_date: '2025-07-01', end_date: '2025-07-03' }, 'user'),
|
||
).rejects.toThrow('boom');
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
|
||
// TP25's own write already landed — R5/§18.6's documented, unfixed quirk:
|
||
// it runs BEFORE generateDays' transaction, so a regen failure never
|
||
// rolls it back with the day rows it failed to regenerate.
|
||
const row = testDb.prepare('SELECT start_date, end_date FROM trips WHERE id = ?').get(trip.id) as {
|
||
start_date: string;
|
||
end_date: string;
|
||
};
|
||
expect(row).toEqual({ start_date: '2025-07-01', end_date: '2025-07-03' });
|
||
// The day rows themselves never got touched by the failed regen — still
|
||
// the original 3, on their original dates.
|
||
expect(getDays(trip.id)).toHaveLength(3);
|
||
expect(getDays(trip.id).map((d) => d.date)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03']);
|
||
});
|
||
|
||
it('TRIP-SVC-073 (Task 7 security review L2, absorbed): the two-phase renumber avoids a UNIQUE(trip_id, day_number) collision on a genuine swap', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id); // undated — no auto-generated days
|
||
// A dateless day sits BETWEEN two dated ones. Regenerating a 3-day range
|
||
// over this reassigns day_number so the dateless day and the LATER dated
|
||
// day trade places — a genuine swap (2↔3), not just a compaction. A
|
||
// single positive-only pass would try to write day_number=2 for dayC
|
||
// while dayB still holds it, colliding with `UNIQUE(trip_id,
|
||
// day_number)`; the two-phase renumber (negative pass first) avoids it.
|
||
const dayA = createDay(testDb, trip.id, { day_number: 1, date: '2025-01-01' });
|
||
const dayB = createDay(testDb, trip.id, { day_number: 2 });
|
||
const dayC = createDay(testDb, trip.id, { day_number: 3, date: '2025-01-02' });
|
||
const place = createPlace(testDb, trip.id);
|
||
const assignmentOnB = createDayAssignment(testDb, dayB.id, place.id);
|
||
|
||
await svc.generateDays(trip.id, '2025-01-01', '2025-01-03');
|
||
|
||
const daysAfter = getDays(trip.id);
|
||
expect(daysAfter).toHaveLength(3);
|
||
const byId = new Map(daysAfter.map((d) => [d.id, d]));
|
||
expect(byId.get(dayA.id)).toMatchObject({ day_number: 1, date: '2025-01-01' });
|
||
expect(byId.get(dayC.id)).toMatchObject({ day_number: 2, date: '2025-01-02' });
|
||
expect(byId.get(dayB.id)).toMatchObject({ day_number: 3, date: '2025-01-03' });
|
||
// dayB's own identity (and its assignment) survived the swap — renumbered
|
||
// in place, never deleted-and-recreated.
|
||
expect(getAssignments(dayB.id)).toHaveLength(1);
|
||
expect(getAssignments(dayB.id)[0].id).toBe(assignmentOnB.id);
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
|
||
// ── Plan 3c Task 8 deliverable: whole-copy parity + rollback ─────────────────
|
||
|
||
describe('copy — whole-trip parity (Task 8)', () => {
|
||
it('TRIP-SVC-069: every table copy touches is byte-identical to the source modulo ids/timestamps, with ids consistently remapped', async () => {
|
||
const { user: owner } = createUser(testDb);
|
||
const { user: member } = createUser(testDb);
|
||
const trip = createTrip(testDb, owner.id, {
|
||
title: 'Full Fixture',
|
||
start_date: '2025-09-01',
|
||
end_date: '2025-09-03',
|
||
});
|
||
testDb
|
||
.prepare(
|
||
"UPDATE trips SET description = 'A full trip', currency = 'EUR', cover_image = 'cover.png', reminder_days = 5 WHERE id = ?",
|
||
)
|
||
.run(trip.id);
|
||
addTripMember(testDb, trip.id, member.id);
|
||
const days = getDays(trip.id);
|
||
testDb.prepare("UPDATE days SET notes = 'Pack light', title = 'Arrival' WHERE id = ?").run(days[0].id);
|
||
testDb.prepare("UPDATE days SET notes = 'Checkout' WHERE id = ?").run(days[1].id);
|
||
|
||
const stop = createPlace(testDb, trip.id, { name: 'Hotel Full', description: 'Nice place' });
|
||
const track = createPlace(testDb, trip.id, { name: 'Scenic road' });
|
||
testDb
|
||
.prepare(
|
||
"UPDATE places SET reservation_status = 'booked', reservation_notes = 'rn', reservation_datetime = '2025-09-01T14:00', route_color = '#ff0000', stop_type = 'hotel', fill_percent = 80 WHERE id = ?",
|
||
)
|
||
.run(stop.id);
|
||
|
||
const tag = Number(
|
||
testDb.prepare("INSERT INTO tags (name, user_id) VALUES ('beach', ?)").run(owner.id).lastInsertRowid,
|
||
);
|
||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(stop.id, tag);
|
||
|
||
const assignment = createDayAssignment(testDb, days[0].id, stop.id);
|
||
testDb
|
||
.prepare(
|
||
`UPDATE day_assignments SET reservation_status = 'booked', reservation_notes = 'arn', reservation_datetime = '2025-09-01T15:00',
|
||
assignment_time = '15:00', assignment_end_time = '16:00', end_day = 1 WHERE id = ?`,
|
||
)
|
||
.run(assignment.id);
|
||
testDb
|
||
.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?), (?, ?)')
|
||
.run(assignment.id, owner.id, assignment.id, member.id);
|
||
|
||
testDb
|
||
.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 0, 0, 48.1, 2.1)')
|
||
.run(days[0].id);
|
||
testDb
|
||
.prepare('INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) VALUES (?, ?, 2.5)')
|
||
.run(days[0].id, track.id);
|
||
testDb
|
||
.prepare("INSERT INTO roadtrip_preferences (trip_id, key, value) VALUES (?, 'avoid_tolls', 'true')")
|
||
.run(trip.id);
|
||
testDb
|
||
.prepare(
|
||
'INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (?, 1, ?, NULL, 0.5)',
|
||
)
|
||
.run(trip.id, assignment.id);
|
||
|
||
const accomId = Number(
|
||
testDb
|
||
.prepare(
|
||
"INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, '15:00', '15:30', '11:00', 'CONF-1', 'Late checkout ok')",
|
||
)
|
||
.run(trip.id, stop.id, days[0].id, days[1].id).lastInsertRowid,
|
||
);
|
||
testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(accomId, assignment.id);
|
||
|
||
const resId = Number(
|
||
testDb
|
||
.prepare(
|
||
`
|
||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
|
||
location, confirmation_number, notes, url, status, type, metadata, day_plan_position, needs_review, ingest_state,
|
||
external_source, external_id, sync_enabled)
|
||
VALUES (?, ?, ?, ?, ?, ?, 'Stay', '15:00', '11:00', 'Front desk', 'CONF-99', 'Bring ID', 'https://example.com/booking',
|
||
'confirmed', 'hotel', '{"note":"seed"}', 1.5, 1, 'live', 'airtrail', 'ext-123', 0)
|
||
`,
|
||
)
|
||
.run(trip.id, days[0].id, days[1].id, stop.id, assignment.id, accomId).lastInsertRowid,
|
||
);
|
||
// Confirms the seed itself reproduces the legacy "14.0" TEXT shape
|
||
// (this is the SOURCE row's own accommodation_id, written the same way
|
||
// the pre-migration `copy` bound it — a plain number through
|
||
// better-sqlite3 into the TEXT column).
|
||
expect(
|
||
(
|
||
testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(resId) as {
|
||
accommodation_id: string;
|
||
}
|
||
).accommodation_id,
|
||
).toBe(`${accomId}.0`);
|
||
|
||
const itemId = Number(
|
||
testDb
|
||
.prepare(
|
||
"INSERT INTO budget_items (trip_id, category, name, total_price, reservation_id, currency) VALUES (?, 'Accommodation', 'Hotel', 300, ?, 'EUR')",
|
||
)
|
||
.run(trip.id, resId).lastInsertRowid,
|
||
);
|
||
testDb
|
||
.prepare(
|
||
'INSERT INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 1, 150), (?, ?, 0, 150)',
|
||
)
|
||
.run(itemId, owner.id, itemId, member.id);
|
||
testDb
|
||
.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, 300)')
|
||
.run(itemId, owner.id);
|
||
testDb
|
||
.prepare("INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, 'Accommodation', 1)")
|
||
.run(trip.id);
|
||
|
||
const bagId = Number(
|
||
testDb.prepare("INSERT INTO packing_bags (trip_id, name) VALUES (?, 'Carry-on')").run(trip.id).lastInsertRowid,
|
||
);
|
||
testDb
|
||
.prepare('INSERT INTO packing_items (trip_id, name, checked, bag_id) VALUES (?, ?, 1, ?)')
|
||
.run(trip.id, 'Shared tent', bagId);
|
||
testDb
|
||
.prepare('INSERT INTO packing_items (trip_id, name, checked, is_private, owner_id) VALUES (?, ?, 1, 1, ?)')
|
||
.run(trip.id, "Owner's diary", owner.id);
|
||
testDb
|
||
.prepare('INSERT INTO packing_items (trip_id, name, checked, is_private, owner_id) VALUES (?, ?, 1, 1, ?)')
|
||
.run(trip.id, "Member's meds", member.id);
|
||
|
||
createDayNote(testDb, days[0].id, trip.id, { text: 'Remember passport', time: '08:00', icon: '🛂', sort_order: 1 });
|
||
testDb
|
||
.prepare(
|
||
"INSERT INTO todo_items (trip_id, name, checked, category, sort_order) VALUES (?, 'Book taxi', 1, 'travel', 1)",
|
||
)
|
||
.run(trip.id);
|
||
|
||
// ── copy, run by the OWNER (so their own private item copies, member's does not) ──
|
||
const newTripId = await svc.copy(trip.id, owner.id, 'Full Copy');
|
||
|
||
// trips
|
||
const newTrip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(newTripId) as Record<string, unknown>;
|
||
expect(newTrip).toMatchObject({
|
||
user_id: owner.id,
|
||
title: 'Full Copy',
|
||
description: 'A full trip',
|
||
start_date: '2025-09-01',
|
||
end_date: '2025-09-03',
|
||
currency: 'EUR',
|
||
cover_image: 'cover.png',
|
||
is_archived: 0,
|
||
reminder_days: 5,
|
||
});
|
||
|
||
// days
|
||
const newDays = getDays(newTripId);
|
||
expect(newDays).toHaveLength(3);
|
||
expect(newDays.map((d) => ({ day_number: d.day_number, date: d.date }))).toEqual(
|
||
days.map((d) => ({ day_number: d.day_number, date: d.date })),
|
||
);
|
||
expect(
|
||
testDb.prepare('SELECT notes, title FROM days WHERE id = ?').get(newDays[0].id) as {
|
||
notes: string | null;
|
||
title: string | null;
|
||
},
|
||
).toEqual({ notes: 'Pack light', title: 'Arrival' });
|
||
expect(
|
||
(testDb.prepare('SELECT notes FROM days WHERE id = ?').get(newDays[1].id) as { notes: string | null }).notes,
|
||
).toBe('Checkout');
|
||
|
||
// places
|
||
const newStop = testDb
|
||
.prepare('SELECT * FROM places WHERE trip_id = ? AND name = ?')
|
||
.get(newTripId, 'Hotel Full') as {
|
||
id: number;
|
||
description: string | null;
|
||
reservation_status: string | null;
|
||
reservation_notes: string | null;
|
||
reservation_datetime: string | null;
|
||
route_color: string | null;
|
||
stop_type: string | null;
|
||
fill_percent: number | null;
|
||
};
|
||
const newTrack = testDb
|
||
.prepare('SELECT * FROM places WHERE trip_id = ? AND name = ?')
|
||
.get(newTripId, 'Scenic road') as { id: number };
|
||
expect(newStop).toMatchObject({
|
||
description: 'Nice place',
|
||
reservation_status: 'booked',
|
||
reservation_notes: 'rn',
|
||
reservation_datetime: '2025-09-01T14:00',
|
||
route_color: '#ff0000',
|
||
stop_type: 'hotel',
|
||
fill_percent: 80,
|
||
});
|
||
|
||
// place_tags
|
||
expect(
|
||
(testDb.prepare('SELECT tag_id FROM place_tags WHERE place_id = ?').get(newStop.id) as { tag_id: number }).tag_id,
|
||
).toBe(tag);
|
||
|
||
// day_assignments (+ the TP57 accommodation stamp)
|
||
const newAssignment = getAssignments(newDays[0].id)[0];
|
||
const newAssignmentFull = testDb.prepare('SELECT * FROM day_assignments WHERE id = ?').get(newAssignment.id) as {
|
||
place_id: number;
|
||
reservation_status: string | null;
|
||
reservation_notes: string | null;
|
||
reservation_datetime: string | null;
|
||
assignment_time: string | null;
|
||
assignment_end_time: string | null;
|
||
end_day: number;
|
||
accommodation_id: number | null;
|
||
};
|
||
expect(newAssignmentFull).toMatchObject({
|
||
place_id: newStop.id,
|
||
reservation_status: 'booked',
|
||
reservation_notes: 'arn',
|
||
reservation_datetime: '2025-09-01T15:00',
|
||
assignment_time: '15:00',
|
||
assignment_end_time: '16:00',
|
||
end_day: 1,
|
||
});
|
||
|
||
// assignment_participants
|
||
const newParticipants = testDb
|
||
.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?')
|
||
.all(newAssignment.id) as { user_id: number }[];
|
||
expect(newParticipants.map((p) => p.user_id).sort((a, b) => a - b)).toEqual(
|
||
[owner.id, member.id].sort((a, b) => a - b),
|
||
);
|
||
|
||
// roadtrip_vias / roadtrip_day_tracks
|
||
const newVia = testDb
|
||
.prepare('SELECT after_order_index, sequence, lat, lng FROM roadtrip_vias WHERE day_id = ?')
|
||
.get(newDays[0].id) as {
|
||
after_order_index: number;
|
||
sequence: number;
|
||
lat: number;
|
||
lng: number;
|
||
};
|
||
expect(newVia).toEqual({ after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 });
|
||
const newTrackRow = 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 | null };
|
||
expect(newTrackRow).toEqual({ place_id: newTrack.id, stray_km: 2.5 });
|
||
|
||
// roadtrip_preferences / roadtrip_day_boundaries
|
||
expect(
|
||
(
|
||
testDb
|
||
.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = ? AND key = 'avoid_tolls'")
|
||
.get(newTripId) as { value: string }
|
||
).value,
|
||
).toBe('true');
|
||
const newBoundary = testDb
|
||
.prepare(
|
||
'SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ?',
|
||
)
|
||
.get(newTripId) as {
|
||
day_number: number;
|
||
from_assignment_id: number;
|
||
to_assignment_id: number | null;
|
||
fraction: number;
|
||
};
|
||
expect(newBoundary).toEqual({
|
||
day_number: 1,
|
||
from_assignment_id: newAssignment.id,
|
||
to_assignment_id: null,
|
||
fraction: 0.5,
|
||
});
|
||
|
||
// day_accommodations (TP55/TP56, byte-diffed against the source — full
|
||
// key set modulo id/trip_id/place_id/start_day_id/end_day_id/created_at,
|
||
// every one of which is either a remapped FK or excluded on principle),
|
||
// and the assignment's accommodation_id stamped at the NEW accommodation
|
||
const newAccom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(newTripId) as {
|
||
id: number;
|
||
place_id: number | null;
|
||
start_day_id: number;
|
||
end_day_id: number;
|
||
check_in: string | null;
|
||
check_in_end: string | null;
|
||
check_out: string | null;
|
||
confirmation: string | null;
|
||
notes: string | null;
|
||
};
|
||
const sourceAccom = testDb
|
||
.prepare('SELECT check_in, check_in_end, check_out, confirmation, notes FROM day_accommodations WHERE id = ?')
|
||
.get(accomId) as {
|
||
check_in: string | null;
|
||
check_in_end: string | null;
|
||
check_out: string | null;
|
||
confirmation: string | null;
|
||
notes: string | null;
|
||
};
|
||
expect({
|
||
check_in: newAccom.check_in,
|
||
check_in_end: newAccom.check_in_end,
|
||
check_out: newAccom.check_out,
|
||
confirmation: newAccom.confirmation,
|
||
notes: newAccom.notes,
|
||
}).toEqual(sourceAccom);
|
||
expect({
|
||
place_id: newAccom.place_id,
|
||
start_day_id: newAccom.start_day_id,
|
||
end_day_id: newAccom.end_day_id,
|
||
}).toEqual({ place_id: newStop.id, start_day_id: newDays[0].id, end_day_id: newDays[1].id });
|
||
expect(newAssignmentFull.accommodation_id).toBe(newAccom.id);
|
||
|
||
// reservations (TP58/TP59), byte-diffed against the source — full key
|
||
// set modulo id/trip_id/day_id/end_day_id/place_id/assignment_id/
|
||
// created_at (remapped FKs) and the external_*/sync_enabled columns
|
||
// (deliberately NOT copied, asserted below).
|
||
const newRes = testDb.prepare('SELECT * FROM reservations WHERE trip_id = ?').get(newTripId) as {
|
||
id: number;
|
||
day_id: number | null;
|
||
end_day_id: number | null;
|
||
place_id: number | null;
|
||
assignment_id: number | null;
|
||
title: string;
|
||
reservation_time: string | null;
|
||
reservation_end_time: string | null;
|
||
location: string | null;
|
||
confirmation_number: string | null;
|
||
notes: string | null;
|
||
url: string | null;
|
||
status: string | null;
|
||
type: string | null;
|
||
metadata: string | null;
|
||
day_plan_position: number | null;
|
||
needs_review: number;
|
||
ingest_state: string;
|
||
accommodation_id: string | null;
|
||
external_source: string | null;
|
||
external_id: string | null;
|
||
sync_enabled: number | null;
|
||
};
|
||
expect({
|
||
title: newRes.title,
|
||
reservation_time: newRes.reservation_time,
|
||
reservation_end_time: newRes.reservation_end_time,
|
||
location: newRes.location,
|
||
confirmation_number: newRes.confirmation_number,
|
||
notes: newRes.notes,
|
||
url: newRes.url,
|
||
status: newRes.status,
|
||
type: newRes.type,
|
||
metadata: newRes.metadata,
|
||
day_plan_position: newRes.day_plan_position,
|
||
needs_review: newRes.needs_review,
|
||
ingest_state: newRes.ingest_state,
|
||
}).toEqual({
|
||
title: 'Stay',
|
||
reservation_time: '15:00',
|
||
reservation_end_time: '11:00',
|
||
location: 'Front desk',
|
||
confirmation_number: 'CONF-99',
|
||
notes: 'Bring ID',
|
||
url: 'https://example.com/booking',
|
||
status: 'confirmed',
|
||
type: 'hotel',
|
||
metadata: '{"note":"seed"}',
|
||
day_plan_position: 1.5,
|
||
needs_review: 1,
|
||
ingest_state: 'live',
|
||
});
|
||
expect({
|
||
day_id: newRes.day_id,
|
||
end_day_id: newRes.end_day_id,
|
||
place_id: newRes.place_id,
|
||
assignment_id: newRes.assignment_id,
|
||
}).toEqual({
|
||
day_id: newDays[0].id,
|
||
end_day_id: newDays[1].id,
|
||
place_id: newStop.id,
|
||
assignment_id: newAssignment.id,
|
||
});
|
||
// The TRAP proof: the copy's accommodation_id is the LEGACY raw-bound
|
||
// TEXT shape (`'<id>.0'`), not the `em.insert()`-inlined `'<id>'` shape
|
||
// rule 22 would otherwise produce.
|
||
expect(newRes.accommodation_id).toBe(`${newAccom.id}.0`);
|
||
// external_* / sync_enabled are deliberately NOT copied — the duplicate
|
||
// must not inherit the source's external sync identity (the source has
|
||
// both set, above).
|
||
expect(newRes.external_source).toBeNull();
|
||
expect(newRes.external_id).toBeNull();
|
||
expect(newRes.sync_enabled).toBe(1); // the column's own DB DEFAULT, never the source's `0`
|
||
|
||
// budget_items / members / payers / category order
|
||
const newItem = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').get(newTripId) as {
|
||
id: number;
|
||
category: string;
|
||
name: string;
|
||
total_price: number;
|
||
reservation_id: number | null;
|
||
currency: string | null;
|
||
};
|
||
expect(newItem).toMatchObject({
|
||
category: 'Accommodation',
|
||
name: 'Hotel',
|
||
total_price: 300,
|
||
reservation_id: newRes.id,
|
||
currency: 'EUR',
|
||
});
|
||
const newMembers = testDb
|
||
.prepare('SELECT user_id, paid, amount FROM budget_item_members WHERE budget_item_id = ? ORDER BY user_id')
|
||
.all(newItem.id) as { user_id: number; paid: number; amount: number | null }[];
|
||
expect(newMembers).toEqual(
|
||
[owner.id, member.id]
|
||
.sort((a, b) => a - b)
|
||
.map((uid) =>
|
||
uid === owner.id ? { user_id: owner.id, paid: 1, amount: 150 } : { user_id: member.id, paid: 0, amount: 150 },
|
||
),
|
||
);
|
||
const newPayers = testDb
|
||
.prepare('SELECT user_id, amount FROM budget_item_payers WHERE budget_item_id = ?')
|
||
.all(newItem.id) as { user_id: number; amount: number }[];
|
||
expect(newPayers).toEqual([{ user_id: owner.id, amount: 300 }]);
|
||
expect(
|
||
(
|
||
testDb
|
||
.prepare("SELECT sort_order FROM budget_category_order WHERE trip_id = ? AND category = 'Accommodation'")
|
||
.get(newTripId) as { sort_order: number }
|
||
).sort_order,
|
||
).toBe(1);
|
||
|
||
// packing_bags / packing_items (incl. the TP68 privacy filter — the copIER is the owner)
|
||
const newBag = testDb
|
||
.prepare("SELECT id FROM packing_bags WHERE trip_id = ? AND name = 'Carry-on'")
|
||
.get(newTripId) as { id: number };
|
||
const newPacking = testDb
|
||
.prepare('SELECT name, checked, is_private, owner_id, bag_id FROM packing_items WHERE trip_id = ? ORDER BY name')
|
||
.all(newTripId) as {
|
||
name: string;
|
||
checked: number | null;
|
||
is_private: number;
|
||
owner_id: number | null;
|
||
bag_id: number | null;
|
||
}[];
|
||
expect(newPacking.map((p) => p.name)).toEqual(["Owner's diary", 'Shared tent']); // member's private item is NOT copied
|
||
expect(newPacking.find((p) => p.name === 'Shared tent')).toMatchObject({
|
||
checked: 0,
|
||
is_private: 0,
|
||
owner_id: null,
|
||
bag_id: newBag.id,
|
||
});
|
||
expect(newPacking.find((p) => p.name === "Owner's diary")).toMatchObject({
|
||
checked: 0,
|
||
is_private: 1,
|
||
owner_id: owner.id,
|
||
});
|
||
|
||
// day_notes
|
||
const newNote = testDb
|
||
.prepare('SELECT day_id, text, time, icon, sort_order FROM day_notes WHERE trip_id = ?')
|
||
.get(newTripId) as {
|
||
day_id: number;
|
||
text: string;
|
||
time: string | null;
|
||
icon: string | null;
|
||
sort_order: number | null;
|
||
};
|
||
expect(newNote).toEqual({
|
||
day_id: newDays[0].id,
|
||
text: 'Remember passport',
|
||
time: '08:00',
|
||
icon: '🛂',
|
||
sort_order: 1,
|
||
});
|
||
|
||
// todo_items — reset to unchecked, no assignee
|
||
const newTodo = testDb
|
||
.prepare('SELECT name, checked, category, sort_order, assigned_user_id FROM todo_items WHERE trip_id = ?')
|
||
.get(newTripId) as {
|
||
name: string;
|
||
checked: number | null;
|
||
category: string | null;
|
||
sort_order: number | null;
|
||
assigned_user_id: number | null;
|
||
};
|
||
expect(newTodo).toEqual({
|
||
name: 'Book taxi',
|
||
checked: 0,
|
||
category: 'travel',
|
||
sort_order: 1,
|
||
assigned_user_id: null,
|
||
});
|
||
|
||
// The source trip is untouched.
|
||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ n: 3 });
|
||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM packing_items WHERE trip_id = ?').get(trip.id)).toEqual({ n: 3 });
|
||
});
|
||
|
||
it('TRIP-SVC-070 (mutation-proved): copy is atomic — a failing late insert leaves no new trip and no partial rows', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, {
|
||
title: 'Rollback source',
|
||
start_date: '2025-10-01',
|
||
end_date: '2025-10-02',
|
||
});
|
||
const days = getDays(trip.id);
|
||
const place = createPlace(testDb, trip.id, { name: 'Doomed place' });
|
||
createDayAssignment(testDb, days[0].id, place.id);
|
||
const tripsBefore = (testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n;
|
||
const daysBefore = (testDb.prepare('SELECT COUNT(*) AS n FROM days').get() as { n: number }).n;
|
||
|
||
const dayNotesRepo = await createTestDayNotesRepo(testDb);
|
||
const spy = vi.spyOn(dayNotesRepo, 'insertNoteCopy').mockRejectedValueOnce(new Error('boom'));
|
||
createDayNote(testDb, days[0].id, trip.id, { text: 'Triggers the late failure' });
|
||
try {
|
||
await expect(svc.copy(trip.id, user.id, 'Never lands')).rejects.toThrow('boom');
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
|
||
// No new trip, and every earlier insert in the same transaction (trips/days/
|
||
// places/assignments/…) rolled back with it — nothing partially lands.
|
||
expect((testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n).toBe(tripsBefore);
|
||
expect((testDb.prepare('SELECT COUNT(*) AS n FROM days').get() as { n: number }).n).toBe(daysBefore);
|
||
expect(testDb.prepare("SELECT id FROM trips WHERE title = 'Never lands'").get()).toBeUndefined();
|
||
// The source trip itself is untouched.
|
||
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined();
|
||
});
|
||
});
|
||
|
||
describe('copy: Tours (#2586)', () => {
|
||
/** A Tour on `tripId`: the place, its `tours` facet and three route waypoints. */
|
||
function seedTour(tripId: number, name: string) {
|
||
const place = createPlace(testDb, tripId, { name });
|
||
testDb
|
||
.prepare("UPDATE places SET transport_mode = 'walking', route_geometry = '[[47,11],[47.2,11.2]]' WHERE id = ?")
|
||
.run(place.id);
|
||
createTour(testDb, place.id, {
|
||
distance: 12.5,
|
||
match_confidence: 0.9,
|
||
max_hiking_difficulty: 4,
|
||
created_at: '2025-01-02 03:04:05',
|
||
});
|
||
testDb
|
||
.prepare(
|
||
"UPDATE tours SET elevation_gain = 800, elevation_loss = 790, duration = 5.5, difficulty = 'T3', wanderer_ref = 'w-1', tour_group_id = 7 WHERE place_id = ?",
|
||
)
|
||
.run(place.id);
|
||
const insert = testDb.prepare(
|
||
'INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, ?, ?, ?, ?)',
|
||
);
|
||
insert.run(place.id, 47, 11, 'start', 0);
|
||
insert.run(place.id, 47.1, 11.1, 'via', 1);
|
||
insert.run(place.id, 47.2, 11.2, 'end', 2);
|
||
return place;
|
||
}
|
||
|
||
const tourColumns = (placeId: number) => {
|
||
const { place_id: _placeId, ...rest } = testDb
|
||
.prepare('SELECT * FROM tours WHERE place_id = ?')
|
||
.get(placeId) as Record<string, unknown>;
|
||
return rest;
|
||
};
|
||
const waypoints = (placeId: number) =>
|
||
testDb
|
||
.prepare('SELECT lat, lng, role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence')
|
||
.all(placeId);
|
||
|
||
it('TRIP-SVC-081: a copied Tour keeps its facet row and waypoints, remapped onto the copied place', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { title: 'Alps' });
|
||
const tour = seedTour(trip.id, 'Ridge walk');
|
||
createPlace(testDb, trip.id, { name: 'Plain stop' });
|
||
|
||
const newTripId = await svc.copy(trip.id, user.id, 'Alps again');
|
||
|
||
const newTour = testDb
|
||
.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Ridge walk'")
|
||
.get(newTripId) as { id: number };
|
||
const newPlain = testDb
|
||
.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Plain stop'")
|
||
.get(newTripId) as { id: number };
|
||
expect(newTour.id).not.toBe(tour.id);
|
||
expect(tourColumns(newTour.id)).toEqual(tourColumns(tour.id));
|
||
expect(tourColumns(newTour.id)).toMatchObject({
|
||
tour_type: 'hike',
|
||
created_at: '2025-01-02 03:04:05',
|
||
max_hiking_difficulty: 4,
|
||
tour_group_id: 7,
|
||
});
|
||
expect(waypoints(newTour.id)).toEqual(waypoints(tour.id));
|
||
expect(waypoints(newTour.id)).toHaveLength(3);
|
||
// The plain place stays plain, and the source keeps its own rows.
|
||
expect(testDb.prepare('SELECT 1 FROM tours WHERE place_id = ?').get(newPlain.id)).toBeUndefined();
|
||
expect(
|
||
testDb
|
||
.prepare('SELECT COUNT(*) AS n FROM tours t JOIN places p ON p.id = t.place_id WHERE p.trip_id = ?')
|
||
.get(trip.id),
|
||
).toEqual({ n: 1 });
|
||
expect(waypoints(tour.id)).toHaveLength(3);
|
||
});
|
||
|
||
it('TRIP-SVC-082: a failing waypoint insert rolls the copied Tour back with the rest of the copy', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id, { title: 'Doomed alps' });
|
||
seedTour(trip.id, 'Doomed ridge');
|
||
const toursBefore = (testDb.prepare('SELECT COUNT(*) AS n FROM tours').get() as { n: number }).n;
|
||
|
||
const waypointsRepo = await createTestTourWaypointsRepo(testDb);
|
||
const spy = vi.spyOn(waypointsRepo, 'insertForPlace').mockRejectedValueOnce(new Error('boom'));
|
||
try {
|
||
await expect(svc.copy(trip.id, user.id, 'Never lands')).rejects.toThrow('boom');
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
|
||
expect((testDb.prepare('SELECT COUNT(*) AS n FROM tours').get() as { n: number }).n).toBe(toursBefore);
|
||
expect(testDb.prepare("SELECT id FROM trips WHERE title = 'Never lands'").get()).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// ── Post-fold quirk fixes (transactions, owner display name) ─────────────────
|
||
|
||
describe('quirk fixes', () => {
|
||
// Plan 3c Task 7 (R8): the SQL-text-keyed `failingConnection`/`failingTrips`
|
||
// Proxy this test used to build a one-off TripsService around is rewritten
|
||
// as a repository-level fault, the same shape TRIP-SVC-052 below already
|
||
// uses for `deleteGuest` — `remove`'s TP32-TP34 (journey_entries stay raw;
|
||
// `deleteById` is TP34, `TripsRepository`'s) are the statements this test
|
||
// pins, and TP34 is now a repository method to `vi.spyOn`, not SQL text to
|
||
// pattern-match. The Proxy could never trigger any more: no statement text
|
||
// matching `'DELETE FROM trips WHERE id = ?'` runs through `this.db`
|
||
// inside `remove`'s transaction any more (TP32/TP33 are the only survivors
|
||
// there, and their own text is untouched).
|
||
|
||
it('TRIP-SVC-051 (mutation-proved): 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 tripsRepo = await createTestTripsRepo(testDb);
|
||
const spy = vi.spyOn(tripsRepo, 'deleteById').mockRejectedValueOnce(new Error('boom'));
|
||
try {
|
||
await expect(svc.remove(trip.id, user.id, 'user')).rejects.toThrow('boom');
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
|
||
// 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();
|
||
});
|
||
|
||
// R8 (Plan 3c program brief item 8): the SQL-text-keyed `failingConnection`
|
||
// Proxy this test used to build a `failingMembers(match)` service around is
|
||
// rewritten as a repository-level fault (Task 6 converted `deleteGuest` off
|
||
// `this.db.prepare(...)` entirely, onto `UsersRepository.deleteGuest` — the
|
||
// Proxy could never trigger any more, since no statement text runs through
|
||
// `this.db` inside `deleteGuest`'s transaction).
|
||
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 usersRepo = await createTestUsersRepo(testDb);
|
||
const spy = vi.spyOn(usersRepo, 'deleteGuest').mockRejectedValueOnce(new Error('boom'));
|
||
try {
|
||
await expect(membersSvc.deleteGuest(trip.id, guest.id)).rejects.toThrow('boom');
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
|
||
// 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('searchPlaces (#2190)', () => {
|
||
it('TRIP-SVC-2190-1: finds places by name or address across own and shared trips, archived ones too', async () => {
|
||
const { user } = createUser(testDb);
|
||
const { user: other } = createUser(testDb);
|
||
const own = createTrip(testDb, user.id, { title: 'Philly' });
|
||
const shared = createTrip(testDb, other.id, { title: 'SF' });
|
||
addTripMember(testDb, shared.id, user.id);
|
||
const foreign = createTrip(testDb, other.id, { title: 'Hidden' });
|
||
createPlace(testDb, own.id, { name: "Dante's Diner" });
|
||
const museum = createPlace(testDb, own.id, { name: 'Museum' });
|
||
testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('1 Diner Street', museum.id);
|
||
createPlace(testDb, shared.id, { name: 'City Lights Books' });
|
||
createPlace(testDb, foreign.id, { name: 'Secret Diner' });
|
||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(own.id);
|
||
|
||
expect(await svc.searchPlaces(user.id, 'diner')).toEqual([
|
||
{ trip_id: own.id, places: ["Dante's Diner", 'Museum'] },
|
||
]);
|
||
expect(await svc.searchPlaces(user.id, 'books')).toEqual([{ trip_id: shared.id, places: ['City Lights Books'] }]);
|
||
});
|
||
|
||
it('TRIP-SVC-2190-2: wildcards are literal, short queries match nothing, and three names per trip at most', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
for (const name of ['Cafe A', 'Cafe B', 'Cafe C', 'Cafe D', '100% Juice']) createPlace(testDb, trip.id, { name });
|
||
expect((await svc.searchPlaces(user.id, 'cafe'))[0].places).toHaveLength(3);
|
||
expect(await svc.searchPlaces(user.id, '%')).toEqual([]);
|
||
expect((await svc.searchPlaces(user.id, '0%'))[0].places).toEqual(['100% Juice']);
|
||
expect(await svc.searchPlaces(user.id, '_a')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|