mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
fa0edfd714
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.
111 lines
6.1 KiB
TypeScript
111 lines
6.1 KiB
TypeScript
/**
|
|
* Core event subscriptions (#1429 eco). Two enforcement points:
|
|
* 1. the websocket broadcast tap announces every CORE event (name only) to the
|
|
* sink, but never plugin:* re-broadcasts (loop guard);
|
|
* 2. supervisor.deliverEvent only invokes plugins that are active, hold
|
|
* 'events:subscribe', AND subscribed to the event (or '*').
|
|
*/
|
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
|
|
import { broadcast } from '../../../src/nest/realtime/ws-state';
|
|
import { setPluginEventSink } from '../../../src/plugin-event-sink';
|
|
|
|
function makeSupervisor(): PluginSupervisor {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return new PluginSupervisor((() => ({})) as any, {}, {});
|
|
}
|
|
function put(s: PluginSupervisor, id: string, status: string, events: string[], granted: string[]): void {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(s as any).running.set(id, { id, status, hooks: [], events, granted: new Set(granted) });
|
|
}
|
|
|
|
describe('supervisor.deliverEvent gating', () => {
|
|
it('invokes only active, granted, subscribed plugins (name + tripId only, fire-and-forget)', () => {
|
|
const s = makeSupervisor();
|
|
put(s, 'sub', 'active', ['place:created'], ['events:subscribe']);
|
|
put(s, 'star', 'active', ['*'], ['events:subscribe']);
|
|
put(s, 'nogrant', 'active', ['place:created'], ['db:own']); // subscribed but no grant
|
|
put(s, 'otherEvent', 'active', ['day:updated'], ['events:subscribe']); // different event
|
|
put(s, 'notactive', 'starting', ['place:created'], ['events:subscribe']);
|
|
const calls: Array<[string, string, Record<string, unknown>]> = [];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(s as any).invoke = (id: string, method: string, params: Record<string, unknown>) => { calls.push([id, method, params]); return Promise.resolve(); };
|
|
|
|
s.deliverEvent(7, 'place:created');
|
|
expect(calls.map((c) => c[0]).sort()).toEqual(['star', 'sub']);
|
|
expect(calls[0][1]).toBe('invoke.event');
|
|
expect(calls.every((c) => c[2].event === 'place:created' && c[2].tripId === 7)).toBe(true);
|
|
});
|
|
|
|
it('threads { entity, entityId } into the payload while keeping actingUserId undefined', () => {
|
|
const s = makeSupervisor();
|
|
put(s, 'sub', 'active', ['reservation:created'], ['events:subscribe']);
|
|
const calls: Array<[string, string, Record<string, unknown>, { actingUserId?: number }]> = [];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(s as any).invoke = (id: string, method: string, params: Record<string, unknown>, opts: any) => { calls.push([id, method, params, opts]); return Promise.resolve(); };
|
|
|
|
s.deliverEvent(7, 'reservation:created', { entity: 'reservation', entityId: 40 });
|
|
expect(calls[0][2]).toEqual({ event: 'reservation:created', tripId: 7, entity: 'reservation', entityId: 40 });
|
|
// the no-user invariant: an enriched event still carries NO acting user
|
|
expect(calls[0][3].actingUserId).toBeUndefined();
|
|
});
|
|
|
|
it('delivers the snapshot ONLY to plugins holding the family read grant', () => {
|
|
const s = makeSupervisor();
|
|
put(s, 'canread', 'active', ['*'], ['events:subscribe', 'db:read:trips']);
|
|
put(s, 'noread', 'active', ['*'], ['events:subscribe']);
|
|
put(s, 'wronggrant', 'active', ['*'], ['events:subscribe', 'db:read:costs']);
|
|
const calls: Array<[string, Record<string, unknown>]> = [];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(s as any).invoke = (id: string, _m: string, params: Record<string, unknown>) => { calls.push([id, params]); return Promise.resolve(); };
|
|
|
|
const snapshot = { id: 3, trip_id: 7, day_number: 1, title: 'Kyoto' };
|
|
s.deliverEvent(7, 'day:updated', { entity: 'day', entityId: 3, snapshot });
|
|
const byId = Object.fromEntries(calls);
|
|
// day rides on db:read:trips — only 'canread' sees the fields
|
|
expect(byId.canread.snapshot).toEqual(snapshot);
|
|
expect(byId.noread.snapshot).toBeUndefined();
|
|
expect(byId.wronggrant.snapshot).toBeUndefined();
|
|
// the hint itself still reaches everyone subscribed
|
|
expect(byId.noread).toMatchObject({ event: 'day:updated', tripId: 7, entity: 'day', entityId: 3 });
|
|
});
|
|
|
|
it('maps budget snapshots to db:read:costs, not the trip grant', () => {
|
|
const s = makeSupervisor();
|
|
put(s, 'costs', 'active', ['*'], ['events:subscribe', 'db:read:costs']);
|
|
put(s, 'trips', 'active', ['*'], ['events:subscribe', 'db:read:trips']);
|
|
const calls: Array<[string, Record<string, unknown>]> = [];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(s as any).invoke = (id: string, _m: string, params: Record<string, unknown>) => { calls.push([id, params]); return Promise.resolve(); };
|
|
|
|
s.deliverEvent(7, 'budget:created', { entity: 'budget', entityId: 8, snapshot: { id: 8, name: 'Hotel' } });
|
|
const byId = Object.fromEntries(calls);
|
|
expect(byId.costs.snapshot).toEqual({ id: 8, name: 'Hotel' });
|
|
expect(byId.trips.snapshot).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('websocket broadcast → plugin event sink', () => {
|
|
afterEach(() => setPluginEventSink(null));
|
|
|
|
it('announces core events by name, and never plugin:* re-broadcasts', () => {
|
|
const seen: Array<[number, string]> = [];
|
|
setPluginEventSink((tripId, event) => seen.push([tripId, event]));
|
|
// fires even with no connected sockets (announced before the room check)
|
|
broadcast(42, 'place:created', { place: { id: 1 } });
|
|
broadcast(42, 'plugin:trip-doctor:rechecked', { count: 3 }); // must be skipped
|
|
expect(seen).toEqual([[42, 'place:created']]);
|
|
});
|
|
|
|
it('derives { entity, entityId, snapshot } for the sink from the broadcast payload', () => {
|
|
const seen: Array<{ tripId: number; event: string; meta: unknown }> = [];
|
|
setPluginEventSink((tripId, event, meta) => seen.push({ tripId, event, meta }));
|
|
broadcast(42, 'reservation:updated', { reservation: { id: 40, title: 'Flight', status: 'confirmed' } });
|
|
expect(seen).toEqual([{
|
|
tripId: 42,
|
|
event: 'reservation:updated',
|
|
meta: { entity: 'reservation', entityId: 40, snapshot: { id: 40, title: 'Flight', status: 'confirmed' } },
|
|
}]);
|
|
});
|
|
});
|