mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
test(realtime): broadcast parity for every MCP write tool
A suite under tests/integration/broadcast-parity boots the app once per group, runs a write tool through a real MCP session the way the assistant attaches the registry, runs its REST twin on a twin scene, and records every broadcast. Per case it checks the exact events in order and count, that no MCP broadcast skips a socket (the asking tab hears its own change), that nothing is sent inside a transaction, that every payload parses with its registered schema, that REST sends the same sequence to the same room and recipients, and that MCP's keys cover REST's. A registry test lists the write tools as an MCP client sees them and fails on a tool without a row, a stale row, or an exemption without a reason; exemptions run too and must send nothing, and the tools still waiting for a case are frozen in a list that may only shrink. The new events the next changes send are registered now (member:updated, todo:reordered, trips:changed, settings:updated, tags:changed, notification:read-all, journey:updated, journey:deleted, atlas:changed); the client keeps them apart as awaiting a sender, and the journey page already leaves a journey that journey:deleted says is gone.
This commit is contained in:
@@ -54,6 +54,7 @@ const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
|
||||
'todo:updated': { item: { id: 4 } },
|
||||
'todo:deleted': { itemId: 4 },
|
||||
'todo:assignees': { category: 'before', assignees: [1] },
|
||||
'todo:reordered': { orderedIds: [4, 3] },
|
||||
'budget:created': { item: { id: 11 } },
|
||||
'budget:updated': { item: { id: 11 } },
|
||||
'budget:deleted': { itemId: 11 },
|
||||
@@ -75,6 +76,7 @@ const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
|
||||
'trip:deleted': { id: 1 },
|
||||
'member:added': { member: { user_id: 2 } },
|
||||
'member:removed': { userId: 2 },
|
||||
'member:updated': { member: { id: 2, username: 'Guest Ana' } },
|
||||
'trip:journal:changed': {},
|
||||
'docsync:changed': { linkId: 4, pulled: 2, pushed: 1 },
|
||||
'file:created': { file: { id: 13 } },
|
||||
@@ -96,6 +98,11 @@ const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
|
||||
'memories:updated': { userId: 1 },
|
||||
'notification:new': { notification: { id: 21, sender_username: 'ana' } },
|
||||
'notification:updated': { notification: { id: 21 } },
|
||||
'notification:read-all': {},
|
||||
'trips:changed': {},
|
||||
'settings:updated': { keys: ['temperature_unit', 'dark_mode'] },
|
||||
'tags:changed': {},
|
||||
'atlas:changed': {},
|
||||
'collections:updated': { collectionId: 4 },
|
||||
'collections:accepted': { collectionId: 4 },
|
||||
'collections:declined': { collectionId: 4 },
|
||||
@@ -114,6 +121,8 @@ const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
|
||||
'vacay:share': { from: { id: 1 } },
|
||||
'vacay:share-removed': {},
|
||||
'vacay:shared-update': {},
|
||||
'journey:updated': { journeyId: 3 },
|
||||
'journey:deleted': { journeyId: 3 },
|
||||
'journey:trip:synced': { journeyId: 3, tripId: 1 },
|
||||
'journey:entry:created': { journeyId: 3, entry: { id: 5 } },
|
||||
'journey:entry:updated': { journeyId: 3, entry: { id: 5 } },
|
||||
@@ -150,7 +159,7 @@ const DRIFT_VARIANTS: Partial<Record<TrekWsEventName, Record<string, unknown>[]>
|
||||
};
|
||||
|
||||
describe('@trek/shared realtime event registry', () => {
|
||||
it('WSEVT-REG-001: pins the authoritative inventory counts (77 trip + 34 user = 111)', () => {
|
||||
it('WSEVT-REG-001: pins the authoritative inventory counts (79 trip + 41 user = 120)', () => {
|
||||
// 67th to 69th trip event: the three collab:link:* a shared link emits.
|
||||
// 70th and 71st: the road trip's vias and tracks, which used to be written silently.
|
||||
// 74th: docsync:changed, so a sync run that moved documents refreshes the
|
||||
@@ -163,9 +172,14 @@ describe('@trek/shared realtime event registry', () => {
|
||||
// capture times of an import have landed (#1587).
|
||||
// 34th: journey:recording:updated, so every map of a journey shows a day's
|
||||
// recorded route once somebody took it over (JRN-01).
|
||||
expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(77);
|
||||
expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(34);
|
||||
expect(TREK_WS_EVENT_NAMES).toHaveLength(111);
|
||||
// 78th and 79th trip event, 35th to 41st user event: what the broadcast
|
||||
// parity work found no event for (todo:reordered, member:updated;
|
||||
// notification:read-all, trips:changed, settings:updated, tags:changed,
|
||||
// atlas:changed, journey:updated, journey:deleted). Registered first, the
|
||||
// writes that send them follow.
|
||||
expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(79);
|
||||
expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(41);
|
||||
expect(TREK_WS_EVENT_NAMES).toHaveLength(120);
|
||||
});
|
||||
|
||||
it('WSEVT-REG-002: every name is domain:action shaped and outside the reserved plugin: namespace', () => {
|
||||
@@ -206,5 +220,16 @@ describe('@trek/shared realtime event registry', () => {
|
||||
_source: 'mcp',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
const strict = TREK_WS_EVENTS['settings:updated'].payload.safeParse({ keys: ['dark_mode'], _source: 'mcp' });
|
||||
expect(strict.success).toBe(true);
|
||||
});
|
||||
|
||||
it('WSEVT-REG-008: settings:updated never carries a value, and member:updated no more than who and the name', () => {
|
||||
const settings = TREK_WS_EVENTS['settings:updated'].payload;
|
||||
expect(settings.safeParse({ keys: ['dark_mode'], values: { dark_mode: 'on' } }).success).toBe(false);
|
||||
const member = TREK_WS_EVENTS['member:updated'].payload;
|
||||
expect(member.safeParse({ member: { id: 2, username: 'Guest Ana', email: 'ana@example.org' } }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,8 @@ import { z } from 'zod';
|
||||
* schemas below describe the fields BESIDES `type`.
|
||||
* - MCP-originated broadcasts add a `_source: 'mcp'` marker key to the payload
|
||||
* (server/src/mcp/tools/_shared.ts). Zod object schemas strip unknown keys,
|
||||
* so it needs no per-event modeling.
|
||||
* so it needs no per-event modeling, except in a strict schema, which names
|
||||
* it (`settings:updated`).
|
||||
*
|
||||
* Deliberately NOT in the registry:
|
||||
* - The dynamic plugin namespace: trip broadcasts force-namespaced to
|
||||
@@ -161,6 +162,8 @@ export const TREK_WS_EVENTS = {
|
||||
scope: 'trip',
|
||||
payload: z.object({ category: z.string(), assignees: z.array(z.unknown()) }),
|
||||
},
|
||||
// The list's new order, as packing:reordered carries it.
|
||||
'todo:reordered': { scope: 'trip', payload: z.object({ orderedIds: idList }) },
|
||||
|
||||
// ── Budget ───────────────────────────────────────────────────────────────
|
||||
'budget:created': { scope: 'trip', payload: z.object({ item: entity }) },
|
||||
@@ -232,6 +235,9 @@ export const TREK_WS_EVENTS = {
|
||||
'trip:deleted': { scope: 'trip', payload: z.object({ id }) },
|
||||
'member:added': { scope: 'trip', payload: z.object({ member: entity }) },
|
||||
'member:removed': { scope: 'trip', payload: z.object({ userId: id }) },
|
||||
// A guest renamed: who it is and the new name, nothing more. A member row
|
||||
// can carry an email, which no fellow traveller's tab needs for a rename.
|
||||
'member:updated': { scope: 'trip', payload: z.object({ member: z.strictObject({ id, username: z.string() }) }) },
|
||||
// The trip's linked journal changed. A signal only: what each member may see of
|
||||
// it differs, so every client asks for its own view of the link.
|
||||
'trip:journal:changed': { scope: 'trip', payload: empty },
|
||||
@@ -279,6 +285,24 @@ export const TREK_WS_EVENTS = {
|
||||
// ── Notifications (user-scoped) ──────────────────────────────────────────
|
||||
'notification:new': { scope: 'user', payload: z.object({ notification: entity }) },
|
||||
'notification:updated': { scope: 'user', payload: z.object({ notification: entity }) },
|
||||
// Every notification of the user was marked read at once: the bell reads its list again.
|
||||
'notification:read-all': { scope: 'user', payload: empty },
|
||||
|
||||
// ── The user's own lists and settings (user-scoped) ─────────────────────
|
||||
// A trip appeared on the user's list (created, copied): the dashboard reads it again.
|
||||
'trips:changed': { scope: 'user', payload: empty },
|
||||
// Display settings changed. Names the keys only, never their values: the
|
||||
// receiving tab reads the settings again through its own session. Strict, so a
|
||||
// payload that carries a value along fails the registry instead of passing
|
||||
// with the value stripped; only the MCP marker may come with it.
|
||||
'settings:updated': {
|
||||
scope: 'user',
|
||||
payload: z.strictObject({ keys: z.array(z.string()), _source: z.literal('mcp').optional() }),
|
||||
},
|
||||
// The user's tags changed (created, renamed, deleted): the tag pickers read them again.
|
||||
'tags:changed': { scope: 'user', payload: empty },
|
||||
// Countries, regions or bucket list entries changed: the atlas reads them again.
|
||||
'atlas:changed': { scope: 'user', payload: empty },
|
||||
|
||||
// ── Collections (user-scoped) ────────────────────────────────────────────
|
||||
'collections:updated': { scope: 'user', payload: z.object({ collectionId: id }) },
|
||||
@@ -309,6 +333,10 @@ export const TREK_WS_EVENTS = {
|
||||
'vacay:shared-update': { scope: 'user', payload: empty },
|
||||
|
||||
// ── Journey (user-scoped; broadcastJourneyEvent injects journeyId) ──────
|
||||
// The journey itself changed (title, trips, preferences) or went away: the
|
||||
// journey list and an open journey read it again, or leave it.
|
||||
'journey:updated': { scope: 'user', payload: z.object({ journeyId: id }) },
|
||||
'journey:deleted': { scope: 'user', payload: z.object({ journeyId: id }) },
|
||||
'journey:trip:synced': { scope: 'user', payload: z.object({ journeyId: id, tripId: id }) },
|
||||
'journey:entry:created': { scope: 'user', payload: z.object({ journeyId: id, entry: entity }) },
|
||||
// DRIFT: the soft-delete path emits { journeyId, entryId } under :updated
|
||||
|
||||
Reference in New Issue
Block a user