test(server): suites record broadcasts on a FakeRealtimeService instead of mocking src/websocket

A hand-built service gets a FakeRealtimeService where it took a RealtimeService, a Nest testing module overrides the provider, and a suite over buildApp() spies on the container's instance (spyOnRealtime). The 167 vi.mock factories on src/websocket are gone, RealtimeService reads the socket registry in ws-state.ts directly, and the re-export stub src/websocket.ts is deleted. The facade's own suite now checks what reaches a socket instead of which module function it called.
This commit is contained in:
Maurice
2026-10-10 18:23:04 +02:00
parent 00cbc5aae3
commit fa0edfd714
181 changed files with 584 additions and 561 deletions
@@ -12,7 +12,7 @@ import type { TripEventSink, TripWriter } from '../common/trip-writer';
* The impure MCP tool guards that used to live as plain functions in
* src/mcp/tools/_shared.ts, reaching the db Proxy, a permissions.bridge
* instance (no longer exists — see auth.service.ts:116) and the
* src/websocket stub as module globals. The @McpController domain classes are
* websocket broadcast functions as module globals. The @McpController domain classes are
* ordinary Nest providers, so they inject this instead. The pure result
* helpers (noAccess/permissionDenied/adminRequired and the
* src/nest-mcp re-exports) stay in _shared.ts — they carry no dependencies.
@@ -50,8 +50,8 @@ export class McpToolGuardsService {
try {
// Legacy MCP call sites pass event names as plain strings; route through
// the facade's implementation signature (its typed overloads are for new
// call sites). Runtime is a pure pass-through to src/websocket's
// broadcast, so the 100+ per-file vi.mock stubs keep intercepting.
// call sites). Runtime is a pure pass-through to the transport's
// broadcast.
const send = this.realtime.broadcast as (
t: number | string, e: string, p: Record<string, unknown>, sid?: number | string, uid?: number,
) => void;
@@ -33,7 +33,7 @@ import type { JourneyPhotosRepository } from '../../db/repositories/JourneyPhoto
*
* File/test renamed to match (Plan 4 Task 8a — 3e Task 6 renamed only the
* class). The old path, `trek-photos.repository.ts`, was kept as a thin
* re-export stub (the same `src/websocket.ts` shape) while its ~20
* re-export stub while its ~20
* importers sat in sibling tasks' exclusively-owned windows on this shared
* branch; Plan 4 Task 4 repointed every one of them at this file directly
* and deleted the stub.
+1 -1
View File
@@ -47,7 +47,7 @@ const HEARTBEAT_INTERVAL = 30_000;
* What did NOT move is the socket registry. It stays one process-wide
* instance in ws-state.ts (the RoomRegistry port, installed in `roomsSlot` by
* RealtimeGatewayModule) because out-of-container code (the no-Nest test
* harnesses, the vi.mock'd src/websocket seam) must see the same rooms; see
* harnesses, hand-built RealtimeService instances) must see the same rooms; see
* the note there.
*
* The wire protocol is unchanged, down to the frame names. TrekWsAdapter maps
+15 -20
View File
@@ -5,31 +5,28 @@ import type {
TrekWsTripEventName,
TrekWsUserEventName,
} from '@trek/shared';
import { broadcast, broadcastToUser, getOnlineUserIds } from '../../websocket';
import { broadcast, broadcastToUser, getOnlineUserIds } from './ws-state';
/**
* Injectable facade over the websocket module singleton (roadmap Phase 0 item 3).
* Injectable facade over the socket registry in ws-state.ts.
*
* Migrated Nest services inject this instead of importing `broadcast`/
* `broadcastToUser` module globals. The actual transport (rooms, auth,
* heartbeat, rate limiting) stays in src/websocket.ts — a future
* @WebSocketGateway swap happens behind this facade, invisibly.
* Nest services inject this instead of importing the `broadcast`/
* `broadcastToUser` module functions. The transport itself (rooms, the
* handshake, the wire protocol) is RealtimeGateway, TrekWsAdapter and
* ws-state.ts next door.
*
* Both methods delegate to the live module exports *at call time* (same
* pattern as DatabaseService.canAccessTrip): tests vi.mock src/websocket
* per file, and the stubs must keep flowing through here. Never capture
* the functions at construction, and never dereference an export the
* method wasn't asked for — many test mocks provide `broadcast` only.
* Tests observe broadcasts through DI: a FakeRealtimeService
* (tests/helpers/fake-realtime.ts) takes this class's place, so nothing has to
* mock the transport module.
*
* The service is deliberately dependency-free so out-of-container code (the
* auth.bridge graph, the no-Nest test harnesses) can construct it with a bare
* `new RealtimeService()`.
* no-Nest test harnesses) can construct it with a bare `new RealtimeService()`.
*/
@Injectable()
export class RealtimeService {
/**
* Broadcast an event to all sockets in a trip room. Mirrors the
* websocket.ts signature exactly: `excludeSid` is the X-Socket-Id
* ws-state.ts signature exactly: `excludeSid` is the X-Socket-Id
* echo-suppression contract (the originating client is skipped);
* `onlyUserId` narrows delivery to one user's sockets (#858 private
* packing items).
@@ -40,9 +37,8 @@ export class RealtimeService {
* ships silence. The `plugin:` overload is the deliberate escape hatch for
* the host's force-namespaced `plugin:<id>:<event>` broadcasts.
*
* Rest-spread keeps the caller's exact argument arity — dozens of test
* mocks assert the precise call shape (3, 4 or 5 args), so the facade
* must not pad omitted optionals with explicit `undefined`s.
* Rest-spread keeps the caller's exact argument arity: the facade must not
* pad omitted optionals with explicit `undefined`s.
*/
broadcast<E extends TrekWsTripEventName>(
tripId: number | string,
@@ -94,12 +90,11 @@ export class RealtimeService {
/**
* Which users currently hold an open socket.
*
* AdminService read this through a lazy `require('../../websocket')` inside a
* AdminService read this through a lazy `require()` of the transport inside a
* try/catch, which is what the facade exists to avoid. Deliberately NOT
* guarded here: a guard on the facade would swallow the failure for every
* future caller, and the one caller that wants a degraded answer rather than
* an error keeps its own catch. Delegates at call time, like its two
* siblings, so per-file vi.mock stubs keep flowing through.
* an error keeps its own catch.
*/
getOnlineUserIds(): Set<number> {
return getOnlineUserIds();
+4 -5
View File
@@ -9,11 +9,10 @@ import { ProcessStoreSlot } from '../common/process-store-slot';
*
* MODULE state, not provider state, and that is load-bearing: the rooms live
* in the registry `roomsSlot` holds, which the container's provider replaces
* when RealtimeGatewayModule is built and nothing else does. The no-Nest test
* harnesses hand-build RealtimeService instances (mcp-test-controllers.ts and
* ~60 unit suites), and the 115 vi.mock('src/websocket') seams assert on these
* exact exports. If the rooms lived on a provider instance, an out-of-container
* broadcast would go to an empty map: no error, no log, just a client that
* when RealtimeGatewayModule is built and nothing else does. Hand-built
* RealtimeService instances (the no-Nest test harnesses, out-of-container
* consumers) deliver through these same functions. If the rooms lived on a
* provider instance, an out-of-container broadcast would go to an empty map: no error, no log, just a client that
* stops updating. Same reasoning as the geo throttle cursor and the
* permissions cache.
*
-14
View File
@@ -1,14 +0,0 @@
/**
* The transport's public surface, for code outside the Nest container.
*
* The implementation moved to nest/realtime: the connection lifecycle and the
* handshake are RealtimeGateway, the wire protocol is TrekWsAdapter, and the
* socket registry is ws-state.ts. This file stays because it is the import path
* the rest of the tree knows, including the more than 160 test files that `vi.mock` it to
* assert on broadcasts. Repointing them all would be a large diff that proves
* nothing, and this re-export keeps every one of those mocks working.
*
* `setupWebSocket` is gone: the server is created by the adapter now, when Nest
* initialises the gateway, rather than by index.ts after listen().
*/
export { broadcast, broadcastToUser, getOnlineUserIds } from './nest/realtime/ws-state';