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
+19
View File
@@ -72,6 +72,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
'vacay:share-removed',
'vacay:shared-update',
// Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener)
// reads the open journey again, and leaves it once journey:deleted says it is gone.
'journey:updated',
'journey:deleted',
'journey:trip:synced',
'journey:entry:created',
'journey:entry:updated',
@@ -142,3 +145,19 @@ export const IGNORED_WS_EVENTS = [
'member:added',
'member:removed',
] as const satisfies readonly TrekWsEventName[]
/**
* Events registered ahead of the write that sends them: nothing on the server
* sends these yet, so there is nothing for the client to act on. They are not
* a decision to ignore. The change that adds a sender moves its event to a
* handler, and the list only shrinks.
*/
export const AWAITING_SENDER_WS_EVENTS = [
'member:updated',
'todo:reordered',
'trips:changed',
'settings:updated',
'tags:changed',
'notification:read-all',
'atlas:changed',
] as const satisfies readonly TrekWsEventName[]
@@ -1,4 +1,4 @@
// FE-JRN-DETHOOK-001 to FE-JRN-DETHOOK-031
// FE-JRN-DETHOOK-001 to FE-JRN-DETHOOK-032
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
import { useLocation } from 'react-router';
import { delay, http, HttpResponse } from 'msw';
@@ -243,6 +243,23 @@ describe('useJourneyDetail', () => {
expect(latest.current?.title).toBe('Japan 2026');
});
it('FE-JRN-DETHOOK-032: a journey deleted elsewhere returns to the journey list instead of reading it again', async () => {
setup();
await waitFor(() => expect(latest.current).not.toBeNull());
const handler = vi.mocked(addListener).mock.calls[0][0] as (e: Record<string, unknown>) => void;
const reads = vi.fn();
server.use(http.get('/api/journeys/7', () => {
reads();
return HttpResponse.json({ error: 'gone' }, { status: 404 });
}));
handler({ type: 'journey:deleted', journeyId: 7 });
expect(mockNavigate).toHaveBeenCalledWith('/journey');
expect(addToast).toHaveBeenCalledWith('Journey deleted', 'info', undefined);
await new Promise(r => setTimeout(r, 20));
expect(reads).not.toHaveBeenCalled();
});
it('FE-JRN-DETHOOK-010: the websocket listener is detached on unmount', async () => {
const { unmount } = setup();
await waitFor(() => expect(addListener).toHaveBeenCalled());
@@ -123,6 +123,15 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
const type = event.type as string
if (!type?.startsWith('journey:')) return
if (event.journeyId !== journeyId) return
// Its owner deleted it elsewhere: there is nothing left to read again, so
// the page goes back to the list (a journal shown inside a trip stays put).
if (type === 'journey:deleted') {
if (!embedded) {
toast.info(t('journey.deleted'))
void navigate('/journey')
}
return
}
// reload journey data on any change from other contributors
loadJourney(journeyId)
}