diff --git a/client/scripts/i18n-unused-baseline.json b/client/scripts/i18n-unused-baseline.json index 04c6e7f33..8f69e89df 100644 --- a/client/scripts/i18n-unused-baseline.json +++ b/client/scripts/i18n-unused-baseline.json @@ -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, diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index 07bb2c16e..fac299062 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -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[] diff --git a/client/src/pages/journeyDetail/useJourneyDetail.test.tsx b/client/src/pages/journeyDetail/useJourneyDetail.test.tsx index 6d264beb5..818788be7 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.test.tsx +++ b/client/src/pages/journeyDetail/useJourneyDetail.test.tsx @@ -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) => 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()); diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index c577a1e8c..6ccc6a27a 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -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) } diff --git a/client/tests/unit/remoteEventHandler/registry-parity.test.ts b/client/tests/unit/remoteEventHandler/registry-parity.test.ts index a70f26b0b..8ea817774 100644 --- a/client/tests/unit/remoteEventHandler/registry-parity.test.ts +++ b/client/tests/unit/remoteEventHandler/registry-parity.test.ts @@ -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([ ]); const handledElsewhere = new Set(HANDLED_OUTSIDE_TRIP_STORE); const ignored = new Set(IGNORED_WS_EVENTS); +const awaitingSender = new Set(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(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([]); + }); }); diff --git a/server/tests/integration/broadcast-parity/bookings.cases.ts b/server/tests/integration/broadcast-parity/bookings.cases.ts new file mode 100644 index 000000000..8f9736a0e --- /dev/null +++ b/server/tests/integration/broadcast-parity/bookings.cases.ts @@ -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', + ], +}; diff --git a/server/tests/integration/broadcast-parity/bookings.parity.test.ts b/server/tests/integration/broadcast-parity/bookings.parity.test.ts new file mode 100644 index 000000000..f9563bf72 --- /dev/null +++ b/server/tests/integration/broadcast-parity/bookings.parity.test.ts @@ -0,0 +1,4 @@ +import { bookings } from './bookings.cases'; +import { runGroup } from './run-group'; + +runGroup(bookings); diff --git a/server/tests/integration/broadcast-parity/collab-trip.cases.ts b/server/tests/integration/broadcast-parity/collab-trip.cases.ts new file mode 100644 index 000000000..e7f2e5d9b --- /dev/null +++ b/server/tests/integration/broadcast-parity/collab-trip.cases.ts @@ -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', + ], +}; diff --git a/server/tests/integration/broadcast-parity/collab-trip.parity.test.ts b/server/tests/integration/broadcast-parity/collab-trip.parity.test.ts new file mode 100644 index 000000000..5a723e16b --- /dev/null +++ b/server/tests/integration/broadcast-parity/collab-trip.parity.test.ts @@ -0,0 +1,4 @@ +import { collabTrip } from './collab-trip.cases'; +import { runGroup } from './run-group'; + +runGroup(collabTrip); diff --git a/server/tests/integration/broadcast-parity/harness.ts b/server/tests/integration/broadcast-parity/harness.ts new file mode 100644 index 000000000..7b126eb15 --- /dev/null +++ b/server/tests/integration/broadcast-parity/harness.ts @@ -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; +} + +/** 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, + 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, 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[] { + return Object.keys(payload) + .filter((key) => key !== '_source') + .sort(); +} + +type TripSend = [ + tripId: number | string, + event: string, + payload: Record, + excludeSid?: number | string, + onlyUserId?: number, +]; +type UserSend = [userId: number, message: Record, 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>; + +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; + /** Calls one tool as `userId`, over a session holding only that tool. */ + callTool(userId: number, tool: string, args: Record): Promise; + /** Every tool a full session of `userId` lists, with its read-only hint. */ + listTools(userId: number): Promise; + /** 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; +} + +/** + * 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 { + 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(userId: number, only: string | null, use: (client: Client) => Promise): Promise { + 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 = { + 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> = { + assignment: ['place'], + budgetItem: ['member'], + settlement: ['member'], +}; + +type Building = Partial & Pick; + +/** A row an earlier need put on the scene. */ +function built(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; + +const BUILDERS: Record = { + 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 { + const users = new Map(); + 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 { + const out = new Set(); + 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 { + 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; +} diff --git a/server/tests/integration/broadcast-parity/index.ts b/server/tests/integration/broadcast-parity/index.ts new file mode 100644 index 000000000..825d2f054 --- /dev/null +++ b/server/tests/integration/broadcast-parity/index.ts @@ -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]; diff --git a/server/tests/integration/broadcast-parity/money.cases.ts b/server/tests/integration/broadcast-parity/money.cases.ts new file mode 100644 index 000000000..5cdd05f73 --- /dev/null +++ b/server/tests/integration/broadcast-parity/money.cases.ts @@ -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', + ], +}; diff --git a/server/tests/integration/broadcast-parity/money.parity.test.ts b/server/tests/integration/broadcast-parity/money.parity.test.ts new file mode 100644 index 000000000..09a674082 --- /dev/null +++ b/server/tests/integration/broadcast-parity/money.parity.test.ts @@ -0,0 +1,4 @@ +import { money } from './money.cases'; +import { runGroup } from './run-group'; + +runGroup(money); diff --git a/server/tests/integration/broadcast-parity/pending-ceiling.ts b/server/tests/integration/broadcast-parity/pending-ceiling.ts new file mode 100644 index 000000000..7cdc7b774 --- /dev/null +++ b/server/tests/integration/broadcast-parity/pending-ceiling.ts @@ -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', +]; diff --git a/server/tests/integration/broadcast-parity/plan.cases.ts b/server/tests/integration/broadcast-parity/plan.cases.ts new file mode 100644 index 000000000..582bd0940 --- /dev/null +++ b/server/tests/integration/broadcast-parity/plan.cases.ts @@ -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', + ], +}; diff --git a/server/tests/integration/broadcast-parity/plan.parity.test.ts b/server/tests/integration/broadcast-parity/plan.parity.test.ts new file mode 100644 index 000000000..b917270ab --- /dev/null +++ b/server/tests/integration/broadcast-parity/plan.parity.test.ts @@ -0,0 +1,4 @@ +import { plan } from './plan.cases'; +import { runGroup } from './run-group'; + +runGroup(plan); diff --git a/server/tests/integration/broadcast-parity/registry.parity.test.ts b/server/tests/integration/broadcast-parity/registry.parity.test.ts new file mode 100644 index 000000000..ccb56c2ba --- /dev/null +++ b/server/tests/integration/broadcast-parity/registry.parity.test.ts @@ -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 { + const rows = new Map(); + 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 }, + ]); + }); +}); diff --git a/server/tests/integration/broadcast-parity/run-group.ts b/server/tests/integration/broadcast-parity/run-group.ts new file mode 100644 index 000000000..fd1f49164 --- /dev/null +++ b/server/tests/integration/broadcast-parity/run-group.ts @@ -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>; + /** 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((resolve) => setImmediate(resolve)); + +async function runCase(h: ParityHarness, c: ParityCase): Promise { + 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 { + 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)); + }); + }); +} diff --git a/server/tests/integration/broadcast-parity/tours-roadtrip.cases.ts b/server/tests/integration/broadcast-parity/tours-roadtrip.cases.ts new file mode 100644 index 000000000..a337b8ac4 --- /dev/null +++ b/server/tests/integration/broadcast-parity/tours-roadtrip.cases.ts @@ -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', + ], +}; diff --git a/server/tests/integration/broadcast-parity/tours-roadtrip.parity.test.ts b/server/tests/integration/broadcast-parity/tours-roadtrip.parity.test.ts new file mode 100644 index 000000000..d53398f06 --- /dev/null +++ b/server/tests/integration/broadcast-parity/tours-roadtrip.parity.test.ts @@ -0,0 +1,4 @@ +import { runGroup } from './run-group'; +import { toursRoadtrip } from './tours-roadtrip.cases'; + +runGroup(toursRoadtrip); diff --git a/server/tests/integration/broadcast-parity/types.ts b/server/tests/integration/broadcast-parity/types.ts new file mode 100644 index 000000000..01fdd4766 --- /dev/null +++ b/server/tests/integration/broadcast-parity/types.ts @@ -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; + /** More rows than the needs give, per scene, before the write. */ + setup?: (s: Scene, app: INestApplication) => Promise; +} + +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 }; +} + +export interface CaseGroup { + name: string; + /** The test id prefix of the group's cases, `-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[]; +} diff --git a/server/tests/integration/broadcast-parity/user.cases.ts b/server/tests/integration/broadcast-parity/user.cases.ts new file mode 100644 index 000000000..65b055e63 --- /dev/null +++ b/server/tests/integration/broadcast-parity/user.cases.ts @@ -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', + ], +}; diff --git a/server/tests/integration/broadcast-parity/user.parity.test.ts b/server/tests/integration/broadcast-parity/user.parity.test.ts new file mode 100644 index 000000000..652b83bef --- /dev/null +++ b/server/tests/integration/broadcast-parity/user.parity.test.ts @@ -0,0 +1,4 @@ +import { runGroup } from './run-group'; +import { user } from './user.cases'; + +runGroup(user); diff --git a/shared/src/realtime/events.schema.spec.ts b/shared/src/realtime/events.schema.spec.ts index 67c393fba..a30cba7cd 100644 --- a/shared/src/realtime/events.schema.spec.ts +++ b/shared/src/realtime/events.schema.spec.ts @@ -54,6 +54,7 @@ const FIXTURES: Record> = { '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> = { '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> = { '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> = { '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[]> }; 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, + ); }); }); diff --git a/shared/src/realtime/events.schema.ts b/shared/src/realtime/events.schema.ts index 98e474c18..9d94b87bb 100644 --- a/shared/src/realtime/events.schema.ts +++ b/shared/src/realtime/events.schema.ts @@ -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