import { describe, expect, it } from 'bun:test'; import { PeripheralTwinInstance } from '../peripheral-twin'; import { TwinResponse, TwinTypeEnum } from '../types/twin.types'; /** * Lifecycle tests for TECH-1334 Phase 2 on PeripheralTwinInstance: * on(), onUpdateReported() and onUpdateDesired() return unsubscribe functions. * Previously the internal wrapper closures were unreachable, so peripheral * listeners could never be removed (offTwinUpdate matches by callback identity * and could not see the wrappers). */ type TwinUpdateHandler = (twin: TwinResponse) => void; function createFakePhyHubClient(): { fakeClient: { onTwinUpdate: (twinId: string, handler: TwinUpdateHandler) => void; offTwinUpdate: (twinId: string, handler: TwinUpdateHandler) => boolean; }; triggerTwinUpdate: (twin: TwinResponse) => void; registeredHandlerCount: () => number; } { const handlers = new Set(); const fakeClient = { onTwinUpdate: (_twinId: string, handler: TwinUpdateHandler) => { handlers.add(handler); }, offTwinUpdate: (_twinId: string, handler: TwinUpdateHandler) => { return handlers.delete(handler); }, }; const triggerTwinUpdate = (twin: TwinResponse) => { for (const handler of Array.from(handlers)) { handler(twin); } }; return { fakeClient, triggerTwinUpdate, registeredHandlerCount: () => handlers.size }; } function buildPeripheralTwin(reported: Record, desired: Record): TwinResponse { return { id: 'peripheral-1', type: TwinTypeEnum.Peripheral, properties: { reported, desired }, } as unknown as TwinResponse; } function createInitializedInstance(fakeClient: unknown): PeripheralTwinInstance { const instance = new PeripheralTwinInstance(fakeClient as never, 'peripheral-1'); (instance as unknown as { peripheralTwinResponse: TwinResponse }).peripheralTwinResponse = buildPeripheralTwin( {}, {}, ); return instance; } describe('PeripheralTwinInstance.onUpdateReported — unsubscribe', () => { it('returns a disposer that unregisters the internal twin-update handler', () => { const { fakeClient, triggerTwinUpdate, registeredHandlerCount } = createFakePhyHubClient(); const instance = createInitializedInstance(fakeClient); const received: Array> = []; const dispose = instance.onUpdateReported((reportedProperties) => { received.push(reportedProperties); }); expect(registeredHandlerCount()).toBe(1); triggerTwinUpdate(buildPeripheralTwin({ status: 'on' }, {})); expect(received.length).toBe(1); dispose(); expect(registeredHandlerCount()).toBe(0); triggerTwinUpdate(buildPeripheralTwin({ status: 'off' }, {})); expect(received.length).toBe(1); }); it('disposing one listener leaves another listener on the same instance working', () => { const { fakeClient, triggerTwinUpdate } = createFakePhyHubClient(); const instance = createInitializedInstance(fakeClient); const firstReceived: Array> = []; const secondReceived: Array> = []; const disposeFirst = instance.onUpdateReported((reportedProperties) => { firstReceived.push(reportedProperties); }); instance.onUpdateReported((reportedProperties) => { secondReceived.push(reportedProperties); }); disposeFirst(); triggerTwinUpdate(buildPeripheralTwin({ status: 'on' }, {})); expect(firstReceived.length).toBe(0); expect(secondReceived.length).toBe(1); }); }); describe('PeripheralTwinInstance.onUpdateDesired — unsubscribe', () => { it('returns a disposer that unregisters the internal twin-update handler', () => { const { fakeClient, triggerTwinUpdate, registeredHandlerCount } = createFakePhyHubClient(); const instance = createInitializedInstance(fakeClient); const received: Array> = []; const dispose = instance.onUpdateDesired((desiredProperties) => { received.push(desiredProperties); }); expect(registeredHandlerCount()).toBe(1); triggerTwinUpdate(buildPeripheralTwin({}, { brightness: 80 })); expect(received.length).toBe(1); dispose(); expect(registeredHandlerCount()).toBe(0); triggerTwinUpdate(buildPeripheralTwin({}, { brightness: 20 })); expect(received.length).toBe(1); }); }); describe('PeripheralTwinInstance.on — unsubscribe', () => { it('returns the messaging disposer in the non-validating branch', () => { const { fakeClient } = createFakePhyHubClient(); const instance = createInitializedInstance(fakeClient); const offCalls: Array<{ type: string }> = []; let registeredCallback: ((message: any) => void) | null = null; (instance as unknown as { messaging: unknown }).messaging = { on: (type: string, callback: (message: any) => void) => { registeredCallback = callback; return () => { offCalls.push({ type }); registeredCallback = null; }; }, }; const received: any[] = []; const dispose = instance.on('buttonPressed', (message) => { received.push(message); }); registeredCallback!({ pressed: true }); expect(received.length).toBe(1); dispose(); expect(offCalls).toEqual([{ type: 'buttonPressed' }]); expect(registeredCallback).toBeNull(); }); it('returns a disposer for the wrapped callback in the advisory-validation branch', () => { const { fakeClient } = createFakePhyHubClient(); const instance = createInitializedInstance(fakeClient); const offCalls: Array<{ type: string }> = []; (instance as unknown as { messaging: unknown }).messaging = { on: (type: string, _callback: (message: any) => void) => { return () => { offCalls.push({ type }); }; }, }; instance.enableAdvisoryValidation({ actionReturns: { unlock: { validate: () => true, errorsText: () => '' } }, } as never); const dispose = instance.on('unlock', () => {}); dispose(); expect(offCalls).toEqual([{ type: 'unlock' }]); }); }); /** * Fake messaging with real listener bookkeeping — a Map of type → Set of * callbacks — so the off() tests assert delivery behavior, not call plumbing. */ function createFakeMessaging(): { fakeMessaging: { on: (type: string, callback: (message: any) => void) => () => void; off: (type: string, callback: (message: any) => void) => boolean; }; trigger: (type: string, message: any) => void; listenerCount: (type: string) => number; } { const listeners = new Map void>>(); const fakeMessaging = { on: (type: string, callback: (message: any) => void) => { let callbacksForType = listeners.get(type); if (!callbacksForType) { callbacksForType = new Set(); listeners.set(type, callbacksForType); } callbacksForType.add(callback); return () => { callbacksForType!.delete(callback); }; }, off: (type: string, callback: (message: any) => void) => { return listeners.get(type)?.delete(callback) ?? false; }, }; const trigger = (type: string, message: any) => { for (const callback of Array.from(listeners.get(type) ?? [])) { callback(message); } }; const listenerCount = (type: string) => listeners.get(type)?.size ?? 0; return { fakeMessaging, trigger, listenerCount }; } describe('PeripheralTwinInstance.off', () => { it('removes a non-validating listener by original callback identity', () => { const { fakeClient } = createFakePhyHubClient(); const instance = createInitializedInstance(fakeClient); const { fakeMessaging, trigger, listenerCount } = createFakeMessaging(); (instance as unknown as { messaging: unknown }).messaging = fakeMessaging; const received: any[] = []; const listener = (message: any) => { received.push(message); }; instance.on('buttonPressed', listener); trigger('buttonPressed', { pressed: true }); expect(received.length).toBe(1); expect(instance.off('buttonPressed', listener)).toBe(true); expect(listenerCount('buttonPressed')).toBe(0); trigger('buttonPressed', { pressed: true }); expect(received.length).toBe(1); }); it('removes a validating listener by original callback identity (translates through the wrapper)', () => { const { fakeClient } = createFakePhyHubClient(); const instance = createInitializedInstance(fakeClient); const { fakeMessaging, trigger, listenerCount } = createFakeMessaging(); (instance as unknown as { messaging: unknown }).messaging = fakeMessaging; instance.enableAdvisoryValidation({ actionReturns: { unlock: { validate: () => true, errorsText: () => '' } }, } as never); const received: any[] = []; const listener = (message: any) => { received.push(message); }; instance.on('unlock', listener); trigger('unlock', { requestId: 'req-1' }); expect(received.length).toBe(1); expect(instance.off('unlock', listener)).toBe(true); expect(listenerCount('unlock')).toBe(0); trigger('unlock', { requestId: 'req-2' }); expect(received.length).toBe(1); }); it('returns false when nothing matches', () => { const { fakeClient } = createFakePhyHubClient(); const instance = createInitializedInstance(fakeClient); const { fakeMessaging } = createFakeMessaging(); (instance as unknown as { messaging: unknown }).messaging = fakeMessaging; expect(instance.off('buttonPressed', () => {})).toBe(false); }); });