From fa0edfd71464d8dccee11383743b46749545d17d Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 10 Oct 2026 18:23:04 +0200 Subject: [PATCH] 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. --- CLAUDE.md | 2 +- server/CLAUDE.md | 2 +- .../mcp-shared/mcp-tool-guards.service.ts | 6 +- .../photos/trek-photo-registration.service.ts | 2 +- server/src/nest/realtime/realtime.gateway.ts | 2 +- server/src/nest/realtime/realtime.service.ts | 35 ++-- server/src/nest/realtime/ws-state.ts | 9 +- server/src/websocket.ts | 14 -- server/tests/e2e/assignments.e2e.test.ts | 13 +- server/tests/e2e/atlas.e2e.test.ts | 2 - server/tests/e2e/auth.e2e.test.ts | 1 - server/tests/e2e/booking-import.e2e.test.ts | 1 - server/tests/e2e/budget.e2e.test.ts | 5 +- server/tests/e2e/collab.e2e.test.ts | 5 +- server/tests/e2e/collections.e2e.test.ts | 3 +- server/tests/e2e/dawarich.e2e.test.ts | 1 - server/tests/e2e/days.e2e.test.ts | 3 +- server/tests/e2e/doc-sync.e2e.test.ts | 1 - server/tests/e2e/feeds.e2e.test.ts | 1 - server/tests/e2e/files.e2e.test.ts | 5 +- server/tests/e2e/mailer.e2e.test.ts | 1 - server/tests/e2e/memories.e2e.test.ts | 1 - server/tests/e2e/oidc.e2e.test.ts | 1 - server/tests/e2e/packing.e2e.test.ts | 5 +- server/tests/e2e/places.e2e.test.ts | 13 +- server/tests/e2e/plugin-pois.e2e.test.ts | 1 - server/tests/e2e/plugin-search.e2e.test.ts | 1 - server/tests/e2e/receipt-scan.e2e.test.ts | 1 - server/tests/e2e/reservations.e2e.test.ts | 5 +- server/tests/e2e/roadtrip.e2e.test.ts | 3 +- .../tests/e2e/session-revocation.e2e.test.ts | 1 - server/tests/e2e/sessions.e2e.test.ts | 1 - server/tests/e2e/todo.e2e.test.ts | 5 +- server/tests/e2e/tours.e2e.test.ts | 13 +- server/tests/e2e/trips.e2e.test.ts | 3 +- server/tests/e2e/vacay.e2e.test.ts | 16 +- .../tests/helpers/accommodations-service.ts | 13 +- server/tests/helpers/fake-realtime.ts | 88 +++++++++ server/tests/helpers/mcp-harness.ts | 11 +- server/tests/helpers/mcp-test-controllers.ts | 3 +- server/tests/integration/admin.test.ts | 1 - .../airtrail-sync-request-context.test.ts | 1 - server/tests/integration/api-docs.test.ts | 1 - server/tests/integration/app-config.test.ts | 1 - server/tests/integration/assignments.test.ts | 1 - .../async-closure-truthiness-guards.test.ts | 1 - .../atlas-geocode-request-context.test.ts | 1 - server/tests/integration/atlas.test.ts | 1 - server/tests/integration/auth.test.ts | 1 - server/tests/integration/backup.test.ts | 1 - .../boot-sweeps-request-context.test.ts | 1 - server/tests/integration/bootstrap.test.ts | 1 - server/tests/integration/budget.test.ts | 1 - server/tests/integration/categories.test.ts | 1 - server/tests/integration/collab.test.ts | 1 - server/tests/integration/collections.test.ts | 1 - server/tests/integration/dayNotes.test.ts | 1 - server/tests/integration/days.test.ts | 1 - .../doc-sync-webhook-request-context.test.ts | 1 - .../integration/feeds-request-context.test.ts | 1 - server/tests/integration/files.test.ts | 1 - server/tests/integration/help.test.ts | 1 - server/tests/integration/immich.test.ts | 1 - .../integration/journey-place-order.test.ts | 5 - server/tests/integration/journey.test.ts | 5 - .../integration/leg-mode-incoming.test.ts | 1 - .../integration/legacy-upgrade-v066.test.ts | 5 - .../integration/legacy-upgrade-v082.test.ts | 5 - .../integration/legacy-upgrade-v205.test.ts | 5 - .../integration/legacy-upgrade-v242.test.ts | 5 - .../integration/legacy-upgrade-v244.test.ts | 5 - server/tests/integration/maps.test.ts | 1 - server/tests/integration/mcp.test.ts | 1 - .../tests/integration/memories-immich.test.ts | 1 - .../integration/memories-synology.test.ts | 1 - .../integration/memories-unified.test.ts | 2 - server/tests/integration/misc.test.ts | 1 - .../tests/integration/notifications.test.ts | 1 - .../tests/integration/oauth-authorize.test.ts | 1 - server/tests/integration/oauth.test.ts | 1 - .../integration/oidc-local-provider.test.ts | 1 - server/tests/integration/oidc.test.ts | 1 - .../orm-request-context-seams.test.ts | 1 - server/tests/integration/packing.test.ts | 1 - server/tests/integration/passkey.test.ts | 1 - server/tests/integration/places.test.ts | 7 +- .../plugins/boot-registry-order.test.ts | 1 - .../integration/plugins/dev-link.test.ts | 1 - .../plugins/plugin-runtime.test.ts | 1 - server/tests/integration/profile.test.ts | 1 - server/tests/integration/reservations.test.ts | 1 - .../tests/integration/scheduler-gate.test.ts | 1 - server/tests/integration/security.test.ts | 1 - server/tests/integration/settings.test.ts | 1 - server/tests/integration/share.test.ts | 1 - ...ge-health-notifier-request-context.test.ts | 1 - .../tests/integration/systemNotices.test.ts | 1 - server/tests/integration/tags.test.ts | 1 - server/tests/integration/todo.test.ts | 1 - .../trip-access-primitives.test.ts | 1 - server/tests/integration/trips.test.ts | 1 - .../uploads-photo-request-context.test.ts | 1 - .../tests/integration/uploads-static.test.ts | 1 - server/tests/integration/vacay.test.ts | 1 - server/tests/unit/mcp/demo-tool-gate.test.ts | 1 - .../tests/unit/mcp/tools-addon-gating.test.ts | 9 +- server/tests/unit/mcp/tools-airtrail.test.ts | 9 +- ...ols-assignments-reservations-extra.test.ts | 9 +- .../tests/unit/mcp/tools-assignments.test.ts | 9 +- .../unit/mcp/tools-atlas-expanded.test.ts | 11 +- server/tests/unit/mcp/tools-atlas.test.ts | 2 - .../unit/mcp/tools-budget-advanced.test.ts | 11 +- server/tests/unit/mcp/tools-budget.test.ts | 9 +- .../unit/mcp/tools-collab-polls-chat.test.ts | 11 +- .../tests/unit/mcp/tools-collections.test.ts | 11 +- .../mcp/tools-days-accommodations.test.ts | 9 +- server/tests/unit/mcp/tools-days.test.ts | 9 +- .../tests/unit/mcp/tools-feeds-invite.test.ts | 1 - server/tests/unit/mcp/tools-files.test.ts | 11 +- .../tests/unit/mcp/tools-help-addons.test.ts | 9 +- server/tests/unit/mcp/tools-journey.test.ts | 13 +- .../unit/mcp/tools-leg-transport-mode.test.ts | 9 +- server/tests/unit/mcp/tools-memories.test.ts | 11 +- server/tests/unit/mcp/tools-notes.test.ts | 12 +- .../unit/mcp/tools-notifications.test.ts | 2 - .../unit/mcp/tools-packing-advanced.test.ts | 9 +- server/tests/unit/mcp/tools-packing.test.ts | 11 +- server/tests/unit/mcp/tools-places.test.ts | 9 +- .../tests/unit/mcp/tools-plugin-pois.test.ts | 1 - .../unit/mcp/tools-plugin-search.test.ts | 10 +- server/tests/unit/mcp/tools-prompts.test.ts | 4 - .../tests/unit/mcp/tools-reservations.test.ts | 9 +- server/tests/unit/mcp/tools-roadtrip.test.ts | 1 - server/tests/unit/mcp/tools-settings.test.ts | 2 - server/tests/unit/mcp/tools-share.test.ts | 1 - .../unit/mcp/tools-tags-maps-weather.test.ts | 11 +- server/tests/unit/mcp/tools-todos.test.ts | 11 +- server/tests/unit/mcp/tools-transit.test.ts | 11 +- .../tests/unit/mcp/tools-transports.test.ts | 9 +- .../tests/unit/mcp/tools-trip-members.test.ts | 9 +- .../unit/mcp/tools-trip-warnings.test.ts | 10 +- server/tests/unit/mcp/tools-trips.test.ts | 17 +- server/tests/unit/mcp/tools-vacay.test.ts | 11 +- .../unit/nest/accommodations.service.test.ts | 11 +- .../nest/admin.version-notification.test.ts | 1 - .../unit/nest/assignments.service.test.ts | 2 - .../booking-import.service.test.ts | 1 - .../import-jobs.service.test.ts | 10 +- .../unit/nest/budget.service.calc.test.ts | 1 - .../tests/unit/nest/budget.service.db.test.ts | 2 - server/tests/unit/nest/budget.service.test.ts | 10 +- .../tests/unit/nest/calendar.service.test.ts | 11 +- .../tests/unit/nest/channel-override.test.ts | 1 - server/tests/unit/nest/collab.service.test.ts | 2 - .../unit/nest/collections.service.test.ts | 10 +- .../nest/dawarich-suggestions.service.test.ts | 2 - .../tests/unit/nest/day-notes.service.test.ts | 1 - .../unit/nest/day-removal.service.test.ts | 1 - server/tests/unit/nest/days.service.test.ts | 2 - .../nest/doc-sync/doc-sync.controller.test.ts | 3 - server/tests/unit/nest/files.service.test.ts | 1 - .../unit/nest/journey-book.service.test.ts | 2 - .../unit/nest/journey-domain.service.test.ts | 2 - .../unit/nest/journey-stats.service.test.ts | 2 - .../unit/nest/mcp-shared.service.test.ts | 13 +- .../tests/unit/nest/memories.service.test.ts | 12 +- .../nest/notifications.inapp-prefs.test.ts | 14 +- .../unit/nest/notifications.service.test.ts | 13 +- .../notifications/web-push-dispatch.test.ts | 1 - .../tests/unit/nest/packing.service.test.ts | 10 +- .../tests/unit/nest/realtime.service.test.ts | 171 ++++++++++++------ .../unit/nest/reminder-jobs.service.test.ts | 1 - .../unit/nest/reservations.service.test.ts | 12 +- server/tests/unit/nest/todo.service.test.ts | 2 - .../unit/nest/trip-members.service.test.ts | 13 +- .../unit/nest/trip-read-model.service.test.ts | 31 ++-- server/tests/unit/nest/trips.service.test.ts | 37 ++-- .../unit/nest/user-cleanup.service.test.ts | 1 - server/tests/unit/nest/vacay.service.test.ts | 2 - .../unit/plugins/event-subscriptions.test.ts | 2 +- server/tests/unit/plugins/host-rpc.test.ts | 10 +- 181 files changed, 584 insertions(+), 561 deletions(-) delete mode 100644 server/src/websocket.ts create mode 100644 server/tests/helpers/fake-realtime.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9ed7832a4..b92e3476e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ Nest owns everything: every domain is a DI module under `server/src/nest/')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler). - **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests** — the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` test builders are gone. Seeding is MikroORM's too (`db/seeders/`), applied at boot from `buildApp()`; the ORM shares the one better-sqlite3 connection (bound in `server/src/db/orm-driver.ts`) rather than opening its own. Queries go through repositories (`server/src/db/repositories/`) and `UnitOfWork` (`server/src/nest/database/unit-of-work.ts`) for transactions — there is no `DatabaseService`. `better-sqlite3` itself is ESLint-restricted to `src/db/` plus a short, named allow-list (`server/eslint.config.mjs`), so a domain reaching for the driver directly fails lint. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB. -- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub. +- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`. Tests observe broadcasts through a `FakeRealtimeService` (`server/tests/helpers/fake-realtime.ts`), never by mocking the transport module. - **MCP**: tools are declared in `.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`. - **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`. - **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 7dda55b31..f6707e3f1 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -66,7 +66,7 @@ Single test: `npx vitest run tests/unit/nest/weather.controller.test.ts`, or `np Nest owns everything. Every domain is a DI module under `src/nest//` (`controller` + `service` + `module`, registered in `src/nest/app.module.ts`); business logic lives in the injectable `.service.ts`, pure halves in `.helpers.ts`, SQL in a `.repository.ts` where one exists. There is no legacy service layer — ESLint errors on any `services/` import. **`weather/` is the reference module shape; `collections.controller.ts` is the reference for body validation and enumeration-safe access checks.** - **`src/bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. Everything on the raw Express instance (global middleware, uploads, discovery metadata, statics, the named `jsonParser`/`urlencodedParser` wrappers) is registered **before `app.init()`**, because Nest throws `NotFoundException` on unmatched routes and nothing after init is reachable. The wrappers exempt `/mcp` so the MCP SDK reads the raw stream — **never `@Body()` a `/mcp` route**. Don't add routes to that pre-init shell; it bypasses every global guard, interceptor and filter. -- **Realtime**: `/ws` is a Nest gateway in `src/nest/realtime/`. Inject `RealtimeService`; `src/websocket.ts` is a re-export stub for old call sites. Trip mutations forward `X-Socket-Id` so the originating client doesn't echo its own change. +- **Realtime**: `/ws` is a Nest gateway in `src/nest/realtime/`. Inject `RealtimeService`; tests hand in a `FakeRealtimeService` (`tests/helpers/fake-realtime.ts`) or spy on the container's instance instead of mocking the transport module. Trip mutations forward `X-Socket-Id` so the originating client doesn't echo its own change. - **MCP**: `src/nest-mcp/` is the decorator/registry layer (`@McpController`/`@Tool`/`@Resource`/`@Prompt`; see its `README.md`); tools live in `.mcp.ts` files beside each module; the HTTP transport is `src/nest/mcp-transport/`; `src/mcp/` holds process-wide state only (scopes, session manager, boot policy). Every tool invocation writes an audit row through the registry's `onInvoke` seam. The demo-mode write block is the registry's `toolGate` (`trekDemoToolGate`): every tool not annotated read-only refuses a demo account there, so a tool never checks demo mode itself (ESLint refuses it in `*.mcp.ts`). MCP tools are adapters over the same services and permission checks as REST — a change on one side lands on the other in the same change. - **Scheduling**: `src/nest/scheduling/` (`CronRegistrarService`) is the one way to schedule a cron. Jobs are `*.job.ts` providers in their owning domain. The registrar refuses to schedule under `NODE_ENV=test` (an integration test pins it). **Never use a `@Cron` decorator**: it bypasses that gate. Every tick first takes the job's row in `scheduler_leases` (`SchedulerLeasesRepository.acquire`, one conditional upsert that decides the race), renews it while it runs and keeps it for a short settle window afterwards, so two processes on one database never run the same tick; a job needs nothing of its own for this. Claims on individual rows (the reminder jobs' `claimReminder`) stay where they are: the lease decides who runs a tick, the claim what a tick may send. - **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest**; it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/`: run `gen:plugin-facts` after touching them and commit the output. What a plugin gets back is `protocol/output-contract.ts`: every wire method has an entry, and an entity result (`trips.getById`, `todos.list`, `vacay.mine`, ...) is cut down to its published fields in `rpc-host.ts` on the way out, together with every child row it carries whose field is listed in `PLUGIN_ENTITY_NESTED` (a day's `notes_items`, a reservation's `endpoints`). A new column on any whole-row table behind those entities, child tables included, fails `tests/unit/plugins/output-contract.schema.test.ts` until it is listed as published or withheld (the SDK's `PLUGIN_ENTITY_FIELDS` and `PLUGIN_ENTITY_NESTED` are generated from it); a method left as `readModel` must return a value built field by field from named columns. A plugin's own SQL (`host/plugin-data.service.ts`) is held to a row cap, and `query`, `tx` and an `exec` script to a time budget checked between rows and statements (`migrate` is deliberately not timed). `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together. diff --git a/server/src/nest/mcp-shared/mcp-tool-guards.service.ts b/server/src/nest/mcp-shared/mcp-tool-guards.service.ts index becfeaaed..8338a0deb 100644 --- a/server/src/nest/mcp-shared/mcp-tool-guards.service.ts +++ b/server/src/nest/mcp-shared/mcp-tool-guards.service.ts @@ -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, sid?: number | string, uid?: number, ) => void; diff --git a/server/src/nest/photos/trek-photo-registration.service.ts b/server/src/nest/photos/trek-photo-registration.service.ts index bd70a9c65..2e7c2066f 100644 --- a/server/src/nest/photos/trek-photo-registration.service.ts +++ b/server/src/nest/photos/trek-photo-registration.service.ts @@ -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. diff --git a/server/src/nest/realtime/realtime.gateway.ts b/server/src/nest/realtime/realtime.gateway.ts index 166f039c2..797c552bd 100644 --- a/server/src/nest/realtime/realtime.gateway.ts +++ b/server/src/nest/realtime/realtime.gateway.ts @@ -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 diff --git a/server/src/nest/realtime/realtime.service.ts b/server/src/nest/realtime/realtime.service.ts index 57f4c5072..af81efffe 100644 --- a/server/src/nest/realtime/realtime.service.ts +++ b/server/src/nest/realtime/realtime.service.ts @@ -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::` 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( 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 { return getOnlineUserIds(); diff --git a/server/src/nest/realtime/ws-state.ts b/server/src/nest/realtime/ws-state.ts index 7cff7a551..c3d96f9fc 100644 --- a/server/src/nest/realtime/ws-state.ts +++ b/server/src/nest/realtime/ws-state.ts @@ -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. * diff --git a/server/src/websocket.ts b/server/src/websocket.ts deleted file mode 100644 index 5d00a5cbb..000000000 --- a/server/src/websocket.ts +++ /dev/null @@ -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'; diff --git a/server/tests/e2e/assignments.e2e.test.ts b/server/tests/e2e/assignments.e2e.test.ts index 626ae2279..f764809cb 100644 --- a/server/tests/e2e/assignments.e2e.test.ts +++ b/server/tests/e2e/assignments.e2e.test.ts @@ -10,8 +10,8 @@ * to resolve a hidden inverse relation this DDL never created — the same * class of failure `days.e2e.test.ts`'s own conversion (Task 2) fixed for * that file. AssignmentsService runs its real SQL (DI-injected, no service - * mock); journeyService, the permission check and the WebSocket broadcast - * stay mocked. Every `it(...)` body below is unchanged from before this + * mock); journeyService and the permission check stay mocked, and the + * broadcast goes to a FakeRealtimeService. Every `it(...)` body below is unchanged from before this * conversion — only the DB bootstrap changed. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; @@ -19,6 +19,7 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; import { Test } from '@nestjs/testing'; import { sessionCookie } from './harness'; @@ -26,8 +27,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); -vi.mock('../../src/websocket', () => ({ broadcast })); import { db } from '../../src/db/database'; @@ -55,6 +54,10 @@ import { Days } from '../../src/db/entities/Days.entity'; import { Places } from '../../src/db/entities/Places.entity'; import { Tours } from '../../src/db/entities/Tours.entity'; import { Trips } from '../../src/db/entities/Trips.entity'; +import { FakeRealtimeService } from '../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcast = realtime.broadcastMock; let orm: TestOrm; @@ -78,6 +81,8 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => { async function build() { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), RealtimeModule, AssignmentsModule] }) + .overrideProvider(RealtimeService) + .useValue(realtime) .overrideProvider(JourneyDomainService) .useValue({ reconcileTripSkeletons }) .compile(); diff --git a/server/tests/e2e/atlas.e2e.test.ts b/server/tests/e2e/atlas.e2e.test.ts index e23888bcc..f020af1da 100644 --- a/server/tests/e2e/atlas.e2e.test.ts +++ b/server/tests/e2e/atlas.e2e.test.ts @@ -27,8 +27,6 @@ vi.mock('../../src/db/database', async () => { }; }); -vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); - import { db } from '../../src/db/database'; import { createUser, createTrip } from '../helpers/factories'; import { AtlasModule } from '../../src/nest/atlas/atlas.module'; diff --git a/server/tests/e2e/auth.e2e.test.ts b/server/tests/e2e/auth.e2e.test.ts index 45462916e..a01126cb5 100644 --- a/server/tests/e2e/auth.e2e.test.ts +++ b/server/tests/e2e/auth.e2e.test.ts @@ -27,7 +27,6 @@ vi.mock('../../src/db/database', async () => { }; }); -vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); // The audit domain is DI-native: writeAudit runs for real against the temp // db's audit_log table; only the file logger is silenced. vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); diff --git a/server/tests/e2e/booking-import.e2e.test.ts b/server/tests/e2e/booking-import.e2e.test.ts index b7ccdb02a..9b5392252 100644 --- a/server/tests/e2e/booking-import.e2e.test.ts +++ b/server/tests/e2e/booking-import.e2e.test.ts @@ -35,7 +35,6 @@ vi.mock('../../src/db/database', async () => { reinitialize: () => {}, }; }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../src/utils/ssrfGuard', async (orig) => ({ ...(await orig>()), safeFetchLlm })); import { db } from '../../src/db/database'; diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index ed22160af..2400aeea0 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -2,8 +2,8 @@ * Budget module e2e — exercises the migrated /api/trips/:tripId/budget endpoints * through the real JwtAuthGuard against a temp SQLite db carrying the full real, * migrated schema (createSnapshotTestDb), so the folded BudgetService runs its - * real SQL. Only the db singleton (trip access) and the WebSocket broadcast are - * mocked; the permission check is a spy on the container's PermissionsService. + * real SQL. Only the db singleton (trip access) is mocked, and broadcasts reach + * the real transport, which has no sockets here; the permission check is a spy on the container's PermissionsService. * ReservationsModule is mounted beside it because an expense can be linked to a * booking (#2084), and deleting that booking has to take the expense with it. */ @@ -21,7 +21,6 @@ vi.mock('../../src/db/database', async () => { // Trip access reads through TripsRepository now; the module only hands out the handle. return { db, closeDb: () => {}, reinitialize: () => {} }; }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); import { PermissionsService } from '../../src/nest/permissions/permissions.service'; diff --git a/server/tests/e2e/collab.e2e.test.ts b/server/tests/e2e/collab.e2e.test.ts index 8d9d7d0ae..b86f1227f 100644 --- a/server/tests/e2e/collab.e2e.test.ts +++ b/server/tests/e2e/collab.e2e.test.ts @@ -2,8 +2,8 @@ * Collab module e2e — exercises the migrated /api/trips/:tripId/collab endpoints * through the real JwtAuthGuard against a real migrated temp SQLite db * (createSnapshotTestDb(), no hand-rolled CREATE TABLEs), running CollabService's - * real queries (DI-injected, no service mock). The permission check, the - * WebSocket broadcast and the chat/note notification are mocked; this focuses + * real queries (DI-injected, no service mock). The permission check and + * the chat/note notification are mocked; this focuses * on auth, trip-access 404, permission 403, the create-201 status codes, the * vote/react 200 overrides and the persisted rows. Rows are seeded and read * through the factories in tests/helpers/factories. @@ -20,7 +20,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); import { PermissionsService } from '../../src/nest/permissions/permissions.service'; diff --git a/server/tests/e2e/collections.e2e.test.ts b/server/tests/e2e/collections.e2e.test.ts index 52f51d7ad..532d5d583 100644 --- a/server/tests/e2e/collections.e2e.test.ts +++ b/server/tests/e2e/collections.e2e.test.ts @@ -2,7 +2,7 @@ * Collections e2e — drives /api/addons/collections through the REAL JwtAuthGuard * AND the real DI-native CollectionsService (DatabaseModule + RealtimeModule + * CollectionsModule) against a temp SQLite db (full schema). Only the addon - * flag, websocket and notification send are mocked. Covers: the addon gate + * flag and notification send are mocked. Covers: the addon gate * (404 before auth), auth, CRUD happy paths, invite/accept/decline, copy-to-trip, * cross-user 404s, the non-owner 403 on /:id/available-users (no enumeration), and * a list out as GPX and back in through the reader and the import (#2301). @@ -27,7 +27,6 @@ vi.mock('../../src/db/database', async () => { }); const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); -vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); import { db } from '../../src/db/database'; import { createUser, createTrip, createCategory, createDay, createPlace, createDayAssignment } from '../helpers/factories'; diff --git a/server/tests/e2e/dawarich.e2e.test.ts b/server/tests/e2e/dawarich.e2e.test.ts index 20912a8e7..4fd08cc49 100644 --- a/server/tests/e2e/dawarich.e2e.test.ts +++ b/server/tests/e2e/dawarich.e2e.test.ts @@ -37,7 +37,6 @@ vi.mock('../../src/db/database', async () => { }); const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); -vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); import { db } from '../../src/db/database'; import { createUser } from '../helpers/factories'; diff --git a/server/tests/e2e/days.e2e.test.ts b/server/tests/e2e/days.e2e.test.ts index d4e7f5477..4c1515701 100644 --- a/server/tests/e2e/days.e2e.test.ts +++ b/server/tests/e2e/days.e2e.test.ts @@ -15,7 +15,7 @@ * `canAccessTrip` mock (the legacy override this file used to export from * `db/database.ts` — deleted there since Plan 3c Task 0b; a stale mock here * would silently do nothing, not fail loudly, which is worse than removing - * it). Only the permission check and the WebSocket broadcast stay mocked. + * it). Only the permission check stays mocked. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; import request from 'supertest'; @@ -29,7 +29,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); import { db } from '../../src/db/database'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; diff --git a/server/tests/e2e/doc-sync.e2e.test.ts b/server/tests/e2e/doc-sync.e2e.test.ts index 10ae9fb8e..52406f8b0 100644 --- a/server/tests/e2e/doc-sync.e2e.test.ts +++ b/server/tests/e2e/doc-sync.e2e.test.ts @@ -37,7 +37,6 @@ vi.mock('../../src/db/database', async () => { }); const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); -vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); /** * The SSRF guard resolves DNS, and `paperless.example.com` does not exist. In diff --git a/server/tests/e2e/feeds.e2e.test.ts b/server/tests/e2e/feeds.e2e.test.ts index ed788c381..c3ee1d96f 100644 --- a/server/tests/e2e/feeds.e2e.test.ts +++ b/server/tests/e2e/feeds.e2e.test.ts @@ -29,7 +29,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); import { db } from '../../src/db/database'; import { resetTestDb } from '../helpers/test-db'; diff --git a/server/tests/e2e/files.e2e.test.ts b/server/tests/e2e/files.e2e.test.ts index dba0ef791..dae0441f0 100644 --- a/server/tests/e2e/files.e2e.test.ts +++ b/server/tests/e2e/files.e2e.test.ts @@ -2,8 +2,8 @@ * Files + photos e2e — exercises the migrated /api/trips/:tripId/files and * /api/photos endpoints through the real JwtAuthGuard against a temp SQLite db. * FilesService is DI-native (no service mock): the file rows live in the temp - * db and the SQL runs for real; only canAccessTrip, the permission check, the - * photo services and the broadcast are mocked. Focuses on auth (incl. the + * db and the SQL runs for real; only canAccessTrip, the permission check and the + * photo services are mocked. Focuses on auth (incl. the * unguarded download's own token auth), trip-access 404, permission 403, the * photo id/access guards and status codes. */ @@ -56,7 +56,6 @@ const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); vi.mock('../../src/db/database', () => ({ db, canAccessTrip, getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, })); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); vi.mock('../../src/nest/common/demo', () => ({ isDemoEmail: vi.fn(() => false) })); import { PermissionsService } from '../../src/nest/permissions/permissions.service'; diff --git a/server/tests/e2e/mailer.e2e.test.ts b/server/tests/e2e/mailer.e2e.test.ts index 856b37e39..86561eb3d 100644 --- a/server/tests/e2e/mailer.e2e.test.ts +++ b/server/tests/e2e/mailer.e2e.test.ts @@ -27,7 +27,6 @@ vi.mock('../../src/db/database', async () => { isOwner: () => false, }; }); -vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); vi.mock('../../src/app-config', async (importOriginal) => { const actual = await importOriginal(); diff --git a/server/tests/e2e/memories.e2e.test.ts b/server/tests/e2e/memories.e2e.test.ts index 8072f3f80..3a61122b4 100644 --- a/server/tests/e2e/memories.e2e.test.ts +++ b/server/tests/e2e/memories.e2e.test.ts @@ -30,7 +30,6 @@ const { db } = vi.hoisted(() => { }); vi.mock('../../src/db/database', () => ({ db, canAccessTrip: vi.fn(), closeDb: () => {}, reinitialize: () => {} })); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); // Provider services — fully mocked. fail/success/canAccessUserPhoto from the // helper module are kept real except canAccessUserPhoto which we override. diff --git a/server/tests/e2e/oidc.e2e.test.ts b/server/tests/e2e/oidc.e2e.test.ts index 346539a86..bfc52cf42 100644 --- a/server/tests/e2e/oidc.e2e.test.ts +++ b/server/tests/e2e/oidc.e2e.test.ts @@ -40,7 +40,6 @@ vi.mock('../../src/db/database', () => ({ canAccessTrip: () => undefined, isOwner: () => false, })); -vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); const toggles = { oidc_login: true }; diff --git a/server/tests/e2e/packing.e2e.test.ts b/server/tests/e2e/packing.e2e.test.ts index 0b5b2a641..1cfdab5a2 100644 --- a/server/tests/e2e/packing.e2e.test.ts +++ b/server/tests/e2e/packing.e2e.test.ts @@ -3,8 +3,8 @@ * endpoints through the real JwtAuthGuard against a migrated temp SQLite db. * PackingService runs its real SQL via DatabaseModule (the DATABASE_CONNECTION * factory picks up the mocked db singleton); trip access resolves through the - * real repositories over the temp db. Only the permission check, the - * WebSocket broadcast and the notification sender stay mocked. + * real repositories over the temp db. Only the permission check and + * the notification sender stay mocked. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; import request from 'supertest'; @@ -17,7 +17,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); import { PermissionsService } from '../../src/nest/permissions/permissions.service'; diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index d8776c4e3..0432b366a 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -10,8 +10,8 @@ * uses — the same class of failure `days.e2e.test.ts` (Task 2) and * `assignments.e2e.test.ts` (Task 3) already fixed for their own files. * PlacesService runs its real SQL (DI-injected, no service mock); - * journeyService, the permission check and the WebSocket broadcast stay - * mocked. Every `it(...)` body below is unchanged from before this + * journeyService and the permission check stay mocked, and the broadcast + * goes to a FakeRealtimeService. Every `it(...)` body below is unchanged from before this * conversion — only the DB bootstrap changed. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; @@ -19,6 +19,7 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; import { Test } from '@nestjs/testing'; import { sessionCookie } from './harness'; @@ -26,8 +27,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); -vi.mock('../../src/websocket', () => ({ broadcast })); import { db } from '../../src/db/database'; @@ -60,6 +59,10 @@ import { PlaceRatings } from '../../src/db/entities/PlaceRatings.entity'; import { Places } from '../../src/db/entities/Places.entity'; import { Tours } from '../../src/db/entities/Tours.entity'; import { Trips } from '../../src/db/entities/Trips.entity'; +import { FakeRealtimeService } from '../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcast = realtime.broadcastMock; let orm: TestOrm; @@ -69,6 +72,8 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { async function build() { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), RealtimeModule, PlacesModule] }) + .overrideProvider(RealtimeService) + .useValue(realtime) .overrideProvider(JourneyDomainService) .useValue({ onPlaceCreated, onPlaceUpdated, onPlaceDeleted }) .compile(); diff --git a/server/tests/e2e/plugin-pois.e2e.test.ts b/server/tests/e2e/plugin-pois.e2e.test.ts index e620afd25..b94487e48 100644 --- a/server/tests/e2e/plugin-pois.e2e.test.ts +++ b/server/tests/e2e/plugin-pois.e2e.test.ts @@ -37,7 +37,6 @@ vi.mock('../../src/db/database', async () => { reinitialize: () => {}, }; }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); vi.mock('../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => pluginsEnabled.value })); import { db } from '../../src/db/database'; diff --git a/server/tests/e2e/plugin-search.e2e.test.ts b/server/tests/e2e/plugin-search.e2e.test.ts index 7ce0f5acc..c4ced7b96 100644 --- a/server/tests/e2e/plugin-search.e2e.test.ts +++ b/server/tests/e2e/plugin-search.e2e.test.ts @@ -38,7 +38,6 @@ vi.mock('../../src/db/database', async () => { reinitialize: () => {}, }; }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); vi.mock('../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => pluginsEnabled.value })); import { db } from '../../src/db/database'; diff --git a/server/tests/e2e/receipt-scan.e2e.test.ts b/server/tests/e2e/receipt-scan.e2e.test.ts index e5db16c7d..28b16ae48 100644 --- a/server/tests/e2e/receipt-scan.e2e.test.ts +++ b/server/tests/e2e/receipt-scan.e2e.test.ts @@ -23,7 +23,6 @@ vi.mock('../../src/db/database', async () => { const tmp = createSnapshotTestDb(); return { db: tmp, canAccessTrip: () => undefined, isOwner: () => false, getPlaceWithTags: () => null, closeDb: () => {}, reinitialize: () => {} }; }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../src/utils/ssrfGuard', async (orig) => ({ ...(await orig>()), safeFetchLlm })); import { db } from '../../src/db/database'; diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index 8bcdbe10d..0ae3b5f58 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -3,8 +3,8 @@ * through the real JwtAuthGuard against a temp SQLite db. Reservation SQL runs * for real (ReservationsService is DI-native, no mock — the temp db carries the * full, real migrated schema via createSnapshotTestDb), and so does the accommodation - * SQL (the injected DaysService is DI-native too); the budget service, the - * permission check and the WebSocket broadcast stay mocked. + * SQL (the injected DaysService is DI-native too); the budget service and the + * permission check stay mocked. */ import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; @@ -36,7 +36,6 @@ vi.mock('../../src/db/database', async () => { reinitialize: () => {}, }; }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); const { notificationSend } = vi.hoisted(() => ({ notificationSend: vi.fn().mockResolvedValue(undefined) })); import { PermissionsService } from '../../src/nest/permissions/permissions.service'; diff --git a/server/tests/e2e/roadtrip.e2e.test.ts b/server/tests/e2e/roadtrip.e2e.test.ts index 6f041d17a..3598864ce 100644 --- a/server/tests/e2e/roadtrip.e2e.test.ts +++ b/server/tests/e2e/roadtrip.e2e.test.ts @@ -13,7 +13,7 @@ import { ChargingService } from '../../src/nest/roadtrip/charging.service'; * fixed for that file). Every service in this module now runs its real SQL * through repositories (DI-injected, no service mock) against the real * request-scoped `EntityManager` `createTestMikroOrmModule` wires in; only - * the permission check and the WebSocket broadcast stay mocked. + * the permission check stays mocked. * * The unit test next door pins the handler bodies. What only a booted container * can show is the thing the controller's own comment calls load-bearing: that @@ -47,7 +47,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); import { db } from '../../src/db/database'; import { Addons } from '../../src/db/entities/Addons.entity'; diff --git a/server/tests/e2e/session-revocation.e2e.test.ts b/server/tests/e2e/session-revocation.e2e.test.ts index 2014abcc2..3f7a73a65 100644 --- a/server/tests/e2e/session-revocation.e2e.test.ts +++ b/server/tests/e2e/session-revocation.e2e.test.ts @@ -20,7 +20,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/e2e/sessions.e2e.test.ts b/server/tests/e2e/sessions.e2e.test.ts index ee4261d27..f1f9a651e 100644 --- a/server/tests/e2e/sessions.e2e.test.ts +++ b/server/tests/e2e/sessions.e2e.test.ts @@ -34,7 +34,6 @@ vi.mock('../../src/db/database', async () => { }; }); -vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); import { MailerService } from '../../src/nest/notifications/mailer/mailer.service'; diff --git a/server/tests/e2e/todo.e2e.test.ts b/server/tests/e2e/todo.e2e.test.ts index cdb99b4e8..36176a5b1 100644 --- a/server/tests/e2e/todo.e2e.test.ts +++ b/server/tests/e2e/todo.e2e.test.ts @@ -3,8 +3,8 @@ * through the real JwtAuthGuard against a migrated temp SQLite db * (createSnapshotTestDb()). TodoService and the trip access check run their * real queries through the repositories; rows are seeded and read through the - * factories in tests/helpers/factories. Only the permission check and the - * WebSocket broadcast stay mocked. + * factories in tests/helpers/factories. Only the permission check stays + * mocked. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; import request from 'supertest'; @@ -17,7 +17,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); import { db } from '../../src/db/database'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; diff --git a/server/tests/e2e/tours.e2e.test.ts b/server/tests/e2e/tours.e2e.test.ts index dc90059e1..f2bb1c093 100644 --- a/server/tests/e2e/tours.e2e.test.ts +++ b/server/tests/e2e/tours.e2e.test.ts @@ -3,8 +3,8 @@ * JwtAuthGuard, AddonGuard and TripAccessGuard against a real migrated and * seeded temp SQLite db (createSnapshotTestDb()). ToursService, PlacesService, * AssignmentsService and TripsService run their real SQL; only the permission - * check (a container spy), the journey hooks and the WebSocket broadcast are - * stubbed. + * check (a container spy) and the journey hooks are stubbed, and the broadcast + * goes to a FakeRealtimeService. * * Fixtures: user 1 owns trips 5 and 6, user 2 is a member of trip 5 whom the * permission spy denies 'place_edit', user 3 has no access to either trip. @@ -16,6 +16,7 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; import { Test } from '@nestjs/testing'; import { sessionCookie } from './harness'; @@ -23,8 +24,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); -vi.mock('../../src/websocket', () => ({ broadcast })); vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); import { db } from '../../src/db/database'; @@ -59,6 +58,10 @@ import { Places } from '../../src/db/entities/Places.entity'; import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity'; import { Tours } from '../../src/db/entities/Tours.entity'; import { Trips } from '../../src/db/entities/Trips.entity'; +import { FakeRealtimeService } from '../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcast = realtime.broadcastMock; let orm: TestOrm; @@ -110,6 +113,8 @@ describe('Tours e2e (real guards + temp SQLite)', () => { TripsModule, ], }) + .overrideProvider(RealtimeService) + .useValue(realtime) .overrideProvider(JourneyDomainService) .useValue(journeyHooks) .compile(); diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index 701612165..accad8b2c 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -18,7 +18,7 @@ * not fail loudly, which is worse than removing it — the same reasoning * `days.e2e.test.ts`'s identical comment gives). Only the permission check, * `BudgetService` (the budget fold's own container spy, unrelated to this - * conversion) and the WebSocket broadcast stay mocked. + * conversion) stay mocked. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; import request from 'supertest'; @@ -33,7 +33,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); // The audit domain is DI-native now: writeAudit runs for real against the temp // db's audit_log table; only the file logger is silenced. vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); diff --git a/server/tests/e2e/vacay.e2e.test.ts b/server/tests/e2e/vacay.e2e.test.ts index 9ab474e40..6592ffef2 100644 --- a/server/tests/e2e/vacay.e2e.test.ts +++ b/server/tests/e2e/vacay.e2e.test.ts @@ -1,8 +1,8 @@ /** * Vacay module e2e — exercises the migrated /api/addons/vacay endpoints through * the real JwtAuthGuard against a migrated temp SQLite db. DI-native: VacayService runs - * its real SQL over the temp db (no legacy service mock); only the websocket - * and notification side channels stay mocked. Focuses on auth, status codes + * its real SQL over the temp db (no legacy service mock); the notification side + * channel stays mocked and the broadcast goes to a FakeRealtimeService. Focuses on auth, status codes * (POSTs stay 200), the Zod-pipe 400 envelope and a 403 body. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; @@ -16,7 +16,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() })); import { db } from '../../src/db/database'; import { VacayEntries } from '../../src/db/entities/VacayEntries.entity'; @@ -25,20 +24,25 @@ import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { VacayModule } from '../../src/nest/vacay/vacay.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; -import { broadcastToUser } from '../../src/websocket'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; +import { FakeRealtimeService } from '../helpers/fake-realtime'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; import { makeUser } from '../helpers/factories/users'; import { findRow } from '../helpers/factories/rows'; let orm: TestOrm; +const realtime = new FakeRealtimeService(); describe('Vacay e2e (real auth guard + migrated temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), RealtimeModule, VacayModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), RealtimeModule, VacayModule] }) + .overrideProvider(RealtimeService) + .useValue(realtime) + .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); @@ -81,7 +85,7 @@ describe('Vacay e2e (real auth guard + migrated temp SQLite)', () => { expect(res.status).toBe(200); expect(res.body).toEqual({ action: 'added', fraction: 1, kind: 'vacation' }); expect(await findRow(orm, VacayEntries, { user: 1, date: '2026-07-01' })).not.toBeNull(); - expect(vi.mocked(broadcastToUser)).toHaveBeenCalledWith(1, { type: 'vacay:update' }, 'sock-7'); + expect(realtime.broadcastToUserMock).toHaveBeenCalledWith(1, { type: 'vacay:update' }, 'sock-7'); }); it('400 from the Zod pipe on entries/toggle without a date', async () => { diff --git a/server/tests/helpers/accommodations-service.ts b/server/tests/helpers/accommodations-service.ts index 68429c98f..a92aafd00 100644 --- a/server/tests/helpers/accommodations-service.ts +++ b/server/tests/helpers/accommodations-service.ts @@ -36,8 +36,11 @@ import { TripAccessService } from '../../src/nest/trip-membership/trip-access.se * instance). Kept as two functions for caller-shape continuity, though they * are now identical. */ -export async function makeAccommodationsService(conn: Database): Promise { - return accommodationsOver(conn); +export async function makeAccommodationsService( + conn: Database, + realtime: RealtimeService = new RealtimeService(), +): Promise { + return accommodationsOver(conn, realtime); } /** @@ -47,9 +50,11 @@ export async function makeAccommodationsService(conn: Database): Promise { +export async function accommodationsOver( + conn: Database, + realtime: RealtimeService = new RealtimeService(), +): Promise { const permissions = new PermissionsService(await createTestAppSettingsRepo(conn), await createTestUnitOfWork(conn)); - const realtime = new RealtimeService(); const t = await sharedTestOrm(conn); const assignments = new AssignmentsService( new TripAccessService(await createTestTripsRepo(conn)), permissions, realtime, diff --git a/server/tests/helpers/fake-realtime.ts b/server/tests/helpers/fake-realtime.ts new file mode 100644 index 000000000..07d6ef964 --- /dev/null +++ b/server/tests/helpers/fake-realtime.ts @@ -0,0 +1,88 @@ +/** + * A RealtimeService that records instead of delivering. + * + * Suites observe broadcasts through DI rather than by mocking a module path: + * a hand-built service gets `new FakeRealtimeService()` where it took a + * RealtimeService, and a booted Nest app gets its container instance swapped + * (`.overrideProvider(RealtimeService).useValue(fake)`) or spied on with + * `spyOnRealtime(app)`. Each method records into a `vi.fn`, so the assertions read the + * same as they did against the old module mock: + * + * const realtime = new FakeRealtimeService(); + * const service = new TodoService(permissions, realtime, ...); + * expect(realtime.broadcastMock).toHaveBeenCalledWith(tripId, 'todo:created', ...); + * + * The fake receives exactly the arguments the service handed RealtimeService + * (the facade passes them through without padding), so call-shape assertions + * keep their meaning. + */ +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; +import type { INestApplicationContext } from '@nestjs/common'; + +import { vi, type Mock } from 'vitest'; + +type BroadcastArgs = [ + tripId: number | string, + eventType: string, + payload: Record, + excludeSid?: number | string, + onlyUserId?: number, +]; +type BroadcastToUserArgs = [userId: number, payload: Record, excludeSid?: number | string]; + +export class FakeRealtimeService extends RealtimeService { + readonly broadcastMock: Mock<(...args: BroadcastArgs) => void> = vi.fn(); + readonly broadcastToUserMock: Mock<(...args: BroadcastToUserArgs) => void> = vi.fn(); + /** Nobody is online unless a case says otherwise. */ + readonly getOnlineUserIdsMock: Mock<() => Set> = vi.fn(() => new Set()); + + constructor() { + super(); + // Some callers take the method off the instance before calling it + // (McpToolGuardsService.safeBroadcast does), as the real facade allows. + this.broadcast = this.broadcast.bind(this); + this.broadcastToUser = this.broadcastToUser.bind(this); + this.getOnlineUserIds = this.getOnlineUserIds.bind(this); + } + + override broadcast(...args: BroadcastArgs): void { + this.broadcastMock(...args); + } + + override broadcastToUser(...args: BroadcastToUserArgs): void { + this.broadcastToUserMock(...args); + } + + override getOnlineUserIds(): Set { + return this.getOnlineUserIdsMock(); + } + + /** Forget every recorded call (and any per-case return value). */ + reset(): void { + this.broadcastMock.mockReset(); + this.broadcastToUserMock.mockReset(); + this.getOnlineUserIdsMock.mockReset(); + this.getOnlineUserIdsMock.mockImplementation(() => new Set()); + } +} + +export interface RealtimeSpies { + broadcast: Mock<(...args: BroadcastArgs) => void>; + broadcastToUser: Mock<(...args: BroadcastToUserArgs) => void>; +} + +/** + * Spy on the RealtimeService a booted app resolved, for suites that build the + * whole app (buildApp) and cannot override a provider. The spies do not + * deliver: a test socket would otherwise receive every broadcast the case + * caused, which is what the old module mock prevented too. + */ +export function spyOnRealtime(app: INestApplicationContext): RealtimeSpies { + const realtime = app.get(RealtimeService); + const broadcast = vi.spyOn(realtime, 'broadcast').mockImplementation(() => undefined); + const broadcastToUser = vi.spyOn(realtime, 'broadcastToUser').mockImplementation(() => undefined); + return { + broadcast: broadcast as unknown as RealtimeSpies['broadcast'], + broadcastToUser: broadcastToUser as unknown as RealtimeSpies['broadcastToUser'], + }; +} diff --git a/server/tests/helpers/mcp-harness.ts b/server/tests/helpers/mcp-harness.ts index 24e2ac962..3125a040d 100644 --- a/server/tests/helpers/mcp-harness.ts +++ b/server/tests/helpers/mcp-harness.ts @@ -16,6 +16,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'; import { registerTools } from '../../src/mcp/tools'; import type { McpAttachOptions } from '../../src/nest-mcp'; import { createMcpTestRegistry } from './mcp-test-controllers'; +import type { RealtimeService } from '../../src/nest/realtime/realtime.service'; export interface McpHarness { client: Client; @@ -47,10 +48,16 @@ export interface McpHarnessOptions { * every suite that does not register one. */ dynamicTools?: McpAttachOptions['dynamicTools']; + /** + * The RealtimeService every tool broadcasts through. A suite that asserts on + * broadcasts hands in a FakeRealtimeService (tests/helpers/fake-realtime.ts); + * left out, the tools get a real one, whose transport has no sockets here. + */ + realtime?: RealtimeService; } export async function createMcpHarness(options: McpHarnessOptions): Promise { - const { userId, withTools = true, scopes = null, isStaticToken = false, getDeprecationNotice, dynamicTools } = options; + const { userId, withTools = true, scopes = null, isStaticToken = false, getDeprecationNotice, dynamicTools, realtime } = options; const server = new McpServer({ name: 'trek-test', version: '1.0.0' }); @@ -59,7 +66,7 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise { +export async function createMcpTestRegistry(realtimeService: RealtimeService = new RealtimeService()): Promise { // Plan 3c Task 0b / Plan 4 Task 4: `mcpOrm` gives every repository below // its `EntityManager` — `canAccessTrip`/`isOwner`/`rosterUserIds`/ // `getPlaceWithTags` resolve `TripsRepository`/`TripMembersRepository`/ @@ -207,7 +207,6 @@ export async function createMcpTestRegistry(): Promise { const auditLogRepo = mcpOrm.repo(AuditLog); const usersRepo = mcpOrm.repo(Users); const permissionsService = new PermissionsService(await createTestAppSettingsRepo(db), await createTestUnitOfWork(db)); - const realtimeService = new RealtimeService(); // Plan 4 Task 1 constructor-ripple: the trip `user_id`/user `role` reads // moved off `DatabaseService` onto `TripsRepository`/`UsersRepository`. const guards = new McpToolGuardsService(mcpOrm.repo(Trips), usersRepo, permissionsService, realtimeService); diff --git a/server/tests/integration/admin.test.ts b/server/tests/integration/admin.test.ts index 1ec52c692..5dde1b122 100644 --- a/server/tests/integration/admin.test.ts +++ b/server/tests/integration/admin.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; diff --git a/server/tests/integration/airtrail-sync-request-context.test.ts b/server/tests/integration/airtrail-sync-request-context.test.ts index 5f059bf91..f50bc51c7 100644 --- a/server/tests/integration/airtrail-sync-request-context.test.ts +++ b/server/tests/integration/airtrail-sync-request-context.test.ts @@ -30,7 +30,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) })); import { MikroORM } from '@mikro-orm/core'; import { SchedulerRegistry } from '@nestjs/schedule'; diff --git a/server/tests/integration/api-docs.test.ts b/server/tests/integration/api-docs.test.ts index f3b003878..de355b339 100644 --- a/server/tests/integration/api-docs.test.ts +++ b/server/tests/integration/api-docs.test.ts @@ -12,7 +12,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { resetTestDb } from '../helpers/test-db'; diff --git a/server/tests/integration/app-config.test.ts b/server/tests/integration/app-config.test.ts index 6b1d771a9..31577badd 100644 --- a/server/tests/integration/app-config.test.ts +++ b/server/tests/integration/app-config.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { resetTestDb } from '../helpers/test-db'; diff --git a/server/tests/integration/assignments.test.ts b/server/tests/integration/assignments.test.ts index c68da1530..4effe2aca 100644 --- a/server/tests/integration/assignments.test.ts +++ b/server/tests/integration/assignments.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/async-closure-truthiness-guards.test.ts b/server/tests/integration/async-closure-truthiness-guards.test.ts index 81c588e81..574adf0fc 100644 --- a/server/tests/integration/async-closure-truthiness-guards.test.ts +++ b/server/tests/integration/async-closure-truthiness-guards.test.ts @@ -87,7 +87,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) })); import request from 'supertest'; import type { Application } from 'express'; diff --git a/server/tests/integration/atlas-geocode-request-context.test.ts b/server/tests/integration/atlas-geocode-request-context.test.ts index 571af6650..16d443d2c 100644 --- a/server/tests/integration/atlas-geocode-request-context.test.ts +++ b/server/tests/integration/atlas-geocode-request-context.test.ts @@ -28,7 +28,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../src/nest/atlas/atlas-geo', async (orig) => { const actual = await orig(); return { diff --git a/server/tests/integration/atlas.test.ts b/server/tests/integration/atlas.test.ts index 5907b5d03..4831d2911 100644 --- a/server/tests/integration/atlas.test.ts +++ b/server/tests/integration/atlas.test.ts @@ -12,7 +12,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/auth.test.ts b/server/tests/integration/auth.test.ts index 20f7e8e32..f523dc99f 100644 --- a/server/tests/integration/auth.test.ts +++ b/server/tests/integration/auth.test.ts @@ -17,7 +17,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/backup.test.ts b/server/tests/integration/backup.test.ts index 29fb5e4ba..a85457157 100644 --- a/server/tests/integration/backup.test.ts +++ b/server/tests/integration/backup.test.ts @@ -15,7 +15,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // Mock filesystem-dependent service functions to avoid real disk I/O in tests vi.mock('../../src/nest/backup/backup.impl', async () => { diff --git a/server/tests/integration/boot-sweeps-request-context.test.ts b/server/tests/integration/boot-sweeps-request-context.test.ts index 0b397b804..0f87581df 100644 --- a/server/tests/integration/boot-sweeps-request-context.test.ts +++ b/server/tests/integration/boot-sweeps-request-context.test.ts @@ -63,7 +63,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/bootstrap.test.ts b/server/tests/integration/bootstrap.test.ts index 6f0e9488e..96b3e70ae 100644 --- a/server/tests/integration/bootstrap.test.ts +++ b/server/tests/integration/bootstrap.test.ts @@ -14,7 +14,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // BOOT-007 boots with NODE_ENV=production, which opens the cron gate — keep the // registrar inert so a bootstrap test never schedules real jobs or runs boot // sweeps against the real uploads/data dirs. The gate itself is covered by diff --git a/server/tests/integration/budget.test.ts b/server/tests/integration/budget.test.ts index 4dd33b271..6163d6ca0 100644 --- a/server/tests/integration/budget.test.ts +++ b/server/tests/integration/budget.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/categories.test.ts b/server/tests/integration/categories.test.ts index e49fe5753..0848c7b14 100644 --- a/server/tests/integration/categories.test.ts +++ b/server/tests/integration/categories.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { MikroORM } from '@mikro-orm/core'; import { db as testDb } from '../../src/db/database'; diff --git a/server/tests/integration/collab.test.ts b/server/tests/integration/collab.test.ts index c8fee53ef..db561eae2 100644 --- a/server/tests/integration/collab.test.ts +++ b/server/tests/integration/collab.test.ts @@ -16,7 +16,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/collections.test.ts b/server/tests/integration/collections.test.ts index 50da3bee8..adeb40ab3 100644 --- a/server/tests/integration/collections.test.ts +++ b/server/tests/integration/collections.test.ts @@ -17,7 +17,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) })); import { db as testDb } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; diff --git a/server/tests/integration/dayNotes.test.ts b/server/tests/integration/dayNotes.test.ts index a7ec7e38e..2ff2cd983 100644 --- a/server/tests/integration/dayNotes.test.ts +++ b/server/tests/integration/dayNotes.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/days.test.ts b/server/tests/integration/days.test.ts index ddbc2cbaa..7fbdc0294 100644 --- a/server/tests/integration/days.test.ts +++ b/server/tests/integration/days.test.ts @@ -15,7 +15,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/doc-sync-webhook-request-context.test.ts b/server/tests/integration/doc-sync-webhook-request-context.test.ts index b6478f65d..a668ebd79 100644 --- a/server/tests/integration/doc-sync-webhook-request-context.test.ts +++ b/server/tests/integration/doc-sync-webhook-request-context.test.ts @@ -44,7 +44,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/feeds-request-context.test.ts b/server/tests/integration/feeds-request-context.test.ts index 4cfce22f9..2e5fcbcac 100644 --- a/server/tests/integration/feeds-request-context.test.ts +++ b/server/tests/integration/feeds-request-context.test.ts @@ -48,7 +48,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) })); import { randomUUID } from 'node:crypto'; import { MikroORM } from '@mikro-orm/core'; diff --git a/server/tests/integration/files.test.ts b/server/tests/integration/files.test.ts index 875b4898a..5eb9f221c 100644 --- a/server/tests/integration/files.test.ts +++ b/server/tests/integration/files.test.ts @@ -18,7 +18,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; diff --git a/server/tests/integration/help.test.ts b/server/tests/integration/help.test.ts index bfb59c9eb..260892cc3 100644 --- a/server/tests/integration/help.test.ts +++ b/server/tests/integration/help.test.ts @@ -14,7 +14,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/immich.test.ts b/server/tests/integration/immich.test.ts index 9e4761c1f..8b02d7e6e 100644 --- a/server/tests/integration/immich.test.ts +++ b/server/tests/integration/immich.test.ts @@ -14,7 +14,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // Mock SSRF guard: block loopback and private IPs, allow external hostnames without DNS. vi.mock('../../src/utils/ssrfGuard', async () => { diff --git a/server/tests/integration/journey-place-order.test.ts b/server/tests/integration/journey-place-order.test.ts index d0916dbc6..52c17078b 100644 --- a/server/tests/integration/journey-place-order.test.ts +++ b/server/tests/integration/journey-place-order.test.ts @@ -25,11 +25,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ - broadcast: vi.fn(), - broadcastToUser: vi.fn(), - getOnlineUserIds: vi.fn(() => []), -})); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/journey.test.ts b/server/tests/integration/journey.test.ts index 9c5a7c3e4..47c6269a8 100644 --- a/server/tests/integration/journey.test.ts +++ b/server/tests/integration/journey.test.ts @@ -14,11 +14,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ - broadcast: vi.fn(), - broadcastToUser: vi.fn(), - getOnlineUserIds: vi.fn(() => []), -})); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/leg-mode-incoming.test.ts b/server/tests/integration/leg-mode-incoming.test.ts index 582773b0d..348e65d4c 100644 --- a/server/tests/integration/leg-mode-incoming.test.ts +++ b/server/tests/integration/leg-mode-incoming.test.ts @@ -17,7 +17,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb2 } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; diff --git a/server/tests/integration/legacy-upgrade-v066.test.ts b/server/tests/integration/legacy-upgrade-v066.test.ts index a0b40cbcb..b0cdce346 100644 --- a/server/tests/integration/legacy-upgrade-v066.test.ts +++ b/server/tests/integration/legacy-upgrade-v066.test.ts @@ -16,11 +16,6 @@ vi.mock('../../src/db/database', async () => { const { openLegacyFixture } = await import('../helpers/legacy-fixture'); return buildDbMock(openLegacyFixture('legacy-v066')); }); -vi.mock('../../src/websocket', () => ({ - broadcast: vi.fn(), - broadcastToUser: vi.fn(), - getOnlineUserIds: vi.fn(() => []), -})); describeLegacyUpgrade( { diff --git a/server/tests/integration/legacy-upgrade-v082.test.ts b/server/tests/integration/legacy-upgrade-v082.test.ts index 20bcc358a..9fca30990 100644 --- a/server/tests/integration/legacy-upgrade-v082.test.ts +++ b/server/tests/integration/legacy-upgrade-v082.test.ts @@ -13,11 +13,6 @@ vi.mock('../../src/db/database', async () => { const { openLegacyFixture } = await import('../helpers/legacy-fixture'); return buildDbMock(openLegacyFixture('legacy-v082')); }); -vi.mock('../../src/websocket', () => ({ - broadcast: vi.fn(), - broadcastToUser: vi.fn(), - getOnlineUserIds: vi.fn(() => []), -})); describeLegacyUpgrade( { fixture: 'legacy-v082', version: 82, reseated: 1, nightOrderIndex: 1, id: 'LEGACYUP-082' }, diff --git a/server/tests/integration/legacy-upgrade-v205.test.ts b/server/tests/integration/legacy-upgrade-v205.test.ts index 59bef9a2b..9b4077574 100644 --- a/server/tests/integration/legacy-upgrade-v205.test.ts +++ b/server/tests/integration/legacy-upgrade-v205.test.ts @@ -13,11 +13,6 @@ vi.mock('../../src/db/database', async () => { const { openLegacyFixture } = await import('../helpers/legacy-fixture'); return buildDbMock(openLegacyFixture('legacy-v205')); }); -vi.mock('../../src/websocket', () => ({ - broadcast: vi.fn(), - broadcastToUser: vi.fn(), - getOnlineUserIds: vi.fn(() => []), -})); describeLegacyUpgrade( { fixture: 'legacy-v205', version: 205, reseated: 1, nightOrderIndex: 1, id: 'LEGACYUP-205' }, diff --git a/server/tests/integration/legacy-upgrade-v242.test.ts b/server/tests/integration/legacy-upgrade-v242.test.ts index 36874ed86..fe45cb175 100644 --- a/server/tests/integration/legacy-upgrade-v242.test.ts +++ b/server/tests/integration/legacy-upgrade-v242.test.ts @@ -13,11 +13,6 @@ vi.mock('../../src/db/database', async () => { const { openLegacyFixture } = await import('../helpers/legacy-fixture'); return buildDbMock(openLegacyFixture('legacy-v242')); }); -vi.mock('../../src/websocket', () => ({ - broadcast: vi.fn(), - broadcastToUser: vi.fn(), - getOnlineUserIds: vi.fn(() => []), -})); describeLegacyUpgrade( { fixture: 'legacy-v242', version: 242, reseated: 0, nightOrderIndex: 3, id: 'LEGACYUP-242' }, diff --git a/server/tests/integration/legacy-upgrade-v244.test.ts b/server/tests/integration/legacy-upgrade-v244.test.ts index 9ac886201..09e3ad523 100644 --- a/server/tests/integration/legacy-upgrade-v244.test.ts +++ b/server/tests/integration/legacy-upgrade-v244.test.ts @@ -19,11 +19,6 @@ vi.mock('../../src/db/database', async () => { const { openLegacyFixture } = await import('../helpers/legacy-fixture'); return buildDbMock(openLegacyFixture('legacy-v244')); }); -vi.mock('../../src/websocket', () => ({ - broadcast: vi.fn(), - broadcastToUser: vi.fn(), - getOnlineUserIds: vi.fn(() => []), -})); // Off by default under NODE_ENV=test; this suite is the one that asks for it. const previousSwitch = process.env.TREK_DB_PRE_MIGRATE_SNAPSHOT; diff --git a/server/tests/integration/maps.test.ts b/server/tests/integration/maps.test.ts index eeb08b2a9..7a72e288f 100644 --- a/server/tests/integration/maps.test.ts +++ b/server/tests/integration/maps.test.ts @@ -14,7 +14,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/mcp.test.ts b/server/tests/integration/mcp.test.ts index 09b6334db..c19835c89 100644 --- a/server/tests/integration/mcp.test.ts +++ b/server/tests/integration/mcp.test.ts @@ -14,7 +14,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts index 343a79498..09cf54cf4 100644 --- a/server/tests/integration/memories-immich.test.ts +++ b/server/tests/integration/memories-immich.test.ts @@ -41,7 +41,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // ── SSRF guard mock — routes all Immich API calls to fake responses ─────────── vi.mock('../../src/utils/ssrfGuard', async () => { diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index a9fa5a35c..36c0e7933 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -19,7 +19,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // ── SSRF guard mock — routes all Synology API calls to fake responses ───────── vi.mock('../../src/utils/ssrfGuard', async () => { diff --git a/server/tests/integration/memories-unified.test.ts b/server/tests/integration/memories-unified.test.ts index 9e662acba..3b6e5ed3b 100644 --- a/server/tests/integration/memories-unified.test.ts +++ b/server/tests/integration/memories-unified.test.ts @@ -4,7 +4,6 @@ * /unified/trips/:tripId/album-links routes. * * No real HTTP is made — safeFetch is mocked to never be called. - * The broadcast WebSocket call is no-op mocked. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; @@ -18,7 +17,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../src/utils/ssrfGuard', async () => { const actual = await vi.importActual('../../src/utils/ssrfGuard'); return { diff --git a/server/tests/integration/misc.test.ts b/server/tests/integration/misc.test.ts index fdedb7c94..e2e36f875 100644 --- a/server/tests/integration/misc.test.ts +++ b/server/tests/integration/misc.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/notifications.test.ts b/server/tests/integration/notifications.test.ts index 64c7ac5ba..34e0d33e7 100644 --- a/server/tests/integration/notifications.test.ts +++ b/server/tests/integration/notifications.test.ts @@ -15,7 +15,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // The two test-send endpoints must not reach a real SMTP server or URL. Since // the fold they are provider methods, so they are stubbed on the prototype // rather than by module path — everything else in the domain stays real. diff --git a/server/tests/integration/oauth-authorize.test.ts b/server/tests/integration/oauth-authorize.test.ts index d1daa6719..3c44bc53f 100644 --- a/server/tests/integration/oauth-authorize.test.ts +++ b/server/tests/integration/oauth-authorize.test.ts @@ -24,7 +24,6 @@ vi.mock('../../src/app-config', async (importOriginal) => { return { ...actual, getMcpSafeUrl: () => 'https://trek.example.com' }; }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() })); import { db as testDb } from '../../src/db/database'; diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index 7f4fb8f2e..5c7319a1b 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -19,7 +19,6 @@ vi.mock('../../src/app-config', async (importOriginal) => { return { ...actual, getMcpSafeUrl: () => 'https://trek.example.com' }; }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() })); import { db as testDb } from '../../src/db/database'; diff --git a/server/tests/integration/oidc-local-provider.test.ts b/server/tests/integration/oidc-local-provider.test.ts index 0779ad114..d66535953 100644 --- a/server/tests/integration/oidc-local-provider.test.ts +++ b/server/tests/integration/oidc-local-provider.test.ts @@ -27,7 +27,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // The provider's names are answered here; every other name goes to the real resolver. vi.mock('dns/promises', async (importOriginal) => { const real = await importOriginal(); diff --git a/server/tests/integration/oidc.test.ts b/server/tests/integration/oidc.test.ts index e8d660f5d..161de70bf 100644 --- a/server/tests/integration/oidc.test.ts +++ b/server/tests/integration/oidc.test.ts @@ -19,7 +19,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import type { MockInstance } from 'vitest'; diff --git a/server/tests/integration/orm-request-context-seams.test.ts b/server/tests/integration/orm-request-context-seams.test.ts index 2205ecc0e..7c39f73b3 100644 --- a/server/tests/integration/orm-request-context-seams.test.ts +++ b/server/tests/integration/orm-request-context-seams.test.ts @@ -36,7 +36,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) })); import { db as testDb } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; diff --git a/server/tests/integration/packing.test.ts b/server/tests/integration/packing.test.ts index 0db5cbea5..74796e420 100644 --- a/server/tests/integration/packing.test.ts +++ b/server/tests/integration/packing.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; diff --git a/server/tests/integration/passkey.test.ts b/server/tests/integration/passkey.test.ts index 966ea7ddb..350a82c89 100644 --- a/server/tests/integration/passkey.test.ts +++ b/server/tests/integration/passkey.test.ts @@ -27,7 +27,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/places.test.ts b/server/tests/integration/places.test.ts index 2eca39ce2..4388fe7b1 100644 --- a/server/tests/integration/places.test.ts +++ b/server/tests/integration/places.test.ts @@ -17,12 +17,10 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; import { buildApp } from '../../src/bootstrap'; -import { DomainError } from '../../src/nest/common/domain-error'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import type { FactoryOrm } from '../helpers/factories/context'; import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories'; @@ -38,7 +36,8 @@ import { PlaceRatings } from '../../src/db/entities/PlaceRatings.entity'; import { Places } from '../../src/db/entities/Places.entity'; import { PlacesService } from '../../src/nest/places/places.service'; import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache'; -import { broadcast } from '../../src/websocket'; +import { spyOnRealtime, type RealtimeSpies } from '../helpers/fake-realtime'; +import { DomainError } from '../../src/nest/common/domain-error'; let nestApp: INestApplication; let app: Application; @@ -49,6 +48,7 @@ let orm: FactoryOrm; // implementation, so only the *Once overrides below change behaviour. let importGoogleList: MockInstance; let searchPlaceImage: MockInstance; +let broadcast: RealtimeSpies['broadcast']; const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx'); const KML_FIXTURE = path.join(__dirname, '../fixtures/test.kml'); const KML_NESTED_FIXTURE = path.join(__dirname, '../fixtures/test-nested.kml'); @@ -69,6 +69,7 @@ beforeEach(async () => { // afterEach, which would otherwise strip these for every later test. importGoogleList = vi.spyOn(nestApp.get(PlacesService), 'importGoogleList'); searchPlaceImage = vi.spyOn(nestApp.get(PlacesService), 'searchImage'); + broadcast = spyOnRealtime(nestApp).broadcast; }); afterAll(async () => { diff --git a/server/tests/integration/plugins/boot-registry-order.test.ts b/server/tests/integration/plugins/boot-registry-order.test.ts index eb04a1c1b..88d1955fc 100644 --- a/server/tests/integration/plugins/boot-registry-order.test.ts +++ b/server/tests/integration/plugins/boot-registry-order.test.ts @@ -28,7 +28,6 @@ vi.mock('../../../src/db/database', async () => { const db = createSnapshotTestDb(); return { db, canAccessTrip: () => undefined }; }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../../src/db/database'; import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; diff --git a/server/tests/integration/plugins/dev-link.test.ts b/server/tests/integration/plugins/dev-link.test.ts index de7e89df2..bf6cc2a05 100644 --- a/server/tests/integration/plugins/dev-link.test.ts +++ b/server/tests/integration/plugins/dev-link.test.ts @@ -18,7 +18,6 @@ import path from 'node:path'; // (`createSnapshotTestDb` + `createTestOrm`) replaces the old hand-rolled table set, // same fix `registry.test.ts` needed. import { createSnapshotTestDb } from '../../helpers/db-mock'; -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; import { createPluginRuntime } from '../../helpers/plugin-host'; diff --git a/server/tests/integration/plugins/plugin-runtime.test.ts b/server/tests/integration/plugins/plugin-runtime.test.ts index 7a3a4b718..b7d663b94 100644 --- a/server/tests/integration/plugins/plugin-runtime.test.ts +++ b/server/tests/integration/plugins/plugin-runtime.test.ts @@ -44,7 +44,6 @@ const { testDb } = vi.hoisted(() => { }); vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined })); import { db as dbConn } from '../../../src/db/database'; -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { PluginRuntimeService, PluginDependencyError } from '../../../src/nest/plugins/plugin-runtime.service'; import { createPluginRuntime } from '../../helpers/plugin-host'; diff --git a/server/tests/integration/profile.test.ts b/server/tests/integration/profile.test.ts index 55f252817..ed1c8c7e3 100644 --- a/server/tests/integration/profile.test.ts +++ b/server/tests/integration/profile.test.ts @@ -12,7 +12,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/reservations.test.ts b/server/tests/integration/reservations.test.ts index 95c087f7f..acba0cd43 100644 --- a/server/tests/integration/reservations.test.ts +++ b/server/tests/integration/reservations.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/scheduler-gate.test.ts b/server/tests/integration/scheduler-gate.test.ts index 49ca9f648..ea4a4cb33 100644 --- a/server/tests/integration/scheduler-gate.test.ts +++ b/server/tests/integration/scheduler-gate.test.ts @@ -15,7 +15,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { SchedulerRegistry } from '@nestjs/schedule'; diff --git a/server/tests/integration/security.test.ts b/server/tests/integration/security.test.ts index 6efcc08a0..a417e5071 100644 --- a/server/tests/integration/security.test.ts +++ b/server/tests/integration/security.test.ts @@ -19,7 +19,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/settings.test.ts b/server/tests/integration/settings.test.ts index f0ac0a7de..bb0b16083 100644 --- a/server/tests/integration/settings.test.ts +++ b/server/tests/integration/settings.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/share.test.ts b/server/tests/integration/share.test.ts index 64fd8a6b1..bb318f597 100644 --- a/server/tests/integration/share.test.ts +++ b/server/tests/integration/share.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; diff --git a/server/tests/integration/storage-health-notifier-request-context.test.ts b/server/tests/integration/storage-health-notifier-request-context.test.ts index d7f86a9e4..8f8206eb0 100644 --- a/server/tests/integration/storage-health-notifier-request-context.test.ts +++ b/server/tests/integration/storage-health-notifier-request-context.test.ts @@ -27,7 +27,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts index 7dbcd4c0a..9958e2a67 100644 --- a/server/tests/integration/systemNotices.test.ts +++ b/server/tests/integration/systemNotices.test.ts @@ -14,7 +14,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; diff --git a/server/tests/integration/tags.test.ts b/server/tests/integration/tags.test.ts index d89716031..085c52b51 100644 --- a/server/tests/integration/tags.test.ts +++ b/server/tests/integration/tags.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/todo.test.ts b/server/tests/integration/todo.test.ts index 9de8c49ff..89a6c3ab5 100644 --- a/server/tests/integration/todo.test.ts +++ b/server/tests/integration/todo.test.ts @@ -11,7 +11,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; diff --git a/server/tests/integration/trip-access-primitives.test.ts b/server/tests/integration/trip-access-primitives.test.ts index 7ba9e35e0..d214683da 100644 --- a/server/tests/integration/trip-access-primitives.test.ts +++ b/server/tests/integration/trip-access-primitives.test.ts @@ -30,7 +30,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts index af105b34c..6e5ce8d1e 100644 --- a/server/tests/integration/trips.test.ts +++ b/server/tests/integration/trips.test.ts @@ -14,7 +14,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { MikroORM } from '@mikro-orm/core'; diff --git a/server/tests/integration/uploads-photo-request-context.test.ts b/server/tests/integration/uploads-photo-request-context.test.ts index 40ac192e1..9b1c6e148 100644 --- a/server/tests/integration/uploads-photo-request-context.test.ts +++ b/server/tests/integration/uploads-photo-request-context.test.ts @@ -36,7 +36,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/uploads-static.test.ts b/server/tests/integration/uploads-static.test.ts index eb5ffa421..bd2af2439 100644 --- a/server/tests/integration/uploads-static.test.ts +++ b/server/tests/integration/uploads-static.test.ts @@ -24,7 +24,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) })); import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; diff --git a/server/tests/integration/vacay.test.ts b/server/tests/integration/vacay.test.ts index a2101b355..c89d19a33 100644 --- a/server/tests/integration/vacay.test.ts +++ b/server/tests/integration/vacay.test.ts @@ -12,7 +12,6 @@ vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // Prevent real HTTP calls (holiday API etc.) vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ diff --git a/server/tests/unit/mcp/demo-tool-gate.test.ts b/server/tests/unit/mcp/demo-tool-gate.test.ts index 861b47ba1..d91e3c317 100644 --- a/server/tests/unit/mcp/demo-tool-gate.test.ts +++ b/server/tests/unit/mcp/demo-tool-gate.test.ts @@ -25,7 +25,6 @@ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); const DEMO_REFUSAL = { content: [{ type: 'text', text: 'Write operations are disabled in demo mode.' }], diff --git a/server/tests/unit/mcp/tools-addon-gating.test.ts b/server/tests/unit/mcp/tools-addon-gating.test.ts index d3e7c6cd9..0c0bc3bc8 100644 --- a/server/tests/unit/mcp/tools-addon-gating.test.ts +++ b/server/tests/unit/mcp/tools-addon-gating.test.ts @@ -9,14 +9,15 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; import { setAddonEnabled } from '../../helpers/test-db'; import { ADDON_IDS } from '../../../src/addons'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; beforeEach(() => { resetTestDb(testDb); @@ -38,7 +39,7 @@ async function withHarness( fn: (h: McpHarness) => Promise, scopes?: string[] | null ) { - const h = await createMcpHarness({ userId, withResources: false, scopes: scopes ?? null }); + const h = await createMcpHarness({ realtime, userId, withResources: false, scopes: scopes ?? null }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-airtrail.test.ts b/server/tests/unit/mcp/tools-airtrail.test.ts index 81097d0f3..9cf465919 100644 --- a/server/tests/unit/mcp/tools-airtrail.test.ts +++ b/server/tests/unit/mcp/tools-airtrail.test.ts @@ -16,9 +16,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb, setAddonEnabled } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -31,6 +28,10 @@ import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { findRows, updateRows } from '../../helpers/factories/rows'; import { Users } from '../../../src/db/entities/Users.entity'; import { Reservations } from '../../../src/db/entities/Reservations.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; // The permissions cache is module-scoped, so a write through any instance is // what the tool's own checkPermission call reads back. @@ -69,7 +70,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise, scopes: string[] | null = null) { - const h = await createMcpHarness({ userId, withResources: false, scopes }); + const h = await createMcpHarness({ realtime, userId, withResources: false, scopes }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-assignments-reservations-extra.test.ts b/server/tests/unit/mcp/tools-assignments-reservations-extra.test.ts index 738f22dbd..59d3bd06c 100644 --- a/server/tests/unit/mcp/tools-assignments-reservations-extra.test.ts +++ b/server/tests/unit/mcp/tools-assignments-reservations-extra.test.ts @@ -10,9 +10,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, createDayAssignment, createReservation } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -20,6 +17,10 @@ import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { findRow, insertRow } from '../../helpers/factories/rows'; import { AssignmentParticipants } from '../../../src/db/entities/AssignmentParticipants.entity'; import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; let orm: TestOrm; @@ -39,7 +40,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-assignments.test.ts b/server/tests/unit/mcp/tools-assignments.test.ts index 62a4b9403..bfd6f13ad 100644 --- a/server/tests/unit/mcp/tools-assignments.test.ts +++ b/server/tests/unit/mcp/tools-assignments.test.ts @@ -10,9 +10,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, createDayAssignment, createJourney } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -22,6 +19,10 @@ import { linkTripToJourney } from '../../helpers/factories/journeys'; import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity'; import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; let orm: TestOrm; @@ -64,7 +65,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-atlas-expanded.test.ts b/server/tests/unit/mcp/tools-atlas-expanded.test.ts index 8665536bb..c6802cccc 100644 --- a/server/tests/unit/mcp/tools-atlas-expanded.test.ts +++ b/server/tests/unit/mcp/tools-atlas-expanded.test.ts @@ -12,9 +12,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createBucketListItem, createVisitedCountry, createTrip, createReservation } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -31,6 +28,10 @@ import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity'; import { Places } from '../../../src/db/entities/Places.entity'; import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity'; import { VisitedRegions } from '../../../src/db/entities/VisitedRegions.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; beforeEach(() => { setAddonEnabled(testDb, ADDON_IDS.ATLAS, true); @@ -51,7 +52,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } @@ -59,7 +60,7 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise) // (AtlasMcp @Resource), so the harness must keep tools on for them to attach // (same shape as tools-vacay.test.ts's withResourceHarness). async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: true }); + const h = await createMcpHarness({ realtime, userId, withResources: true }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-atlas.test.ts b/server/tests/unit/mcp/tools-atlas.test.ts index f99f2bd7c..b533871fb 100644 --- a/server/tests/unit/mcp/tools-atlas.test.ts +++ b/server/tests/unit/mcp/tools-atlas.test.ts @@ -10,8 +10,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createBucketListItem, createVisitedCountry } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; diff --git a/server/tests/unit/mcp/tools-budget-advanced.test.ts b/server/tests/unit/mcp/tools-budget-advanced.test.ts index 7bb5fa688..1cd5db912 100644 --- a/server/tests/unit/mcp/tools-budget-advanced.test.ts +++ b/server/tests/unit/mcp/tools-budget-advanced.test.ts @@ -11,9 +11,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -33,6 +30,10 @@ import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.en import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; import { BudgetSettlements } from '../../../src/db/entities/BudgetSettlements.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; beforeEach(() => { resetTestDb(testDb); @@ -57,12 +58,12 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: true }); + const h = await createMcpHarness({ realtime, userId, withResources: true }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-budget.test.ts b/server/tests/unit/mcp/tools-budget.test.ts index 1c7cca1b5..96e845b50 100644 --- a/server/tests/unit/mcp/tools-budget.test.ts +++ b/server/tests/unit/mcp/tools-budget.test.ts @@ -12,9 +12,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createBudgetItem, createPlace, createReservation, addTripMember } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -30,6 +27,10 @@ import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; import { BudgetSettlements } from '../../../src/db/entities/BudgetSettlements.entity'; import { Reservations } from '../../../src/db/entities/Reservations.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; beforeEach(() => { resetTestDb(testDb); @@ -54,7 +55,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-collab-polls-chat.test.ts b/server/tests/unit/mcp/tools-collab-polls-chat.test.ts index 89b29c30c..79437dcf2 100644 --- a/server/tests/unit/mcp/tools-collab-polls-chat.test.ts +++ b/server/tests/unit/mcp/tools-collab-polls-chat.test.ts @@ -14,9 +14,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -28,6 +25,10 @@ import { makeCollabMessage } from '../../helpers/factories/collab'; import { CollabPolls } from '../../../src/db/entities/CollabPolls.entity'; import { CollabPollVotes } from '../../../src/db/entities/CollabPollVotes.entity'; import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; beforeEach(() => { setAddonEnabled(testDb, ADDON_IDS.COLLAB, true); @@ -48,12 +49,12 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: true }); + const h = await createMcpHarness({ realtime, userId, withResources: true }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-collections.test.ts b/server/tests/unit/mcp/tools-collections.test.ts index 59a94380e..c6bccd0d5 100644 --- a/server/tests/unit/mcp/tools-collections.test.ts +++ b/server/tests/unit/mcp/tools-collections.test.ts @@ -14,13 +14,10 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { db as testDb } from '../../../src/db/database'; -const { broadcastToUser } = vi.hoisted(() => ({ broadcastToUser: vi.fn() })); - vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser })); const { notifSend } = vi.hoisted(() => ({ notifSend: vi.fn().mockResolvedValue(undefined) })); import { createUser, createTrip, createPlace, createCategory } from '../../helpers/factories'; @@ -35,6 +32,10 @@ import { CollectionMembers } from '../../../src/db/entities/CollectionMembers.en import { CollectionPlaces } from '../../../src/db/entities/CollectionPlaces.entity'; import { Collections } from '../../../src/db/entities/Collections.entity'; import { Places } from '../../../src/db/entities/Places.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastToUser = realtime.broadcastToUserMock; function clearCollections() { testDb.exec(` @@ -73,7 +74,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } @@ -689,7 +690,7 @@ const WRITE_TOOLS = [ describe('Collection tools — scope gating', () => { async function listToolNames(userId: number, scopes: string[] | null): Promise { - const h = await createMcpHarness({ userId, withResources: false, scopes }); + const h = await createMcpHarness({ realtime, userId, withResources: false, scopes }); try { return (await h.client.listTools()).tools.map((t) => t.name); } finally { diff --git a/server/tests/unit/mcp/tools-days-accommodations.test.ts b/server/tests/unit/mcp/tools-days-accommodations.test.ts index a91838ea5..929b018cd 100644 --- a/server/tests/unit/mcp/tools-days-accommodations.test.ts +++ b/server/tests/unit/mcp/tools-days-accommodations.test.ts @@ -14,9 +14,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, createDayAccommodation, addTripMember } from '../../helpers/factories'; import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache'; @@ -34,6 +31,10 @@ import { Places } from '../../../src/db/entities/Places.entity'; import { Reservations } from '../../../src/db/entities/Reservations.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; import { legacyBoundIntegerText } from '../../../src/nest/common/row-id'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; let orm: TestOrm; @@ -58,7 +59,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-days.test.ts b/server/tests/unit/mcp/tools-days.test.ts index a760be2db..4dd971ac4 100644 --- a/server/tests/unit/mcp/tools-days.test.ts +++ b/server/tests/unit/mcp/tools-days.test.ts @@ -12,9 +12,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation, @@ -27,6 +24,10 @@ import { readTripDays } from '../../helpers/factories/trips'; import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; import { Days } from '../../../src/db/entities/Days.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; beforeEach(() => { resetTestDb(testDb); @@ -46,7 +47,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-feeds-invite.test.ts b/server/tests/unit/mcp/tools-feeds-invite.test.ts index 4b052fcc0..22d44e1b0 100644 --- a/server/tests/unit/mcp/tools-feeds-invite.test.ts +++ b/server/tests/unit/mcp/tools-feeds-invite.test.ts @@ -14,7 +14,6 @@ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember } from '../../helpers/factories'; diff --git a/server/tests/unit/mcp/tools-files.test.ts b/server/tests/unit/mcp/tools-files.test.ts index 76df3ecda..10cfd8124 100644 --- a/server/tests/unit/mcp/tools-files.test.ts +++ b/server/tests/unit/mcp/tools-files.test.ts @@ -18,9 +18,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - const { fixture } = vi.hoisted(() => ({ fixture: {} as { root: string } })); // Each createMcpHarness() builds a fresh registry, and the helper hands every one // of those its own mkdtemp root, so bytes written by a test would land in a @@ -49,6 +46,10 @@ import { FilesModule } from '../../../src/nest/files/files.module'; import { FilesMcp } from '../../../src/nest/files/files.mcp'; import { FILE_CONTENT_MAX } from '../../../src/nest/files/files.service'; import { StorageService } from '../../../src/nest/storage/storage.service'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; beforeEach(async () => { resetTestDb(testDb); @@ -71,7 +72,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } @@ -923,7 +924,7 @@ describe('File tools: scope gating', () => { const WRITE_TOOLS = ['upload_trip_file', 'update_trip_file', 'link_trip_file', 'unlink_trip_file']; async function listToolNames(userId: number, scopes: string[] | null): Promise { - const h = await createMcpHarness({ userId, withResources: false, scopes }); + const h = await createMcpHarness({ realtime, userId, withResources: false, scopes }); try { return (await h.client.listTools()).tools.map((t) => t.name); } finally { diff --git a/server/tests/unit/mcp/tools-help-addons.test.ts b/server/tests/unit/mcp/tools-help-addons.test.ts index 18200ff10..e4c402825 100644 --- a/server/tests/unit/mcp/tools-help-addons.test.ts +++ b/server/tests/unit/mcp/tools-help-addons.test.ts @@ -10,9 +10,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - // The wiki reader is stubbed at the module boundary, the way the weather tools // stub weather.impl: help.mcp.ts calls these functions directly, and the reader // has its own tests in tests/unit/nest/wiki.test.ts. Stubbing keeps these cases @@ -45,6 +42,10 @@ import { findRow, updateRows } from '../../helpers/factories/rows'; import { setAppSetting } from '../../helpers/factories/settings'; import { Addons } from '../../../src/db/entities/Addons.entity'; import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; const SECTIONS = [ { title: 'Getting Started', pages: [{ title: 'Quick Start', slug: 'Quick-Start' }] }, @@ -143,7 +144,7 @@ async function withHarness( fn: (h: McpHarness) => Promise, scopes?: string[] | null, ) { - const h = await createMcpHarness({ userId, withResources: false, scopes: scopes ?? null }); + const h = await createMcpHarness({ realtime, userId, withResources: false, scopes: scopes ?? null }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-journey.test.ts b/server/tests/unit/mcp/tools-journey.test.ts index 914d84640..5efbf7796 100644 --- a/server/tests/unit/mcp/tools-journey.test.ts +++ b/server/tests/unit/mcp/tools-journey.test.ts @@ -11,9 +11,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock, broadcastToUser: broadcastMock })); - /* * get_journey_stats resolves a country per stop. The real lookup loads and * indexes 4MB of gzipped admin-0 boundaries on first call, which is Atlas' @@ -41,6 +38,12 @@ import { createUser, createTrip } from '../../helpers/factories'; import { setAddonEnabled } from '../../helpers/test-db'; import { ADDON_IDS } from '../../../src/addons'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; +// The suite asserts both channels on one recorder, as its module mock did. +realtime.broadcastToUserMock.mockImplementation((...args) => broadcastMock(...(args as unknown as Parameters))); beforeEach(() => { resetTestDb(testDb); @@ -63,7 +66,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } @@ -977,7 +980,7 @@ describe('Journey resource gating', () => { it('does not register the resources without the journey:read scope', async () => { const { user } = createUser(testDb); - const h = await createMcpHarness({ userId: user.id, scopes: ['trips:read'] }); + const h = await createMcpHarness({ realtime, userId: user.id, scopes: ['trips:read'] }); try { await expect(h.client.readResource({ uri: 'trek://journeys' })).rejects.toThrow(); } finally { diff --git a/server/tests/unit/mcp/tools-leg-transport-mode.test.ts b/server/tests/unit/mcp/tools-leg-transport-mode.test.ts index 37eacca2e..c9b0136b7 100644 --- a/server/tests/unit/mcp/tools-leg-transport-mode.test.ts +++ b/server/tests/unit/mcp/tools-leg-transport-mode.test.ts @@ -10,9 +10,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, createDayAssignment } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -20,6 +17,10 @@ import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { findRow, updateRows } from '../../helpers/factories/rows'; import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; import { Days } from '../../../src/db/entities/Days.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; beforeEach(() => { resetTestDb(testDb); @@ -39,7 +40,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-memories.test.ts b/server/tests/unit/mcp/tools-memories.test.ts index b63b93d68..496a9bdc8 100644 --- a/server/tests/unit/mcp/tools-memories.test.ts +++ b/server/tests/unit/mcp/tools-memories.test.ts @@ -15,9 +15,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock, broadcastToUser: broadcastMock })); - import { resetTestDb, setAddonEnabled } from '../../helpers/test-db'; import { createUser, createJourney, createJourneyEntry, addJourneyContributor } from '../../helpers/factories'; import { ADDON_IDS } from '../../../src/addons'; @@ -31,6 +28,12 @@ import { JourneyEntryPhotos } from '../../../src/db/entities/JourneyEntryPhotos. import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity'; import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity'; import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; +// The suite asserts both channels on one recorder, as its module mock did. +realtime.broadcastToUserMock.mockImplementation((...args) => broadcastMock(...(args as unknown as Parameters))); const immichSearch = vi.spyOn(ImmichService.prototype, 'searchPhotos'); const immichAlbums = vi.spyOn(ImmichService.prototype, 'listAlbums'); @@ -87,7 +90,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise, scopes?: string[] | null) { - const h = await createMcpHarness({ userId, withResources: false, scopes: scopes ?? null }); + const h = await createMcpHarness({ realtime, userId, withResources: false, scopes: scopes ?? null }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-notes.test.ts b/server/tests/unit/mcp/tools-notes.test.ts index 2fab98d18..d0e71eade 100644 --- a/server/tests/unit/mcp/tools-notes.test.ts +++ b/server/tests/unit/mcp/tools-notes.test.ts @@ -13,11 +13,9 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock, unlinkSyncMock } = vi.hoisted(() => ({ - broadcastMock: vi.fn(), +const { unlinkSyncMock } = vi.hoisted(() => ({ unlinkSyncMock: vi.fn(), })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, unlinkSync: unlinkSyncMock }; @@ -32,6 +30,10 @@ import { makeTripFile } from '../../helpers/factories/files'; import { CollabNotes } from '../../../src/db/entities/CollabNotes.entity'; import { DayNotes } from '../../../src/db/entities/DayNotes.entity'; import { TripFiles } from '../../../src/db/entities/TripFiles.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; beforeEach(() => { resetTestDb(testDb); @@ -66,7 +68,7 @@ async function collabNote(id: number) { } async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } @@ -380,7 +382,7 @@ describe('Day-note tools — scope gating', () => { const WRITE_TOOLS = ['create_day_note', 'update_day_note', 'delete_day_note']; async function listToolNames(userId: number, scopes: string[] | null): Promise { - const h = await createMcpHarness({ userId, withResources: false, scopes }); + const h = await createMcpHarness({ realtime, userId, withResources: false, scopes }); try { return (await h.client.listTools()).tools.map((t) => t.name); } finally { diff --git a/server/tests/unit/mcp/tools-notifications.test.ts b/server/tests/unit/mcp/tools-notifications.test.ts index 48d3a659b..3b1d2555a 100644 --- a/server/tests/unit/mcp/tools-notifications.test.ts +++ b/server/tests/unit/mcp/tools-notifications.test.ts @@ -17,8 +17,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; diff --git a/server/tests/unit/mcp/tools-packing-advanced.test.ts b/server/tests/unit/mcp/tools-packing-advanced.test.ts index fb10fe80e..a492b5501 100644 --- a/server/tests/unit/mcp/tools-packing-advanced.test.ts +++ b/server/tests/unit/mcp/tools-packing-advanced.test.ts @@ -14,9 +14,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createAdmin, createTrip, createPackingItem } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -29,6 +26,10 @@ import { PackingItems } from '../../../src/db/entities/PackingItems.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 { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; let orm: TestOrm; @@ -48,7 +49,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-packing.test.ts b/server/tests/unit/mcp/tools-packing.test.ts index 2e17ee15f..c75d6f0ce 100644 --- a/server/tests/unit/mcp/tools-packing.test.ts +++ b/server/tests/unit/mcp/tools-packing.test.ts @@ -19,9 +19,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createPackingItem, addTripMember } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -33,6 +30,10 @@ import { addPackingBagMembers, addPackingItemRecipients, makePackingBag } from ' import { setAddonEnabled } from '../../helpers/factories/settings'; import { PackingBags } from '../../../src/db/entities/PackingBags.entity'; import { PackingItems } from '../../../src/db/entities/PackingItems.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; let orm: TestOrm; @@ -52,7 +53,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } @@ -849,7 +850,7 @@ describe('Packing tools — scope gating', () => { ]; async function listToolNames(userId: number, scopes: string[] | null): Promise { - const h = await createMcpHarness({ userId, withResources: false, scopes }); + const h = await createMcpHarness({ realtime, userId, withResources: false, scopes }); try { return (await h.client.listTools()).tools.map((t) => t.name); } finally { diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index 2d2aa38f8..d107f60b6 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -16,9 +16,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - const { searchPlacesMock } = vi.hoisted(() => ({ searchPlacesMock: vi.fn() })); // PlacesMcp and PlacesService both inject MapsService (search_place and the // import enrichment), so the geo calls are stubbed on the prototype — see @@ -42,6 +39,10 @@ import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity'; import { PlaceRatings } from '../../../src/db/entities/PlaceRatings.entity'; import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity'; import { Places } from '../../../src/db/entities/Places.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; let orm: TestOrm; @@ -75,7 +76,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-plugin-pois.test.ts b/server/tests/unit/mcp/tools-plugin-pois.test.ts index 3a91dda74..d8fa687c6 100644 --- a/server/tests/unit/mcp/tools-plugin-pois.test.ts +++ b/server/tests/unit/mcp/tools-plugin-pois.test.ts @@ -12,7 +12,6 @@ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { db as testDb } from '../../../src/db/database'; diff --git a/server/tests/unit/mcp/tools-plugin-search.test.ts b/server/tests/unit/mcp/tools-plugin-search.test.ts index deedaca0f..fcd005e7b 100644 --- a/server/tests/unit/mcp/tools-plugin-search.test.ts +++ b/server/tests/unit/mcp/tools-plugin-search.test.ts @@ -5,8 +5,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { db as testDb } from '../../../src/db/database'; -const { broadcastMock, pluginsEnabled } = vi.hoisted(() => ({ - broadcastMock: vi.fn(), +const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true), })); @@ -14,7 +13,6 @@ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); // The admin kill switch reads live env; drive it from the test instead of the process. vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); @@ -22,6 +20,10 @@ import { createUser } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; import { resetTestDb } from '../../helpers/test-db'; import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; const providersOfMock = vi.spyOn(PluginHooks.prototype, 'providersOf'); const searchPlacesMock = vi.spyOn(PluginHooks.prototype, 'searchPlaces'); @@ -39,7 +41,7 @@ afterAll(() => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false, scopes: null }); + const h = await createMcpHarness({ realtime, userId, withResources: false, scopes: null }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index 6bcff83a1..bf0b81d8d 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -27,9 +27,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - const { isAddonEnabledMock } = vi.hoisted(() => { const isAddonEnabledMock = vi.fn().mockReturnValue(true); return { isAddonEnabledMock }; @@ -159,7 +156,6 @@ const authMcp = new AuthMcp(); beforeEach(() => { resetTestDb(testDb); - broadcastMock.mockClear(); isAddonEnabledMock.mockReturnValue(true); // Default mock: returns a trip-summary-shaped value from the real in-memory DB diff --git a/server/tests/unit/mcp/tools-reservations.test.ts b/server/tests/unit/mcp/tools-reservations.test.ts index cfd35201f..f7c108233 100644 --- a/server/tests/unit/mcp/tools-reservations.test.ts +++ b/server/tests/unit/mcp/tools-reservations.test.ts @@ -10,9 +10,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, createReservation, createBudgetItem, createDayAssignment, createDayAccommodation, createCategory, addTripMember } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -23,6 +20,10 @@ import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.en import { ReservationTravelers } from '../../../src/db/entities/ReservationTravelers.entity'; import { Reservations } from '../../../src/db/entities/Reservations.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; let orm: TestOrm; @@ -51,7 +52,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-roadtrip.test.ts b/server/tests/unit/mcp/tools-roadtrip.test.ts index beb4cd04d..bb1023f51 100644 --- a/server/tests/unit/mcp/tools-roadtrip.test.ts +++ b/server/tests/unit/mcp/tools-roadtrip.test.ts @@ -15,7 +15,6 @@ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); import { resetTestDb, setAddonEnabled } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace } from '../../helpers/factories'; diff --git a/server/tests/unit/mcp/tools-settings.test.ts b/server/tests/unit/mcp/tools-settings.test.ts index f8cf38df3..7cc132df8 100644 --- a/server/tests/unit/mcp/tools-settings.test.ts +++ b/server/tests/unit/mcp/tools-settings.test.ts @@ -17,8 +17,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); - import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { createUser } from '../../helpers/factories'; diff --git a/server/tests/unit/mcp/tools-share.test.ts b/server/tests/unit/mcp/tools-share.test.ts index d532e458d..1c31ed632 100644 --- a/server/tests/unit/mcp/tools-share.test.ts +++ b/server/tests/unit/mcp/tools-share.test.ts @@ -11,7 +11,6 @@ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip } from '../../helpers/factories'; diff --git a/server/tests/unit/mcp/tools-tags-maps-weather.test.ts b/server/tests/unit/mcp/tools-tags-maps-weather.test.ts index 52cbcfd32..112d903f9 100644 --- a/server/tests/unit/mcp/tools-tags-maps-weather.test.ts +++ b/server/tests/unit/mcp/tools-tags-maps-weather.test.ts @@ -12,9 +12,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - vi.mock('../../../src/nest/weather/weather.impl', () => ({ getWeather: vi.fn().mockResolvedValue({ temp: 20, condition: 'sunny' }), getDetailedWeather: vi.fn().mockResolvedValue({ hourly: [] }), @@ -46,6 +43,10 @@ import { Tags } from '../../../src/db/entities/Tags.entity'; import { MapsService } from '../../../src/nest/maps/maps.service'; import { OsmClient } from '../../../src/nest/maps/providers/osm.client'; import { getWeather, getDetailedWeather } from '../../../src/nest/weather/weather.impl'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; // The geo tools live on the DI-discovered maps.mcp.ts since the maps fold; the // test registry builds a real MapsService over the mocked db proxy, so stub the @@ -95,7 +96,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } @@ -271,7 +272,7 @@ describe('Tags tools: scope gating', () => { const TAG_TOOLS = ['list_tags', 'create_tag', 'update_tag', 'delete_tag']; async function listToolNames(userId: number, scopes: string[] | null): Promise { - const h = await createMcpHarness({ userId, withResources: false, scopes }); + const h = await createMcpHarness({ realtime, userId, withResources: false, scopes }); try { return (await h.client.listTools()).tools.map((t) => t.name); } finally { diff --git a/server/tests/unit/mcp/tools-todos.test.ts b/server/tests/unit/mcp/tools-todos.test.ts index 7ca4e023a..7632692b3 100644 --- a/server/tests/unit/mcp/tools-todos.test.ts +++ b/server/tests/unit/mcp/tools-todos.test.ts @@ -16,9 +16,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createTodoItem } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -29,6 +26,10 @@ import { makeTodoItem } from '../../helpers/factories/todos'; import { setAddonEnabled } from '../../helpers/factories/settings'; import { TodoItems } from '../../../src/db/entities/TodoItems.entity'; import { TodoCategoryAssignees } from '../../../src/db/entities/TodoCategoryAssignees.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; beforeEach(() => { resetTestDb(testDb); @@ -48,7 +49,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } @@ -437,7 +438,7 @@ describe('Todo tools — scope gating', () => { const WRITE_TOOLS = ['create_todo', 'update_todo', 'toggle_todo', 'delete_todo', 'reorder_todos', 'set_todo_category_assignees']; async function listToolNames(userId: number, scopes: string[] | null): Promise { - const h = await createMcpHarness({ userId, withResources: false, scopes }); + const h = await createMcpHarness({ realtime, userId, withResources: false, scopes }); try { return (await h.client.listTools()).tools.map((t) => t.name); } finally { diff --git a/server/tests/unit/mcp/tools-transit.test.ts b/server/tests/unit/mcp/tools-transit.test.ts index 8f9fd2047..a050db092 100644 --- a/server/tests/unit/mcp/tools-transit.test.ts +++ b/server/tests/unit/mcp/tools-transit.test.ts @@ -7,15 +7,10 @@ import { resetTestDb } from '../../helpers/test-db'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { db as testDb } from '../../../src/db/database'; -const { broadcastMock } = vi.hoisted(() => ({ - broadcastMock: vi.fn(), -})); - vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); import { ReservationsService } from '../../../src/nest/reservations/reservations.service'; import type { TransitPlace } from '../../../src/nest/transit/transit.helpers'; @@ -25,6 +20,10 @@ import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { countRows, findRow } from '../../helpers/factories/rows'; import { Days } from '../../../src/db/entities/Days.entity'; import { Reservations } from '../../../src/db/entities/Reservations.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; // savePermissions is no longer bridged; write through a service instance — the // permissions cache is module-scoped, so the MCP _shared checkPermission path @@ -136,7 +135,7 @@ async function dayOf(tripId: number, date?: string) { const transitCount = () => countRows(orm, Reservations, { type: 'transit' }); async function withHarness(userId: number, scopes: string[] | null, fn: (harness: McpHarness) => Promise) { - const harness = await createMcpHarness({ userId, scopes, withResources: false }); + const harness = await createMcpHarness({ realtime, userId, scopes, withResources: false }); try { await fn(harness); } finally { diff --git a/server/tests/unit/mcp/tools-transports.test.ts b/server/tests/unit/mcp/tools-transports.test.ts index 8b828a48d..d91189aab 100644 --- a/server/tests/unit/mcp/tools-transports.test.ts +++ b/server/tests/unit/mcp/tools-transports.test.ts @@ -12,9 +12,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -25,6 +22,10 @@ import { makeBudgetItem } from '../../helpers/factories/budget'; import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity'; import { Reservations } from '../../../src/db/entities/Reservations.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; /** Overwrites the booking's stored metadata text, the way an import or a legacy row leaves it. */ async function setMetadata(metadata: string, reservationId: number): Promise { @@ -49,7 +50,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-trip-members.test.ts b/server/tests/unit/mcp/tools-trip-members.test.ts index 17d16ee31..173755f8f 100644 --- a/server/tests/unit/mcp/tools-trip-members.test.ts +++ b/server/tests/unit/mcp/tools-trip-members.test.ts @@ -12,9 +12,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache'; import { createUser, createAdmin, createTrip, addTripMember } from '../../helpers/factories'; @@ -27,6 +24,10 @@ import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity'; import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; import { Users } from '../../../src/db/entities/Users.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; let orm: TestOrm; @@ -57,7 +58,7 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-trip-warnings.test.ts b/server/tests/unit/mcp/tools-trip-warnings.test.ts index 5fa240fa8..83b8deb7d 100644 --- a/server/tests/unit/mcp/tools-trip-warnings.test.ts +++ b/server/tests/unit/mcp/tools-trip-warnings.test.ts @@ -5,8 +5,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { db as testDb } from '../../../src/db/database'; -const { broadcastMock, pluginsEnabled } = vi.hoisted(() => ({ - broadcastMock: vi.fn(), +const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true), })); @@ -14,7 +13,6 @@ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); // The admin kill switch reads live env; drive it from the test instead of the process. vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); @@ -22,6 +20,10 @@ import { addTripMember, createTrip, createUser } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; import { resetTestDb } from '../../helpers/test-db'; import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; // The harness builds the registry itself, so the fan-out is played on the prototype // (the tools-transit.test.ts pattern). vitest is not configured to auto-restore, so @@ -46,7 +48,7 @@ async function withHarness( fn: (h: McpHarness) => Promise, scopes?: string[] | null, ) { - const h = await createMcpHarness({ userId, withResources: false, scopes: scopes ?? null }); + const h = await createMcpHarness({ realtime, userId, withResources: false, scopes: scopes ?? null }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/mcp/tools-trips.test.ts b/server/tests/unit/mcp/tools-trips.test.ts index d7b0a5f2c..4a5e98f98 100644 --- a/server/tests/unit/mcp/tools-trips.test.ts +++ b/server/tests/unit/mcp/tools-trips.test.ts @@ -15,9 +15,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, addTripMember, createBudgetItem, createPackingItem, createReservation, createDayNote, createCollabNote, createDayAssignment, createDayAccommodation } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; @@ -32,6 +29,10 @@ import { Trips } from '../../../src/db/entities/Trips.entity'; import { VacayEntries } from '../../../src/db/entities/VacayEntries.entity'; import { VacayUserYears } from '../../../src/db/entities/VacayUserYears.entity'; import { VacayYears } from '../../../src/db/entities/VacayYears.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; let orm: TestOrm; @@ -74,7 +75,7 @@ async function vacayPlanWithYear(userId: number): Promise { } async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } @@ -739,7 +740,7 @@ describe('Tool: get_trip_summary', () => { createDayAssignment(testDb, day.id, place.id); createDayAccommodation(testDb, trip.id, place.id, day.id, day.id); - const h = await createMcpHarness({ userId: user.id, withResources: false, scopes: ['weather:read'] }); + const h = await createMcpHarness({ realtime, userId: user.id, withResources: false, scopes: ['weather:read'] }); try { const result = await h.client.callTool({ name: 'get_trip_summary', arguments: { tripId: trip.id } }); const data = parseToolResult(result) as any; @@ -765,7 +766,7 @@ describe('Tool: get_trip_summary', () => { addTripMember(testDb, trip.id, member.id); createDay(testDb, trip.id); - const h = await createMcpHarness({ userId: user.id, withResources: false, scopes: ['trips:read'] }); + const h = await createMcpHarness({ realtime, userId: user.id, withResources: false, scopes: ['trips:read'] }); try { const result = await h.client.callTool({ name: 'get_trip_summary', arguments: { tripId: trip.id } }); const data = parseToolResult(result) as any; @@ -927,7 +928,7 @@ describe('static-token deprecation notice', () => { emitted = true; return 'static tokens are deprecated'; }; - const h = await createMcpHarness({ userId: user.id, withResources: false, isStaticToken: true, getDeprecationNotice }); + const h = await createMcpHarness({ realtime, userId: user.id, withResources: false, isStaticToken: true, getDeprecationNotice }); try { const first = await h.client.callTool({ name: 'list_trips', arguments: {} }); expect(first.isError).toBe(true); @@ -952,7 +953,7 @@ describe('static-token deprecation notice', () => { describe('scope gating', () => { async function toolNames(scopes: string[] | null): Promise { - const h = await createMcpHarness({ userId: 1, withResources: false, scopes }); + const h = await createMcpHarness({ realtime, userId: 1, withResources: false, scopes }); try { const { tools } = await h.client.listTools(); return tools.map((t) => t.name); diff --git a/server/tests/unit/mcp/tools-vacay.test.ts b/server/tests/unit/mcp/tools-vacay.test.ts index 0bfcb2e73..2a706a43f 100644 --- a/server/tests/unit/mcp/tools-vacay.test.ts +++ b/server/tests/unit/mcp/tools-vacay.test.ts @@ -22,9 +22,6 @@ vi.mock('../../../src/db/database', async () => { return buildDbMock(createSnapshotTestDb()); }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - // share_vacay_calendar fires a user notification after inserting; stub it out import { resetTestDb } from '../../helpers/test-db'; @@ -41,6 +38,10 @@ import { VacayPlanMembers } from '../../../src/db/entities/VacayPlanMembers.enti import { VacayPlans } from '../../../src/db/entities/VacayPlans.entity'; import { VacayShares } from '../../../src/db/entities/VacayShares.entity'; import { VacayUserSettings } from '../../../src/db/entities/VacayUserSettings.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; let orm: TestOrm; @@ -90,12 +91,12 @@ afterAll(async () => { }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: false }); + const h = await createMcpHarness({ realtime, userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise) { - const h = await createMcpHarness({ userId, withResources: true }); + const h = await createMcpHarness({ realtime, userId, withResources: true }); try { await fn(h); } finally { await h.cleanup(); } } diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts index 1e326ad06..8c78e420b 100644 --- a/server/tests/unit/nest/accommodations.service.test.ts +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -16,15 +16,10 @@ vi.mock('../../../src/db/database', async () => { }; }); - -const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast })); - import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, createDayAccommodation, createDayAssignment, addTripMember } from '../../helpers/factories'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service'; import { makeAccommodationsService } from '../../helpers/accommodations-service'; import { AccommodationsModule } from '../../../src/nest/accommodations/accommodations.module'; @@ -44,6 +39,10 @@ import { Places } from '../../../src/db/entities/Places.entity'; import { Reservations } from '../../../src/db/entities/Reservations.entity'; import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity'; import { legacyBoundIntegerText } from '../../../src/nest/common/row-id'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcast = realtime.broadcastMock; const orm = () => sharedTestOrm(testDb); @@ -80,7 +79,7 @@ async function setStops(where: FilterQuery, data: Partial>; beforeAll(async () => { - svc = await makeAccommodationsService(testDb); + svc = await makeAccommodationsService(testDb, realtime); }); beforeEach(() => { diff --git a/server/tests/unit/nest/admin.version-notification.test.ts b/server/tests/unit/nest/admin.version-notification.test.ts index ba05bf7ce..324bec81c 100644 --- a/server/tests/unit/nest/admin.version-notification.test.ts +++ b/server/tests/unit/nest/admin.version-notification.test.ts @@ -27,7 +27,6 @@ vi.mock('../../../src/db/database', async () => { return mock; }); -vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() })); // Mock MCP to avoid session side-effects vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn(), invalidateMcpSessions: vi.fn() })); vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn() })); diff --git a/server/tests/unit/nest/assignments.service.test.ts b/server/tests/unit/nest/assignments.service.test.ts index d1123ca19..0ca7eb4f7 100644 --- a/server/tests/unit/nest/assignments.service.test.ts +++ b/server/tests/unit/nest/assignments.service.test.ts @@ -22,8 +22,6 @@ vi.mock('../../../src/db/database', async () => { return { db, closeDb: () => {}, reinitialize: () => {} }; }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); - import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createTag } from '../../helpers/factories'; diff --git a/server/tests/unit/nest/booking-import/booking-import.service.test.ts b/server/tests/unit/nest/booking-import/booking-import.service.test.ts index 32ae88ead..5e06dba6d 100644 --- a/server/tests/unit/nest/booking-import/booking-import.service.test.ts +++ b/server/tests/unit/nest/booking-import/booking-import.service.test.ts @@ -9,7 +9,6 @@ vi.mock('../../../../src/db/database', () => ({ // them, but the module-level import has to resolve. canAccessTrip: vi.fn(), isOwner: () => false, getPlaceWithTags: () => null, })); -vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn() })); const permissionsStub = { checkPermission: vi.fn(() => true) }; // Runs the callback in place; the tests that care about the boundary wrap it to record it. const uowStub = { transactional: (fn: () => Promise) => fn() }; diff --git a/server/tests/unit/nest/booking-import/import-jobs.service.test.ts b/server/tests/unit/nest/booking-import/import-jobs.service.test.ts index 65e081786..05afdfc4b 100644 --- a/server/tests/unit/nest/booking-import/import-jobs.service.test.ts +++ b/server/tests/unit/nest/booking-import/import-jobs.service.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; -const { broadcastToUser } = vi.hoisted(() => ({ broadcastToUser: vi.fn() })); -vi.mock('../../../../src/websocket', () => ({ broadcastToUser })); - import { ImportJobsService } from '../../../../src/nest/booking-import/import-jobs.service'; -import { RealtimeService } from '../../../../src/nest/realtime/realtime.service'; import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { Users } from '../../../../src/db/entities/Users.entity'; +import { FakeRealtimeService } from '../../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastToUser = realtime.broadcastToUserMock; // R9 (Plan 3h Task 4): `run()` now forks its own `withRequestContext`, so the // service needs a real `MikroORM` — the `StorageHealthNotifierService` @@ -28,7 +28,7 @@ afterAll(async () => { type Preview = ReturnType; function makeService(preview: Preview, readReceipt: Preview = vi.fn()) { - return new ImportJobsService({ preview } as never, new RealtimeService(), { readReceipt } as never, t.orm); + return new ImportJobsService({ preview } as never, realtime, { readReceipt } as never, t.orm); } const files = (n: number) => Array.from({ length: n }, (_, i) => ({ originalname: `f${i}.pdf` })) as never; const eventsFor = (jobId: string) => broadcastToUser.mock.calls.map((c) => c[1]).filter((p) => p.jobId === jobId); diff --git a/server/tests/unit/nest/budget.service.calc.test.ts b/server/tests/unit/nest/budget.service.calc.test.ts index c3633141a..8a0207fa9 100644 --- a/server/tests/unit/nest/budget.service.calc.test.ts +++ b/server/tests/unit/nest/budget.service.calc.test.ts @@ -27,7 +27,6 @@ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 3976bc99e..3e00d9e5a 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -17,8 +17,6 @@ vi.mock('../../../src/db/database', async () => { return { db, closeDb: () => {}, reinitialize: () => {} }; }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); - // Frozen snapshot of the rates in play in #1543 (rates[X] = units of X per 1 base). const { RATES } = vi.hoisted(() => ({ RATES: { diff --git a/server/tests/unit/nest/budget.service.test.ts b/server/tests/unit/nest/budget.service.test.ts index c0788fee5..699b74198 100644 --- a/server/tests/unit/nest/budget.service.test.ts +++ b/server/tests/unit/nest/budget.service.test.ts @@ -20,9 +20,6 @@ vi.mock('../../../src/db/database', () => ({ })); import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast })); - const checkPermission = vi.fn(() => true); const permissionsStub = { checkPermission } as unknown as PermissionsService; @@ -32,7 +29,6 @@ const getRates = vi.fn(); const exchangeRatesStub = { getRates } as unknown as ExchangeRatesService; import { BudgetService } from '../../../src/nest/budget/budget.service'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; import type { BudgetItemsRepository } from '../../../src/db/repositories/BudgetItems.repository'; import type { BudgetItemMembersRepository } from '../../../src/db/repositories/BudgetItemMembers.repository'; @@ -43,6 +39,10 @@ import type { ReservationsRepository } from '../../../src/db/repositories/Reserv import type { PlacesRepository } from '../../../src/db/repositories/Places.repository'; import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; import type { TripMembersRepository } from '../../../src/db/repositories/TripMembers.repository'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcast = realtime.broadcastMock; /** * There is no database behind this suite (every repository is a stub), so the @@ -77,7 +77,7 @@ function svc( budgetItemsRepo: BudgetItemsRepository = {} as unknown as BudgetItemsRepository, ) { return new BudgetService( - permissionsStub, exchangeRatesStub, new RealtimeService(), uowStub, + permissionsStub, exchangeRatesStub, realtime, uowStub, budgetItemsRepo, {} as unknown as BudgetItemMembersRepository, {} as unknown as BudgetItemPayersRepository, diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index d3c8fc8e6..07d27b587 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -22,14 +22,10 @@ vi.mock('../../../src/db/database', async () => { return mock; }); -const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast })); - import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote } from '../../helpers/factories'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { ReservationsService } from '../../../src/nest/reservations/reservations.service'; import { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service'; import { BudgetService } from '../../../src/nest/budget/budget.service'; @@ -51,15 +47,18 @@ import { Days } from '../../../src/db/entities/Days.entity'; import { Places } from '../../../src/db/entities/Places.entity'; import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity'; import { Reservations } from '../../../src/db/entities/Reservations.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); // Named `svc` so the moved cases below read exactly as they did on TripsService. let budgetSvc: BudgetService; let svc: CalendarService; let orm: TestOrm; beforeAll(async () => { - budgetSvc = new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))); + budgetSvc = new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), realtime, await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))); svc = new CalendarService( - new ReservationsService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadService(await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb)), await accommodationsOver(testDb), await createTestUnitOfWork(testDb), await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb), await createTestReservationDayPositionsRepo(testDb), await createTestDayAccommodationsRepo(testDb), await createTestDaysRepo(testDb), await createTestPlacesRepo(testDb), await createTestDayAssignmentsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb), await createTestTripsRepo(testDb), await createTestBudgetItemsRepo(testDb)), + new ReservationsService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), budgetSvc, realtime, notificationsStub(), new ReservationsReadService(await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb)), await accommodationsOver(testDb, realtime), await createTestUnitOfWork(testDb), await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb), await createTestReservationDayPositionsRepo(testDb), await createTestDayAccommodationsRepo(testDb), await createTestDaysRepo(testDb), await createTestPlacesRepo(testDb), await createTestDayAssignmentsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb), await createTestTripsRepo(testDb), await createTestBudgetItemsRepo(testDb)), await createTestTripsRepo(testDb), await createTestDaysRepo(testDb), await createTestDayNotesRepo(testDb), await createTestReservationsRepo(testDb), ); }); diff --git a/server/tests/unit/nest/channel-override.test.ts b/server/tests/unit/nest/channel-override.test.ts index 6d81e1e35..1ddb8075d 100644 --- a/server/tests/unit/nest/channel-override.test.ts +++ b/server/tests/unit/nest/channel-override.test.ts @@ -20,7 +20,6 @@ vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ decrypt_api_key const { sendMailMock } = vi.hoisted(() => ({ sendMailMock: vi.fn().mockResolvedValue({ accepted: ['a@b.c'] }) })); vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: sendMailMock, verify: vi.fn() })) } })); vi.stubGlobal('fetch', vi.fn()); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../../src/utils/ssrfGuard', () => { class SsrfBlockedError extends Error { constructor(message: string) { diff --git a/server/tests/unit/nest/collab.service.test.ts b/server/tests/unit/nest/collab.service.test.ts index e856bfa1c..59f933cd9 100644 --- a/server/tests/unit/nest/collab.service.test.ts +++ b/server/tests/unit/nest/collab.service.test.ts @@ -25,8 +25,6 @@ vi.mock('../../../src/db/database', async () => { return mock; }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); - // Stub checkSsrf so linkPreview tests can control SSRF behaviour. Typed from the real // checkSsrf rather than from the default implementation below, so the stubbed results // stay full SsrfResult objects instead of whatever shape the first fixture happened to have. diff --git a/server/tests/unit/nest/collections.service.test.ts b/server/tests/unit/nest/collections.service.test.ts index a5b54d42f..5ed845b62 100644 --- a/server/tests/unit/nest/collections.service.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -22,18 +22,14 @@ vi.mock('../../../src/db/database', async () => { }; }); -const { broadcastToUser } = vi.hoisted(() => ({ broadcastToUser: vi.fn() })); - import { db as testDb } from '../../../src/db/database'; -vi.mock('../../../src/websocket', () => ({ broadcastToUser, broadcast: vi.fn() })); const notifSend = vi.fn().mockResolvedValue(undefined); import fs from 'fs'; import path from 'path'; import { createUser, createTrip, createPlace, createCategory, createTag, addTripMember, createDay, createDayAssignment } from '../../helpers/factories'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { CollectionsService } from '../../../src/nest/collections/collections.service'; import { buildPlaceImportService } from '../../helpers/place-import'; import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; @@ -58,6 +54,10 @@ import { PlaceRatings } from '../../../src/db/entities/PlaceRatings.entity'; import { Places } from '../../../src/db/entities/Places.entity'; import { Tags } from '../../../src/db/entities/Tags.entity'; import { Users } from '../../../src/db/entities/Users.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastToUser = realtime.broadcastToUserMock; const orm = () => sharedTestOrm(testDb); @@ -121,7 +121,7 @@ beforeAll(async () => { permissionsForSpy = new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)); svc = new CollectionsService( permissionsForSpy, - new RealtimeService(), notificationsStub(notifSend), storageFx.storage, await createTestUnitOfWork(testDb), + realtime, notificationsStub(notifSend), storageFx.storage, await createTestUnitOfWork(testDb), await createTestCollectionsRepo(testDb), await createTestCollectionMembersRepo(testDb), await createTestCollectionLabelsRepo(testDb), await createTestCategoriesRepo(testDb), await createTestCollectionPlacesRepo(testDb), await createTestCollectionPlaceRatingsRepo(testDb), diff --git a/server/tests/unit/nest/dawarich-suggestions.service.test.ts b/server/tests/unit/nest/dawarich-suggestions.service.test.ts index 9a509dbb3..2c584a205 100644 --- a/server/tests/unit/nest/dawarich-suggestions.service.test.ts +++ b/server/tests/unit/nest/dawarich-suggestions.service.test.ts @@ -17,8 +17,6 @@ import { createSnapshotTestDb } from '../../helpers/db-mock'; const testDb = createSnapshotTestDb(); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); - import { DAWARICH_BUCKET_SCAN_LIMIT } from '@trek/shared'; import type { DawarichConnection } from '@trek/shared'; import { resetTestDb } from '../../helpers/test-db'; diff --git a/server/tests/unit/nest/day-notes.service.test.ts b/server/tests/unit/nest/day-notes.service.test.ts index 8f9ff94dc..90f5a6e31 100644 --- a/server/tests/unit/nest/day-notes.service.test.ts +++ b/server/tests/unit/nest/day-notes.service.test.ts @@ -21,7 +21,6 @@ vi.mock('../../../src/db/database', async () => { getPlaceWithTags: () => null, }; }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; diff --git a/server/tests/unit/nest/day-removal.service.test.ts b/server/tests/unit/nest/day-removal.service.test.ts index f830871bc..269378196 100644 --- a/server/tests/unit/nest/day-removal.service.test.ts +++ b/server/tests/unit/nest/day-removal.service.test.ts @@ -14,7 +14,6 @@ vi.mock('../../../src/db/database', async () => { // Trip access reads through TripsRepository now; the module only hands out the handle. return { db, closeDb: () => {}, reinitialize: () => {} }; }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; diff --git a/server/tests/unit/nest/days.service.test.ts b/server/tests/unit/nest/days.service.test.ts index ee0d9eb50..88e31861c 100644 --- a/server/tests/unit/nest/days.service.test.ts +++ b/server/tests/unit/nest/days.service.test.ts @@ -22,8 +22,6 @@ vi.mock('../../../src/db/database', async () => { return { db, closeDb: () => {}, reinitialize: () => {} }; }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); - import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation, createDayNote, createTag, createReservation } from '../../helpers/factories'; diff --git a/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts index ff30bd5c0..4e3dc63c4 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts @@ -32,9 +32,6 @@ vi.mock('../../../../src/db/database', async () => { }; }); - -vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); - /** * The SSRF guard resolves DNS, and none of these hostnames exist. It is not * what this file tests, and a suite that needs a resolver is a suite that fails diff --git a/server/tests/unit/nest/files.service.test.ts b/server/tests/unit/nest/files.service.test.ts index 1f3cb784a..e5919ad12 100644 --- a/server/tests/unit/nest/files.service.test.ts +++ b/server/tests/unit/nest/files.service.test.ts @@ -31,7 +31,6 @@ vi.mock('../../../src/db/database', async () => { }); import { db as testDb } from '../../../src/db/database'; -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); const checkPermission = vi.fn(() => true); const permissionsStub = { checkPermission } as unknown as PermissionsService; diff --git a/server/tests/unit/nest/journey-book.service.test.ts b/server/tests/unit/nest/journey-book.service.test.ts index 056e8ea54..07127a894 100644 --- a/server/tests/unit/nest/journey-book.service.test.ts +++ b/server/tests/unit/nest/journey-book.service.test.ts @@ -22,8 +22,6 @@ vi.mock('../../../src/db/database', async () => { }; }); -vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() })); - import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createJourney, addJourneyContributor } from '../../helpers/factories'; diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index 241c0861e..59bd82867 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -24,8 +24,6 @@ vi.mock('../../../src/db/database', async () => { return mock; }); -vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() })); - import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { diff --git a/server/tests/unit/nest/journey-stats.service.test.ts b/server/tests/unit/nest/journey-stats.service.test.ts index 9e4b5de8c..e589e40e3 100644 --- a/server/tests/unit/nest/journey-stats.service.test.ts +++ b/server/tests/unit/nest/journey-stats.service.test.ts @@ -22,8 +22,6 @@ vi.mock('../../../src/db/database', async () => { }; }); -vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() })); - /* * The country lookup is stubbed rather than exercised. The real one loads and * indexes 4MB of gzipped admin-0 boundaries on first call — minutes of work diff --git a/server/tests/unit/nest/mcp-shared.service.test.ts b/server/tests/unit/nest/mcp-shared.service.test.ts index 818ea3874..64f49c031 100644 --- a/server/tests/unit/nest/mcp-shared.service.test.ts +++ b/server/tests/unit/nest/mcp-shared.service.test.ts @@ -1,8 +1,8 @@ /** * McpToolGuardsService — the injectable fold of the impure _shared.ts helpers. * A real in-memory SQLite backs hasTripPermission/isAdminUser so the SQL stays - * byte-faithful to the module functions it replaces; broadcast flows through - * the vi.mock'd src/websocket, exactly like every .mcp.ts consumer expects. + * byte-faithful to the module functions it replaces; broadcast goes to a + * FakeRealtimeService handed in through the constructor. */ import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; @@ -11,8 +11,6 @@ vi.mock('../../../src/db/database', async () => { const db = createSnapshotTestDb(); return { db, closeDb: () => {}, reinitialize: () => {} }; }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); import { db as testDb } from '../../../src/db/database'; @@ -20,13 +18,16 @@ import { createUser } from '../../helpers/factories'; import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service'; import { McpSharedModule } from '../../../src/nest/mcp-shared/mcp-shared.module'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow'; import { deleteRows, updateRows } from '../../helpers/factories/rows'; import { makeTrip } from '../../helpers/factories/trips'; import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; import { Users } from '../../../src/db/entities/Users.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; let svc: McpToolGuardsService; beforeAll(async () => { @@ -36,7 +37,7 @@ beforeAll(async () => { await createTestTripsRepo(testDb), await createTestUsersRepo(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), - new RealtimeService(), + realtime, ); }); diff --git a/server/tests/unit/nest/memories.service.test.ts b/server/tests/unit/nest/memories.service.test.ts index 35b9ff067..a27e58c39 100644 --- a/server/tests/unit/nest/memories.service.test.ts +++ b/server/tests/unit/nest/memories.service.test.ts @@ -47,17 +47,15 @@ const synology = vi.hoisted(() => ({ const helpers = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn(() => true) })); -const ws = vi.hoisted(() => ({ broadcast: vi.fn() })); -vi.mock('../../../src/websocket', () => ws); - import { MemoriesService } from '../../../src/nest/memories/memories.service'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; import type { UnifiedMemoriesService } from '../../../src/nest/memories/unified-memories.service'; import type { ImmichService } from '../../../src/nest/memories/immich.service'; import type { SynologyService } from '../../../src/nest/memories/synology.service'; import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service'; const res = {} as import('express').Response; +const realtime = new FakeRealtimeService(); describe('MemoriesService (delegation wrapper over services/memories/*)', () => { let svc: MemoriesService; @@ -65,7 +63,7 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () => beforeEach(() => { vi.clearAllMocks(); svc = new MemoriesService( - new RealtimeService(), + realtime, unified as unknown as UnifiedMemoriesService, immich as unknown as ImmichService, synology as unknown as SynologyService, @@ -79,12 +77,12 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () => expect(helpers.canAccessUserPhoto).toHaveBeenCalledWith(1, 2, '5', 'a', 'immich'); svc.broadcast('5', 'memories:updated', { userId: 1 }, 'sock'); - expect(ws.broadcast).toHaveBeenCalledWith('5', 'memories:updated', { userId: 1 }, 'sock'); + expect(realtime.broadcastMock).toHaveBeenCalledWith('5', 'memories:updated', { userId: 1 }, 'sock'); }); it('broadcast forwards an absent socket id as undefined', () => { svc.broadcast('5', 'memories:updated', { userId: 1 }); - expect(ws.broadcast).toHaveBeenCalledWith('5', 'memories:updated', { userId: 1 }, undefined); + expect(realtime.broadcastMock).toHaveBeenCalledWith('5', 'memories:updated', { userId: 1 }, undefined); }); it('unified methods delegate', async () => { diff --git a/server/tests/unit/nest/notifications.inapp-prefs.test.ts b/server/tests/unit/nest/notifications.inapp-prefs.test.ts index ced25f028..8f8349bb8 100644 --- a/server/tests/unit/nest/notifications.inapp-prefs.test.ts +++ b/server/tests/unit/nest/notifications.inapp-prefs.test.ts @@ -22,18 +22,10 @@ vi.mock('../../../src/db/database', async () => { return mock; }); -// Mock WebSocket broadcast — must use vi.hoisted() so broadcastMock is available -// when the vi.mock factory is evaluated (factories are hoisted before const declarations) -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -// RealtimeService imports both names from src/websocket, so the mock must -// export both even though only broadcastToUser is asserted here. -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: broadcastMock })); - import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createAdmin, disableNotificationPref } from '../../helpers/factories'; import { registerAction } from '../../../src/nest/notifications/in-app-actions'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { NotificationsService } from '../../../src/nest/notifications/notifications.service'; import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; @@ -42,6 +34,10 @@ import { makeNotification } from '../../helpers/factories/notifications'; import { makeTrip } from '../../helpers/factories/trips'; import { Notifications } from '../../../src/db/entities/Notifications.entity'; import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastToUserMock; // Built in beforeAll: the service now takes a UnitOfWork, which is async to build. let notifications: NotificationsService; @@ -54,7 +50,7 @@ const createNotificationForRecipient = (...a: Parameters) => notifications.respond(...a); beforeAll(async () => { - notifications = await makeNotificationsService(testDb); + notifications = await makeNotificationsService(testDb, realtime); }); beforeEach(() => { diff --git a/server/tests/unit/nest/notifications.service.test.ts b/server/tests/unit/nest/notifications.service.test.ts index 29a1af919..a3d838cdc 100644 --- a/server/tests/unit/nest/notifications.service.test.ts +++ b/server/tests/unit/nest/notifications.service.test.ts @@ -30,10 +30,9 @@ vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ encrypt_api_key: (v: string) => v, })); -const { sendMailMock, fetchMock, broadcastMock, logErrorMock } = vi.hoisted(() => ({ +const { sendMailMock, fetchMock, logErrorMock } = vi.hoisted(() => ({ sendMailMock: vi.fn().mockResolvedValue({ accepted: ['test@test.com'] }), fetchMock: vi.fn(), - broadcastMock: vi.fn(), logErrorMock: vi.fn(), })); vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ @@ -54,9 +53,6 @@ vi.mock('nodemailer', () => ({ })); vi.stubGlobal('fetch', fetchMock); -// RealtimeService imports both names from src/websocket, so the mock must -// export both even though only broadcastToUser is asserted here. -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: broadcastMock })); vi.mock('../../../src/utils/ssrfGuard', () => { class SsrfBlockedError extends Error { constructor(message: string) { @@ -84,7 +80,6 @@ vi.mock('../../../src/utils/ssrfGuard', () => { import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { NotificationsService, type NotificationPayload } from '../../../src/nest/notifications/notifications.service'; import { setPluginChannelSource } from '../../../src/nest/notifications/channel-registry'; // The channel interface lives in notification-events; channel-registry only imports @@ -97,6 +92,10 @@ import { setUserSetting } from '../../helpers/factories/settings'; import { addTripMember, makeTrip } from '../../helpers/factories/trips'; import { Notifications } from '../../../src/db/entities/Notifications.entity'; import { Users } from '../../../src/db/entities/Users.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastToUserMock; // Built in beforeAll: the service now takes a UnitOfWork, which is async to build. let notifications: NotificationsService; @@ -146,7 +145,7 @@ async function setRecipientEmail(userId: number): Promise { // ── Setup ────────────────────────────────────────────────────────────────── beforeAll(async () => { - notifications = await makeNotificationsService(testDb); + notifications = await makeNotificationsService(testDb, realtime); }); beforeEach(() => { diff --git a/server/tests/unit/nest/notifications/web-push-dispatch.test.ts b/server/tests/unit/nest/notifications/web-push-dispatch.test.ts index e6482ad12..323582ea6 100644 --- a/server/tests/unit/nest/notifications/web-push-dispatch.test.ts +++ b/server/tests/unit/nest/notifications/web-push-dispatch.test.ts @@ -29,7 +29,6 @@ vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({ logError, logWarn: vi.fn(), })); -vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../../../src/utils/ssrfGuard', () => { class SsrfBlockedError extends Error {} return { SsrfBlockedError, safeFetchFollow }; diff --git a/server/tests/unit/nest/packing.service.test.ts b/server/tests/unit/nest/packing.service.test.ts index 54dd69baa..16239ba7b 100644 --- a/server/tests/unit/nest/packing.service.test.ts +++ b/server/tests/unit/nest/packing.service.test.ts @@ -26,9 +26,6 @@ vi.mock('../../../src/db/database', async () => { return { db, closeDb: () => {}, reinitialize: () => {} }; }); -const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); - const checkPermission = vi.fn(() => true); const permissionsStub = { checkPermission } as unknown as PermissionsService; @@ -42,7 +39,6 @@ import { PackingService, isInvalidBagRef } from '../../../src/nest/packing/packi // Was packing.bridge, deleted with the other three that had no consumer outside the // container. The assertions stayed; they point at the service now. const bridgeListItems = (tripId: string | number, viewerId?: number) => svc.listItems(tripId, viewerId); -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { notificationsStub } from '../../helpers/notifications'; import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; import { addTripMember as addTripMemberRow } from '../../helpers/factories/trips'; @@ -86,6 +82,10 @@ import { createTestPackingTemplateCategoriesRepo, createTestPackingTemplateItemsRepo, } from '../../helpers/packing-repos'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcastMock = realtime.broadcastMock; let svc: PackingService; let packingItemsRepoDirect: Awaited>; @@ -104,7 +104,7 @@ beforeAll(async () => { packingItemContributorsRepoDirect = await createTestPackingItemContributorsRepo(testDb); svc = new PackingService( permissionsStub, - new RealtimeService(), + realtime, notificationsStub(send), await createTestUnitOfWork(testDb), packingItemsRepoDirect, diff --git a/server/tests/unit/nest/realtime.service.test.ts b/server/tests/unit/nest/realtime.service.test.ts index a81702e76..72964273e 100644 --- a/server/tests/unit/nest/realtime.service.test.ts +++ b/server/tests/unit/nest/realtime.service.test.ts @@ -1,23 +1,29 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { WebSocketServer } from 'ws'; import type { TrekWsPayload, TrekWsUserEventName } from '@trek/shared'; - -const { broadcast, broadcastToUser, getOnlineUserIds } = vi.hoisted(() => ({ - broadcast: vi.fn(), - broadcastToUser: vi.fn(), - getOnlineUserIds: vi.fn(), -})); -vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser, getOnlineUserIds })); - import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; - -const svc = new RealtimeService(); +import { + InMemoryRoomRegistry, + joinRoom, + registerSocket, + roomsSlot, + setServer, + type TrekWebSocket, +} from '../../../src/nest/realtime/ws-state'; +import type { User } from '../../../src/types'; /** - * These cases exercise argument pass-through, not payload shape, so they hand - * the facade only the fields their assertions read. The registry contract asks - * for more (`vacay:invite` for a `from`, `notification:new` for a - * `notification`); filling that in would put data into the recorded call that - * nothing here checks, so the shortcut is named instead of padded out. + * The facade over the socket registry, exercised against the real ws-state + * functions with sockets that record what they are sent: what reaches a + * socket is the contract, not which module function the facade calls. + */ + +/** + * These cases exercise delivery, not payload shape, so they hand the facade + * only the fields their assertions read. The registry contract asks for more + * (`vacay:invite` for a `from`, `notification:new` for a `notification`); + * filling that in would put data on the wire that nothing here checks, so the + * shortcut is named instead of padded out. */ function partialUserPayload( payload: { type: E } & Partial>, @@ -25,59 +31,114 @@ function partialUserPayload( return payload as { type: E } & TrekWsPayload; } -beforeEach(() => vi.clearAllMocks()); +interface RecordingSocket { + ws: TrekWebSocket; + sent: () => unknown[]; + sid: number; +} + +const clients = new Set(); + +function connect(userId: number, readyState = 1): RecordingSocket { + const send = vi.fn(); + const ws = { readyState, send, isAlive: true } as unknown as TrekWebSocket; + const sid = registerSocket(ws, { id: userId, username: `u${userId}` } as User); + clients.add(ws); + return { ws, sid, sent: () => send.mock.calls.map(([frame]) => JSON.parse(frame as string) as unknown) }; +} + +const svc = new RealtimeService(); +let rooms: InMemoryRoomRegistry; + +beforeEach(() => { + clients.clear(); + rooms = new InMemoryRoomRegistry(); + roomsSlot.install(rooms); + setServer({ clients } as unknown as WebSocketServer); +}); + +afterEach(() => { + setServer(null); + roomsSlot.release(rooms); +}); describe('RealtimeService', () => { - it('broadcast delegates every argument to the websocket module untouched', () => { - svc.broadcast('7', 'place:created', { place: { id: 1 } }, '42', 9); - expect(broadcast).toHaveBeenCalledTimes(1); - expect(broadcast).toHaveBeenCalledWith('7', 'place:created', { place: { id: 1 } }, '42', 9); + it('RTSVC-001: broadcast delivers the event to every open socket in the trip room', () => { + const a = connect(1); + const b = connect(2); + const outsider = connect(3); + joinRoom(a.ws, 7); + joinRoom(b.ws, 7); + joinRoom(outsider.ws, 8); + + svc.broadcast('7', 'place:created', { place: { id: 1 } }); + + expect(a.sent()).toEqual([{ type: 'place:created', tripId: 7, place: { id: 1 } }]); + expect(b.sent()).toEqual([{ type: 'place:created', tripId: 7, place: { id: 1 } }]); + expect(outsider.sent()).toEqual([]); }); - it('broadcast preserves the caller argument arity exactly (no undefined padding)', () => { - // The excludeSid truthiness quirk and onlyUserId `!= null` check live in - // websocket.ts — the facade must not normalize, default, or pad anything - // on the way through: existing suites assert exact 3/4/5-arg call shapes. - svc.broadcast(7, 'day:updated', { day: null }); - expect(broadcast.mock.calls[0]).toEqual([7, 'day:updated', { day: null }]); - svc.broadcast(7, 'day:updated', { day: null }, undefined); - expect(broadcast.mock.calls[1]).toEqual([7, 'day:updated', { day: null }, undefined]); - expect(broadcast.mock.calls[1].length).toBe(4); + it('RTSVC-002: broadcast skips the originating socket named by excludeSid', () => { + const origin = connect(1); + const other = connect(1); + joinRoom(origin.ws, 7); + joinRoom(other.ws, 7); + + svc.broadcast(7, 'day:updated', { day: null }, String(origin.sid)); + + expect(origin.sent()).toEqual([]); + expect(other.sent()).toEqual([{ type: 'day:updated', tripId: 7, day: null }]); }); - it('broadcastToUser delegates every argument untouched', () => { + it('RTSVC-003: broadcast with onlyUserId reaches only that user\'s sockets in the room', () => { + const owner = connect(4); + const member = connect(5); + joinRoom(owner.ws, 7); + joinRoom(member.ws, 7); + + svc.broadcast(7, 'day:updated', { day: null }, undefined, 4); + + expect(owner.sent()).toHaveLength(1); + expect(member.sent()).toEqual([]); + }); + + it('RTSVC-004: broadcastToUser sends the payload as is to every open socket of that user', () => { + const mine = connect(5); + const closed = connect(5, 3); + const someoneElse = connect(6); const payload = partialUserPayload({ type: 'vacay:invite', planId: 3 }); - svc.broadcastToUser(5, payload, '11'); - expect(broadcastToUser).toHaveBeenCalledTimes(1); - expect(broadcastToUser).toHaveBeenCalledWith(5, payload, '11'); - // Same object reference — the facade must not clone or re-envelope. - expect(broadcastToUser.mock.calls[0][1]).toBe(payload); + + svc.broadcastToUser(5, payload); + + expect(mine.sent()).toEqual([{ type: 'vacay:invite', planId: 3 }]); + expect(closed.sent()).toEqual([]); + expect(someoneElse.sent()).toEqual([]); }); - it('per-file vi.mock of src/websocket flows through the facade (call-time delegation)', () => { - // The 106 existing suites mock src/websocket by path and assert on those - // mocks; this pins that a facade constructed before/after the mock still - // routes through the mocked module functions rather than captured bindings. - const late = new RealtimeService(); - late.broadcast('1', 'todo:created', { item: {} }, undefined); - late.broadcastToUser(2, partialUserPayload({ type: 'notification:new' })); - expect(broadcast).toHaveBeenCalledWith('1', 'todo:created', { item: {} }, undefined); - expect(broadcastToUser).toHaveBeenCalledWith(2, { type: 'notification:new' }); + it('RTSVC-004b: broadcastToUser skips the socket named by excludeSid', () => { + const origin = connect(5); + const otherTab = connect(5); + + svc.broadcastToUser(5, partialUserPayload({ type: 'notification:new' }), origin.sid); + + expect(origin.sent()).toEqual([]); + expect(otherTab.sent()).toEqual([{ type: 'notification:new' }]); }); - it('RTSVC-005: getOnlineUserIds hands back the live set from the websocket module, read at call time', () => { - const online = new Set([3, 8]); - getOnlineUserIds.mockReturnValueOnce(online); - expect(svc.getOnlineUserIds()).toBe(online); - getOnlineUserIds.mockReturnValueOnce(new Set()); - expect(svc.getOnlineUserIds().size).toBe(0); - expect(getOnlineUserIds).toHaveBeenCalledTimes(2); + it('RTSVC-005: getOnlineUserIds answers the users holding an open socket, read at call time', () => { + expect(svc.getOnlineUserIds()).toEqual(new Set()); + connect(3); + connect(8); + connect(9, 3); + expect(svc.getOnlineUserIds()).toEqual(new Set([3, 8])); }); it('RTSVC-006: getOnlineUserIds is not guarded, so a failure reaches the caller instead of reading as nobody online', () => { - getOnlineUserIds.mockImplementationOnce(() => { - throw new Error('socket server not ready'); - }); + setServer({ + get clients(): Set { + throw new Error('socket server not ready'); + }, + } as unknown as WebSocketServer); expect(() => svc.getOnlineUserIds()).toThrow('socket server not ready'); }); }); diff --git a/server/tests/unit/nest/reminder-jobs.service.test.ts b/server/tests/unit/nest/reminder-jobs.service.test.ts index 14a4acc0c..c42eb2e46 100644 --- a/server/tests/unit/nest/reminder-jobs.service.test.ts +++ b/server/tests/unit/nest/reminder-jobs.service.test.ts @@ -17,7 +17,6 @@ vi.mock('../../../src/db/database', async () => { const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock); import { db as testDb } from '../../../src/db/database'; diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index 4e29d8a6f..05e1c02f9 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -19,11 +19,6 @@ vi.mock('../../../src/db/database', async () => { return mock; }); - - -const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast })); - const checkPermission = vi.fn(() => true); const permissionsStub = { checkPermission } as unknown as PermissionsService; @@ -61,7 +56,6 @@ const bridge = { deleteReservation: (id: string | number, tripId: string | number) => svc.remove(id, tripId), notifyBookingChange: (...a: Parameters) => svc.notifyBookingChange(...a), }; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { notificationsStub } from '../../helpers/notifications'; import { makeAccommodationsService } from '../../helpers/accommodations-service'; import { createTestUnitOfWork, createTestReservationsRepo, createTestReservationEndpointsRepo, createTestReservationTravelersRepo, createTestReservationDayPositionsRepo, createTestDayAccommodationsRepo, createTestDaysRepo, createTestPlacesRepo, createTestDayAssignmentsRepo, createTestTripMembersRepo, createTestUsersRepo, createTestTripsRepo } from '../../helpers/test-uow'; @@ -78,11 +72,15 @@ import { ReservationDayPositions } from '../../../src/db/entities/ReservationDay import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity'; import { Reservations } from '../../../src/db/entities/Reservations.entity'; import { Users } from '../../../src/db/entities/Users.entity'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcast = realtime.broadcastMock; let svc: ReservationsService; let orm: TestOrm; beforeAll(async () => { - svc = new ReservationsService(permissionsStub, budget as unknown as BudgetService, new RealtimeService(), notificationsStub(notif.send), new ReservationsReadService(await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb)), await makeAccommodationsService(testDb), await createTestUnitOfWork(testDb), await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb), await createTestReservationDayPositionsRepo(testDb), await createTestDayAccommodationsRepo(testDb), await createTestDaysRepo(testDb), await createTestPlacesRepo(testDb), await createTestDayAssignmentsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb), await createTestTripsRepo(testDb), await createTestBudgetItemsRepo(testDb)); + svc = new ReservationsService(permissionsStub, budget as unknown as BudgetService, realtime, notificationsStub(notif.send), new ReservationsReadService(await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb)), await makeAccommodationsService(testDb, realtime), await createTestUnitOfWork(testDb), await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb), await createTestReservationDayPositionsRepo(testDb), await createTestDayAccommodationsRepo(testDb), await createTestDaysRepo(testDb), await createTestPlacesRepo(testDb), await createTestDayAssignmentsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb), await createTestTripsRepo(testDb), await createTestBudgetItemsRepo(testDb)); }); beforeEach(async () => { diff --git a/server/tests/unit/nest/todo.service.test.ts b/server/tests/unit/nest/todo.service.test.ts index 0c3129ce7..db0ba8f35 100644 --- a/server/tests/unit/nest/todo.service.test.ts +++ b/server/tests/unit/nest/todo.service.test.ts @@ -17,8 +17,6 @@ vi.mock('../../../src/db/database', async () => { return { db, closeDb: () => {}, reinitialize: () => {} }; }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); - import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember } from '../../helpers/factories'; diff --git a/server/tests/unit/nest/trip-members.service.test.ts b/server/tests/unit/nest/trip-members.service.test.ts index b53ff2f81..190ad6b40 100644 --- a/server/tests/unit/nest/trip-members.service.test.ts +++ b/server/tests/unit/nest/trip-members.service.test.ts @@ -15,8 +15,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites // ── DB setup ────────────────────────────────────────────────────────────────── -const { broadcast, notifySend } = vi.hoisted(() => ({ - broadcast: vi.fn(), +const { notifySend } = vi.hoisted(() => ({ notifySend: vi.fn().mockResolvedValue(undefined), })); @@ -28,14 +27,12 @@ vi.mock('../../../src/db/database', async () => { }); import { db as testDb } from '../../../src/db/database'; -vi.mock('../../../src/websocket', () => ({ broadcast })); // notifyInvite reaches the bridge through a dynamic import — keep the send in scope // but out of the transports. import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember } from '../../helpers/factories'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { BudgetService } from '../../../src/nest/budget/budget.service'; import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service'; import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service'; @@ -76,6 +73,10 @@ import type { UsersRepository } from '../../../src/db/repositories/Users.reposit import type { TripMembersRepository } from '../../../src/db/repositories/TripMembers.repository'; import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos'; import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcast = realtime.broadcastMock; // Plan 3c Task 0b: `dbsEm` is resolved once, at the top of the `beforeAll` // below — `canAccessTrip`/`isOwner`/`rosterUserIds`/`getPlaceWithTags` @@ -99,12 +100,12 @@ beforeAll(async () => { tripsRepo = await createTestTripsRepo(testDb); usersRepo = await createTestUsersRepo(testDb); tripMembersRepo = await createTestTripMembersRepo(testDb); - budgetSvc = new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))); + budgetSvc = new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), realtime, await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))); roster = new TripMembersService( budgetSvc, new UserCleanupService(new MaintenanceRepository(dbsEm!), budgetSvc, await createTestUnitOfWork(testDb), usersRepo, await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestBudgetSettlementsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), - new RealtimeService(), notificationsStub(notifySend), await createTestUnitOfWork(testDb), + realtime, notificationsStub(notifySend), await createTestUnitOfWork(testDb), tripsRepo, tripMembersRepo, usersRepo, ); }); diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index 732989892..effa13794 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -25,15 +25,11 @@ vi.mock('../../../src/db/database', async () => { return mock; }); -const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast })); - import { db as testDb } from '../../../src/db/database'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember } from '../../helpers/factories'; import { DaysService } from '../../../src/nest/days/days.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { TodoService } from '../../../src/nest/todo/todo.service'; import { PackingService } from '../../../src/nest/packing/packing.service'; import { FilesService } from '../../../src/nest/files/files.service'; @@ -104,6 +100,9 @@ import { noGoogleQuota } from '../../helpers/google-quota'; import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos'; import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository'; import { TripAccessService } from '../../../src/nest/trip-membership/trip-access.service'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); // Real sibling services over the same in-memory DB — the aggregation runs the // actual SQL of every domain it fans out to, so a shape change downstream shows @@ -117,7 +116,7 @@ let photoCache: PlacePhotoCacheService; let accommodationsSvc: Awaited>; beforeAll(async () => { - accommodationsSvc = await makeAccommodationsService(testDb); + accommodationsSvc = await makeAccommodationsService(testDb, realtime); }); let budgetSvc: BudgetService; let daysSvc: DaysService; @@ -130,10 +129,10 @@ beforeAll(async () => { await createTestPlacesRepo(testDb), await createTestCollectionPlacesRepo(testDb), ); - budgetSvc = new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))); + budgetSvc = new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), realtime, await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))); daysSvc = new DaysService( new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), - new RealtimeService(), + realtime, new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)), await createTestUnitOfWork(testDb), await createTestDaysRepo(testDb), @@ -147,18 +146,18 @@ beforeAll(async () => { await createTestRoadtripDayBoundariesRepo(testDb), ); placesSvc = new PlacesService( - new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), + new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), realtime, buildMapsService(photoCache, await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), await createTestPlaceDetailsCacheRepo(testDb), await createTestPlacesRepo(testDb), noGoogleQuota), new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)), new UnsplashService(await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), new RuntimeEnvService(), makeStorageFixture('').storage), photoCache, new JourneyDomainService( - new RealtimeService(), new TrekPhotoRegistrationService((await sharedTestOrm(testDb)).repo(TrekPhotos), (await sharedTestOrm(testDb)).repo(TripPhotos), await createTestJourneyPhotosRepo(testDb)), await createTestUnitOfWork(testDb), + realtime, new TrekPhotoRegistrationService((await sharedTestOrm(testDb)).repo(TrekPhotos), (await sharedTestOrm(testDb)).repo(TripPhotos), await createTestJourneyPhotosRepo(testDb)), await createTestUnitOfWork(testDb), await createTestJourneysRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestJourneyTripsRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestTripsRepo(testDb), // Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository. await createTestJourneyPhotosRepo(testDb), await createTestJourneyEntryPhotosRepo(testDb), await createTestPlacesRepo(testDb), ), makeStorageFixture('').storage, - await accommodationsOver(testDb), await createTestUnitOfWork(testDb), + await accommodationsOver(testDb, realtime), await createTestUnitOfWork(testDb), await createTestPlacesRepo(testDb), await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), @@ -170,30 +169,30 @@ beforeAll(async () => { await createTestCollectionPlacesRepo(testDb), buildPlaceImportService(), ); - membersSvc = new TripMembersService(budgetSvc, new UserCleanupService(new MaintenanceRepository((await sharedTestOrm(testDb)).em), budgetSvc, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestBudgetSettlementsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb), await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb)); + membersSvc = new TripMembersService(budgetSvc, new UserCleanupService(new MaintenanceRepository((await sharedTestOrm(testDb)).em), budgetSvc, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestBudgetSettlementsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), realtime, notificationsStub(), await createTestUnitOfWork(testDb), await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb)); }); const buildReadModel = async (tripsRepo: TripsRepository, roster: TripMembersService = membersSvc) => new TripReadModelService( tripsRepo, roster, daysSvc, accommodationsSvc, budgetSvc, new PackingService( - new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb), + new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), realtime, notificationsStub(), await createTestUnitOfWork(testDb), await createTestPackingItemsRepo(testDb), await createTestPackingItemContributorsRepo(testDb), await createTestPackingBagsRepo(testDb), await createTestPackingCategoryAssigneesRepo(testDb), await createTestPackingTemplatesRepo(testDb), await createTestPackingTemplateCategoriesRepo(testDb), await createTestPackingTemplateItemsRepo(testDb), await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb), ), - new ReservationsService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadService(await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb)), await accommodationsOver(testDb), await createTestUnitOfWork(testDb), await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb), await createTestReservationDayPositionsRepo(testDb), await createTestDayAccommodationsRepo(testDb), await createTestDaysRepo(testDb), await createTestPlacesRepo(testDb), await createTestDayAssignmentsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb), await createTestTripsRepo(testDb), await createTestBudgetItemsRepo(testDb)), + new ReservationsService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), budgetSvc, realtime, notificationsStub(), new ReservationsReadService(await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb)), await accommodationsOver(testDb, realtime), await createTestUnitOfWork(testDb), await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb), await createTestReservationDayPositionsRepo(testDb), await createTestDayAccommodationsRepo(testDb), await createTestDaysRepo(testDb), await createTestPlacesRepo(testDb), await createTestDayAssignmentsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb), await createTestTripsRepo(testDb), await createTestBudgetItemsRepo(testDb)), new CollabService( // Plan 4 Task 2 — CollabService's own DatabaseService param is gone: // canAccessTrip now reads through the TripsRepository at the end. - new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(), makeStorageFixture('').storage, new RateLimitService(), await createTestUnitOfWork(testDb), + new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), realtime, notificationsStub(), makeStorageFixture('').storage, new RateLimitService(), await createTestUnitOfWork(testDb), await createTestCollabMessageReactionsRepo(testDb), await createTestCollabNotesRepo(testDb), await createTestCollabPollsRepo(testDb), await createTestCollabPollVotesRepo(testDb), await createTestCollabLinksRepo(testDb), await createTestCollabMessagesRepo(testDb), await createTestTripsRepo(testDb), ), placesSvc, new TodoService( - new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), await createTestUnitOfWork(testDb), + new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), realtime, await createTestUnitOfWork(testDb), await createTestTodoItemsRepo(testDb), await createTestTodoCategoryAssigneesRepo(testDb), // Plan 4 Task 2 — TodoService's own canAccessTrip delegate is now // TripsRepository.findAccessible, a new trailing constructor param. @@ -207,7 +206,7 @@ const buildReadModel = async (tripsRepo: TripsRepository, roster: TripMembersSer // TripsRepository.findAccessible, in the same constructor slot. new TripAccessService(await createTestTripsRepo(testDb)), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), - new RealtimeService(), + realtime, new EphemeralTokenService(), makeStorageFixture('').storage, (await sharedTestOrm(testDb)).em, diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 705d894b6..1ac9c9f51 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -29,8 +29,6 @@ vi.mock('../../../src/db/database', async () => { return mock; }); -const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); -vi.mock('../../../src/websocket', () => ({ broadcast })); // notifyInvite fires a notification via a dynamic import — keep it out of unit scope import { db as testDb } from '../../../src/db/database'; @@ -40,7 +38,6 @@ import { createTestTourWaypointsRepo, createTour } from '../../helpers/tours-rep import { MAX_TRIP_DAYS, resolveDayGridRange, tripSpanDays } from '@trek/shared'; import { DaysService } from '../../../src/nest/days/days.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { TodoService } from '../../../src/nest/todo/todo.service'; import { PackingService } from '../../../src/nest/packing/packing.service'; import { FilesService } from '../../../src/nest/files/files.service'; @@ -149,6 +146,10 @@ import { Trips } from '../../../src/db/entities/Trips.entity'; import { Users } from '../../../src/db/entities/Users.entity'; import { legacyBoundIntegerText } from '../../../src/nest/common/row-id'; import { TripAccessService } from '../../../src/nest/trip-membership/trip-access.service'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcast = realtime.broadcastMock; const orm = () => sharedTestOrm(testDb); @@ -215,7 +216,7 @@ const coversFx = makeStorageFixture('covers/'); let accommodationsSvc: Awaited>; let createAccommodation: (...args: Parameters) => ReturnType; beforeAll(async () => { - accommodationsSvc = await makeAccommodationsService(testDb); + accommodationsSvc = await makeAccommodationsService(testDb, realtime); createAccommodation = (...args) => accommodationsSvc.createAccommodation(...args); }); @@ -233,10 +234,10 @@ beforeAll(async () => { await createTestPlacesRepo(testDb), await createTestCollectionPlacesRepo(testDb), ); - budgetSvc = new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))); + budgetSvc = new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), realtime, await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))); daysSvc = new DaysService( new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), - new RealtimeService(), + realtime, new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)), await createTestUnitOfWork(testDb), await createTestDaysRepo(testDb), @@ -251,20 +252,20 @@ beforeAll(async () => { ); placesSvc = new PlacesService( new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), - new RealtimeService(), + realtime, buildMapsService(photoCache, await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), await createTestPlaceDetailsCacheRepo(testDb), await createTestPlacesRepo(testDb), noGoogleQuota), new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)), new UnsplashService(await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), new RuntimeEnvService(), coversFx.storage), photoCache, new JourneyDomainService( - new RealtimeService(), new TrekPhotoRegistrationService(dbsEm!.getRepository(TrekPhotos), dbsEm!.getRepository(TripPhotos), dbsEm!.getRepository(JourneyPhotos)), await createTestUnitOfWork(testDb), + realtime, new TrekPhotoRegistrationService(dbsEm!.getRepository(TrekPhotos), dbsEm!.getRepository(TripPhotos), dbsEm!.getRepository(JourneyPhotos)), await createTestUnitOfWork(testDb), await createTestJourneysRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestJourneyTripsRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestTripsRepo(testDb), // Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository. await createTestJourneyPhotosRepo(testDb), await createTestJourneyEntryPhotosRepo(testDb), await createTestPlacesRepo(testDb), ), makeStorageFixture('').storage, - await accommodationsOver(testDb), await createTestUnitOfWork(testDb), + await accommodationsOver(testDb, realtime), await createTestUnitOfWork(testDb), await createTestPlacesRepo(testDb), await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), @@ -277,7 +278,7 @@ beforeAll(async () => { buildPlaceImportService(), ); svc = new TripsService( - new ReservationsService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadService(await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb)), accommodationsSvc, await createTestUnitOfWork(testDb), await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb), await createTestReservationDayPositionsRepo(testDb), await createTestDayAccommodationsRepo(testDb), await createTestDaysRepo(testDb), await createTestPlacesRepo(testDb), await createTestDayAssignmentsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb), await createTestTripsRepo(testDb), await createTestBudgetItemsRepo(testDb)), + new ReservationsService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), budgetSvc, realtime, notificationsStub(), new ReservationsReadService(await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb)), accommodationsSvc, await createTestUnitOfWork(testDb), await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb), await createTestReservationDayPositionsRepo(testDb), await createTestDayAccommodationsRepo(testDb), await createTestDaysRepo(testDb), await createTestPlacesRepo(testDb), await createTestDayAssignmentsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb), await createTestTripsRepo(testDb), await createTestBudgetItemsRepo(testDb)), daysSvc, new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), budgetSvc, @@ -288,36 +289,36 @@ beforeAll(async () => { await createTestVacayCompanyHolidaysRepo(testDb), await createTestVacayHolidayCalendarsRepo(testDb), await createTestVacaySharesRepo(testDb), await createTestVacayUserSettingsRepo(testDb), await createTestSchoolHolidayRegionsRepo(testDb), - new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb), + realtime, notificationsStub(), await createTestUnitOfWork(testDb), ), - new RealtimeService(), + realtime, undefined as never, // unsplash — not exercised here coversFx.storage, await createTestUnitOfWork(testDb), (await sharedTestOrm(testDb)).em, new SettingsService(await createTestUnitOfWork(testDb), await createTestAppSettingsRepo(testDb), await createTestSettingsRepo(testDb)), ); - membersSvc = new TripMembersService(budgetSvc, new UserCleanupService(new MaintenanceRepository(dbsEm!), budgetSvc, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestBudgetSettlementsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb), await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb)); + membersSvc = new TripMembersService(budgetSvc, new UserCleanupService(new MaintenanceRepository(dbsEm!), budgetSvc, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestBudgetSettlementsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), realtime, notificationsStub(), await createTestUnitOfWork(testDb), await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb)); readModelSvc = new TripReadModelService( await createTestTripsRepo(testDb), membersSvc, daysSvc, accommodationsSvc, budgetSvc, new PackingService( - new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb), + new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), realtime, notificationsStub(), await createTestUnitOfWork(testDb), await createTestPackingItemsRepo(testDb), await createTestPackingItemContributorsRepo(testDb), await createTestPackingBagsRepo(testDb), await createTestPackingCategoryAssigneesRepo(testDb), await createTestPackingTemplatesRepo(testDb), await createTestPackingTemplateCategoriesRepo(testDb), await createTestPackingTemplateItemsRepo(testDb), await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb), ), - new ReservationsService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadService(await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb)), accommodationsSvc, await createTestUnitOfWork(testDb), await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb), await createTestReservationDayPositionsRepo(testDb), await createTestDayAccommodationsRepo(testDb), await createTestDaysRepo(testDb), await createTestPlacesRepo(testDb), await createTestDayAssignmentsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb), await createTestTripsRepo(testDb), await createTestBudgetItemsRepo(testDb)), + new ReservationsService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), budgetSvc, realtime, notificationsStub(), new ReservationsReadService(await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb)), accommodationsSvc, await createTestUnitOfWork(testDb), await createTestReservationsRepo(testDb), await createTestReservationEndpointsRepo(testDb), await createTestReservationTravelersRepo(testDb), await createTestReservationDayPositionsRepo(testDb), await createTestDayAccommodationsRepo(testDb), await createTestDaysRepo(testDb), await createTestPlacesRepo(testDb), await createTestDayAssignmentsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb), await createTestTripsRepo(testDb), await createTestBudgetItemsRepo(testDb)), new CollabService( // Plan 4 Task 2 — CollabService's own DatabaseService param is gone: // canAccessTrip now reads through the TripsRepository at the end. - new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(), coversFx.storage, new RateLimitService(), await createTestUnitOfWork(testDb), + new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), realtime, notificationsStub(), coversFx.storage, new RateLimitService(), await createTestUnitOfWork(testDb), await createTestCollabMessageReactionsRepo(testDb), await createTestCollabNotesRepo(testDb), await createTestCollabPollsRepo(testDb), await createTestCollabPollVotesRepo(testDb), await createTestCollabLinksRepo(testDb), await createTestCollabMessagesRepo(testDb), await createTestTripsRepo(testDb), ), placesSvc, new TodoService( - new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), await createTestUnitOfWork(testDb), + new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), realtime, await createTestUnitOfWork(testDb), await createTestTodoItemsRepo(testDb), await createTestTodoCategoryAssigneesRepo(testDb), new TripAccessService(await createTestTripsRepo(testDb)), await createTestTripMembersRepo(testDb), @@ -327,7 +328,7 @@ beforeAll(async () => { // TripsRepository.findAccessible, in the same constructor slot. new TripAccessService(await createTestTripsRepo(testDb)), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), - new RealtimeService(), + realtime, new EphemeralTokenService(), coversFx.storage, (await sharedTestOrm(testDb)).em, diff --git a/server/tests/unit/nest/user-cleanup.service.test.ts b/server/tests/unit/nest/user-cleanup.service.test.ts index 860b9ccdf..3b12d8824 100644 --- a/server/tests/unit/nest/user-cleanup.service.test.ts +++ b/server/tests/unit/nest/user-cleanup.service.test.ts @@ -29,7 +29,6 @@ vi.mock('../../../src/db/database', async () => { isOwner: () => false, }; }); -vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); vi.mock('../../../src/nest/plugins/paths', () => ({ pluginsDataRoot: () => dataRootRef.value })); import fs from 'node:fs'; diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts index c2ba54ed7..7103940ec 100644 --- a/server/tests/unit/nest/vacay.service.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -15,8 +15,6 @@ vi.mock('../../../src/db/database', async () => { return mock; }); -// Mock websocket so notifyPlanUsers doesn't throw -vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() })); // shareCalendar fires a notification after inserting — keep that out of unit scope import { db as testDb } from '../../../src/db/database'; diff --git a/server/tests/unit/plugins/event-subscriptions.test.ts b/server/tests/unit/plugins/event-subscriptions.test.ts index ced1b97a3..21006bab5 100644 --- a/server/tests/unit/plugins/event-subscriptions.test.ts +++ b/server/tests/unit/plugins/event-subscriptions.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect, afterEach } from 'vitest'; import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor'; -import { broadcast } from '../../../src/websocket'; +import { broadcast } from '../../../src/nest/realtime/ws-state'; import { setPluginEventSink } from '../../../src/plugin-event-sink'; function makeSupervisor(): PluginSupervisor { diff --git a/server/tests/unit/plugins/host-rpc.test.ts b/server/tests/unit/plugins/host-rpc.test.ts index 5c0efc927..c0f052006 100644 --- a/server/tests/unit/plugins/host-rpc.test.ts +++ b/server/tests/unit/plugins/host-rpc.test.ts @@ -13,7 +13,6 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -const { broadcast, broadcastToUser } = vi.hoisted(() => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // A real in-memory core db, so the metadata SQL and the entity->trip resolution run // for real. Trip 1 is owned by user 5; user 6 is a member; user 9 shares nothing. vi.mock('../../../src/db/database', () => { @@ -46,7 +45,6 @@ vi.mock('../../../src/db/database', () => { tripId === 1 && (userId === 5 || userId === 6) ? { id: 1, user_id: 5 } : undefined, }; }); -vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser })); const notifySend = vi.fn(async () => undefined); const { llmExtract } = vi.hoisted(() => ({ @@ -65,7 +63,6 @@ import { UnreadableLlmResponse } from '../../../src/nest/llm-parse/clients/opena import { getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/plugin-host-state'; import { verifyChain } from '../../../src/nest/plugins/host/plugin-audit'; import { db as mockDb } from '../../../src/db/database'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import type { AddonsService } from '../../../src/nest/addons/addons.service'; import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; @@ -81,6 +78,11 @@ import type { ReservationsRepository } from '../../../src/db/repositories/Reserv import type { DayAccommodationsRepository } from '../../../src/db/repositories/DayAccommodations.repository'; import type { PluginEntityMetadataRepository } from '../../../src/db/repositories/PluginEntityMetadata.repository'; import type { PluginScheduledTasksRepository } from '../../../src/db/repositories/PluginScheduledTasks.repository'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; + +const realtime = new FakeRealtimeService(); +const broadcast = realtime.broadcastMock; +const broadcastToUser = realtime.broadcastToUserMock; // Typed from the real method rather than from the always-true body below, so a case that // swaps in an implementation reading the action key (HOSTRPC-015) still type-checks. @@ -317,7 +319,7 @@ const guards = new PluginGuards(tripsRepo, permissions, addons, usersRepo); const registry = createTestPluginRegistry([ new DbRpc(userSettings), new MetaRpc(guards, metaRepo, tripsRepo, placesRepo, daysRepo, reservationsRepo, dayAccommodationsRepo), - new HostSurfaceRpc(new RealtimeService(), notifications, llmConfig, oauth, guards, pluginAuditRepo, usersRepo, tripsRepo, scheduledTasksRepo), + new HostSurfaceRpc(realtime, notifications, llmConfig, oauth, guards, pluginAuditRepo, usersRepo, tripsRepo, scheduledTasksRepo), ]); const factory = new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService); const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: async () => {} };