fix(reservations): a booking read on its own carries its day slots, so an edit no longer drops them

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
This commit is contained in:
Maurice
2026-10-12 12:32:24 +02:00
parent c2dd66986c
commit eaab7dc4e4
12 changed files with 108 additions and 7 deletions
@@ -75,4 +75,14 @@ describe('ReservationDayPositionsRepository — fully seeded world', () => {
const trip = createTrip(testDb, user.id);
expect(await repo.listForTrip(trip.id)).toEqual([]);
});
it('listForReservation — the slots of one booking per day, none of another booking', async () => {
const { multiDay, day1, day2 } = await seed();
const rows = await repo.listForReservation(multiDay.id);
expect(rows.map((r) => [r.day_id, r.position]).sort()).toEqual([
[day1.id, 0],
[day2.id, 1],
]);
expect(await repo.listForReservation(999999)).toEqual([]);
});
});
@@ -5,6 +5,7 @@
import { db as testDb } from '../../../src/db/database';
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity';
import { ReservationDayPositions } from '../../../src/db/entities/ReservationDayPositions.entity';
import { ReservationTravelers } from '../../../src/db/entities/ReservationTravelers.entity';
import { Reservations } from '../../../src/db/entities/Reservations.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
@@ -293,6 +294,26 @@ describe('Tool: update_reservation', () => {
expect(events.indexOf('budget:updated')).toBeLessThan(events.indexOf('reservation:updated'));
});
// #2631: the day planner puts a booking moved on a day by its day slot, and the
// edit's answer and broadcast replace the booking in every open list.
it('keeps the day slots in the answer and the broadcast', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const reservation = createReservation(testDb, trip.id, { title: 'Bus', type: 'bus' });
await insertRow(orm, ReservationDayPositions, { reservation: reservation.id, day: day.id, position: 2.5 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_reservation',
arguments: { tripId: trip.id, reservationId: reservation.id, notes: 'aisle' },
});
type Answer = { reservation: { day_positions: Record<string, number> | null } };
expect((parseToolResult(result) as Answer).reservation.day_positions).toEqual({ [day.id]: 2.5 });
const sent = broadcastMock.mock.calls.find((c) => c[1] === 'reservation:updated')?.[2] as Answer;
expect(sent.reservation.day_positions).toEqual({ [day.id]: 2.5 });
});
});
it('returns error for reservation not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -58,7 +58,7 @@ let orm: TestOrm;
beforeAll(async () => {
budgetSvc = new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), realtime, await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb)));
svc = new CalendarService(
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 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 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)),
await createTestTripsRepo(testDb), await createTestDaysRepo(testDb), await createTestDayNotesRepo(testDb), await createTestReservationsRepo(testDb),
);
});
@@ -73,6 +73,7 @@ async function makeImportService(): Promise<AirtrailImportService> {
await createTestReservationsRepo(db),
await createTestReservationEndpointsRepo(db),
await createTestReservationTravelersRepo(db),
await createTestReservationDayPositionsRepo(db),
),
await accommodationsOver(db),
await createTestUnitOfWork(db),
@@ -80,7 +80,7 @@ const broadcast = realtime.broadcastMock;
let svc: ReservationsService;
let orm: TestOrm;
beforeAll(async () => {
svc = new ReservationsService(permissionsStub, budget as unknown as BudgetService, realtime, notificationsStub(notif.send), new ReservationsReadService(await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb)), await makeAccommodationsService(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));
svc = new ReservationsService(permissionsStub, budget as unknown as BudgetService, realtime, notificationsStub(notif.send), new ReservationsReadService(await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb), await createTestReservationDayPositionsRepo(testDb)), await makeAccommodationsService(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));
});
beforeEach(async () => {
@@ -387,6 +387,23 @@ describe('ReservationsService (DI-native, real SQL)', () => {
const single = (await svc.getReservationWithJoins(res.id))!;
expect(single.accommodation_id).toBe(acc.id);
expect(single.travelers).toHaveLength(1);
expect(single.day_positions).toEqual({ [days[0].id]: 2 });
});
// The create and update responses and their broadcasts come from the single read.
// Without the day slots in it, a booking moved on a day went back to its time in
// the list after every edit until the next reload (#2631).
it('RESV-SVC-015b: the update result keeps the day slot, null for a booking without one', async () => {
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
const days = await findRows(orm, Days, { trip: trip.id }, { day_number: 'asc' }) as { id: number }[];
const moved = createReservation(testDb, trip.id, { title: 'Train', type: 'train' });
const untouched = createReservation(testDb, trip.id, { title: 'Bus', type: 'bus' });
await svc.updatePositions(String(trip.id), [{ id: moved.id, day_plan_position: 3.5 }], days[1].id);
const current = await svc.getReservation(String(moved.id), String(trip.id));
const { reservation } = await svc.update(String(moved.id), String(trip.id), { notes: 'window seat' }, current as Parameters<ReservationsService['update']>[3]);
expect(reservation.day_positions).toEqual({ [days[1].id]: 3.5 });
expect((await svc.getReservationWithJoins(untouched.id))?.day_positions).toBeNull();
});
it('RESV-SVC-016: getReservationWithJoins returns undefined for a missing id', async () => {
@@ -296,6 +296,7 @@ const buildReadModel = async (tripsRepo: TripsRepository, roster: TripMembersSer
await createTestReservationsRepo(testDb),
await createTestReservationEndpointsRepo(testDb),
await createTestReservationTravelersRepo(testDb),
await createTestReservationDayPositionsRepo(testDb),
),
await accommodationsOver(testDb, realtime),
await createTestUnitOfWork(testDb),
+2 -2
View File
@@ -278,7 +278,7 @@ beforeAll(async () => {
buildPlaceImportService(),
);
svc = new TripsService(
new ReservationsService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), budgetSvc, realtime, 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 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)), 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,
@@ -307,7 +307,7 @@ beforeAll(async () => {
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)), 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 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)), 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.