mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
test(realtime): broadcast parity for every MCP write tool
A suite under tests/integration/broadcast-parity boots the app once per group, runs a write tool through a real MCP session the way the assistant attaches the registry, runs its REST twin on a twin scene, and records every broadcast. Per case it checks the exact events in order and count, that no MCP broadcast skips a socket (the asking tab hears its own change), that nothing is sent inside a transaction, that every payload parses with its registered schema, that REST sends the same sequence to the same room and recipients, and that MCP's keys cover REST's. A registry test lists the write tools as an MCP client sees them and fails on a tool without a row, a stale row, or an exemption without a reason; exemptions run too and must send nothing, and the tools still waiting for a case are frozen in a list that may only shrink. The new events the next changes send are registered now (member:updated, todo:reordered, trips:changed, settings:updated, tags:changed, notification:read-all, journey:updated, journey:deleted, atlas:changed); the client keeps them apart as awaiting a sender, and the journey page already leaves a journey that journey:deleted says is gone.
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
"files.ts": 4,
|
||||
"help.ts": 3,
|
||||
"inspector.ts": 4,
|
||||
"journey.ts": 70,
|
||||
"journey.ts": 69,
|
||||
"login.ts": 1,
|
||||
"map.ts": 5,
|
||||
"memories.ts": 8,
|
||||
|
||||
@@ -72,6 +72,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'vacay:share-removed',
|
||||
'vacay:shared-update',
|
||||
// Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener)
|
||||
// reads the open journey again, and leaves it once journey:deleted says it is gone.
|
||||
'journey:updated',
|
||||
'journey:deleted',
|
||||
'journey:trip:synced',
|
||||
'journey:entry:created',
|
||||
'journey:entry:updated',
|
||||
@@ -142,3 +145,19 @@ export const IGNORED_WS_EVENTS = [
|
||||
'member:added',
|
||||
'member:removed',
|
||||
] as const satisfies readonly TrekWsEventName[]
|
||||
|
||||
/**
|
||||
* Events registered ahead of the write that sends them: nothing on the server
|
||||
* sends these yet, so there is nothing for the client to act on. They are not
|
||||
* a decision to ignore. The change that adds a sender moves its event to a
|
||||
* handler, and the list only shrinks.
|
||||
*/
|
||||
export const AWAITING_SENDER_WS_EVENTS = [
|
||||
'member:updated',
|
||||
'todo:reordered',
|
||||
'trips:changed',
|
||||
'settings:updated',
|
||||
'tags:changed',
|
||||
'notification:read-all',
|
||||
'atlas:changed',
|
||||
] as const satisfies readonly TrekWsEventName[]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-DETHOOK-001 to FE-JRN-DETHOOK-031
|
||||
// FE-JRN-DETHOOK-001 to FE-JRN-DETHOOK-032
|
||||
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
|
||||
import { useLocation } from 'react-router';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
@@ -243,6 +243,23 @@ describe('useJourneyDetail', () => {
|
||||
expect(latest.current?.title).toBe('Japan 2026');
|
||||
});
|
||||
|
||||
it('FE-JRN-DETHOOK-032: a journey deleted elsewhere returns to the journey list instead of reading it again', async () => {
|
||||
setup();
|
||||
await waitFor(() => expect(latest.current).not.toBeNull());
|
||||
const handler = vi.mocked(addListener).mock.calls[0][0] as (e: Record<string, unknown>) => void;
|
||||
const reads = vi.fn();
|
||||
server.use(http.get('/api/journeys/7', () => {
|
||||
reads();
|
||||
return HttpResponse.json({ error: 'gone' }, { status: 404 });
|
||||
}));
|
||||
|
||||
handler({ type: 'journey:deleted', journeyId: 7 });
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/journey');
|
||||
expect(addToast).toHaveBeenCalledWith('Journey deleted', 'info', undefined);
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
expect(reads).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-JRN-DETHOOK-010: the websocket listener is detached on unmount', async () => {
|
||||
const { unmount } = setup();
|
||||
await waitFor(() => expect(addListener).toHaveBeenCalled());
|
||||
|
||||
@@ -123,6 +123,15 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
|
||||
const type = event.type as string
|
||||
if (!type?.startsWith('journey:')) return
|
||||
if (event.journeyId !== journeyId) return
|
||||
// Its owner deleted it elsewhere: there is nothing left to read again, so
|
||||
// the page goes back to the list (a journal shown inside a trip stays put).
|
||||
if (type === 'journey:deleted') {
|
||||
if (!embedded) {
|
||||
toast.info(t('journey.deleted'))
|
||||
void navigate('/journey')
|
||||
}
|
||||
return
|
||||
}
|
||||
// reload journey data on any change from other contributors
|
||||
loadJourney(journeyId)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { TREK_WS_EVENT_NAMES, TREK_WS_TRIP_EVENT_NAMES } from '@trek/shared';
|
||||
import { DEXIE_WRITERS, STATE_APPLIERS } from '../../../src/store/slices/remoteEventHandler';
|
||||
import { HANDLED_OUTSIDE_TRIP_STORE, IGNORED_WS_EVENTS } from '../../../src/api/wsEventPolicy';
|
||||
import { AWAITING_SENDER_WS_EVENTS, HANDLED_OUTSIDE_TRIP_STORE, IGNORED_WS_EVENTS } from '../../../src/api/wsEventPolicy';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildPackingItem, buildPlace } from '../../helpers/factories';
|
||||
@@ -9,8 +9,8 @@ import { buildPackingItem, buildPlace } from '../../helpers/factories';
|
||||
/**
|
||||
* The exhaustiveness ratchet (roadmap: WS event contract registry). Every
|
||||
* event in the shared registry must be classified on the client — applied by
|
||||
* the tripStore reducer, consumed by a dedicated listener, or explicitly
|
||||
* ignored. A server-side event added without a client decision turns this
|
||||
* the tripStore reducer, consumed by a dedicated listener, explicitly
|
||||
* ignored, or registered ahead of the write that will send it. A server-side event added without a client decision turns this
|
||||
* test red instead of vanishing into the old silent `default:` branches.
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,7 @@ const handledInStore = new Set<string>([
|
||||
]);
|
||||
const handledElsewhere = new Set<string>(HANDLED_OUTSIDE_TRIP_STORE);
|
||||
const ignored = new Set<string>(IGNORED_WS_EVENTS);
|
||||
const awaitingSender = new Set<string>(AWAITING_SENDER_WS_EVENTS);
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
@@ -28,7 +29,7 @@ beforeEach(() => {
|
||||
describe('remoteEventHandler > registry parity', () => {
|
||||
it('FE-WSEVT-REGISTRY-001: every registry event is handled or explicitly ignored', () => {
|
||||
const unclassified = TREK_WS_EVENT_NAMES.filter(
|
||||
name => !handledInStore.has(name) && !handledElsewhere.has(name) && !ignored.has(name),
|
||||
name => !handledInStore.has(name) && !handledElsewhere.has(name) && !ignored.has(name) && !awaitingSender.has(name),
|
||||
);
|
||||
expect(
|
||||
unclassified,
|
||||
@@ -38,10 +39,13 @@ describe('remoteEventHandler > registry parity', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-REGISTRY-002: the three buckets are disjoint (exactly one decision per event)', () => {
|
||||
it('FE-WSEVT-REGISTRY-002: the four buckets are disjoint (exactly one decision per event)', () => {
|
||||
const doubleClassified = TREK_WS_EVENT_NAMES.filter(name => {
|
||||
const buckets =
|
||||
Number(handledInStore.has(name)) + Number(handledElsewhere.has(name)) + Number(ignored.has(name));
|
||||
Number(handledInStore.has(name)) +
|
||||
Number(handledElsewhere.has(name)) +
|
||||
Number(ignored.has(name)) +
|
||||
Number(awaitingSender.has(name));
|
||||
return buckets > 1;
|
||||
});
|
||||
expect(
|
||||
@@ -52,7 +56,7 @@ describe('remoteEventHandler > registry parity', () => {
|
||||
|
||||
it('FE-WSEVT-REGISTRY-003: no stale names — every classified event still exists in the registry', () => {
|
||||
const registry = new Set<string>(TREK_WS_EVENT_NAMES);
|
||||
const stale = [...handledInStore, ...handledElsewhere, ...ignored].filter(
|
||||
const stale = [...handledInStore, ...handledElsewhere, ...ignored, ...awaitingSender].filter(
|
||||
name => !registry.has(name),
|
||||
);
|
||||
expect(stale, `Name(s) not in the shared registry (renamed or removed?): ${stale.join(', ')}`).toEqual([]);
|
||||
@@ -89,4 +93,19 @@ describe('remoteEventHandler > registry parity', () => {
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'not:a:real:event', payload: 1 });
|
||||
expect(useTripStore.getState().places.map(p => p.id)).toEqual([5]);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-REGISTRY-008: the events awaiting a sender only shrink', () => {
|
||||
// Registered by the broadcast parity work ahead of their writes. A new event
|
||||
// gets a handler or a deliberate IGNORED_WS_EVENTS slot, never a place here.
|
||||
const registeredAhead = [
|
||||
'member:updated',
|
||||
'todo:reordered',
|
||||
'trips:changed',
|
||||
'settings:updated',
|
||||
'tags:changed',
|
||||
'notification:read-all',
|
||||
'atlas:changed',
|
||||
];
|
||||
expect(AWAITING_SENDER_WS_EVENTS.filter(name => !registeredAhead.includes(name))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CaseGroup } from './types';
|
||||
|
||||
/** Bookings: reservations, transports, transit, stays and the AirTrail import. */
|
||||
export const bookings: CaseGroup = {
|
||||
name: 'bookings',
|
||||
prefix: 'BCP-BOOK',
|
||||
cases: [
|
||||
{
|
||||
tool: 'create_accommodation',
|
||||
needs: ['place'],
|
||||
args: (s) => ({
|
||||
tripId: s.trip.id,
|
||||
place_id: s.place.id,
|
||||
start_day_id: s.days[0].id,
|
||||
end_day_id: s.days[1].id,
|
||||
}),
|
||||
rest: {
|
||||
method: 'post',
|
||||
path: (s) => `/api/trips/${s.trip.id}/accommodations`,
|
||||
body: (s) => ({ place_id: s.place.id, start_day_id: s.days[0].id, end_day_id: s.days[1].id }),
|
||||
},
|
||||
// The stay, the ping for its partner hotel booking, then the check-in stop
|
||||
// the day plan gains (with the stop's time stamped on the hotel place),
|
||||
// which the booking tab has not drawn yet either.
|
||||
expect: [
|
||||
'accommodation:created',
|
||||
'reservation:created',
|
||||
{ event: 'assignment:created', all: true },
|
||||
{ event: 'place:updated', all: true },
|
||||
{ event: 'assignment:reordered', all: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
exempt: [],
|
||||
pending: [
|
||||
'update_accommodation',
|
||||
'delete_accommodation',
|
||||
'import_airtrail_flights',
|
||||
'create_reservation',
|
||||
'update_reservation',
|
||||
'delete_reservation',
|
||||
'set_reservation_travelers',
|
||||
'reorder_reservations',
|
||||
'link_hotel_accommodation',
|
||||
'create_transport',
|
||||
'update_transport',
|
||||
'delete_transport',
|
||||
'create_transit_journey',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { bookings } from './bookings.cases';
|
||||
import { runGroup } from './run-group';
|
||||
|
||||
runGroup(bookings);
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { CaseGroup, Scene } from './types';
|
||||
|
||||
/**
|
||||
* Working together on a trip: collab, the trip itself and its members, files,
|
||||
* and the account-wide settings, notifications and tags the planner reads.
|
||||
*/
|
||||
const TOKEN_ONLY = 'manages a link or feed token: nothing on any screen follows it live, so there is nothing to send';
|
||||
const onTrip = (s: Scene) => ({ tripId: s.trip.id });
|
||||
const noArgs = () => ({});
|
||||
|
||||
export const collabTrip: CaseGroup = {
|
||||
name: 'collab-trip',
|
||||
prefix: 'BCP-COLLAB',
|
||||
cases: [],
|
||||
exempt: [
|
||||
{ tool: 'create_share_link', reason: TOKEN_ONLY, args: onTrip },
|
||||
{ tool: 'delete_share_link', reason: TOKEN_ONLY, args: onTrip, first: { tool: 'create_share_link', args: onTrip } },
|
||||
{ tool: 'create_trip_invite_link', reason: TOKEN_ONLY, args: onTrip },
|
||||
{
|
||||
tool: 'delete_trip_invite_link',
|
||||
reason: TOKEN_ONLY,
|
||||
args: onTrip,
|
||||
first: { tool: 'create_trip_invite_link', args: onTrip },
|
||||
},
|
||||
{ tool: 'enable_trip_calendar_feed', reason: TOKEN_ONLY, args: onTrip },
|
||||
{
|
||||
tool: 'rotate_trip_calendar_feed',
|
||||
reason: TOKEN_ONLY,
|
||||
args: onTrip,
|
||||
first: { tool: 'enable_trip_calendar_feed', args: onTrip },
|
||||
},
|
||||
{
|
||||
tool: 'disable_trip_calendar_feed',
|
||||
reason: TOKEN_ONLY,
|
||||
args: onTrip,
|
||||
first: { tool: 'enable_trip_calendar_feed', args: onTrip },
|
||||
},
|
||||
{ tool: 'enable_all_trips_calendar_feed', reason: TOKEN_ONLY, args: noArgs },
|
||||
{
|
||||
tool: 'rotate_all_trips_calendar_feed',
|
||||
reason: TOKEN_ONLY,
|
||||
args: noArgs,
|
||||
first: { tool: 'enable_all_trips_calendar_feed', args: noArgs },
|
||||
},
|
||||
{
|
||||
tool: 'disable_all_trips_calendar_feed',
|
||||
reason: TOKEN_ONLY,
|
||||
args: noArgs,
|
||||
first: { tool: 'enable_all_trips_calendar_feed', args: noArgs },
|
||||
},
|
||||
],
|
||||
pending: [
|
||||
'create_collab_note',
|
||||
'update_collab_note',
|
||||
'delete_collab_note',
|
||||
'create_collab_poll',
|
||||
'vote_collab_poll',
|
||||
'close_collab_poll',
|
||||
'delete_collab_poll',
|
||||
'send_collab_message',
|
||||
'delete_collab_message',
|
||||
'react_collab_message',
|
||||
'sync_trip_documents',
|
||||
'upload_trip_file',
|
||||
'update_trip_file',
|
||||
'link_trip_file',
|
||||
'unlink_trip_file',
|
||||
'mark_notification_read',
|
||||
'mark_notification_unread',
|
||||
'mark_all_notifications_read',
|
||||
'update_display_settings',
|
||||
'create_tag',
|
||||
'update_tag',
|
||||
'delete_tag',
|
||||
'create_trip',
|
||||
'update_trip',
|
||||
'delete_trip',
|
||||
'add_trip_member',
|
||||
'remove_trip_member',
|
||||
'leave_trip',
|
||||
'create_trip_guest',
|
||||
'rename_trip_guest',
|
||||
'delete_trip_guest',
|
||||
'copy_trip',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { collabTrip } from './collab-trip.cases';
|
||||
import { runGroup } from './run-group';
|
||||
|
||||
runGroup(collabTrip);
|
||||
@@ -0,0 +1,570 @@
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
import { buildApp } from '../../../src/bootstrap';
|
||||
import { BudgetSettlements } from '../../../src/db/entities/BudgetSettlements.entity';
|
||||
import { CollabPolls } from '../../../src/db/entities/CollabPolls.entity';
|
||||
import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity';
|
||||
import { PackingTemplateCategories } from '../../../src/db/entities/PackingTemplateCategories.entity';
|
||||
import { PackingTemplateItems } from '../../../src/db/entities/PackingTemplateItems.entity';
|
||||
import { PackingTemplates } from '../../../src/db/entities/PackingTemplates.entity';
|
||||
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
|
||||
import { Reservations } from '../../../src/db/entities/Reservations.entity';
|
||||
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
|
||||
import { createLocalMcpServer, McpRegistryService } from '../../../src/nest-mcp';
|
||||
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
|
||||
import { withRequestContext } from '../../../src/nest/database/request-context';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { SchoolHolidaysService } from '../../../src/nest/school-holidays/school-holidays.service';
|
||||
import { authCookie } from '../../helpers/auth';
|
||||
import { makeBucketListItem } from '../../helpers/factories/atlas';
|
||||
import { addBudgetItemMember, makeBudgetItem } from '../../helpers/factories/budget';
|
||||
import { makeCollabMessage, makeCollabNote } from '../../helpers/factories/collab';
|
||||
import { makeCollection, makeCollectionPlace } from '../../helpers/factories/collections';
|
||||
import type { FactoryOrm } from '../../helpers/factories/context';
|
||||
import { makeTripFile } from '../../helpers/factories/files';
|
||||
import { makeDayAssignment, makeDayNote } from '../../helpers/factories/itinerary';
|
||||
import { makeJourney, makeJourneyEntry } from '../../helpers/factories/journeys';
|
||||
import { makeNotification } from '../../helpers/factories/notifications';
|
||||
import { makePackingBag, makePackingItem } from '../../helpers/factories/packing';
|
||||
import { makeCategory, makePlace, makeTag } from '../../helpers/factories/places';
|
||||
import { makeReservation } from '../../helpers/factories/reservations';
|
||||
import { createRow, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { setAddonEnabled, setCollabFeature } from '../../helpers/factories/settings';
|
||||
import { makeTodoItem } from '../../helpers/factories/todos';
|
||||
import { makeTour } from '../../helpers/factories/tours';
|
||||
import { addTripMember, makeTrip, readTripDays } from '../../helpers/factories/trips';
|
||||
import { makeAdmin, makeUser, type UserRow } from '../../helpers/factories/users';
|
||||
import { makeVacayPlan } from '../../helpers/factories/vacay';
|
||||
import { oneByOne } from '../../helpers/one-by-one';
|
||||
import type { Need, RestCall, Scene } from './types';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
import request from 'supertest';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* The broadcast parity harness: the whole app booted once per test file,
|
||||
* every write tool called through a real MCP session, its REST twin through
|
||||
* HTTP, and every trip broadcast and user message in between written down.
|
||||
*
|
||||
* The app is `buildApp()` as production builds it, with no module mocked:
|
||||
* under NODE_ENV=test each worker opens its own `:memory:` copy of the
|
||||
* migrated snapshot. The two RealtimeService methods are spied on in the
|
||||
* container, so what is recorded is exactly what the services hand the
|
||||
* transport, in the order they hand it over.
|
||||
*/
|
||||
|
||||
/** The X-Socket-Id every REST call carries: an event REST sends with `emit` leaves it out. */
|
||||
export const REST_SOCKET = 'sock-rest';
|
||||
|
||||
/** One send as the checks read it. Consecutive sends of the same payload to more users are one entry. */
|
||||
export interface Recorded {
|
||||
channel: 'trip' | 'user';
|
||||
event: string;
|
||||
/** The trip of a room broadcast, the receivers of a user message. */
|
||||
to: (number | string)[];
|
||||
/** The payload's keys, sorted, without the MCP marker (and without `type` on a user message). */
|
||||
keys: string[];
|
||||
excludeSid: number | string | undefined;
|
||||
/** Set when a room broadcast went only to these users' sockets. */
|
||||
onlyUserIds: number[] | undefined;
|
||||
/** A transaction was open when it was sent: it would reach the clients before the commit, or after a rollback. */
|
||||
inTx: boolean;
|
||||
/** The payload as sent, minus `type` on a user message. */
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Writes down every send, folding a fan-out (one payload, one user after the other) into one entry. */
|
||||
export class BroadcastRecorder {
|
||||
private sent: Recorded[] = [];
|
||||
private bodies: string[] = [];
|
||||
|
||||
trip(
|
||||
tripId: number | string,
|
||||
event: string,
|
||||
payload: Record<string, unknown>,
|
||||
excludeSid: number | string | undefined,
|
||||
onlyUserId: number | undefined,
|
||||
inTx: boolean,
|
||||
): void {
|
||||
const body = JSON.stringify(payload);
|
||||
const last = this.sent.at(-1);
|
||||
if (
|
||||
onlyUserId !== undefined &&
|
||||
last?.channel === 'trip' &&
|
||||
last.onlyUserIds !== undefined &&
|
||||
!last.onlyUserIds.includes(onlyUserId) &&
|
||||
this.sameAs(last, event, body, excludeSid, inTx) &&
|
||||
last.to[0] === tripId
|
||||
) {
|
||||
last.onlyUserIds.push(onlyUserId);
|
||||
return;
|
||||
}
|
||||
this.push(
|
||||
{
|
||||
channel: 'trip',
|
||||
event,
|
||||
to: [tripId],
|
||||
keys: keysOf(payload),
|
||||
excludeSid,
|
||||
onlyUserIds: onlyUserId === undefined ? undefined : [onlyUserId],
|
||||
inTx,
|
||||
payload,
|
||||
},
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
user(userId: number, message: Record<string, unknown>, excludeSid: number | string | undefined, inTx: boolean): void {
|
||||
const { type, ...payload } = message;
|
||||
const event = String(type);
|
||||
const body = JSON.stringify(payload);
|
||||
const last = this.sent.at(-1);
|
||||
if (last?.channel === 'user' && !last.to.includes(userId) && this.sameAs(last, event, body, excludeSid, inTx)) {
|
||||
last.to.push(userId);
|
||||
return;
|
||||
}
|
||||
this.push(
|
||||
{
|
||||
channel: 'user',
|
||||
event,
|
||||
to: [userId],
|
||||
keys: keysOf(payload),
|
||||
excludeSid,
|
||||
onlyUserIds: undefined,
|
||||
inTx,
|
||||
payload,
|
||||
},
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything sent since the last take, and forgets it. */
|
||||
take(): Recorded[] {
|
||||
const out = this.sent;
|
||||
this.sent = [];
|
||||
this.bodies = [];
|
||||
return out;
|
||||
}
|
||||
|
||||
private push(entry: Recorded, body: string): void {
|
||||
this.sent.push(entry);
|
||||
this.bodies.push(body);
|
||||
}
|
||||
|
||||
private sameAs(last: Recorded, event: string, body: string, excludeSid: number | string | undefined, inTx: boolean) {
|
||||
return last.event === event && last.excludeSid === excludeSid && last.inTx === inTx && this.bodies.at(-1) === body;
|
||||
}
|
||||
}
|
||||
|
||||
function keysOf(payload: Record<string, unknown>): string[] {
|
||||
return Object.keys(payload)
|
||||
.filter((key) => key !== '_source')
|
||||
.sort();
|
||||
}
|
||||
|
||||
type TripSend = [
|
||||
tripId: number | string,
|
||||
event: string,
|
||||
payload: Record<string, unknown>,
|
||||
excludeSid?: number | string,
|
||||
onlyUserId?: number,
|
||||
];
|
||||
type UserSend = [userId: number, message: Record<string, unknown>, excludeSid?: number | string];
|
||||
|
||||
/** What a tool call answered, as far as the checks read it. */
|
||||
export interface ToolOutcome {
|
||||
isError: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
type ToolCallResult = Awaited<ReturnType<Client['callTool']>>;
|
||||
|
||||
function outcomeOf(result: ToolCallResult): ToolOutcome {
|
||||
const content: unknown[] = 'content' in result && Array.isArray(result.content) ? result.content : [];
|
||||
const text = content
|
||||
.map((part) => (part && typeof part === 'object' && 'text' in part ? String(part.text) : ''))
|
||||
.join('\n');
|
||||
return { isError: result.isError === true, text };
|
||||
}
|
||||
|
||||
/** A tool as `tools/list` shows it to a client. */
|
||||
export interface ListedTool {
|
||||
name: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export interface ParityHarness {
|
||||
app: INestApplication;
|
||||
orm: FactoryOrm;
|
||||
recorder: BroadcastRecorder;
|
||||
/** A fresh scene with the rows the case names, and what they need in turn. */
|
||||
scene(needs?: readonly Need[]): Promise<Scene>;
|
||||
/** Calls one tool as `userId`, over a session holding only that tool. */
|
||||
callTool(userId: number, tool: string, args: Record<string, unknown>): Promise<ToolOutcome>;
|
||||
/** Every tool a full session of `userId` lists, with its read-only hint. */
|
||||
listTools(userId: number): Promise<ListedTool[]>;
|
||||
/** The REST twin, as `userId`, with the session cookie and X-Socket-Id 'sock-rest'. */
|
||||
rest(userId: number, call: RestCall, s: Scene): Promise<{ status: number; text: string }>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the app, switches every addon (and its sub-features) on so no gate
|
||||
* hides a tool, and starts recording. One per test file.
|
||||
*/
|
||||
export async function openHarness(): Promise<ParityHarness> {
|
||||
const app = await buildApp();
|
||||
await app.init();
|
||||
const orm = app.get(MikroORM);
|
||||
await oneByOne(Object.values(ADDON_IDS), (addon) => setAddonEnabled(orm, addon, true));
|
||||
await oneByOne(['chat', 'notes', 'polls', 'whatsnext'] as const, (feature) => setCollabFeature(orm, feature, true));
|
||||
// The photo search tools show only while a provider is on.
|
||||
await updateRows(orm, PhotoProviders, { id: { $in: ['immich', 'synologyphotos'] } }, { enabled: 1 });
|
||||
|
||||
const recorder = new BroadcastRecorder();
|
||||
const realtime = app.get(RealtimeService);
|
||||
// Read at the moment of the send: the transaction context is the caller's async context.
|
||||
const inTx = () => orm.em.isInTransaction();
|
||||
vi.spyOn(realtime, 'broadcast').mockImplementation((...args: unknown[]) => {
|
||||
const [tripId, event, payload, excludeSid, onlyUserId] = args as TripSend;
|
||||
recorder.trip(tripId, event, payload, excludeSid, onlyUserId, inTx());
|
||||
});
|
||||
vi.spyOn(realtime, 'broadcastToUser').mockImplementation((...args: unknown[]) => {
|
||||
const [userId, message, excludeSid] = args as UserSend;
|
||||
recorder.user(userId, message, excludeSid, inTx());
|
||||
});
|
||||
|
||||
const registry = app.get(McpRegistryService);
|
||||
// As AssistantToolsService.open attaches the registry: every call in a request context of its own.
|
||||
const around = (_info: { kind: string; name: string }, call: () => unknown): unknown => withRequestContext(orm, call);
|
||||
|
||||
async function session<T>(userId: number, only: string | null, use: (client: Client) => Promise<T>): Promise<T> {
|
||||
const server = createLocalMcpServer('TREK broadcast parity');
|
||||
await withRequestContext(orm, () =>
|
||||
registry.attach(
|
||||
server,
|
||||
{ userId, scopes: null, isStaticToken: false },
|
||||
{ include: (kind, name) => kind === 'tool' && (only === null || name === only), around },
|
||||
),
|
||||
);
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: 'broadcast-parity', version: '1.0.0' });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
return await use(client);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
app,
|
||||
orm,
|
||||
recorder,
|
||||
scene: (needs = []) => buildScene(app, orm, needs),
|
||||
callTool: (userId, tool, args) =>
|
||||
session(userId, tool, async (client) => outcomeOf(await client.callTool({ name: tool, arguments: args }))),
|
||||
listTools: (userId) =>
|
||||
session(userId, null, async (client) =>
|
||||
(await client.listTools()).tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
readOnly: tool.annotations?.readOnlyHint === true,
|
||||
})),
|
||||
),
|
||||
async rest(userId, call, s) {
|
||||
const agent = request(app.getHttpServer());
|
||||
const path = call.path(s);
|
||||
const req =
|
||||
call.method === 'post'
|
||||
? agent.post(path)
|
||||
: call.method === 'put'
|
||||
? agent.put(path)
|
||||
: call.method === 'patch'
|
||||
? agent.patch(path)
|
||||
: agent.delete(path);
|
||||
req.set('Cookie', authCookie(userId)).set('X-Socket-Id', REST_SOCKET);
|
||||
const res = call.body ? await req.send(call.body(s)) : await req;
|
||||
return { status: res.status, text: res.text };
|
||||
},
|
||||
close: async () => {
|
||||
vi.restoreAllMocks();
|
||||
await app.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The scene keys each need fills; reading one the case did not name throws. */
|
||||
export const NEED_KEYS: Record<Need, readonly (keyof Scene)[]> = {
|
||||
member: ['member'],
|
||||
guest: ['guest'],
|
||||
place: ['place'],
|
||||
place2: ['place2'],
|
||||
assignment: ['assignment'],
|
||||
dayNote: ['dayNote'],
|
||||
stay: ['stay'],
|
||||
transport: ['transport'],
|
||||
budgetItem: ['budgetItem'],
|
||||
settlement: ['settlement'],
|
||||
packingItem: ['packingItem'],
|
||||
bag: ['bag'],
|
||||
template: ['template'],
|
||||
todo: ['todo'],
|
||||
collabNote: ['collabNote'],
|
||||
collabMessage: ['collabMessage'],
|
||||
poll: ['poll'],
|
||||
file: ['file'],
|
||||
collection: ['collection', 'collectionPlace'],
|
||||
journey: ['journey', 'journeyEntry'],
|
||||
vacay: ['vacay'],
|
||||
bucket: ['bucket'],
|
||||
tag: ['tag'],
|
||||
notification: ['notification'],
|
||||
via: ['via'],
|
||||
tour: ['tour', 'tourPlace'],
|
||||
category: ['category'],
|
||||
holidayCountry: ['holidayCountry'],
|
||||
holidayRegion: ['holidayRegion'],
|
||||
};
|
||||
|
||||
/** Every need, in the order they are built (a need comes after the ones it reads). */
|
||||
export const ALL_NEEDS = Object.keys(NEED_KEYS) as Need[];
|
||||
|
||||
/** Needs that read another need's rows. */
|
||||
const NEED_DEPENDS: Partial<Record<Need, Need[]>> = {
|
||||
assignment: ['place'],
|
||||
budgetItem: ['member'],
|
||||
settlement: ['member'],
|
||||
};
|
||||
|
||||
type Building = Partial<Scene> & Pick<Scene, 'n' | 'owner' | 'admin' | 'trip' | 'days'>;
|
||||
|
||||
/** A row an earlier need put on the scene. */
|
||||
function built<K extends keyof Scene>(s: Building, key: K): Scene[K] {
|
||||
const value = s[key];
|
||||
if (value === undefined) throw new Error(`scene.${key} is read before it was built`);
|
||||
return value as Scene[K];
|
||||
}
|
||||
|
||||
type Builder = (s: Building, app: INestApplication, orm: FactoryOrm) => Promise<void>;
|
||||
|
||||
const BUILDERS: Record<Need, Builder> = {
|
||||
async member(s, _app, orm) {
|
||||
s.member = (await makeUser(orm, { username: `Member ${s.n}` })).user;
|
||||
await addTripMember(orm, s.trip.id, s.member.id, s.owner.id);
|
||||
},
|
||||
async guest(s, _app, orm) {
|
||||
s.guest = (await makeUser(orm, { username: `Guest ${s.n}`, is_guest: 1 })).user;
|
||||
await addTripMember(orm, s.trip.id, s.guest.id, s.owner.id);
|
||||
},
|
||||
async place(s, _app, orm) {
|
||||
s.place = await makePlace(orm, s.trip.id, { name: `Louvre ${s.n}` });
|
||||
},
|
||||
async place2(s, _app, orm) {
|
||||
s.place2 = await makePlace(orm, s.trip.id, { name: `Orsay ${s.n}`, lat: 48.86, lng: 2.3266 });
|
||||
},
|
||||
async assignment(s, _app, orm) {
|
||||
s.assignment = await makeDayAssignment(orm, s.days[0].id, built(s, 'place').id);
|
||||
},
|
||||
async dayNote(s, _app, orm) {
|
||||
s.dayNote = await makeDayNote(orm, s.days[0].id, s.trip.id);
|
||||
},
|
||||
async stay(s, app, orm) {
|
||||
// Through the service's own building block: it writes the night, the partner
|
||||
// hotel booking and the check-in stop the way every surface does, and sends nothing.
|
||||
const hotel = await makePlace(orm, s.trip.id, { name: `Hotel ${s.n}`, lat: 48.87, lng: 2.33 });
|
||||
const [checkIn, checkOut] = [s.days[0].id, s.days[1].id];
|
||||
await withRequestContext(orm, () =>
|
||||
app
|
||||
.get(AccommodationsService)
|
||||
.createAccommodation(s.trip.id, { place_id: hotel.id, start_day_id: checkIn, end_day_id: checkOut }),
|
||||
);
|
||||
const stay = await findRow(orm, DayAccommodations, { trip: s.trip.id, place: hotel.id });
|
||||
const booking = await findRow(orm, Reservations, { trip: s.trip.id, type: 'hotel' });
|
||||
if (!stay || !booking) throw new Error('the scene stay has no row or no partner booking');
|
||||
s.stay = { id: stay.id, placeId: hotel.id, startDayId: checkIn, endDayId: checkOut, reservationId: booking.id };
|
||||
},
|
||||
async transport(s, _app, orm) {
|
||||
s.transport = await makeReservation(orm, s.trip.id, {
|
||||
type: 'train',
|
||||
title: `Train ${s.n}`,
|
||||
day: s.days[0].id,
|
||||
});
|
||||
},
|
||||
async budgetItem(s, _app, orm) {
|
||||
s.budgetItem = await makeBudgetItem(orm, s.trip.id, { name: `Dinner ${s.n}`, total_price: 60 });
|
||||
await addBudgetItemMember(orm, s.budgetItem.id, s.owner.id);
|
||||
await addBudgetItemMember(orm, s.budgetItem.id, built(s, 'member').id);
|
||||
},
|
||||
async settlement(s, _app, orm) {
|
||||
const row = await createRow(orm, BudgetSettlements, {
|
||||
trip: s.trip.id,
|
||||
fromUser: built(s, 'member').id,
|
||||
toUser: s.owner.id,
|
||||
amount: 30,
|
||||
currency: 'EUR',
|
||||
createdByUser: s.owner.id,
|
||||
});
|
||||
s.settlement = { id: row.id };
|
||||
},
|
||||
async packingItem(s, _app, orm) {
|
||||
s.packingItem = await makePackingItem(orm, s.trip.id, { name: `Socks ${s.n}` });
|
||||
},
|
||||
async bag(s, _app, orm) {
|
||||
s.bag = await makePackingBag(orm, s.trip.id);
|
||||
},
|
||||
async template(s, _app, orm) {
|
||||
const id = await insertRow(orm, PackingTemplates, { name: `Camping ${s.n}`, createdByRef: s.admin.id });
|
||||
const category = await insertRow(orm, PackingTemplateCategories, { template: id, name: 'Gear', sort_order: 0 });
|
||||
await insertRow(orm, PackingTemplateItems, { category, name: 'Tent', sort_order: 0 });
|
||||
s.template = { id };
|
||||
},
|
||||
async todo(s, _app, orm) {
|
||||
s.todo = { id: (await makeTodoItem(orm, s.trip.id, { name: `Book museum ${s.n}` })).id };
|
||||
},
|
||||
async collabNote(s, _app, orm) {
|
||||
s.collabNote = await makeCollabNote(orm, s.trip.id, s.owner.id);
|
||||
},
|
||||
async collabMessage(s, _app, orm) {
|
||||
s.collabMessage = await makeCollabMessage(orm, s.trip.id, s.owner.id);
|
||||
},
|
||||
async poll(s, _app, orm) {
|
||||
const row = await createRow(orm, CollabPolls, {
|
||||
trip: s.trip.id,
|
||||
user: s.owner.id,
|
||||
question: `Dinner on day ${s.n}?`,
|
||||
options: JSON.stringify(['Pasta', 'Sushi']),
|
||||
});
|
||||
s.poll = { id: row.id };
|
||||
},
|
||||
async file(s, _app, orm) {
|
||||
s.file = await makeTripFile(orm, s.trip.id);
|
||||
},
|
||||
async collection(s, _app, orm) {
|
||||
s.collection = await makeCollection(orm, s.owner.id);
|
||||
s.collectionPlace = await makeCollectionPlace(orm, s.collection.id, s.owner.id);
|
||||
},
|
||||
async journey(s, _app, orm) {
|
||||
s.journey = await makeJourney(orm, s.owner.id);
|
||||
s.journeyEntry = await makeJourneyEntry(orm, s.journey.id, s.owner.id);
|
||||
},
|
||||
async vacay(s, _app, orm) {
|
||||
s.vacay = await makeVacayPlan(orm, s.owner.id);
|
||||
},
|
||||
async bucket(s, _app, orm) {
|
||||
s.bucket = { id: (await makeBucketListItem(orm, s.owner.id, { name: `Kyoto ${s.n}` })).id };
|
||||
},
|
||||
async tag(s, _app, orm) {
|
||||
s.tag = await makeTag(orm, s.owner.id);
|
||||
},
|
||||
async notification(s, _app, orm) {
|
||||
s.notification = await makeNotification(orm, s.owner.id);
|
||||
},
|
||||
async via(s, _app, orm) {
|
||||
const row = await createRow(orm, RoadtripVias, {
|
||||
day: s.days[0].id,
|
||||
after_order_index: 0,
|
||||
lat: 48.85,
|
||||
lng: 2.34,
|
||||
});
|
||||
s.via = { id: row.id };
|
||||
},
|
||||
async tour(s, _app, orm) {
|
||||
s.tourPlace = await makePlace(orm, s.trip.id, { name: `Hike ${s.n}`, lat: 47.42, lng: 10.98 });
|
||||
s.tour = await makeTour(orm, s.tourPlace.id);
|
||||
},
|
||||
async category(s, _app, orm) {
|
||||
s.category = await makeCategory(orm, { name: `Street food ${s.n}` });
|
||||
},
|
||||
// The catalog through its service, as the admin's form writes it; nothing is sent.
|
||||
async holidayCountry(s, app, orm) {
|
||||
const country = { code: holidayCode(s.n, 0), name: `Holiday land ${s.n}` };
|
||||
await withRequestContext(orm, () => app.get(SchoolHolidaysService).createCountry(country));
|
||||
s.holidayCountry = country;
|
||||
},
|
||||
async holidayRegion(s, app, orm) {
|
||||
const code = holidayCode(s.n, 1);
|
||||
const holidays = app.get(SchoolHolidaysService);
|
||||
const region = await withRequestContext(orm, async () => {
|
||||
await holidays.createCountry({ code, name: `Region land ${s.n}` });
|
||||
return holidays.createRegion(code, {
|
||||
name: `District ${s.n}`,
|
||||
revision: 0,
|
||||
holidays: [{ name: 'Summer', startDate: '2027-07-01', endDate: '2027-08-15' }],
|
||||
});
|
||||
});
|
||||
s.holidayRegion = { id: Number(region.id), revision: region.revision };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A two-letter country code of the manual school holiday catalog that no
|
||||
* other scene uses: two per scene, the first for its empty country (or for a
|
||||
* case creating one), the second for the country of its region.
|
||||
*/
|
||||
export function holidayCode(n: number, which: 0 | 1): string {
|
||||
const index = 2 * n + which;
|
||||
return String.fromCharCode(65 + (Math.floor(index / 26) % 26), 65 + (index % 26));
|
||||
}
|
||||
|
||||
/**
|
||||
* The users a scene built, by id, under the names a case reads them by, so
|
||||
* the receivers of a send compare across the two scenes of a case. A user the
|
||||
* case did not ask for is left out without reading it.
|
||||
*/
|
||||
export function sceneUsers(s: Scene): Map<number, string> {
|
||||
const users = new Map<number, string>();
|
||||
for (const key of ['owner', 'admin', 'member', 'guest'] as const) {
|
||||
const built = Object.getOwnPropertyDescriptor(s, key);
|
||||
if (built && 'value' in built) users.set((built.value as UserRow).id, key);
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
/** The needs together with every need they read, in build order. */
|
||||
function withDependencies(needs: readonly Need[]): Set<Need> {
|
||||
const out = new Set<Need>();
|
||||
const add = (need: Need) => {
|
||||
for (const dependency of NEED_DEPENDS[need] ?? []) add(dependency);
|
||||
out.add(need);
|
||||
};
|
||||
for (const need of needs) add(need);
|
||||
return out;
|
||||
}
|
||||
|
||||
let scenes = 0;
|
||||
|
||||
async function buildScene(app: INestApplication, orm: FactoryOrm, needs: readonly Need[]): Promise<Scene> {
|
||||
const n = ++scenes;
|
||||
const owner = (await makeUser(orm, { username: `Owner ${n}` })).user;
|
||||
const admin = (await makeAdmin(orm, { username: `Admin ${n}` })).user;
|
||||
const trip = await makeTrip(orm, owner.id, {
|
||||
title: `Parity trip ${n}`,
|
||||
start_date: '2027-05-03',
|
||||
end_date: '2027-05-05',
|
||||
});
|
||||
const [day1, day2, day3] = await readTripDays(orm, trip.id);
|
||||
if (!day1 || !day2 || !day3) throw new Error('the scene trip did not get its three days');
|
||||
const s: Building = { n, owner, admin, trip, days: [day1, day2, day3] };
|
||||
const wanted = withDependencies(needs);
|
||||
// One after the other, in ALL_NEEDS order: a need reads the rows of the ones before it.
|
||||
await oneByOne(
|
||||
ALL_NEEDS.filter((need) => wanted.has(need)),
|
||||
(need) => BUILDERS[need](s, app, orm),
|
||||
);
|
||||
for (const need of ALL_NEEDS) {
|
||||
if (wanted.has(need)) continue;
|
||||
for (const key of NEED_KEYS[need]) {
|
||||
Object.defineProperty(s, key, {
|
||||
get() {
|
||||
throw new Error(`scene.${key} is not built: add '${need}' to the case's needs`);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return s as Scene;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { bookings } from './bookings.cases';
|
||||
import { collabTrip } from './collab-trip.cases';
|
||||
import { money } from './money.cases';
|
||||
import { plan } from './plan.cases';
|
||||
import { toursRoadtrip } from './tours-roadtrip.cases';
|
||||
import type { CaseGroup } from './types';
|
||||
import { user } from './user.cases';
|
||||
|
||||
/** Every group, imported statically, so the registry test sees each row without running a case. */
|
||||
export const ALL_GROUPS: readonly CaseGroup[] = [plan, bookings, toursRoadtrip, money, collabTrip, user];
|
||||
@@ -0,0 +1,61 @@
|
||||
import { addTripMember } from '../../helpers/factories/trips';
|
||||
import type { CaseGroup } from './types';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
const TEMPLATES = "packing templates are the admin's global list: no trip room, and no screen shows them live";
|
||||
|
||||
/** Money and lists: the budget with its settlements, packing and to-dos. */
|
||||
export const money: CaseGroup = {
|
||||
name: 'money',
|
||||
prefix: 'BCP-MONEY',
|
||||
cases: [],
|
||||
exempt: [
|
||||
{
|
||||
tool: 'save_packing_template',
|
||||
reason: TEMPLATES,
|
||||
actor: 'admin',
|
||||
needs: ['packingItem'],
|
||||
// Saving reads a trip's list, so the admin travels along on this one.
|
||||
setup: (s, app) => addTripMember(app.get(MikroORM), s.trip.id, s.admin.id, s.owner.id),
|
||||
args: (s) => ({ tripId: s.trip.id, templateName: `Beach ${s.n}` }),
|
||||
},
|
||||
{
|
||||
tool: 'delete_packing_template',
|
||||
reason: TEMPLATES,
|
||||
actor: 'admin',
|
||||
needs: ['template'],
|
||||
args: (s) => ({ templateId: s.template.id }),
|
||||
},
|
||||
],
|
||||
pending: [
|
||||
'create_budget_item',
|
||||
'delete_budget_item',
|
||||
'update_budget_item',
|
||||
'create_budget_item_with_members',
|
||||
'set_budget_item_members',
|
||||
'toggle_budget_member_paid',
|
||||
'create_settlement',
|
||||
'update_settlement',
|
||||
'delete_settlement',
|
||||
'freeze_budget_rates',
|
||||
'create_packing_item',
|
||||
'toggle_packing_item',
|
||||
'delete_packing_item',
|
||||
'update_packing_item',
|
||||
'set_packing_item_sharing',
|
||||
'reorder_packing_items',
|
||||
'create_packing_bag',
|
||||
'update_packing_bag',
|
||||
'delete_packing_bag',
|
||||
'set_bag_members',
|
||||
'set_packing_category_assignees',
|
||||
'apply_packing_template',
|
||||
'bulk_import_packing',
|
||||
'create_todo',
|
||||
'update_todo',
|
||||
'toggle_todo',
|
||||
'delete_todo',
|
||||
'reorder_todos',
|
||||
'set_todo_category_assignees',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { money } from './money.cases';
|
||||
import { runGroup } from './run-group';
|
||||
|
||||
runGroup(money);
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* The write tools that may still stand in a group's `pending` list (plan
|
||||
* section 3.5), so pending only shrinks. A new write tool gets a case or an
|
||||
* exemption, never a pending row: BCP-REG-008 fails on a pending row that is
|
||||
* not listed here. A tool that gets its case leaves this list in the same
|
||||
* change, so it cannot slip back, and a listed tool no group holds as pending
|
||||
* fails as well. The list goes once `pending` leaves the type (plan section
|
||||
* 2.10, closing phase B).
|
||||
*/
|
||||
export const PENDING_CEILING: readonly string[] = [
|
||||
'accept_atlas_countries',
|
||||
'accept_collection_invite',
|
||||
'accept_dawarich_suggestion_as_journal_entry',
|
||||
'accept_dawarich_suggestion_as_place',
|
||||
'accept_vacay_invite',
|
||||
'add_holiday_calendar',
|
||||
'add_journey_contributor',
|
||||
'add_journey_provider_photos',
|
||||
'add_journey_trip',
|
||||
'add_route_via',
|
||||
'add_route_vias',
|
||||
'add_trip_member',
|
||||
'add_vacay_year',
|
||||
'apply_packing_template',
|
||||
'assign_collection_labels',
|
||||
'assign_place_to_day',
|
||||
'assign_tour_track',
|
||||
'bulk_delete_places',
|
||||
'bulk_import_packing',
|
||||
'bulk_update_places',
|
||||
'cancel_collection_invite',
|
||||
'cancel_vacay_invite',
|
||||
'clear_bucket_list_visit',
|
||||
'clear_day_assignments',
|
||||
'close_collab_poll',
|
||||
'confirm_bucket_list_visits',
|
||||
'copy_collection_places_to_trip',
|
||||
'copy_trip',
|
||||
'create_and_assign_place',
|
||||
'create_bucket_list_item',
|
||||
'create_budget_item',
|
||||
'create_budget_item_with_members',
|
||||
'create_city_walk',
|
||||
'create_collab_note',
|
||||
'create_collab_poll',
|
||||
'create_collection',
|
||||
'create_collection_label',
|
||||
'create_day',
|
||||
'create_day_note',
|
||||
'create_hike',
|
||||
'create_journey',
|
||||
'create_journey_entry',
|
||||
'create_packing_bag',
|
||||
'create_packing_item',
|
||||
'create_reservation',
|
||||
'create_settlement',
|
||||
'create_tag',
|
||||
'create_todo',
|
||||
'create_transit_journey',
|
||||
'create_transport',
|
||||
'create_trip',
|
||||
'create_trip_guest',
|
||||
'decline_collection_invite',
|
||||
'decline_vacay_invite',
|
||||
'delete_accommodation',
|
||||
'delete_bucket_list_item',
|
||||
'delete_budget_item',
|
||||
'delete_collab_message',
|
||||
'delete_collab_note',
|
||||
'delete_collab_poll',
|
||||
'delete_collection',
|
||||
'delete_collection_label',
|
||||
'delete_collection_place',
|
||||
'delete_collection_tour',
|
||||
'delete_day',
|
||||
'delete_day_note',
|
||||
'delete_holiday_calendar',
|
||||
'delete_journey',
|
||||
'delete_journey_entry',
|
||||
'delete_journey_recorded_day',
|
||||
'delete_packing_bag',
|
||||
'delete_packing_item',
|
||||
'delete_place',
|
||||
'delete_reservation',
|
||||
'delete_settlement',
|
||||
'delete_tag',
|
||||
'delete_todo',
|
||||
'delete_transport',
|
||||
'delete_trip',
|
||||
'delete_trip_guest',
|
||||
'delete_vacay_year',
|
||||
'dismiss_dawarich_suggestion',
|
||||
'dissolve_vacay_plan',
|
||||
'freeze_budget_rates',
|
||||
'import_airtrail_flights',
|
||||
'import_google_maps_route',
|
||||
'import_places_from_url',
|
||||
'import_tour_gpx',
|
||||
'import_trip_gpx',
|
||||
'invite_to_collection',
|
||||
'leave_collection',
|
||||
'leave_trip',
|
||||
'link_hotel_accommodation',
|
||||
'link_tour_packing',
|
||||
'link_trip_file',
|
||||
'link_trip_journal',
|
||||
'mark_all_notifications_read',
|
||||
'mark_bucket_list_item_visited_from_dawarich',
|
||||
'mark_country_visited',
|
||||
'mark_notification_read',
|
||||
'mark_notification_unread',
|
||||
'mark_region_visited',
|
||||
'move_assignment',
|
||||
'rate_collection_place',
|
||||
'rate_place',
|
||||
'react_collab_message',
|
||||
'reanchor_route_vias',
|
||||
'remove_collection_member',
|
||||
'remove_journey_contributor',
|
||||
'remove_journey_trip',
|
||||
'remove_route_via',
|
||||
'remove_trip_member',
|
||||
'rename_trip_guest',
|
||||
'reorder_collections',
|
||||
'reorder_day_assignments',
|
||||
'reorder_days',
|
||||
'reorder_journey_entries',
|
||||
'reorder_journey_entry_photos',
|
||||
'reorder_packing_items',
|
||||
'reorder_reservations',
|
||||
'reorder_todos',
|
||||
'restore_journey_suggestions',
|
||||
'save_journey_recorded_day',
|
||||
'save_place_to_collection',
|
||||
'save_tour_change',
|
||||
'save_tour_to_collection',
|
||||
'save_trip_places_to_collection',
|
||||
'send_collab_message',
|
||||
'send_vacay_invite',
|
||||
'set_assignment_end_day',
|
||||
'set_assignment_participants',
|
||||
'set_assignment_route_excluded',
|
||||
'set_bag_members',
|
||||
'set_budget_item_members',
|
||||
'set_collection_member_role',
|
||||
'set_collection_place_status',
|
||||
'set_collection_place_status_from_trip',
|
||||
'set_day_boundary',
|
||||
'set_day_default_transport_mode',
|
||||
'set_leg_transport_mode',
|
||||
'set_packing_category_assignees',
|
||||
'set_packing_item_sharing',
|
||||
'set_place_image_from_file',
|
||||
'set_reservation_travelers',
|
||||
'set_todo_category_assignees',
|
||||
'set_tour_activity_excluded',
|
||||
'set_trip_journal_members',
|
||||
'set_vacay_color',
|
||||
'share_vacay_calendar',
|
||||
'sync_trip_documents',
|
||||
'take_over_collection_tour',
|
||||
'take_over_journey_day_stays',
|
||||
'take_over_tour',
|
||||
'toggle_budget_member_paid',
|
||||
'toggle_company_holiday',
|
||||
'toggle_packing_item',
|
||||
'toggle_todo',
|
||||
'toggle_vacay_entry',
|
||||
'unassign_place',
|
||||
'unassign_tour_track',
|
||||
'undo_tour_change',
|
||||
'unlink_tour_packing',
|
||||
'unlink_trip_file',
|
||||
'unlink_trip_journal',
|
||||
'unmark_country_visited',
|
||||
'unmark_region_visited',
|
||||
'unshare_vacay_calendar',
|
||||
'update_accommodation',
|
||||
'update_assignment_notes',
|
||||
'update_assignment_time',
|
||||
'update_bucket_list_item',
|
||||
'update_budget_item',
|
||||
'update_collab_note',
|
||||
'update_collection',
|
||||
'update_collection_label',
|
||||
'update_collection_place',
|
||||
'update_day',
|
||||
'update_day_note',
|
||||
'update_display_settings',
|
||||
'update_holiday_calendar',
|
||||
'update_journey',
|
||||
'update_journey_contributor_role',
|
||||
'update_journey_entry',
|
||||
'update_journey_preferences',
|
||||
'update_packing_bag',
|
||||
'update_packing_item',
|
||||
'update_place',
|
||||
'update_reservation',
|
||||
'update_roadtrip_settings',
|
||||
'update_route_via',
|
||||
'update_settlement',
|
||||
'update_tag',
|
||||
'update_todo',
|
||||
'update_tour',
|
||||
'update_transport',
|
||||
'update_trip',
|
||||
'update_trip_file',
|
||||
'update_vacay_plan',
|
||||
'update_vacay_stats',
|
||||
'upload_trip_file',
|
||||
'vote_collab_poll',
|
||||
];
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { CaseGroup } from './types';
|
||||
|
||||
const CATALOG = "the admin's category catalog is global: no trip room, and the registry has no event for it";
|
||||
|
||||
/**
|
||||
* The planner: places, the day plan, days, day notes, categories, and the
|
||||
* collection places copied into a trip.
|
||||
*/
|
||||
export const plan: CaseGroup = {
|
||||
name: 'plan',
|
||||
prefix: 'BCP-PLAN',
|
||||
cases: [
|
||||
{
|
||||
tool: 'create_place',
|
||||
args: (s) => ({ tripId: s.trip.id, name: 'Sacré-Cœur', lat: 48.8867, lng: 2.3431 }),
|
||||
rest: {
|
||||
method: 'post',
|
||||
path: (s) => `/api/trips/${s.trip.id}/places`,
|
||||
body: () => ({ name: 'Sacré-Cœur', lat: 48.8867, lng: 2.3431 }),
|
||||
},
|
||||
expect: ['place:created'],
|
||||
},
|
||||
{
|
||||
// The assistant's way to book a hotel the trip does not have yet (handoff
|
||||
// section 8): every tab, the asking one included, gets the new place, the
|
||||
// stay with its partner booking and the check-in stop.
|
||||
tool: 'create_place_accommodation',
|
||||
args: (s) => ({
|
||||
tripId: s.trip.id,
|
||||
name: 'Mercure Hotel Berlin City',
|
||||
lat: 52.5306,
|
||||
lng: 13.3846,
|
||||
start_day_id: s.days[0].id,
|
||||
end_day_id: s.days[2].id,
|
||||
check_in: '15:00',
|
||||
check_out: '11:00',
|
||||
}),
|
||||
twin: 'POST /api/trips/:tripId/places, then POST /api/trips/:tripId/accommodations',
|
||||
// The place goes out once, already typed as lodging: the stamp the stay
|
||||
// puts on it is not sent again as place:updated.
|
||||
expect: [
|
||||
'place:created',
|
||||
'accommodation:created',
|
||||
'reservation:created',
|
||||
{ event: 'assignment:created', all: true },
|
||||
{ event: 'assignment:reordered', all: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
exempt: [
|
||||
{
|
||||
tool: 'create_category',
|
||||
reason: CATALOG,
|
||||
actor: 'admin',
|
||||
args: (s) => ({ name: `Rooftop bars ${s.n}` }),
|
||||
},
|
||||
{
|
||||
tool: 'update_category',
|
||||
reason: CATALOG,
|
||||
actor: 'admin',
|
||||
needs: ['category'],
|
||||
args: (s) => ({ categoryId: s.category.id, name: `Night markets ${s.n}` }),
|
||||
},
|
||||
{
|
||||
tool: 'delete_category',
|
||||
reason: CATALOG,
|
||||
actor: 'admin',
|
||||
needs: ['category'],
|
||||
args: (s) => ({ categoryId: s.category.id }),
|
||||
},
|
||||
],
|
||||
pending: [
|
||||
'assign_place_to_day',
|
||||
'set_assignment_end_day',
|
||||
'unassign_place',
|
||||
'clear_day_assignments',
|
||||
'update_assignment_time',
|
||||
'update_assignment_notes',
|
||||
'set_leg_transport_mode',
|
||||
'set_assignment_route_excluded',
|
||||
'move_assignment',
|
||||
'set_assignment_participants',
|
||||
'reorder_day_assignments',
|
||||
'copy_collection_places_to_trip',
|
||||
'create_day_note',
|
||||
'update_day_note',
|
||||
'delete_day_note',
|
||||
'update_day',
|
||||
'create_day',
|
||||
'reorder_days',
|
||||
'delete_day',
|
||||
'set_day_default_transport_mode',
|
||||
'create_and_assign_place',
|
||||
'update_place',
|
||||
'set_place_image_from_file',
|
||||
'rate_place',
|
||||
'delete_place',
|
||||
'import_places_from_url',
|
||||
'import_trip_gpx',
|
||||
'bulk_delete_places',
|
||||
'bulk_update_places',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { plan } from './plan.cases';
|
||||
import { runGroup } from './run-group';
|
||||
|
||||
runGroup(plan);
|
||||
@@ -0,0 +1,154 @@
|
||||
import { McpRegistryService } from '../../../src/nest-mcp';
|
||||
import { withRequestContext } from '../../../src/nest/database/request-context';
|
||||
import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { ALL_NEEDS, NEED_KEYS, openHarness, REST_SOCKET, type ListedTool, type ParityHarness } from './harness';
|
||||
import { ALL_GROUPS } from './index';
|
||||
import { PENDING_CEILING } from './pending-ceiling';
|
||||
import type { Scene } from './types';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Completeness of the broadcast parity groups over the MCP registry (plan
|
||||
* section 3.5). It cannot skip a registry tool quietly: the write tools are
|
||||
* what an admin session with every addon on lists (annotations as a client
|
||||
* sees them, `readOnlyHint !== true`), checked against everything the
|
||||
* registry holds, and every one of them must stand in exactly one group as a
|
||||
* case, an exemption with its reason, or pending. A new write tool without a
|
||||
* row fails here, and so does a row for a tool that is gone or only reads.
|
||||
* Pending only shrinks (`PENDING_CEILING`): a new tool gets a case or an
|
||||
* exemption.
|
||||
*
|
||||
* Out of its reach on purpose: the tools installed plugins contribute
|
||||
* (`dynamicTools`, attached per session from the plugin source, never held by
|
||||
* the registry). They are not silent: a plugin's tool writes through the
|
||||
* plugin RPC, which sends the trip's events itself (`places.rpc.ts` sends
|
||||
* place:created, for one). Those RPC broadcasts stay where they are by
|
||||
* decision of this work, so their parity is not checked here.
|
||||
*/
|
||||
|
||||
let h: ParityHarness;
|
||||
let listed: ListedTool[];
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await openHarness();
|
||||
listed = await h.listTools((await h.scene()).admin.id);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
/** Every row of every group, as `group: kind`, by tool. */
|
||||
function rowsByTool(): Map<string, string[]> {
|
||||
const rows = new Map<string, string[]>();
|
||||
const add = (tool: string, where: string) => rows.set(tool, [...(rows.get(tool) ?? []), where]);
|
||||
for (const group of ALL_GROUPS) {
|
||||
for (const c of group.cases) add(c.tool, `${group.name}: case`);
|
||||
for (const e of group.exempt) add(e.tool, `${group.name}: exempt`);
|
||||
for (const tool of group.pending ?? []) add(tool, `${group.name}: pending`);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const writeTools = () => listed.filter((tool) => !tool.readOnly).map((tool) => tool.name);
|
||||
|
||||
describe('broadcast parity: every write tool has a row', () => {
|
||||
it('BCP-REG-001: an admin session with every addon on lists every tool the registry holds', () => {
|
||||
const registered = h.app
|
||||
.get(McpRegistryService)
|
||||
.list()
|
||||
.filter((entry) => entry.kind === 'tool')
|
||||
.map((entry) => entry.name);
|
||||
const names = new Set(listed.map((tool) => tool.name));
|
||||
expect(
|
||||
registered.filter((name) => !names.has(name)),
|
||||
'a when: or addon gate hid these tools from the test',
|
||||
).toEqual([]);
|
||||
expect(
|
||||
[...names].filter((name) => !registered.includes(name)),
|
||||
'listed, but not in the registry',
|
||||
).toEqual([]);
|
||||
expect(writeTools().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('BCP-REG-002: every write tool stands in exactly one group, once', () => {
|
||||
const rows = rowsByTool();
|
||||
const missing = writeTools().filter((tool) => !rows.has(tool));
|
||||
expect(missing, 'write tools without a case, an exemption or a pending row').toEqual([]);
|
||||
const doubled = [...rows]
|
||||
.filter(([, where]) => where.length > 1)
|
||||
.map(([tool, where]) => `${tool} (${where.join(', ')})`);
|
||||
expect(doubled, 'write tools with more than one row').toEqual([]);
|
||||
});
|
||||
|
||||
it('BCP-REG-003: no row names a tool that is gone or only reads', () => {
|
||||
const writes = new Set(writeTools());
|
||||
const stale = [...rowsByTool().keys()].filter((tool) => !writes.has(tool));
|
||||
expect(stale, 'rows for unknown or read-only tools').toEqual([]);
|
||||
});
|
||||
|
||||
it('BCP-REG-004: every exemption says why the tool sends nothing, in at least 20 characters', () => {
|
||||
const thin = ALL_GROUPS.flatMap((group) => group.exempt)
|
||||
.filter((e) => e.reason.trim().length < 20)
|
||||
.map((e) => e.tool);
|
||||
expect(thin).toEqual([]);
|
||||
});
|
||||
|
||||
it('BCP-REG-008: pending only shrinks: no tool joins it, and one that got its case leaves the ceiling', () => {
|
||||
const pending = ALL_GROUPS.flatMap((group) => group.pending ?? []);
|
||||
const ceiling = new Set(PENDING_CEILING);
|
||||
expect(
|
||||
pending.filter((tool) => !ceiling.has(tool)),
|
||||
'pending rows for tools the ceiling does not list: give each a case or an exemption',
|
||||
).toEqual([]);
|
||||
const held = new Set(pending);
|
||||
expect(
|
||||
PENDING_CEILING.filter((tool) => !held.has(tool)),
|
||||
'listed in pending-ceiling.ts, but no group holds the tool as pending any more: drop it from the list',
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcast parity: the scenes', () => {
|
||||
it('BCP-REG-005: a scene builds every need, and every row it names is there', async () => {
|
||||
const s = await h.scene(ALL_NEEDS);
|
||||
const keys: (keyof Scene)[] = ['owner', 'admin', 'trip', ...Object.values(NEED_KEYS).flat()];
|
||||
const without = keys.filter((key) => {
|
||||
const row = s[key] as { id?: unknown; place_id?: unknown; code?: unknown };
|
||||
return typeof (row.id ?? row.place_id) !== 'number' && typeof row.code !== 'string';
|
||||
});
|
||||
expect(without, 'scene rows without an id').toEqual([]);
|
||||
expect(s.days.map((day) => day.date)).toEqual(['2027-05-03', '2027-05-04', '2027-05-05']);
|
||||
expect(s.stay.reservationId).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('BCP-REG-006: a row the case did not ask for throws instead of reading as missing', async () => {
|
||||
const s = await h.scene(['assignment']);
|
||||
expect(s.place.id).toBe(s.assignment.place_id);
|
||||
expect(() => s.stay).toThrow("add 'stay' to the case's needs");
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcast parity: the recorder', () => {
|
||||
it('BCP-REG-007: marks a send made inside a transaction, and folds a fan-out into one entry', async () => {
|
||||
const realtime = h.app.get(RealtimeService);
|
||||
const uow = h.app.get(UnitOfWork);
|
||||
h.recorder.take();
|
||||
await withRequestContext(h.orm, () =>
|
||||
uow.transactional(async () => {
|
||||
realtime.broadcast(7, 'place:deleted', { placeId: 3 });
|
||||
}),
|
||||
);
|
||||
realtime.broadcast(7, 'packing:updated', { item: { id: 4 } }, REST_SOCKET, 11);
|
||||
realtime.broadcast(7, 'packing:updated', { item: { id: 4 } }, REST_SOCKET, 12);
|
||||
realtime.broadcastToUser(11, { type: 'tags:changed' });
|
||||
realtime.broadcastToUser(12, { type: 'tags:changed' });
|
||||
expect(h.recorder.take().map((r) => ({ event: r.event, to: r.to, only: r.onlyUserIds, inTx: r.inTx }))).toEqual([
|
||||
{ event: 'place:deleted', to: [7], only: undefined, inTx: true },
|
||||
{ event: 'packing:updated', to: [7], only: [11, 12], inTx: false },
|
||||
{ event: 'tags:changed', to: [11, 12], only: undefined, inTx: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import { openHarness, REST_SOCKET, sceneUsers, type ParityHarness, type Recorded } from './harness';
|
||||
import type { CaseGroup, ExemptTool, Expect, ParityCase, Receivers, Scene, ToolCall } from './types';
|
||||
import { TREK_WS_EVENTS, type TrekWsEventName } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* One group's cases (plan section 3.4). Each case runs its tool over MCP on
|
||||
* one scene and, when it has one, its REST twin on a second scene built the
|
||||
* same way, then holds both recordings to the case's list:
|
||||
*
|
||||
* 1. MCP: the tool answers without an error, and the events are exactly the
|
||||
* list, in order and count, so a doubled broadcast shows.
|
||||
* 2. MCP: no room broadcast skips a socket (the asking tab hears everything),
|
||||
* an event for the actor reaches the actor's own sockets, and a send
|
||||
* limited to some users of the room reaches the actor too.
|
||||
* 3. No event leaves while a transaction is open.
|
||||
* 4. Every payload passes the registry's schema in @trek/shared.
|
||||
* 5. REST: the same events in the same order; MCP sends at least REST's
|
||||
* payload keys and reaches the same users; REST leaves out the requesting
|
||||
* socket unless the event is one every socket needs (`all`).
|
||||
*
|
||||
* On both surfaces a room broadcast goes to the scene's trip (or the room the
|
||||
* case names), and only to the users the case says it is limited to. Nothing
|
||||
* may be on the recorder when a write starts, so a send that lands late, after
|
||||
* its own case was read, fails rather than being thrown away.
|
||||
*
|
||||
* Each exemption runs too, over MCP: it must answer without an error and send
|
||||
* nothing at all.
|
||||
*/
|
||||
|
||||
type Surface = 'MCP' | 'REST';
|
||||
|
||||
interface Wanted {
|
||||
channel: 'trip' | 'user';
|
||||
event: string;
|
||||
all: boolean;
|
||||
user?: 'actor' | 'others';
|
||||
/** The users of the room the send is limited to, per surface; none means the whole room. */
|
||||
only: Partial<Record<Surface, Receivers>>;
|
||||
/** The surfaces limit it differently on purpose, so their receivers are not compared. */
|
||||
onlyDiffers: boolean;
|
||||
}
|
||||
|
||||
function wanted(e: Expect): Wanted {
|
||||
if (typeof e === 'string') return { channel: 'trip', event: e, all: false, only: {}, onlyDiffers: false };
|
||||
if ('user' in e) {
|
||||
return { channel: 'user', event: e.event, all: e.all === true, user: e.user, only: {}, onlyDiffers: false };
|
||||
}
|
||||
const only =
|
||||
e.only === undefined
|
||||
? {}
|
||||
: typeof e.only === 'string'
|
||||
? { MCP: e.only, REST: e.only }
|
||||
: { MCP: e.only.mcp, REST: e.only.rest };
|
||||
return { channel: 'trip', event: e.event, all: e.all === true, only, onlyDiffers: only.MCP !== only.REST };
|
||||
}
|
||||
|
||||
const signature = (r: { channel: string; event: string }) => `${r.channel} ${r.event}`;
|
||||
|
||||
/** The recording as one line per send, for a failure message. */
|
||||
function trace(records: Recorded[]): string {
|
||||
if (records.length === 0) return ' (nothing sent)';
|
||||
return records
|
||||
.map((r) => {
|
||||
const extra = [
|
||||
`to ${r.to.join(',')}`,
|
||||
r.excludeSid === undefined ? null : `skips ${String(r.excludeSid)}`,
|
||||
r.onlyUserIds ? `only ${r.onlyUserIds.join(',')}` : null,
|
||||
r.inTx ? 'IN TRANSACTION' : null,
|
||||
].filter((part) => part !== null);
|
||||
return ` ${signature(r)} {${r.keys.join(', ')}} ${extra.join(', ')}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Who a send reaches, by the scene's names for its users, so the two scenes compare. */
|
||||
function receivers(r: Recorded, s: Scene): string {
|
||||
const ids = r.channel === 'user' ? r.to.map(Number) : r.onlyUserIds;
|
||||
if (!ids) return 'the whole room';
|
||||
const users = sceneUsers(s);
|
||||
return ids
|
||||
.map((id) => users.get(id) ?? `user ${id}`)
|
||||
.sort()
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
const isRegistered = (event: string): event is TrekWsEventName => Object.hasOwn(TREK_WS_EVENTS, event);
|
||||
|
||||
function actorOf(call: ToolCall, s: Scene) {
|
||||
return call.actor === 'admin' ? s.admin : s.owner;
|
||||
}
|
||||
|
||||
/** A room broadcast limited to some users of the room, held to what the case says. */
|
||||
function checkLimit(limit: Receivers | undefined, r: Recorded, actorId: number, where: string): void {
|
||||
if (limit === undefined) {
|
||||
expect(r.onlyUserIds, `limited to some users of the room: ${where}`).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
expect(
|
||||
r.onlyUserIds,
|
||||
`sent to the whole room, not to its ${limit === 'actor' ? 'writer' : 'viewers'}: ${where}`,
|
||||
).toBeDefined();
|
||||
if (limit === 'actor') expect(r.onlyUserIds, `not limited to the writer alone: ${where}`).toEqual([actorId]);
|
||||
else expect(r.onlyUserIds, `the writer is not among the viewers it reaches: ${where}`).toContain(actorId);
|
||||
}
|
||||
|
||||
/** Checks 1 to 4 for one surface, the room and its limits, and the socket rule of check 5 for REST. */
|
||||
function checkSurface(surface: Surface, c: ParityCase, records: Recorded[], s: Scene): void {
|
||||
const actorId = actorOf(c, s).id;
|
||||
const room = c.room?.(s) ?? s.trip.id;
|
||||
const expected = c.expect.map(wanted);
|
||||
const seen = trace(records);
|
||||
expect(records.map(signature), `${c.tool} over ${surface} sent:\n${seen}`).toEqual(expected.map(signature));
|
||||
records.forEach((r, i) => {
|
||||
const want = expected[i];
|
||||
const where = `${c.tool} over ${surface}, send ${i + 1} (${signature(r)}):\n${seen}`;
|
||||
expect(r.inTx, `sent inside a transaction, before the commit: ${where}`).toBe(false);
|
||||
expect(isRegistered(r.event), `not an event of the registry: ${where}`).toBe(true);
|
||||
if (isRegistered(r.event)) {
|
||||
const parsed = TREK_WS_EVENTS[r.event].payload.safeParse(r.payload);
|
||||
expect(parsed.success, `payload fails the registry schema (${parsed.error?.message ?? ''}): ${where}`).toBe(true);
|
||||
}
|
||||
if (!want) return;
|
||||
if (want.channel === 'user') {
|
||||
const reachesActor = r.to.includes(actorId);
|
||||
expect(reachesActor, `the actor ${want.user === 'actor' ? 'is not' : 'is'} among the receivers: ${where}`).toBe(
|
||||
want.user === 'actor',
|
||||
);
|
||||
if (want.user === 'others') return;
|
||||
} else {
|
||||
expect(Number(r.to[0]), `sent to another room than the trip's: ${where}`).toBe(room);
|
||||
checkLimit(want.only[surface], r, actorId, where);
|
||||
}
|
||||
const skips = surface === 'MCP' || want.all ? undefined : REST_SOCKET;
|
||||
expect(r.excludeSid, `skips the wrong socket: ${where}`).toBe(skips);
|
||||
});
|
||||
}
|
||||
|
||||
/** Check 5 across the surfaces: MCP sends REST's payload keys, to the same users. */
|
||||
function compareSurfaces(c: ParityCase, mcp: Recorded[], mcpScene: Scene, rest: Recorded[], restScene: Scene): void {
|
||||
const expected = c.expect.map(wanted);
|
||||
mcp.forEach((m, i) => {
|
||||
const r = rest[i];
|
||||
if (!r) return;
|
||||
const missing = r.keys.filter((key) => !m.keys.includes(key));
|
||||
expect(missing, `${c.tool}: MCP's ${m.event} lacks payload keys REST sends`).toEqual([]);
|
||||
if (expected[i]?.onlyDiffers) return;
|
||||
expect(receivers(r, restScene), `${c.tool}: ${m.event} reaches other users over REST than over MCP`).toBe(
|
||||
receivers(m, mcpScene),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Nothing may wait on the recorder when a write starts: a late send of the case before, or a scene that sent. */
|
||||
function nothingSentYet(h: ParityHarness, before: string): void {
|
||||
const early = h.recorder.take();
|
||||
expect(early, `sent before ${before}, by the case before it or by the scene:\n${trace(early)}`).toEqual([]);
|
||||
}
|
||||
|
||||
const nextMacrotask = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
async function runCase(h: ParityHarness, c: ParityCase): Promise<void> {
|
||||
const restore = c.stub?.(h.app);
|
||||
try {
|
||||
const mcpScene = await h.scene(c.needs);
|
||||
await c.setup?.(mcpScene, h.app);
|
||||
nothingSentYet(h, `${c.tool} ran over MCP`);
|
||||
const outcome = await h.callTool(actorOf(c, mcpScene).id, c.tool, c.args(mcpScene));
|
||||
if (c.settle) await nextMacrotask();
|
||||
const mcp = h.recorder.take();
|
||||
expect(outcome.isError, `${c.tool} failed over MCP: ${outcome.text}`).toBe(false);
|
||||
checkSurface('MCP', c, mcp, mcpScene);
|
||||
|
||||
if (!c.rest) return;
|
||||
const restScene = await h.scene(c.needs);
|
||||
await c.setup?.(restScene, h.app);
|
||||
nothingSentYet(h, `the REST twin of ${c.tool} ran`);
|
||||
const res = await h.rest(actorOf(c, restScene).id, c.rest, restScene);
|
||||
if (c.settle) await nextMacrotask();
|
||||
const rest = h.recorder.take();
|
||||
expect(res.status, `the REST twin of ${c.tool} answered ${res.status}: ${res.text}`).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status, `the REST twin of ${c.tool} answered ${res.status}: ${res.text}`).toBeLessThan(300);
|
||||
checkSurface('REST', c, rest, restScene);
|
||||
compareSurfaces(c, mcp, mcpScene, rest, restScene);
|
||||
} finally {
|
||||
if (typeof restore === 'function') restore();
|
||||
}
|
||||
}
|
||||
|
||||
/** An exemption, run: the tool answers without an error and sends nothing, not even one macrotask later. */
|
||||
async function runExempt(h: ParityHarness, e: ExemptTool): Promise<void> {
|
||||
const s = await h.scene(e.needs);
|
||||
await e.setup?.(s, h.app);
|
||||
const actor = actorOf(e, s);
|
||||
nothingSentYet(h, `${e.tool} ran`);
|
||||
if (e.first) {
|
||||
const before = await h.callTool(actor.id, e.first.tool, e.first.args(s));
|
||||
expect(before.isError, `${e.first.tool}, run before ${e.tool}, failed: ${before.text}`).toBe(false);
|
||||
}
|
||||
const outcome = await h.callTool(actor.id, e.tool, e.args(s));
|
||||
await nextMacrotask();
|
||||
const sent = h.recorder.take();
|
||||
expect(outcome.isError, `${e.tool} failed over MCP: ${outcome.text}`).toBe(false);
|
||||
expect(sent, `${e.tool} is exempt as silent, yet sent:\n${trace(sent)}`).toEqual([]);
|
||||
}
|
||||
|
||||
const caseId = (prefix: string, index: number) => `${prefix}-${String(index).padStart(3, '0')}`;
|
||||
|
||||
/** The rows of a group that the registry test cannot see from outside: each tool once, and every case well-formed. */
|
||||
function groupProblems(group: CaseGroup): string[] {
|
||||
const problems: string[] = [];
|
||||
const rows = [...group.cases.map((c) => c.tool), ...group.exempt.map((e) => e.tool), ...(group.pending ?? [])];
|
||||
for (const tool of new Set(rows)) {
|
||||
const count = rows.filter((row) => row === tool).length;
|
||||
if (count > 1) problems.push(`${tool} has ${count} rows`);
|
||||
}
|
||||
for (const c of group.cases) {
|
||||
if (c.expect.length === 0) problems.push(`${c.tool} expects no event: a silent tool is an exemption`);
|
||||
if (!c.rest && !c.twin) problems.push(`${c.tool} has neither a REST twin nor a note naming the nearest one`);
|
||||
}
|
||||
const exempt = new Set(group.exempt.map((e) => e.tool));
|
||||
for (const e of group.exempt) {
|
||||
if (e.reason.trim().length < 20) problems.push(`${e.tool}'s exemption gives no real reason`);
|
||||
if (e.first && !exempt.has(e.first.tool))
|
||||
problems.push(`${e.tool} runs ${e.first.tool} first, which is no exemption here`);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** The test file of one group: one boot, one test per case and per exemption. */
|
||||
export function runGroup(group: CaseGroup): void {
|
||||
describe(`broadcast parity: ${group.name}`, () => {
|
||||
let h: ParityHarness;
|
||||
|
||||
// A group with nothing to run yet does not boot the app for it.
|
||||
if (group.cases.length > 0 || group.exempt.length > 0) {
|
||||
beforeAll(async () => {
|
||||
h = await openHarness();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
}
|
||||
|
||||
it(`${caseId(group.prefix, 0)}: names each of its write tools once, and every case can fail`, () => {
|
||||
expect(groupProblems(group)).toEqual([]);
|
||||
});
|
||||
|
||||
group.cases.forEach((c, i) => {
|
||||
const title = c.rest
|
||||
? `${c.tool} sends the same events over MCP and REST`
|
||||
: `${c.tool} sends its events to every tab over MCP (twin: ${c.twin ?? ''})`;
|
||||
it(`${caseId(group.prefix, i + 1)}: ${title}`, () => runCase(h, c));
|
||||
});
|
||||
|
||||
group.exempt.forEach((e, i) => {
|
||||
it(`${caseId(`${group.prefix}-EX`, i + 1)}: ${e.tool} sends nothing, as its exemption says`, () =>
|
||||
runExempt(h, e));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CaseGroup } from './types';
|
||||
|
||||
/** Tours and road trips: city walks, hikes and their library, route vias, day boundaries and the Google route import. */
|
||||
export const toursRoadtrip: CaseGroup = {
|
||||
name: 'tours-roadtrip',
|
||||
prefix: 'BCP-TOURS',
|
||||
cases: [],
|
||||
exempt: [],
|
||||
pending: [
|
||||
'set_day_boundary',
|
||||
'import_google_maps_route',
|
||||
'update_roadtrip_settings',
|
||||
'add_route_via',
|
||||
'add_route_vias',
|
||||
'reanchor_route_vias',
|
||||
'remove_route_via',
|
||||
'update_route_via',
|
||||
'create_city_walk',
|
||||
'save_tour_to_collection',
|
||||
'take_over_collection_tour',
|
||||
'delete_collection_tour',
|
||||
'assign_tour_track',
|
||||
'unassign_tour_track',
|
||||
'set_tour_activity_excluded',
|
||||
'undo_tour_change',
|
||||
'save_tour_change',
|
||||
'take_over_tour',
|
||||
'link_tour_packing',
|
||||
'unlink_tour_packing',
|
||||
'create_hike',
|
||||
'update_tour',
|
||||
'import_tour_gpx',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { runGroup } from './run-group';
|
||||
import { toursRoadtrip } from './tours-roadtrip.cases';
|
||||
|
||||
runGroup(toursRoadtrip);
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { BudgetItemRow } from '../../helpers/factories/budget';
|
||||
import type { CollabMessageRow, CollabNoteRow } from '../../helpers/factories/collab';
|
||||
import type { CollectionPlaceRow, CollectionRow } from '../../helpers/factories/collections';
|
||||
import type { TripFileRow } from '../../helpers/factories/files';
|
||||
import type { DayAssignmentRow, DayNoteRow } from '../../helpers/factories/itinerary';
|
||||
import type { JourneyEntryRow, JourneyRow } from '../../helpers/factories/journeys';
|
||||
import type { NotificationRow } from '../../helpers/factories/notifications';
|
||||
import type { PackingBagRow, PackingItemRow } from '../../helpers/factories/packing';
|
||||
import type { CategoryRow, PlaceRow, TagRow } from '../../helpers/factories/places';
|
||||
import type { ReservationRow } from '../../helpers/factories/reservations';
|
||||
import type { TourRow } from '../../helpers/factories/tours';
|
||||
import type { DayRow, TripRow } from '../../helpers/factories/trips';
|
||||
import type { UserRow } from '../../helpers/factories/users';
|
||||
import type { VacayPlanRow } from '../../helpers/factories/vacay';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { TrekWsTripEventName, TrekWsUserEventName } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The rows a case asks its scene for, beyond the owner, the admin and a trip
|
||||
* with three dated days, which every scene has. A row a case reads without
|
||||
* naming it here throws, so a case cannot lean on a row by accident.
|
||||
*/
|
||||
export type Need =
|
||||
| 'place'
|
||||
| 'place2'
|
||||
| 'assignment'
|
||||
| 'dayNote'
|
||||
| 'stay'
|
||||
| 'transport'
|
||||
| 'budgetItem'
|
||||
| 'settlement'
|
||||
| 'packingItem'
|
||||
| 'bag'
|
||||
| 'template'
|
||||
| 'todo'
|
||||
| 'collabNote'
|
||||
| 'collabMessage'
|
||||
| 'poll'
|
||||
| 'file'
|
||||
| 'guest'
|
||||
| 'member'
|
||||
| 'collection'
|
||||
| 'journey'
|
||||
| 'vacay'
|
||||
| 'bucket'
|
||||
| 'tag'
|
||||
| 'notification'
|
||||
| 'via'
|
||||
| 'tour'
|
||||
| 'category'
|
||||
| 'holidayCountry'
|
||||
| 'holidayRegion';
|
||||
|
||||
/** A stay as the accommodation service writes it: the night, its partner hotel booking and the place it is at. */
|
||||
export interface StayRow {
|
||||
id: number;
|
||||
placeId: number;
|
||||
startDayId: number;
|
||||
endDayId: number;
|
||||
reservationId: number;
|
||||
}
|
||||
|
||||
/** A row without a factory of its own: the scene keeps its id. */
|
||||
export interface IdRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One case's world, built twice (once for REST, once for MCP) so a write on
|
||||
* one surface never sees what the other already changed. Every name carries
|
||||
* the scene's number, since usernames are unique across the file.
|
||||
*/
|
||||
export interface Scene {
|
||||
n: number;
|
||||
/** Who makes the write on both surfaces, unless the case names the admin. */
|
||||
owner: UserRow;
|
||||
admin: UserRow;
|
||||
trip: TripRow;
|
||||
/** Three days with dates (2027-05-03 to 05), in day order. */
|
||||
days: [DayRow, DayRow, DayRow];
|
||||
place: PlaceRow;
|
||||
place2: PlaceRow;
|
||||
/** `place` on the first day. */
|
||||
assignment: DayAssignmentRow;
|
||||
dayNote: DayNoteRow;
|
||||
/** From the first to the second day, at a place of its own. */
|
||||
stay: StayRow;
|
||||
/** A train on the first day. */
|
||||
transport: ReservationRow;
|
||||
/** Split between the owner and `member`. */
|
||||
budgetItem: BudgetItemRow;
|
||||
/** `member` paid the owner back. */
|
||||
settlement: IdRow;
|
||||
packingItem: PackingItemRow;
|
||||
bag: PackingBagRow;
|
||||
/** A global packing template with one category and one item, made by the admin. */
|
||||
template: IdRow;
|
||||
todo: IdRow;
|
||||
collabNote: CollabNoteRow;
|
||||
collabMessage: CollabMessageRow;
|
||||
/** Two options, open. */
|
||||
poll: IdRow;
|
||||
file: TripFileRow;
|
||||
/** A guest (no account) on the trip. */
|
||||
guest: UserRow;
|
||||
/** A second traveller with an account. */
|
||||
member: UserRow;
|
||||
/** The owner's collection with one saved place. */
|
||||
collection: CollectionRow;
|
||||
collectionPlace: CollectionPlaceRow;
|
||||
/** The owner's journey with one entry. */
|
||||
journey: JourneyRow;
|
||||
journeyEntry: JourneyEntryRow;
|
||||
vacay: VacayPlanRow;
|
||||
bucket: IdRow;
|
||||
tag: TagRow;
|
||||
/** An unread notification for the owner. */
|
||||
notification: NotificationRow;
|
||||
/** A route via on the first day. */
|
||||
via: IdRow;
|
||||
/** A hike on a place of its own. */
|
||||
tour: TourRow;
|
||||
tourPlace: PlaceRow;
|
||||
/** A place category of the admin's global catalog, on no place. */
|
||||
category: CategoryRow;
|
||||
/** A country of the admin's manual school holiday catalog, without regions. */
|
||||
holidayCountry: { code: string; name: string };
|
||||
/** A region with one holiday period, in a country of its own. */
|
||||
holidayRegion: { id: number; revision: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which users of the trip room a send is limited to (`onlyUserIds`):
|
||||
* `actor` the writer alone, `viewers` the users who may see the row (a
|
||||
* restricted packing item), the writer among them.
|
||||
*/
|
||||
export type Receivers = 'actor' | 'viewers';
|
||||
|
||||
/**
|
||||
* One event a write sends, in the order it sends them.
|
||||
*
|
||||
* - a trip event name: to the whole trip room; REST leaves out the socket
|
||||
* that made the request (`emit`).
|
||||
* - `{ event, all: true }`: to the trip room, the requesting socket included
|
||||
* on REST too (`emitAll`, the day-plan side of a write).
|
||||
* - `{ event, only }`: to some users of the trip room only. A surface that
|
||||
* limits it differently on purpose names both, `{ rest, mcp }`, and the
|
||||
* case says why in its note.
|
||||
* - `{ event, user }`: to users' own sockets (`broadcastToUser`). `actor` means
|
||||
* the writer is among the receivers (REST leaves out the requesting socket
|
||||
* unless `all` is set too), `others` that only other users receive it.
|
||||
*/
|
||||
export type Expect =
|
||||
| TrekWsTripEventName
|
||||
| { event: TrekWsTripEventName; all?: true; only?: Receivers | { rest: Receivers; mcp: Receivers } }
|
||||
| { event: TrekWsUserEventName; user: 'actor' | 'others'; all?: true };
|
||||
|
||||
/** The REST twin of a tool, called as the same user with X-Socket-Id 'sock-rest'. */
|
||||
export interface RestCall {
|
||||
method: 'post' | 'put' | 'patch' | 'delete';
|
||||
path: (s: Scene) => string;
|
||||
body?: (s: Scene) => object;
|
||||
}
|
||||
|
||||
/** What a case and an exemption both say about the call: the rows, the caller and the arguments. */
|
||||
export interface ToolCall {
|
||||
tool: string;
|
||||
needs?: Need[];
|
||||
/** Who calls; the owner unless the tool is an admin's. */
|
||||
actor?: 'owner' | 'admin';
|
||||
/** The tool's arguments, pointing at rows of the scene. A wrong field fails in the SDK's validation. */
|
||||
args: (s: Scene) => Record<string, unknown>;
|
||||
/** More rows than the needs give, per scene, before the write. */
|
||||
setup?: (s: Scene, app: INestApplication) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ParityCase extends ToolCall {
|
||||
/** The REST route doing the same write. A case has this or `twin`. */
|
||||
rest?: RestCall;
|
||||
/** For a tool without one REST route: the nearest twin, in words, and no REST run. */
|
||||
twin?: string;
|
||||
/** Every event the write sends, in order and with repeats. Never empty: a silent tool is an exemption. */
|
||||
expect: Expect[];
|
||||
/**
|
||||
* Replaces a network call with `vi.spyOn` on the provider's service, never
|
||||
* `vi.mock`. Return the restore, called once the case is done.
|
||||
*/
|
||||
stub?: (app: INestApplication) => (() => void) | void;
|
||||
/** The trip room the events go to, when it is not the scene's trip (a copy, a new trip). */
|
||||
room?: (s: Scene) => number;
|
||||
/** Events a write sends after it answered: read them one macrotask later. */
|
||||
settle?: boolean;
|
||||
/** A deliberate difference between the surfaces, and why. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A write tool that sends nothing, on purpose. It runs like a case, over MCP,
|
||||
* and must answer without an error and send nothing at all, so the reason is
|
||||
* checked rather than only claimed.
|
||||
*/
|
||||
export interface ExemptTool extends ToolCall {
|
||||
/** Why the tool sends nothing to anyone, at least 20 characters. */
|
||||
reason: string;
|
||||
/** Another exempt tool run first as the same caller, for a link or feed to rotate or revoke. */
|
||||
first?: { tool: string; args: (s: Scene) => Record<string, unknown> };
|
||||
}
|
||||
|
||||
export interface CaseGroup {
|
||||
name: string;
|
||||
/** The test id prefix of the group's cases, `<prefix>-001` and on. */
|
||||
prefix: string;
|
||||
cases: ParityCase[];
|
||||
exempt: ExemptTool[];
|
||||
/**
|
||||
* Write tools without a case yet. Only for the build-up: the group's package
|
||||
* empties it, and no tool may join it (PENDING_CEILING in pending-ceiling.ts).
|
||||
*/
|
||||
pending?: string[];
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { holidayCode } from './harness';
|
||||
import type { CaseGroup, Scene } from './types';
|
||||
|
||||
/** A user's own addons: journey, atlas, collections, vacay, dawarich and the school holiday catalog. */
|
||||
const HOLIDAY_CATALOG = "the admin's school holiday catalog is global: no room, and the registry has no event for it";
|
||||
const TOKEN_ONLY = 'manages a public link token: nothing on any screen follows it live, so there is nothing to send';
|
||||
const onJourney = (s: Scene) => ({ journeyId: s.journey.id });
|
||||
const WINTER = { name: 'Winter', startDate: '2027-02-01', endDate: '2027-02-12' };
|
||||
|
||||
export const user: CaseGroup = {
|
||||
name: 'user',
|
||||
prefix: 'BCP-USER',
|
||||
cases: [],
|
||||
exempt: [
|
||||
{ tool: 'create_journey_share_link', reason: TOKEN_ONLY, needs: ['journey'], args: onJourney },
|
||||
{
|
||||
tool: 'delete_journey_share_link',
|
||||
reason: TOKEN_ONLY,
|
||||
needs: ['journey'],
|
||||
args: onJourney,
|
||||
first: { tool: 'create_journey_share_link', args: onJourney },
|
||||
},
|
||||
{
|
||||
tool: 'create_manual_school_holiday_country',
|
||||
reason: HOLIDAY_CATALOG,
|
||||
actor: 'admin',
|
||||
args: (s) => ({ code: holidayCode(s.n, 0), name: `Holiday land ${s.n}` }),
|
||||
},
|
||||
{
|
||||
tool: 'create_manual_school_holiday_region',
|
||||
reason: HOLIDAY_CATALOG,
|
||||
actor: 'admin',
|
||||
needs: ['holidayCountry'],
|
||||
args: (s) => ({ country: s.holidayCountry.code, name: `North ${s.n}`, revision: 0, holidays: [WINTER] }),
|
||||
},
|
||||
{
|
||||
tool: 'update_manual_school_holiday_region',
|
||||
reason: HOLIDAY_CATALOG,
|
||||
actor: 'admin',
|
||||
needs: ['holidayRegion'],
|
||||
args: (s) => ({
|
||||
regionId: s.holidayRegion.id,
|
||||
name: `South ${s.n}`,
|
||||
revision: s.holidayRegion.revision,
|
||||
holidays: [WINTER],
|
||||
}),
|
||||
},
|
||||
{
|
||||
tool: 'delete_manual_school_holiday_region',
|
||||
reason: HOLIDAY_CATALOG,
|
||||
actor: 'admin',
|
||||
needs: ['holidayRegion'],
|
||||
args: (s) => ({ regionId: s.holidayRegion.id, revision: s.holidayRegion.revision }),
|
||||
},
|
||||
{
|
||||
tool: 'delete_manual_school_holiday_country',
|
||||
reason: HOLIDAY_CATALOG,
|
||||
actor: 'admin',
|
||||
needs: ['holidayCountry'],
|
||||
args: (s) => ({ code: s.holidayCountry.code }),
|
||||
},
|
||||
],
|
||||
pending: [
|
||||
'create_bucket_list_item',
|
||||
'delete_bucket_list_item',
|
||||
'mark_country_visited',
|
||||
'unmark_country_visited',
|
||||
'mark_region_visited',
|
||||
'unmark_region_visited',
|
||||
'update_bucket_list_item',
|
||||
'create_collection',
|
||||
'update_collection',
|
||||
'delete_collection',
|
||||
'reorder_collections',
|
||||
'save_place_to_collection',
|
||||
'save_trip_places_to_collection',
|
||||
'update_collection_place',
|
||||
'set_collection_place_status',
|
||||
'set_collection_place_status_from_trip',
|
||||
'rate_collection_place',
|
||||
'delete_collection_place',
|
||||
'create_collection_label',
|
||||
'update_collection_label',
|
||||
'delete_collection_label',
|
||||
'assign_collection_labels',
|
||||
'invite_to_collection',
|
||||
'set_collection_member_role',
|
||||
'remove_collection_member',
|
||||
'cancel_collection_invite',
|
||||
'accept_collection_invite',
|
||||
'decline_collection_invite',
|
||||
'leave_collection',
|
||||
'accept_dawarich_suggestion_as_place',
|
||||
'accept_dawarich_suggestion_as_journal_entry',
|
||||
'mark_bucket_list_item_visited_from_dawarich',
|
||||
'dismiss_dawarich_suggestion',
|
||||
'confirm_bucket_list_visits',
|
||||
'clear_bucket_list_visit',
|
||||
'accept_atlas_countries',
|
||||
'create_journey',
|
||||
'update_journey',
|
||||
'restore_journey_suggestions',
|
||||
'delete_journey',
|
||||
'add_journey_trip',
|
||||
'remove_journey_trip',
|
||||
'create_journey_entry',
|
||||
'update_journey_entry',
|
||||
'delete_journey_entry',
|
||||
'reorder_journey_entries',
|
||||
'reorder_journey_entry_photos',
|
||||
'add_journey_contributor',
|
||||
'update_journey_contributor_role',
|
||||
'remove_journey_contributor',
|
||||
'update_journey_preferences',
|
||||
'add_journey_provider_photos',
|
||||
'link_trip_journal',
|
||||
'set_trip_journal_members',
|
||||
'unlink_trip_journal',
|
||||
'take_over_journey_day_stays',
|
||||
'save_journey_recorded_day',
|
||||
'delete_journey_recorded_day',
|
||||
'update_vacay_plan',
|
||||
'set_vacay_color',
|
||||
'send_vacay_invite',
|
||||
'accept_vacay_invite',
|
||||
'decline_vacay_invite',
|
||||
'cancel_vacay_invite',
|
||||
'dissolve_vacay_plan',
|
||||
'add_vacay_year',
|
||||
'delete_vacay_year',
|
||||
'toggle_vacay_entry',
|
||||
'toggle_company_holiday',
|
||||
'update_vacay_stats',
|
||||
'add_holiday_calendar',
|
||||
'update_holiday_calendar',
|
||||
'delete_holiday_calendar',
|
||||
'share_vacay_calendar',
|
||||
'unshare_vacay_calendar',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { runGroup } from './run-group';
|
||||
import { user } from './user.cases';
|
||||
|
||||
runGroup(user);
|
||||
@@ -54,6 +54,7 @@ const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
|
||||
'todo:updated': { item: { id: 4 } },
|
||||
'todo:deleted': { itemId: 4 },
|
||||
'todo:assignees': { category: 'before', assignees: [1] },
|
||||
'todo:reordered': { orderedIds: [4, 3] },
|
||||
'budget:created': { item: { id: 11 } },
|
||||
'budget:updated': { item: { id: 11 } },
|
||||
'budget:deleted': { itemId: 11 },
|
||||
@@ -75,6 +76,7 @@ const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
|
||||
'trip:deleted': { id: 1 },
|
||||
'member:added': { member: { user_id: 2 } },
|
||||
'member:removed': { userId: 2 },
|
||||
'member:updated': { member: { id: 2, username: 'Guest Ana' } },
|
||||
'trip:journal:changed': {},
|
||||
'docsync:changed': { linkId: 4, pulled: 2, pushed: 1 },
|
||||
'file:created': { file: { id: 13 } },
|
||||
@@ -96,6 +98,11 @@ const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
|
||||
'memories:updated': { userId: 1 },
|
||||
'notification:new': { notification: { id: 21, sender_username: 'ana' } },
|
||||
'notification:updated': { notification: { id: 21 } },
|
||||
'notification:read-all': {},
|
||||
'trips:changed': {},
|
||||
'settings:updated': { keys: ['temperature_unit', 'dark_mode'] },
|
||||
'tags:changed': {},
|
||||
'atlas:changed': {},
|
||||
'collections:updated': { collectionId: 4 },
|
||||
'collections:accepted': { collectionId: 4 },
|
||||
'collections:declined': { collectionId: 4 },
|
||||
@@ -114,6 +121,8 @@ const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
|
||||
'vacay:share': { from: { id: 1 } },
|
||||
'vacay:share-removed': {},
|
||||
'vacay:shared-update': {},
|
||||
'journey:updated': { journeyId: 3 },
|
||||
'journey:deleted': { journeyId: 3 },
|
||||
'journey:trip:synced': { journeyId: 3, tripId: 1 },
|
||||
'journey:entry:created': { journeyId: 3, entry: { id: 5 } },
|
||||
'journey:entry:updated': { journeyId: 3, entry: { id: 5 } },
|
||||
@@ -150,7 +159,7 @@ const DRIFT_VARIANTS: Partial<Record<TrekWsEventName, Record<string, unknown>[]>
|
||||
};
|
||||
|
||||
describe('@trek/shared realtime event registry', () => {
|
||||
it('WSEVT-REG-001: pins the authoritative inventory counts (77 trip + 34 user = 111)', () => {
|
||||
it('WSEVT-REG-001: pins the authoritative inventory counts (79 trip + 41 user = 120)', () => {
|
||||
// 67th to 69th trip event: the three collab:link:* a shared link emits.
|
||||
// 70th and 71st: the road trip's vias and tracks, which used to be written silently.
|
||||
// 74th: docsync:changed, so a sync run that moved documents refreshes the
|
||||
@@ -163,9 +172,14 @@ describe('@trek/shared realtime event registry', () => {
|
||||
// capture times of an import have landed (#1587).
|
||||
// 34th: journey:recording:updated, so every map of a journey shows a day's
|
||||
// recorded route once somebody took it over (JRN-01).
|
||||
expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(77);
|
||||
expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(34);
|
||||
expect(TREK_WS_EVENT_NAMES).toHaveLength(111);
|
||||
// 78th and 79th trip event, 35th to 41st user event: what the broadcast
|
||||
// parity work found no event for (todo:reordered, member:updated;
|
||||
// notification:read-all, trips:changed, settings:updated, tags:changed,
|
||||
// atlas:changed, journey:updated, journey:deleted). Registered first, the
|
||||
// writes that send them follow.
|
||||
expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(79);
|
||||
expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(41);
|
||||
expect(TREK_WS_EVENT_NAMES).toHaveLength(120);
|
||||
});
|
||||
|
||||
it('WSEVT-REG-002: every name is domain:action shaped and outside the reserved plugin: namespace', () => {
|
||||
@@ -206,5 +220,16 @@ describe('@trek/shared realtime event registry', () => {
|
||||
_source: 'mcp',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
const strict = TREK_WS_EVENTS['settings:updated'].payload.safeParse({ keys: ['dark_mode'], _source: 'mcp' });
|
||||
expect(strict.success).toBe(true);
|
||||
});
|
||||
|
||||
it('WSEVT-REG-008: settings:updated never carries a value, and member:updated no more than who and the name', () => {
|
||||
const settings = TREK_WS_EVENTS['settings:updated'].payload;
|
||||
expect(settings.safeParse({ keys: ['dark_mode'], values: { dark_mode: 'on' } }).success).toBe(false);
|
||||
const member = TREK_WS_EVENTS['member:updated'].payload;
|
||||
expect(member.safeParse({ member: { id: 2, username: 'Guest Ana', email: 'ana@example.org' } }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,8 @@ import { z } from 'zod';
|
||||
* schemas below describe the fields BESIDES `type`.
|
||||
* - MCP-originated broadcasts add a `_source: 'mcp'` marker key to the payload
|
||||
* (server/src/mcp/tools/_shared.ts). Zod object schemas strip unknown keys,
|
||||
* so it needs no per-event modeling.
|
||||
* so it needs no per-event modeling, except in a strict schema, which names
|
||||
* it (`settings:updated`).
|
||||
*
|
||||
* Deliberately NOT in the registry:
|
||||
* - The dynamic plugin namespace: trip broadcasts force-namespaced to
|
||||
@@ -161,6 +162,8 @@ export const TREK_WS_EVENTS = {
|
||||
scope: 'trip',
|
||||
payload: z.object({ category: z.string(), assignees: z.array(z.unknown()) }),
|
||||
},
|
||||
// The list's new order, as packing:reordered carries it.
|
||||
'todo:reordered': { scope: 'trip', payload: z.object({ orderedIds: idList }) },
|
||||
|
||||
// ── Budget ───────────────────────────────────────────────────────────────
|
||||
'budget:created': { scope: 'trip', payload: z.object({ item: entity }) },
|
||||
@@ -232,6 +235,9 @@ export const TREK_WS_EVENTS = {
|
||||
'trip:deleted': { scope: 'trip', payload: z.object({ id }) },
|
||||
'member:added': { scope: 'trip', payload: z.object({ member: entity }) },
|
||||
'member:removed': { scope: 'trip', payload: z.object({ userId: id }) },
|
||||
// A guest renamed: who it is and the new name, nothing more. A member row
|
||||
// can carry an email, which no fellow traveller's tab needs for a rename.
|
||||
'member:updated': { scope: 'trip', payload: z.object({ member: z.strictObject({ id, username: z.string() }) }) },
|
||||
// The trip's linked journal changed. A signal only: what each member may see of
|
||||
// it differs, so every client asks for its own view of the link.
|
||||
'trip:journal:changed': { scope: 'trip', payload: empty },
|
||||
@@ -279,6 +285,24 @@ export const TREK_WS_EVENTS = {
|
||||
// ── Notifications (user-scoped) ──────────────────────────────────────────
|
||||
'notification:new': { scope: 'user', payload: z.object({ notification: entity }) },
|
||||
'notification:updated': { scope: 'user', payload: z.object({ notification: entity }) },
|
||||
// Every notification of the user was marked read at once: the bell reads its list again.
|
||||
'notification:read-all': { scope: 'user', payload: empty },
|
||||
|
||||
// ── The user's own lists and settings (user-scoped) ─────────────────────
|
||||
// A trip appeared on the user's list (created, copied): the dashboard reads it again.
|
||||
'trips:changed': { scope: 'user', payload: empty },
|
||||
// Display settings changed. Names the keys only, never their values: the
|
||||
// receiving tab reads the settings again through its own session. Strict, so a
|
||||
// payload that carries a value along fails the registry instead of passing
|
||||
// with the value stripped; only the MCP marker may come with it.
|
||||
'settings:updated': {
|
||||
scope: 'user',
|
||||
payload: z.strictObject({ keys: z.array(z.string()), _source: z.literal('mcp').optional() }),
|
||||
},
|
||||
// The user's tags changed (created, renamed, deleted): the tag pickers read them again.
|
||||
'tags:changed': { scope: 'user', payload: empty },
|
||||
// Countries, regions or bucket list entries changed: the atlas reads them again.
|
||||
'atlas:changed': { scope: 'user', payload: empty },
|
||||
|
||||
// ── Collections (user-scoped) ────────────────────────────────────────────
|
||||
'collections:updated': { scope: 'user', payload: z.object({ collectionId: id }) },
|
||||
@@ -309,6 +333,10 @@ export const TREK_WS_EVENTS = {
|
||||
'vacay:shared-update': { scope: 'user', payload: empty },
|
||||
|
||||
// ── Journey (user-scoped; broadcastJourneyEvent injects journeyId) ──────
|
||||
// The journey itself changed (title, trips, preferences) or went away: the
|
||||
// journey list and an open journey read it again, or leave it.
|
||||
'journey:updated': { scope: 'user', payload: z.object({ journeyId: id }) },
|
||||
'journey:deleted': { scope: 'user', payload: z.object({ journeyId: id }) },
|
||||
'journey:trip:synced': { scope: 'user', payload: z.object({ journeyId: id, tripId: id }) },
|
||||
'journey:entry:created': { scope: 'user', payload: z.object({ journeyId: id, entry: entity }) },
|
||||
// DRIFT: the soft-delete path emits { journeyId, entryId } under :updated
|
||||
|
||||
Reference in New Issue
Block a user