import { describe, expect, it } from 'bun:test'; import { createTwinMessaging } from '../twin-messaging'; import { TwinTransport } from '../services/webrtc/types'; /** * Lifecycle tests for TECH-1334 Phase 2: createTwinMessaging.on() returns an * unsubscribe function, and off(type, callback) removes by the caller's * original callback identity. Previously on() wrapped callbacks in closures * that were never returned, so listeners could never be removed. */ type MessageCallback = (payload: any) => void; function createFakeTransport(): { transport: TwinTransport; deliver: (payload: any) => void } { const callbacks = new Set(); const transport: TwinTransport = { twinId: 'twin-1', sendMessage: async () => {}, subscribe: async () => {}, onMessage: (_twinId: string, callback: MessageCallback) => { callbacks.add(callback); }, offMessage: (_twinId: string, callback: MessageCallback) => { callbacks.delete(callback); }, }; const deliver = (payload: any) => { // Copy first — a callback may unregister during delivery. for (const callback of Array.from(callbacks)) { callback(payload); } }; return { transport, deliver }; } function buildEventPayload(type: string, data: Record = {}): any { return { data: { type, data }, sourceTwinId: 'source-twin' }; } describe('createTwinMessaging — listener removal', () => { it('on() returns a disposer that stops further deliveries', () => { const { transport, deliver } = createFakeTransport(); const messaging = createTwinMessaging(transport, 'edgeInstance'); const received: any[] = []; const dispose = messaging.on('doorOpened', (message) => { received.push(message); }); deliver(buildEventPayload('doorOpened', { door: 'front' })); expect(received.length).toBe(1); dispose(); deliver(buildEventPayload('doorOpened', { door: 'front' })); expect(received.length).toBe(1); }); it('double-dispose is a no-op and does not affect other listeners of the same type', () => { const { transport, deliver } = createFakeTransport(); const messaging = createTwinMessaging(transport, 'edgeInstance'); const firstReceived: any[] = []; const secondReceived: any[] = []; const disposeFirst = messaging.on('doorOpened', (message) => { firstReceived.push(message); }); messaging.on('doorOpened', (message) => { secondReceived.push(message); }); disposeFirst(); disposeFirst(); deliver(buildEventPayload('doorOpened')); expect(firstReceived.length).toBe(0); expect(secondReceived.length).toBe(1); }); it('off(type, callback) removes by original callback identity and reports whether anything was removed', () => { const { transport, deliver } = createFakeTransport(); const messaging = createTwinMessaging(transport, 'edgeInstance'); const received: any[] = []; const listener = (message: any) => { received.push(message); }; messaging.on('lightsChanged', listener); expect(messaging.off('lightsChanged', listener)).toBe(true); deliver(buildEventPayload('lightsChanged')); expect(received.length).toBe(0); // Nothing registered anymore — off() reports false. expect(messaging.off('lightsChanged', listener)).toBe(false); const neverRegistered = () => {}; expect(messaging.off('unknownType', neverRegistered)).toBe(false); }); it('a stale disposer does not unregister a newer registration of the same callback', () => { const { transport, deliver } = createFakeTransport(); const messaging = createTwinMessaging(transport, 'edgeInstance'); const received: any[] = []; const listener = (message: any) => { received.push(message); }; const staleDispose = messaging.on('doorOpened', listener); staleDispose(); messaging.on('doorOpened', listener); // Firing the stale disposer again must leave the new registration intact. staleDispose(); deliver(buildEventPayload('doorOpened')); expect(received.length).toBe(1); }); it('listeners still match legacy-prefixed message types after the lifecycle change', () => { const { transport, deliver } = createFakeTransport(); const messaging = createTwinMessaging(transport, 'edgeInstance'); const received: any[] = []; const dispose = messaging.on('hello', (message) => { received.push(message); }); deliver({ data: { type: 'edgeInstance:hello', data: { greeting: 'hi' } }, sourceTwinId: 'source-twin' }); expect(received.length).toBe(1); dispose(); deliver({ data: { type: 'edgeInstance:hello', data: { greeting: 'hi' } }, sourceTwinId: 'source-twin' }); expect(received.length).toBe(1); }); });