/** * Unit tests for PeripheralTwinInstance ownership-loss revocation * (TECH-1394). What matters: markOwnershipLost fires onOwnershipLost * callbacks exactly once (idempotent) for both loss shapes — cross-device * and same-device instance takeover — a thrown callback cannot break the * revocation, the owner-write path (updateReported) rejects locally * with an explicit reason after revocation instead of round-tripping a * write the server would reject anyway, and the watcher arms only on an * exact device+instance match (`desired.instanceId` is mandatory — * phyhub stamps it on every registration). */ import { describe, expect, it, jest, spyOn } from 'bun:test'; import { PeripheralTwinInstance } from '../peripheral-twin'; import { PeripheralOwnershipLoss, TwinTypeEnum } from '../types/twin.types'; const TWIN_ID = '6a5e06e3a31934fa2f37d63f'; const buildInstance = () => { const phyHubClient: any = { updateReportedProperties: jest.fn().mockResolvedValue({ id: TWIN_ID }), }; const peripheralInstance = new PeripheralTwinInstance(phyHubClient, TWIN_ID); // Uninitialized transport is fine for these tests — updateReported only // needs the twin snapshot, and initialize() would need a live socket. peripheralInstance.peripheralTwinResponse = { id: TWIN_ID, type: TwinTypeEnum.Peripheral, deviceId: 'device-1', tenantId: 'tenant-1', properties: { desired: {}, reported: {} }, } as any; return { peripheralInstance, phyHubClient }; }; const buildInitializedInstance = async ( twinDesired: Record, twinDeviceId = 'device-1', ): Promise => { const phyHubClient: any = { getInstance: jest.fn().mockResolvedValue({ id: 'instance-1', deviceId: 'device-1' }), getTwinById: jest.fn().mockResolvedValue({ id: TWIN_ID, type: TwinTypeEnum.Peripheral, deviceId: twinDeviceId, tenantId: 'tenant-1', properties: { desired: twinDesired, reported: {} }, }), subscribeTwin: jest.fn().mockResolvedValue(undefined), onTwinMessage: jest.fn(), offTwinMessage: jest.fn(), onTwinUpdate: jest.fn(), offTwinUpdate: jest.fn(), }; const peripheralInstance = new PeripheralTwinInstance(phyHubClient, TWIN_ID); await peripheralInstance.initialize(); return peripheralInstance; }; describe('PeripheralTwinInstance owner detection', () => { it('arms the watcher when the twin names this device and this app instance', async () => { const peripheralInstance = await buildInitializedInstance({ instanceId: 'instance-1' }); expect(peripheralInstance.acquiredAsOwner).toBe(true); }); it('does not arm without desired.instanceId — the field is mandatory, absence means another owner', async () => { const peripheralInstance = await buildInitializedInstance({}); expect(peripheralInstance.acquiredAsOwner).toBe(false); }); it('does not arm for a same-device consumer holding a sibling instance twin', async () => { const peripheralInstance = await buildInitializedInstance({ instanceId: 'instance-2' }); expect(peripheralInstance.acquiredAsOwner).toBe(false); }); it('does not arm for a consumer of another device twin', async () => { const peripheralInstance = await buildInitializedInstance({ instanceId: 'instance-1' }, 'device-2'); expect(peripheralInstance.acquiredAsOwner).toBe(false); }); }); describe('PeripheralTwinInstance revocation teardown', () => { it('markOwnershipLost unregisters every gated message listener', async () => { const peripheralInstance = await buildInitializedInstance({ instanceId: 'instance-1' }); const phyHubClient = peripheralInstance.phyHubClient as any; expect(phyHubClient.onTwinMessage.mock.calls.length).toBeGreaterThan(0); peripheralInstance.markOwnershipLost({ newOwnerInstanceId: 'instance-2' }); expect(phyHubClient.offTwinMessage.mock.calls.length).toBe(phyHubClient.onTwinMessage.mock.calls.length); for (const [registeredTwinId, registeredListener] of phyHubClient.onTwinMessage.mock.calls) { expect(phyHubClient.offTwinMessage).toHaveBeenCalledWith(registeredTwinId, registeredListener); } }); it('markOwnershipLost disposes onUpdateReported listeners so the loser stops observing the twin', async () => { const peripheralInstance = await buildInitializedInstance({ instanceId: 'instance-1' }); const phyHubClient = peripheralInstance.phyHubClient as any; peripheralInstance.onUpdateReported(() => undefined); const [registeredTwinId, registeredHandler] = phyHubClient.onTwinUpdate.mock.calls[0]; peripheralInstance.markOwnershipLost({ newOwnerDeviceId: 'device-2' }); expect(phyHubClient.offTwinUpdate).toHaveBeenCalledWith(registeredTwinId, registeredHandler); }); it('onUpdateReported on a revoked instance throws instead of quietly re-subscribing', async () => { const peripheralInstance = await buildInitializedInstance({ instanceId: 'instance-1' }); peripheralInstance.markOwnershipLost({ newOwnerInstanceId: 'instance-2' }); expect(() => peripheralInstance.onUpdateReported(() => undefined)).toThrow('ownership lost'); expect(() => peripheralInstance.onUpdateDesired(() => undefined)).toThrow('ownership lost'); }); it('the disposer returned by onUpdateReported still works after being disposed by revocation', async () => { const peripheralInstance = await buildInitializedInstance({ instanceId: 'instance-1' }); const phyHubClient = peripheralInstance.phyHubClient as any; const dispose = peripheralInstance.onUpdateReported(() => undefined); peripheralInstance.markOwnershipLost({ newOwnerDeviceId: 'device-2' }); dispose(); // Both calls target the same (twinId, handler) pair — double-off is a no-op. expect(phyHubClient.offTwinUpdate.mock.calls.length).toBe(2); }); }); describe('PeripheralTwinInstance ownership loss', () => { it('fires onOwnershipLost callbacks with the new owner device, exactly once', () => { const { peripheralInstance } = buildInstance(); const observedLosses: PeripheralOwnershipLoss[] = []; peripheralInstance.onOwnershipLost((loss) => observedLosses.push(loss)); peripheralInstance.markOwnershipLost({ newOwnerDeviceId: 'device-2' }); peripheralInstance.markOwnershipLost({ newOwnerDeviceId: 'device-3' }); expect(observedLosses).toEqual([{ newOwnerDeviceId: 'device-2' }]); }); it('fires onOwnershipLost with the new owner instance on a same-device takeover', () => { const { peripheralInstance } = buildInstance(); const observedLosses: PeripheralOwnershipLoss[] = []; peripheralInstance.onOwnershipLost((loss) => observedLosses.push(loss)); peripheralInstance.markOwnershipLost({ newOwnerInstanceId: 'instance-2' }); expect(observedLosses).toEqual([{ newOwnerInstanceId: 'instance-2' }]); }); it('a throwing callback does not break revocation or other callbacks', () => { const { peripheralInstance } = buildInstance(); const errorSpy = spyOn(console, 'error').mockImplementation(() => undefined); const laterCallback = jest.fn(); peripheralInstance.onOwnershipLost(() => { throw new Error('release failed'); }); peripheralInstance.onOwnershipLost(laterCallback); try { peripheralInstance.markOwnershipLost({ newOwnerDeviceId: 'device-2' }); expect(laterCallback).toHaveBeenCalledWith({ newOwnerDeviceId: 'device-2' }); } finally { errorSpy.mockRestore(); } }); it('updateReported rejects locally with the new owner device named after revocation', async () => { const { peripheralInstance, phyHubClient } = buildInstance(); peripheralInstance.markOwnershipLost({ newOwnerDeviceId: 'device-2' }); await expect(peripheralInstance.updateReported({ status: 'ready' })).rejects.toThrow( `ownership lost: peripheral ${TWIN_ID} is now owned by device device-2`, ); expect(phyHubClient.updateReportedProperties).not.toHaveBeenCalled(); }); it('updateReported rejects locally naming the sibling instance after a same-device takeover', async () => { const { peripheralInstance, phyHubClient } = buildInstance(); peripheralInstance.markOwnershipLost({ newOwnerInstanceId: 'instance-2' }); await expect(peripheralInstance.updateReported({ status: 'ready' })).rejects.toThrow( `ownership lost: peripheral ${TWIN_ID} is now owned by instance instance-2 on this device`, ); expect(phyHubClient.updateReportedProperties).not.toHaveBeenCalled(); }); it('a loss without owner info (typed report rejection) still revokes', async () => { const { peripheralInstance } = buildInstance(); const observedLosses: PeripheralOwnershipLoss[] = []; peripheralInstance.onOwnershipLost((loss) => observedLosses.push(loss)); peripheralInstance.markOwnershipLost(); expect(observedLosses).toEqual([{}]); await expect(peripheralInstance.updateReported({ status: 'ready' })).rejects.toThrow( `ownership lost: peripheral ${TWIN_ID} is now owned by another owner`, ); }); it('updateReported works normally while ownership is held', async () => { const { peripheralInstance, phyHubClient } = buildInstance(); await peripheralInstance.updateReported({ status: 'ready' }); expect(phyHubClient.updateReportedProperties).toHaveBeenCalledWith( TWIN_ID, { status: 'ready' }, TwinTypeEnum.Peripheral, ); }); });