mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
eaab7dc4e4
The create and update answers, the reservation:updated broadcast and the MCP reservation tools all hand back the single booking read, and that read never loaded reservation_day_positions. The day list puts a booking moved on a day by that slot before its time, and the client replaces the booking with the answer, so a booking moved on a day jumped back to its time after every edit until the next reload. The read now attaches day_positions in the shape the trip list already sends. Refs #2631
602 lines
27 KiB
TypeScript
602 lines
27 KiB
TypeScript
/**
|
|
* Unit tests for the DI-native TripReadModelService — TRIP-READ-001..006.
|
|
* The two read aggregates (MCP summary + offline bundle) moved here when the
|
|
* trips aggregate root was split; the happy-path aggregation cases stayed in
|
|
* tests/unit/nest/trips.service.test.ts so that diff reads as a move. This file
|
|
* pins the guards around them: the early returns, the falsy-price fold, the
|
|
* checked tally and the viewer scoping that keeps other members' private
|
|
* packing items (#858) out of both aggregates.
|
|
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
|
|
*/
|
|
import { db as testDb } from '../../../src/db/database';
|
|
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
|
|
import { PackingItems } from '../../../src/db/entities/PackingItems.entity';
|
|
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
|
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
|
|
import { Trips } from '../../../src/db/entities/Trips.entity';
|
|
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
|
|
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
|
|
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
|
|
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import { EphemeralTokenService } from '../../../src/nest/auth-core/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 { 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 { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service';
|
|
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
|
import { TodoService } from '../../../src/nest/todo/todo.service';
|
|
import { TripMembersService } from '../../../src/nest/trip-members/trip-members.service';
|
|
import { TripAccessService } from '../../../src/nest/trip-membership/trip-access.service';
|
|
import { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service';
|
|
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
|
import { makeAccommodationsService } from '../../helpers/accommodations-service';
|
|
import { accommodationsOver } from '../../helpers/accommodations-service';
|
|
import { budgetRepoArgs } from '../../helpers/budget-repos';
|
|
import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos';
|
|
import {
|
|
createTestCollabMessageReactionsRepo,
|
|
createTestCollabNotesRepo,
|
|
createTestCollabPollsRepo,
|
|
createTestCollabPollVotesRepo,
|
|
createTestCollabLinksRepo,
|
|
createTestCollabMessagesRepo,
|
|
} from '../../helpers/collab-repos';
|
|
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
|
|
import { insertRow, updateRows } from '../../helpers/factories/rows';
|
|
import { FakeRealtimeService } from '../../helpers/fake-realtime';
|
|
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 { buildMapsService } from '../../helpers/maps-service';
|
|
import { notificationsStub } from '../../helpers/notifications';
|
|
import {
|
|
createTestPackingItemsRepo,
|
|
createTestPackingItemContributorsRepo,
|
|
createTestPackingBagsRepo,
|
|
createTestPackingCategoryAssigneesRepo,
|
|
createTestPackingTemplatesRepo,
|
|
createTestPackingTemplateCategoriesRepo,
|
|
createTestPackingTemplateItemsRepo,
|
|
} from '../../helpers/packing-repos';
|
|
import { buildPlaceImportService } from '../../helpers/place-import';
|
|
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,
|
|
createTestRoadtripViasRepo,
|
|
createTestRoadtripDayBoundariesRepo,
|
|
} from '../../helpers/test-uow';
|
|
import { createTestCollectionPlacesRepo } from '../../helpers/test-uow';
|
|
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from '../../helpers/todo-repos';
|
|
|
|
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,
|
|
};
|
|
return mock;
|
|
});
|
|
|
|
const realtime = new FakeRealtimeService();
|
|
|
|
// Real sibling services over the same in-memory DB — the aggregation runs the
|
|
// actual SQL of every domain it fans out to, so a shape change downstream shows
|
|
// up here instead of being papered over by a stub.
|
|
|
|
// One shared cache instance (the PlacePhotoCacheService rule): the in-flight dedup in
|
|
// PlacePhotoCacheService only works while both consumers hold the same object.
|
|
// Plan 3c Task 1: built inside the async `beforeAll` below now — the
|
|
// constructor needs two repositories, resolved through `createTestOrm`.
|
|
let photoCache: PlacePhotoCacheService;
|
|
|
|
let accommodationsSvc: Awaited<ReturnType<typeof makeAccommodationsService>>;
|
|
beforeAll(async () => {
|
|
accommodationsSvc = await makeAccommodationsService(testDb, realtime);
|
|
});
|
|
let budgetSvc: BudgetService;
|
|
let daysSvc: DaysService;
|
|
let placesSvc: PlacesService;
|
|
let membersSvc: TripMembersService;
|
|
beforeAll(async () => {
|
|
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(),
|
|
realtime,
|
|
await createTestUnitOfWork(testDb),
|
|
...(await budgetRepoArgs(testDb)),
|
|
);
|
|
daysSvc = new DaysService(
|
|
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
|
realtime,
|
|
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)),
|
|
realtime,
|
|
buildMapsService(
|
|
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(),
|
|
makeStorageFixture('').storage,
|
|
),
|
|
photoCache,
|
|
new JourneyDomainService(
|
|
realtime,
|
|
new TrekPhotoRegistrationService(
|
|
(await sharedTestOrm(testDb)).repo(TrekPhotos),
|
|
(await sharedTestOrm(testDb)).repo(TripPhotos),
|
|
await createTestJourneyPhotosRepo(testDb),
|
|
),
|
|
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, realtime),
|
|
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),
|
|
buildPlaceImportService(),
|
|
);
|
|
membersSvc = new TripMembersService(
|
|
budgetSvc,
|
|
new UserCleanupService(
|
|
new MaintenanceRepository((await sharedTestOrm(testDb)).em),
|
|
budgetSvc,
|
|
await createTestUnitOfWork(testDb),
|
|
await createTestUsersRepo(testDb),
|
|
await createTestTripMembersRepo(testDb),
|
|
await createTestBudgetItemsRepo(testDb),
|
|
await createTestBudgetSettlementsRepo(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)),
|
|
realtime,
|
|
notificationsStub(),
|
|
await createTestUnitOfWork(testDb),
|
|
await createTestTripsRepo(testDb),
|
|
await createTestTripMembersRepo(testDb),
|
|
await createTestUsersRepo(testDb),
|
|
);
|
|
});
|
|
|
|
const buildReadModel = async (tripsRepo: TripsRepository, roster: TripMembersService = membersSvc) =>
|
|
new TripReadModelService(
|
|
tripsRepo,
|
|
roster,
|
|
daysSvc,
|
|
accommodationsSvc,
|
|
budgetSvc,
|
|
new PackingService(
|
|
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
|
realtime,
|
|
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,
|
|
realtime,
|
|
notificationsStub(),
|
|
new ReservationsReadService(
|
|
await createTestReservationsRepo(testDb),
|
|
await createTestReservationEndpointsRepo(testDb),
|
|
await createTestReservationTravelersRepo(testDb),
|
|
await createTestReservationDayPositionsRepo(testDb),
|
|
),
|
|
await accommodationsOver(testDb, realtime),
|
|
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)),
|
|
realtime,
|
|
notificationsStub(),
|
|
makeStorageFixture('').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)),
|
|
realtime,
|
|
await createTestUnitOfWork(testDb),
|
|
await createTestTodoItemsRepo(testDb),
|
|
await createTestTodoCategoryAssigneesRepo(testDb),
|
|
// Plan 4 Task 2 — TodoService's own canAccessTrip delegate is now
|
|
// TripsRepository.findAccessible, a new trailing constructor param.
|
|
new TripAccessService(await createTestTripsRepo(testDb)),
|
|
// Plan 4 Task 3 — DatabaseService.rosterUserIds inlined onto
|
|
// TripMembersRepository.rosterUserIds directly.
|
|
await createTestTripMembersRepo(testDb),
|
|
),
|
|
new FilesService(
|
|
// Plan 4 Task 2 — FilesService's own canAccessTrip delegate is now
|
|
// TripsRepository.findAccessible, in the same constructor slot.
|
|
new TripAccessService(await createTestTripsRepo(testDb)),
|
|
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
|
realtime,
|
|
new EphemeralTokenService(),
|
|
makeStorageFixture('').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),
|
|
),
|
|
);
|
|
|
|
let svc: Awaited<ReturnType<typeof buildReadModel>>;
|
|
let tripsRepo: TripsRepository;
|
|
beforeAll(async () => {
|
|
tripsRepo = await createTestTripsRepo(testDb);
|
|
svc = await buildReadModel(tripsRepo);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
const orm = () => sharedTestOrm(testDb);
|
|
|
|
const addBudgetItem = async (tripId: number, name: string, totalPrice: number) =>
|
|
insertRow(await orm(), BudgetItems, { trip: tripId, category: 'food', name, total_price: totalPrice });
|
|
|
|
const addPackingItem = async (tripId: number, name: string, checked: number) =>
|
|
insertRow(await orm(), PackingItems, { trip: tripId, name, checked });
|
|
|
|
// R8 (Plan 3c program brief item 8): the SQL-text-keyed Proxy fault injection
|
|
// this file used to build (`ownerlessDbs`, keyed on the literal
|
|
// `'SELECT user_id FROM trips'` fragment) is rewritten onto a repository-level
|
|
// fault: TR-A's `getOwnerId` failing while TR-B's `findRaw` still succeeds —
|
|
// the trip row disappearing between the two reads `getTripSummary` issues.
|
|
function forceMissingOwner(): ReturnType<typeof vi.spyOn> {
|
|
return vi.spyOn(tripsRepo, 'getOwnerId').mockResolvedValueOnce(null);
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe('getTripSummary guards', () => {
|
|
it('TRIP-READ-001: returns null for a missing trip instead of throwing', async () => {
|
|
// The MCP get_trip_summary tool hands over whatever id the model produced, so
|
|
// an unknown id has to come back as an empty answer; a throw there surfaces as
|
|
// a tool error rather than "no such trip".
|
|
expect(await svc.getTripSummary(99999)).toBeNull();
|
|
expect(await svc.getTripSummary(99999, 1)).toBeNull();
|
|
});
|
|
|
|
it('TRIP-READ-002: returns null when the owner row cannot be read', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
|
|
// Trip row and owner id are two separate reads; if the second one comes back
|
|
// empty (trip deleted in between) the guard has to stop. Without it listMembers
|
|
// runs with an undefined owner id and the summary reports an ownerless trip.
|
|
const spy = forceMissingOwner();
|
|
try {
|
|
expect(await svc.getTripSummary(trip.id, owner.id)).toBeNull();
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
|
|
// Same trip through the real repository still aggregates — the null above is
|
|
// the missing owner row, not a broken fixture.
|
|
expect(await svc.getTripSummary(trip.id, owner.id)).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getTripSummary — feed_token never reaches the wire (TR-B)', () => {
|
|
it('TRIP-READ-002b: withoutFeedToken strips feed_token even though findRaw hands it back intact', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
await updateRows(await orm(), Trips, { id: trip.id }, { feed_token: 'secret-anon-feed-credential' });
|
|
|
|
// TripsRepository.findRaw is a plain `SELECT *` — it hands feed_token back
|
|
// intact (proven directly, bypassing the service, so this test would fail
|
|
// if the repository ever started blanking the column itself).
|
|
expect((await tripsRepo.findRaw(trip.id))?.feed_token).toBe('secret-anon-feed-credential');
|
|
|
|
// getTripSummary's own withoutFeedToken() strip is the only thing standing
|
|
// between that credential and an MCP reader — assert it on the actual
|
|
// result object, not just that the call succeeds.
|
|
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
|
expect(summary.trip).not.toHaveProperty('feed_token');
|
|
expect(JSON.stringify(summary)).not.toContain('secret-anon-feed-credential');
|
|
});
|
|
});
|
|
|
|
describe('getTripSummary shaping', () => {
|
|
it('TRIP-READ-003: folds a falsy total_price into the budget total instead of poisoning it', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
await addBudgetItem(trip.id, 'Dinner', 40);
|
|
// total_price is NOT NULL DEFAULT 0, so 0 is the reachable falsy price: a free
|
|
// entry someone logged to keep it on the list. The `|| 0` in the reduce is what
|
|
// keeps any such value out of the sum — drop it and a price that arrives
|
|
// non-numeric turns the whole trip total into NaN, which the MCP summary and
|
|
// the offline clients both render as an empty budget.
|
|
await addBudgetItem(trip.id, 'Free walking tour', 0);
|
|
|
|
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
|
expect(summary.budget.item_count).toBe(2);
|
|
expect(summary.budget.total).toBe(40);
|
|
expect(summary.budget.currency).toBe('EUR');
|
|
});
|
|
|
|
it('TRIP-READ-008: totals a foreign-currency bill in the trip currency, at its booked rate (#2525)', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
await addBudgetItem(trip.id, 'Dinner', 100);
|
|
// 801.76 USD booked when a euro bought 1.17 dollars: 685.26 EUR of trip money. The
|
|
// summary used to add the 801.76 to the euros and report 901.76 EUR.
|
|
await insertRow(await orm(), BudgetItems, {
|
|
trip: trip.id,
|
|
category: 'accommodation',
|
|
name: 'Aparthotel Silver',
|
|
total_price: 801.76,
|
|
currency: 'USD',
|
|
exchange_rate: 1.17,
|
|
});
|
|
|
|
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
|
expect(summary.budget.total).toBe(785.26);
|
|
expect(summary.budget.by_category).toEqual({ food: 100, accommodation: 685.26 });
|
|
expect(summary.budget.currency).toBe('EUR');
|
|
// Both rows convert, so none is reported as left out of the total.
|
|
expect(summary.budget.unconverted_item_ids).toEqual([]);
|
|
});
|
|
|
|
it('TRIP-READ-009: totals a trip saved without a currency in euros, the default the rest of the app reads it in (#2525)', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
await updateRows(await orm(), Trips, { id: trip.id }, { currency: null });
|
|
await addBudgetItem(trip.id, 'Dinner', 100);
|
|
// 117.33 USD booked at 1.1733 dollars to the euro is 100 EUR of trip money.
|
|
await insertRow(await orm(), BudgetItems, {
|
|
trip: trip.id,
|
|
category: 'transport',
|
|
name: 'Taxi',
|
|
total_price: 117.33,
|
|
currency: 'USD',
|
|
exchange_rate: 1.1733,
|
|
});
|
|
|
|
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
|
expect(summary.budget.total).toBe(200);
|
|
expect(summary.budget.by_category).toEqual({ food: 100, transport: 100 });
|
|
expect(summary.budget.currency).toBeNull();
|
|
});
|
|
|
|
it('TRIP-READ-004: counts only checked packing items, not the whole list', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
await addPackingItem(trip.id, 'Socks', 1);
|
|
await addPackingItem(trip.id, 'Charger', 1);
|
|
await addPackingItem(trip.id, 'Passport', 0);
|
|
|
|
// total and checked come from the same array; if the filter is ever widened the
|
|
// packing progress the summary reports jumps to 100% while items are still open.
|
|
const summary = (await svc.getTripSummary(trip.id, owner.id))!;
|
|
expect(summary.packing.total).toBe(3);
|
|
expect(summary.packing.checked).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('bundle shaping', () => {
|
|
it('TRIP-READ-005: keeps the member list a flat array when the roster has no members', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
|
|
// The bundle spreads the roster's members into the array it ships offline. The
|
|
// `|| []` is the guard for a roster shape without that array — without it the
|
|
// spread throws and the whole offline bundle fails, taking days, places and
|
|
// reservations down with it over a trip that simply has no collaborators.
|
|
const roster = vi.spyOn(membersSvc, 'listMembers').mockReturnValue({
|
|
owner: { id: owner.id, username: 'solo' },
|
|
members: undefined,
|
|
} as never);
|
|
try {
|
|
const result = (await svc.bundle(String(trip.id), { user_id: owner.id }, owner.id)) as any;
|
|
expect(result.members).toEqual([{ id: owner.id, username: 'solo' }]);
|
|
} finally {
|
|
roster.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('TRIP-READ-006: drops a falsy owner rather than shipping a hole in the member list', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
|
|
// filter(Boolean) exists because the owner lookup can come back empty (a user
|
|
// row deleted while the trip lingers). Clients index the member list to render
|
|
// avatars, so an undefined slot in it crashes the offline view.
|
|
const roster = vi.spyOn(membersSvc, 'listMembers').mockReturnValue({
|
|
owner: undefined,
|
|
members: [{ id: 42, username: 'left-behind' }],
|
|
} as never);
|
|
try {
|
|
const result = (await svc.bundle(String(trip.id), { user_id: owner.id }, owner.id)) as any;
|
|
expect(result.members).toEqual([{ id: 42, username: 'left-behind' }]);
|
|
} finally {
|
|
roster.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('private packing items stay viewer-scoped (#858)', () => {
|
|
it("TRIP-READ-007: neither summary nor bundle leaks another member's private item", 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);
|
|
await insertRow(await orm(), PackingItems, { trip: trip.id, name: 'Ring', is_private: 1, owner: owner.id });
|
|
await insertRow(await orm(), PackingItems, { trip: trip.id, name: 'Tent' });
|
|
|
|
// Both read paths pass the viewer into packing.listItems; that argument is the
|
|
// ONLY thing filtering the list. If either call site loses it, listItems falls
|
|
// back to the unfiltered query and the surprise the owner is carrying shows up
|
|
// in the other member's MCP summary and in their offline cache.
|
|
const asViewer = (await svc.getTripSummary(trip.id, viewer.id))!;
|
|
expect(asViewer.packing.items.map((i: any) => i.name)).toEqual(['Tent']);
|
|
expect(asViewer.packing.total).toBe(1);
|
|
|
|
const bundled = (await svc.bundle(String(trip.id), { user_id: owner.id }, viewer.id)) as any;
|
|
expect(bundled.packingItems.map((i: any) => i.name)).toEqual(['Tent']);
|
|
|
|
// The owner still sees their own private item through both paths, so the
|
|
// assertions above are the filter working, not an empty fixture.
|
|
expect((await svc.getTripSummary(trip.id, owner.id))!.packing.items.map((i: any) => i.name)).toEqual([
|
|
'Ring',
|
|
'Tent',
|
|
]);
|
|
expect(
|
|
((await svc.bundle(String(trip.id), { user_id: owner.id }, owner.id)) as any).packingItems.map(
|
|
(i: any) => i.name,
|
|
),
|
|
).toEqual(['Ring', 'Tent']);
|
|
});
|
|
});
|