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:
Maurice
2026-10-22 00:03:34 +00:00
parent 5daa2a0311
commit 72ff9600f4
25 changed files with 2058 additions and 14 deletions
+29 -4
View File
@@ -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,
);
});
});
+29 -1
View File
@@ -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