mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
564 lines
24 KiB
TypeScript
564 lines
24 KiB
TypeScript
/**
|
|
* The booking plugin surfaces after they moved onto @PluginMethod: reservations and
|
|
* the lodging blocks.
|
|
*
|
|
* Both are cascade-heavy, which is the point of most cases here. Creating a
|
|
* reservation can create an accommodation and a budget item; deleting one can remove
|
|
* both again; and deleting a lodging block can take its partner reservation and its
|
|
* budget item with it. Every one of those side effects is a broadcast the web app
|
|
* relies on, so they are asserted by event name and order.
|
|
*/
|
|
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
|
|
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
|
import { PluginGuards } from '../../../src/nest-rpc/plugin-guards.service';
|
|
import { createTestPluginRegistry } from '../../../src/nest-rpc/rpc-kit/testing';
|
|
import { AccommodationsModule } from '../../../src/nest/accommodations/accommodations.module';
|
|
import { AccommodationsRpc } from '../../../src/nest/accommodations/accommodations.rpc';
|
|
import type { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
|
|
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
|
|
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
|
|
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import { ReservationsModule } from '../../../src/nest/reservations/reservations.module';
|
|
import { ReservationsRpc } from '../../../src/nest/reservations/reservations.rpc';
|
|
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
|
import { expectRegisteredProvider } from '../../helpers/module-providers';
|
|
import { makeDeps } from '../../helpers/rpc-host-deps';
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({
|
|
k: 'req',
|
|
id: 'x',
|
|
method,
|
|
params,
|
|
});
|
|
const events = (r: { broadcast: ReturnType<typeof vi.fn> }) => r.broadcast.mock.calls.map((c) => c[1]);
|
|
|
|
/** Trip 1 belongs to user 42; reservation 5 and accommodation 11 sit on it. */
|
|
function build(
|
|
opts: {
|
|
canEdit?: boolean;
|
|
cascade?: boolean;
|
|
/** The update wrote the booking's stay for the first time. */
|
|
created?: boolean;
|
|
stop?: boolean;
|
|
seenActions?: string[];
|
|
unresolved?: string[];
|
|
foreign?: string[];
|
|
expenses?: number[];
|
|
} = {},
|
|
) {
|
|
const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType<typeof vi.fn> };
|
|
// The expenses a delete takes with it: one with `cascade`, any number with `expenses` (#2084).
|
|
const expenses = opts.expenses ?? (opts.cascade ? [7] : []);
|
|
const reservations = {
|
|
createWithCost: vi.fn(() => ({ reservation: { id: 40 }, accommodationCreated: !!opts.cascade, costEvents: [] })),
|
|
getReservation: vi.fn((id: string) => (id === '5' ? { id: 5, title: 'Hotel', type: 'lodging' } : undefined)),
|
|
updateWithCost: vi.fn(() => ({
|
|
reservation: { id: 5 },
|
|
accommodationChanged: !!opts.cascade || !!opts.created,
|
|
accommodationCreated: !!opts.created,
|
|
costEvents: [],
|
|
})),
|
|
remove: vi.fn((id: string) =>
|
|
id === '5'
|
|
? {
|
|
deleted: { title: 'Hotel', type: 'lodging', accommodation_id: opts.cascade ? 11 : null },
|
|
accommodationDeleted: !!opts.cascade,
|
|
deletedBudgetItemId: expenses[0] ?? null,
|
|
deletedBudgetItemIds: expenses,
|
|
}
|
|
: { deleted: null, accommodationDeleted: false, deletedBudgetItemId: null, deletedBudgetItemIds: [] },
|
|
),
|
|
announceCost: vi.fn(),
|
|
// The price's currency and rate are the service's to resolve; here it passes through.
|
|
withFrozenRate: vi.fn(async (_tripId: number, entry: unknown) => entry),
|
|
notifyBookingChange: vi.fn(),
|
|
// Both guards run before a write now, so the fixture answers for them.
|
|
referencesOutsideTrip: vi.fn(() => opts.foreign ?? []),
|
|
unresolvedReferences: vi.fn(() => opts.unresolved ?? []),
|
|
} as unknown as ReservationsService & Record<string, ReturnType<typeof vi.fn>>;
|
|
/** What a stay write did to the day plan, on top of writing the stay itself. */
|
|
type Mirror = {
|
|
created: { id: number; day_id: number } | null;
|
|
removed: { id: number; dayId: number }[];
|
|
stamped: null;
|
|
};
|
|
/** A stay write that left the day plan alone, which is what most of these cases are. */
|
|
const noMirror = (): Mirror => ({ created: null, removed: [], stamped: null });
|
|
/** With `stop`, the write moved its day stop from day 3 to day 4. */
|
|
const mirror = (): Mirror =>
|
|
opts.stop ? { created: { id: 78, day_id: 4 }, removed: [{ id: 77, dayId: 3 }], stamped: null } : noMirror();
|
|
// The lodging blocks live in AccommodationsService; AccommodationsRpc still calls its
|
|
// injected copy `days`, which is why the fixture keeps that name.
|
|
const days = {
|
|
validateAccommodationRefs: vi.fn((_t: number, placeId?: number) =>
|
|
placeId === 404 ? [{ message: 'place 404 is not on this trip' }] : [],
|
|
),
|
|
createAccommodation: vi.fn(() => ({ accommodation: { id: 60 }, mirror: mirror() })),
|
|
getAccommodation: vi.fn((id: number) => (id === 11 ? { id: 11 } : undefined)),
|
|
updateAccommodation: vi.fn(() => ({ accommodation: { id: 11 }, mirror: mirror() })),
|
|
deleteAccommodation: vi.fn(() => ({
|
|
linkedReservationId: opts.cascade ? 40 : null,
|
|
deletedBudgetItemId: opts.cascade ? 7 : null,
|
|
linkedReservationIds: opts.cascade ? [40] : [],
|
|
deletedBudgetItemIds: opts.cascade ? [7] : [],
|
|
mirror: mirror(),
|
|
})),
|
|
// Stands in for the real fan-out rather than swallowing it, so the sender each
|
|
// handler builds is exercised: that sender is the plugin surface's only link
|
|
// between a stay write and the sockets, and a mock that never calls it would
|
|
// let a broken one through. Inert whenever the mirror is empty, which is every
|
|
// case not built with `stop`.
|
|
announceMirror: vi.fn((_tripId: number, m: Mirror, send: (event: string, payload: unknown) => void) => {
|
|
for (const stop of m.removed) send('assignment:deleted', { assignmentId: stop.id, dayId: stop.dayId });
|
|
if (m.created) send('assignment:created', { assignment: m.created });
|
|
}),
|
|
} as unknown as AccommodationsService & Record<string, ReturnType<typeof vi.fn>>;
|
|
const guards = new PluginGuards(
|
|
{
|
|
findAccessible: vi.fn(async (tripId: number, userId: number) =>
|
|
tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined,
|
|
),
|
|
} as unknown as TripsRepository,
|
|
{
|
|
checkPermission: vi.fn((action: string) => {
|
|
opts.seenActions?.push(action);
|
|
return opts.canEdit ?? true;
|
|
}),
|
|
} as unknown as PermissionsService,
|
|
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
|
|
{ getRole: vi.fn(async () => 'user') } as unknown as UsersRepository,
|
|
);
|
|
const registry = createTestPluginRegistry([
|
|
new ReservationsRpc(reservations, realtime, guards),
|
|
new AccommodationsRpc(days, realtime, guards),
|
|
]);
|
|
const host = (...grants: string[]) =>
|
|
new PluginRpcHost(
|
|
'p',
|
|
new Set(grants.length ? grants : ['db:write:reservations', 'db:write:accommodations']),
|
|
makeDeps(),
|
|
registry,
|
|
);
|
|
return { reservations, days, realtime, host };
|
|
}
|
|
|
|
describe('ReservationsRpc', () => {
|
|
it('BOOK-RPC-001 writes need the grant, reservation_edit and a bound user', async () => {
|
|
const seen: string[] = [];
|
|
const f = build({ seenActions: seen });
|
|
expect(
|
|
(
|
|
await f
|
|
.host()
|
|
.dispatch(req('reservations.create', { tripId: 1, input: { title: 'Hotel', type: 'lodging' } }), 42)
|
|
).ok,
|
|
).toBe(true);
|
|
expect(seen).toContain('reservation_edit');
|
|
const noUser = (await f
|
|
.host()
|
|
.dispatch(
|
|
req('reservations.create', { tripId: 1, input: { title: 'x', type: 'lodging' } }),
|
|
undefined,
|
|
)) as RpcError;
|
|
expect(noUser.error.message).toBe('reservation writes require an authenticated user context');
|
|
});
|
|
|
|
it('BOOK-RPC-002 a malformed endpoints array is rejected up front', async () => {
|
|
const f = build();
|
|
const res = (await f.host().dispatch(
|
|
req('reservations.create', {
|
|
tripId: 1,
|
|
input: { title: 'Flight', type: 'flight', endpoints: [{ nonsense: true }] },
|
|
}),
|
|
42,
|
|
)) as RpcError;
|
|
expect(res.error.code).toBe('BAD_PARAMS');
|
|
// Otherwise it fails deep in the service mid-transaction, or is silently dropped.
|
|
expect(f.reservations.createWithCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('BOOK-RPC-003 an absent endpoints field stays absent, which means "keep"', async () => {
|
|
const f = build();
|
|
expect(
|
|
(
|
|
await f
|
|
.host()
|
|
.dispatch(req('reservations.create', { tripId: 1, input: { title: 'Hotel', type: 'lodging' } }), 42)
|
|
).ok,
|
|
).toBe(true);
|
|
});
|
|
|
|
it('BOOK-RPC-004 a missing reservation is RESOURCE_FORBIDDEN, naming it', async () => {
|
|
const f = build();
|
|
expect(
|
|
(
|
|
(await f
|
|
.host()
|
|
.dispatch(
|
|
req('reservations.update', { tripId: 1, reservationId: 404, input: { title: 'x', type: 'lodging' } }),
|
|
42,
|
|
)) as RpcError
|
|
).error.message,
|
|
).toBe('no reservation 404 on trip 1');
|
|
expect(
|
|
((await f.host().dispatch(req('reservations.delete', { tripId: 1, reservationId: 404 }), 42)) as RpcError).error
|
|
.message,
|
|
).toBe('no reservation 404 on trip 1');
|
|
});
|
|
|
|
it('BOOK-RPC-005 creating a lodging booking cascades into the accommodation event', async () => {
|
|
const f = build({ cascade: true });
|
|
await f.host().dispatch(req('reservations.create', { tripId: 1, input: { title: 'Hotel', type: 'lodging' } }), 42);
|
|
expect(events(f.realtime)).toEqual(['accommodation:created', 'reservation:created']);
|
|
});
|
|
|
|
it('BOOK-RPC-006 deleting one takes its accommodation and budget item with it', async () => {
|
|
const f = build({ cascade: true });
|
|
await f.host().dispatch(req('reservations.delete', { tripId: 1, reservationId: 5 }), 42);
|
|
expect(events(f.realtime)).toEqual(['accommodation:deleted', 'budget:deleted', 'reservation:deleted']);
|
|
});
|
|
|
|
it('BOOK-RPC-021 a booking carrying several expenses announces each one it took with it (#2084)', async () => {
|
|
const f = build({ expenses: [7, 8] });
|
|
await f.host().dispatch(req('reservations.delete', { tripId: 1, reservationId: 5 }), 42);
|
|
expect(f.realtime.broadcast.mock.calls).toEqual([
|
|
[1, 'budget:deleted', { itemId: 7 }, undefined],
|
|
[1, 'budget:deleted', { itemId: 8 }, undefined],
|
|
[1, 'reservation:deleted', { reservationId: 5 }, undefined],
|
|
]);
|
|
});
|
|
|
|
it('BOOK-RPC-007 the booking and its cost are one service write, and the booking notification runs', async () => {
|
|
const f = build();
|
|
await f.host().dispatch(req('reservations.create', { tripId: 1, input: { title: 'Hotel', type: 'lodging' } }), 42);
|
|
expect(f.reservations.createWithCost).toHaveBeenCalledWith(
|
|
'1',
|
|
expect.objectContaining({ title: 'Hotel' }),
|
|
undefined,
|
|
);
|
|
expect(f.reservations.announceCost).toHaveBeenCalledWith(1, [], undefined);
|
|
expect(f.reservations.notifyBookingChange).toHaveBeenCalledWith(1, 42, 'Hotel', 'lodging');
|
|
});
|
|
|
|
it('BOOK-RPC-008 a refused write cascades nothing', async () => {
|
|
const f = build({ canEdit: false, cascade: true });
|
|
await f.host().dispatch(req('reservations.create', { tripId: 1, input: { title: 'x', type: 'lodging' } }), 42);
|
|
expect(f.realtime.broadcast).not.toHaveBeenCalled();
|
|
expect(f.reservations.notifyBookingChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('BOOK-RPC-008b title and type are required by the shared schema', async () => {
|
|
const f = build();
|
|
// The handler still carries ?? '' fallbacks behind this, kept from the router as
|
|
// defence in depth, but the schema is what a plugin actually hits first.
|
|
const res = (await f.host().dispatch(req('reservations.create', { tripId: 1, input: {} }), 42)) as RpcError;
|
|
expect(res.error.code).toBe('BAD_PARAMS');
|
|
expect(f.reservations.createWithCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('BOOK-RPC-008c an update without a title keeps the current one', async () => {
|
|
const f = build();
|
|
await f.host().dispatch(req('reservations.update', { tripId: 1, reservationId: 5, input: {} }), 42);
|
|
// Falls back to the stored title and type rather than blanking them.
|
|
expect(f.reservations.notifyBookingChange).toHaveBeenCalledWith(1, 42, 'Hotel', 'lodging');
|
|
});
|
|
|
|
it('BOOK-RPC-008d a non-lodging booking cascades nothing', async () => {
|
|
const f = build();
|
|
await f
|
|
.host()
|
|
.dispatch(req('reservations.create', { tripId: 1, input: { title: 'Museum', type: 'activity' } }), 42);
|
|
expect(events(f.realtime)).toEqual(['reservation:created']);
|
|
f.realtime.broadcast.mockClear();
|
|
await f
|
|
.host()
|
|
.dispatch(
|
|
req('reservations.update', { tripId: 1, reservationId: 5, input: { title: 'x', type: 'activity' } }),
|
|
42,
|
|
);
|
|
expect(events(f.realtime)).toEqual(['reservation:updated']);
|
|
f.realtime.broadcast.mockClear();
|
|
await f.host().dispatch(req('reservations.delete', { tripId: 1, reservationId: 5 }), 42);
|
|
expect(events(f.realtime)).toEqual(['reservation:deleted']);
|
|
});
|
|
|
|
it('BOOK-RPC-022 an update that writes the stay for the first time announces it as created, as REST does', async () => {
|
|
const f = build({ created: true });
|
|
await f
|
|
.host()
|
|
.dispatch(req('reservations.update', { tripId: 1, reservationId: 5, input: { title: 'x', type: 'hotel' } }), 42);
|
|
expect(events(f.realtime)).toEqual(['accommodation:created', 'reservation:updated']);
|
|
});
|
|
|
|
it('BOOK-RPC-008e an update with a malformed endpoints array is BAD_PARAMS too', async () => {
|
|
const f = build();
|
|
const res = (await f.host().dispatch(
|
|
req('reservations.update', {
|
|
tripId: 1,
|
|
reservationId: 5,
|
|
input: { title: 'x', type: 'lodging', endpoints: [{ nonsense: true }] },
|
|
}),
|
|
42,
|
|
)) as RpcError;
|
|
expect(res.error.code).toBe('BAD_PARAMS');
|
|
expect(f.reservations.updateWithCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('BOOK-RPC-018 an id that resolves to nothing is BAD_PARAMS, not a constraint failure', async () => {
|
|
const f = build({ unresolved: ['place_id'] });
|
|
const res = (await f
|
|
.host()
|
|
.dispatch(
|
|
req('reservations.create', { tripId: 1, input: { title: 'Hotel', type: 'lodging', place_id: 999999 } }),
|
|
42,
|
|
)) as RpcError;
|
|
expect(res.error.code).toBe('BAD_PARAMS');
|
|
expect(res.error.message).toBe('unknown reference: place_id');
|
|
expect(f.reservations.createWithCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('BOOK-RPC-019 the same on update', async () => {
|
|
const f = build({ unresolved: ['day_id'] });
|
|
const res = (await f.host().dispatch(
|
|
req('reservations.update', {
|
|
tripId: 1,
|
|
reservationId: 5,
|
|
input: { title: 'x', type: 'lodging', day_id: 999999 },
|
|
}),
|
|
42,
|
|
)) as RpcError;
|
|
expect(res.error.code).toBe('BAD_PARAMS');
|
|
expect(f.reservations.updateWithCost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('BOOK-RPC-020 an id from another trip is RESOURCE_FORBIDDEN before the write, on create and update', async () => {
|
|
// reservation_edit on trip 1 says nothing about the ids in the body. A stay
|
|
// written against another trip's day puts a stop on that day, in a plan the
|
|
// acting user may not even read, so the body is checked the way the REST route
|
|
// and the MCP tool check it, and ownership is judged before existence.
|
|
const f = build({
|
|
foreign: ['create_accommodation.start_day_id'],
|
|
unresolved: ['create_accommodation.start_day_id'],
|
|
});
|
|
const stay = {
|
|
title: 'Hotel',
|
|
type: 'lodging',
|
|
create_accommodation: { place_id: 9, start_day_id: 4711, end_day_id: 4712 },
|
|
};
|
|
const created = (await f.host().dispatch(req('reservations.create', { tripId: 1, input: stay }), 42)) as RpcError;
|
|
expect(created.error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(created.error.message).toBe('not part of trip 1: create_accommodation.start_day_id');
|
|
expect(f.reservations.createWithCost).not.toHaveBeenCalled();
|
|
|
|
const g = build({ foreign: ['day_id'] });
|
|
const updated = (await g.host().dispatch(
|
|
req('reservations.update', {
|
|
tripId: 1,
|
|
reservationId: 5,
|
|
input: { title: 'x', type: 'lodging', day_id: 4711 },
|
|
}),
|
|
42,
|
|
)) as RpcError;
|
|
expect(updated.error.code).toBe('RESOURCE_FORBIDDEN');
|
|
expect(updated.error.message).toBe('not part of trip 1: day_id');
|
|
expect(g.reservations.updateWithCost).not.toHaveBeenCalled();
|
|
expect(g.realtime.broadcast).not.toHaveBeenCalled();
|
|
|
|
// The same body with this trip's ids goes through.
|
|
const h = build();
|
|
expect(
|
|
(
|
|
await h.host().dispatch(
|
|
req('reservations.create', {
|
|
tripId: 1,
|
|
input: { ...stay, create_accommodation: { ...stay.create_accommodation, start_day_id: 3 } },
|
|
}),
|
|
42,
|
|
)
|
|
).ok,
|
|
).toBe(true);
|
|
expect(h.reservations.referencesOutsideTrip).toHaveBeenLastCalledWith(
|
|
'1',
|
|
expect.objectContaining({ create_accommodation: expect.objectContaining({ start_day_id: 3 }) }),
|
|
);
|
|
});
|
|
|
|
it('BOOK-RPC-009 the class is listed in its module providers', () => {
|
|
expectRegisteredProvider(ReservationsModule, ReservationsRpc);
|
|
});
|
|
});
|
|
|
|
describe('AccommodationsRpc', () => {
|
|
it('BOOK-RPC-010 lodging blocks run under day_edit, NOT reservation_edit', async () => {
|
|
const seen: string[] = [];
|
|
const f = build({ seenActions: seen });
|
|
await f
|
|
.host()
|
|
.dispatch(
|
|
req('accommodations.create', { tripId: 1, input: { place_id: 7, start_day_id: 3, end_day_id: 4 } }),
|
|
42,
|
|
);
|
|
// They hold db:write:accommodations but the app gates them on the day permission.
|
|
expect(seen).toEqual(['day_edit']);
|
|
});
|
|
|
|
it('BOOK-RPC-011 the three ids are required, and the schema rejects them first', async () => {
|
|
const f = build();
|
|
// The shared schema runs before the handler's own check, so a missing id is
|
|
// reported as a schema failure. The handler's check is the second line of
|
|
// defence, for values the schema lets through but the service cannot use.
|
|
const missing = (await f
|
|
.host()
|
|
.dispatch(req('accommodations.create', { tripId: 1, input: { place_id: 7 } }), 42)) as RpcError;
|
|
expect(missing.error.code).toBe('BAD_PARAMS');
|
|
const zero = (await f
|
|
.host()
|
|
.dispatch(
|
|
req('accommodations.create', { tripId: 1, input: { place_id: 0, start_day_id: 0, end_day_id: 0 } }),
|
|
42,
|
|
)) as RpcError;
|
|
expect(zero.error.code).toBe('BAD_PARAMS');
|
|
expect(f.days.createAccommodation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('BOOK-RPC-012 a place or day from another trip is refused with the validator message', async () => {
|
|
const f = build();
|
|
const res = (await f
|
|
.host()
|
|
.dispatch(
|
|
req('accommodations.create', { tripId: 1, input: { place_id: 404, start_day_id: 3, end_day_id: 4 } }),
|
|
42,
|
|
)) as RpcError;
|
|
expect(res.error.message).toBe('place 404 is not on this trip');
|
|
expect(f.days.createAccommodation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('BOOK-RPC-013 creating a block announces the partner reservation too', async () => {
|
|
const f = build();
|
|
await f
|
|
.host()
|
|
.dispatch(
|
|
req('accommodations.create', { tripId: 1, input: { place_id: 7, start_day_id: 3, end_day_id: 4 } }),
|
|
42,
|
|
);
|
|
expect(events(f.realtime)).toEqual(['accommodation:created', 'reservation:created']);
|
|
});
|
|
|
|
it('BOOK-RPC-014 deleting a block cascades to its reservation and budget item', async () => {
|
|
const f = build({ cascade: true });
|
|
await f.host().dispatch(req('accommodations.delete', { tripId: 1, accommodationId: 11 }), 42);
|
|
expect(events(f.realtime)).toEqual(['reservation:deleted', 'budget:deleted', 'accommodation:deleted']);
|
|
});
|
|
|
|
it('BOOK-RPC-015 a missing block is RESOURCE_FORBIDDEN, naming it', async () => {
|
|
const f = build();
|
|
expect(
|
|
(
|
|
(await f
|
|
.host()
|
|
.dispatch(req('accommodations.update', { tripId: 1, accommodationId: 404, input: {} }), 42)) as RpcError
|
|
).error.message,
|
|
).toBe('no accommodation 404 on trip 1');
|
|
expect(
|
|
((await f.host().dispatch(req('accommodations.delete', { tripId: 1, accommodationId: 404 }), 42)) as RpcError)
|
|
.error.message,
|
|
).toBe('no accommodation 404 on trip 1');
|
|
});
|
|
|
|
it('BOOK-RPC-015b optional block fields are passed through, absent ones as undefined', async () => {
|
|
const f = build();
|
|
await f.host().dispatch(
|
|
req('accommodations.create', {
|
|
tripId: 1,
|
|
input: { place_id: 7, start_day_id: 3, end_day_id: 4, check_in: '15:00', notes: 'late' },
|
|
}),
|
|
42,
|
|
);
|
|
expect(f.days.createAccommodation).toHaveBeenCalledWith(
|
|
1,
|
|
expect.objectContaining({ check_in: '15:00', notes: 'late', check_out: undefined }),
|
|
);
|
|
});
|
|
|
|
it('BOOK-RPC-015c an update with a bad ref is refused before the write', async () => {
|
|
const f = build();
|
|
const res = (await f
|
|
.host()
|
|
.dispatch(
|
|
req('accommodations.update', { tripId: 1, accommodationId: 11, input: { place_id: 404 } }),
|
|
42,
|
|
)) as RpcError;
|
|
expect(res.error.message).toBe('place 404 is not on this trip');
|
|
expect(f.days.updateAccommodation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('BOOK-RPC-017 the day stop a booking writes is announced through the plugin surface too', async () => {
|
|
const f = build();
|
|
await f
|
|
.host()
|
|
.dispatch(
|
|
req('accommodations.create', { tripId: 1, input: { place_id: 7, start_day_id: 3, end_day_id: 4 } }),
|
|
42,
|
|
);
|
|
// Same call the REST route makes, so a plugin booking a night cannot leave the
|
|
// other sessions without the stop that puts it on the route.
|
|
expect(f.days.announceMirror).toHaveBeenCalledWith(
|
|
1,
|
|
{ created: null, removed: [], stamped: null },
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
|
|
it('BOOK-RPC-017b editing a block writes it, announces it and answers with the block', async () => {
|
|
const f = build();
|
|
const res = await f
|
|
.host()
|
|
.dispatch(req('accommodations.update', { tripId: 1, accommodationId: 11, input: { check_in: '15:00' } }), 42);
|
|
expect(f.days.updateAccommodation).toHaveBeenCalledWith(
|
|
11,
|
|
{ id: 11 },
|
|
expect.objectContaining({ check_in: '15:00' }),
|
|
);
|
|
expect(events(f.realtime)).toEqual(['accommodation:updated']);
|
|
expect(f.days.announceMirror).toHaveBeenCalledWith(
|
|
1,
|
|
{ created: null, removed: [], stamped: null },
|
|
expect.any(Function),
|
|
);
|
|
// The block itself, not the { accommodation, mirror } pair the service returns:
|
|
// the mirror is a broadcast concern and plugins never asked for it.
|
|
expect(res).toMatchObject({ k: 'res', result: { id: 11 } });
|
|
});
|
|
|
|
it('BOOK-RPC-017c what announceMirror sends goes out over the trip socket, on all three writes', async () => {
|
|
// The sender each handler hands announceMirror is the plugin surface's only link
|
|
// between a stay write and the day plan every other session is looking at. What
|
|
// belongs in the mirror is the service's call; that it reaches a socket at all is
|
|
// this one's, which is why the fixture's mirror is the same on all three.
|
|
for (const request of [
|
|
req('accommodations.create', { tripId: 1, input: { place_id: 7, start_day_id: 3, end_day_id: 4 } }),
|
|
req('accommodations.update', { tripId: 1, accommodationId: 11, input: { start_day_id: 4 } }),
|
|
req('accommodations.delete', { tripId: 1, accommodationId: 11 }),
|
|
]) {
|
|
const f = build({ stop: true });
|
|
await f.host().dispatch(request, 42);
|
|
expect(f.realtime.broadcast).toHaveBeenCalledWith(1, 'assignment:deleted', { assignmentId: 77, dayId: 3 });
|
|
expect(f.realtime.broadcast).toHaveBeenCalledWith(1, 'assignment:created', { assignment: { id: 78, day_id: 4 } });
|
|
}
|
|
});
|
|
|
|
it('BOOK-RPC-015d deleting a block without a partner cascades only its own event', async () => {
|
|
const f = build();
|
|
await f.host().dispatch(req('accommodations.delete', { tripId: 1, accommodationId: 11 }), 42);
|
|
expect(events(f.realtime)).toEqual(['accommodation:deleted']);
|
|
});
|
|
|
|
it('BOOK-RPC-016 the class is listed in its module providers', () => {
|
|
expectRegisteredProvider(AccommodationsModule, AccommodationsRpc);
|
|
});
|
|
});
|