import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { PeripheralTwinInstance } from './peripheral-twin'; import type { CompiledValidator, DescriptorValidator } from './advisory-validation'; import { TwinMessageResultStatus, type TwinMessageResult } from './types/twin.types'; /** * These tests exercise the three emit-path insertion points (updateReported, * emit/event, respond/action-returns) in isolation by stubbing the messaging + * phyHubClient seams. The load-bearing assertions are the routing-independent * invariant: the message is ALWAYS sent regardless of validation outcome, and a * non-validating instance behaves byte-identically to one that wasn't opted in. */ const passing: CompiledValidator = () => true; const failing: CompiledValidator = Object.assign(() => false, { errors: [{ message: 'nope' }], }); const throwing: CompiledValidator = () => { throw new Error('boom'); }; interface SentEmit { type: string; payload: unknown; hadCallback: boolean; } /** Build an instance with stubbed internals so emit paths run without a socket. */ function buildInstance(validator?: DescriptorValidator) { const emits: SentEmit[] = []; const reportedCalls: Array> = []; const reportedTypes: Array = []; // Captures the (message, respond) handlers registered through messaging.on, // so a test can simulate an inbound action invocation. const onHandlers = new Map void) => void>(); const fakeMessaging = { emit: (type: string, payload: unknown) => { emits.push({ type, payload, hadCallback: false }); }, on: (type: string, callback: (message: unknown, respond?: (result: TwinMessageResult) => void) => void) => { onHandlers.set(type, callback); }, to: (_targetTwinId: string) => ({ emit: (type: string, payload: unknown, callback?: (response: TwinMessageResult) => void) => { emits.push({ type, payload, hadCallback: Boolean(callback) }); return undefined as unknown as Promise; }, }), }; const fakePhyHubClient = { updateReportedProperties: async ( _twinId: string, properties: Record, twinType?: unknown, ) => { reportedCalls.push(properties); reportedTypes.push(twinType); return { id: 'twin-1', properties: { reported: properties } } as unknown; }, }; const instance = new PeripheralTwinInstance(fakePhyHubClient as never, 'twin-1'); // Stub the seams initialize() would otherwise populate. (instance as unknown as { messaging: unknown }).messaging = fakeMessaging; (instance as unknown as { peripheralTwinResponse: unknown }).peripheralTwinResponse = { id: 'twin-1', type: 'PERIPHERAL', }; if (validator) { instance.enableAdvisoryValidation(validator); } return { instance, emits, reportedCalls, reportedTypes, onHandlers }; } let warnings: Array<{ args: unknown[] }>; let originalWarn: typeof console.warn; beforeEach(() => { warnings = []; originalWarn = console.warn; console.warn = (...args: unknown[]) => { warnings.push({ args }); }; }); afterEach(() => { console.warn = originalWarn; }); describe('PeripheralTwinInstance advisory validation — opt-in & routing independence', () => { it('no validator supplied: updateReported stores and never warns (existing behavior)', async () => { const { instance, reportedCalls } = buildInstance(); await instance.updateReported({ temperature: 'not-a-number' }); expect(reportedCalls).toHaveLength(1); expect(reportedCalls[0]).toEqual({ temperature: 'not-a-number' }); expect(warnings).toHaveLength(0); }); it('updateReported forwards the twin type to updateReportedProperties (regression)', async () => { // Regression: PeripheralTwin.updateReported omitted the twinType (3rd) arg, // so phyhub rejected every peripheral report with "unsupported twin type // undefined" — caught by the live VM e2e. The twin's type MUST be forwarded. const { instance, reportedTypes } = buildInstance(); await instance.updateReported({ temperature: 21 }); expect(reportedTypes).toEqual(['PERIPHERAL']); }); it('no validator supplied: emit sends and never warns (existing behavior)', () => { const { instance, emits } = buildInstance(); instance.emit('scan', { code: 123 }); expect(emits).toHaveLength(1); expect(emits[0]?.type).toBe('scan'); expect(warnings).toHaveLength(0); }); it('conforming reported: stored, no warning', async () => { const { instance, reportedCalls } = buildInstance({ reported: passing }); await instance.updateReported({ temperature: 21 }); expect(reportedCalls).toHaveLength(1); expect(warnings).toHaveLength(0); }); it('non-conforming reported: STILL stored + warns (advisory)', async () => { const { instance, reportedCalls } = buildInstance({ reported: failing }); await instance.updateReported({ temperature: 'hot' }); expect(reportedCalls).toHaveLength(1); expect(reportedCalls[0]).toEqual({ temperature: 'hot' }); expect(warnings).toHaveLength(1); expect(String(warnings[0]?.args[0])).toContain('mismatch'); }); it('conforming event: sent, no warning', () => { const { instance, emits } = buildInstance({ events: { scan: passing } }); instance.emit('scan', { code: 123 }); expect(emits).toHaveLength(1); expect(warnings).toHaveLength(0); }); it('non-conforming event: STILL sent + warns (advisory)', () => { const { instance, emits } = buildInstance({ events: { scan: failing } }); instance.emit('scan', { code: 'bad' }); expect(emits).toHaveLength(1); expect(emits[0]?.payload).toEqual({ code: 'bad' }); expect(warnings).toHaveLength(1); expect(String(warnings[0]?.args[0])).toContain("event 'scan'"); }); it('event with no validator for that type: sent, no warning (per-event fail-open)', () => { const { instance, emits } = buildInstance({ events: { scan: failing } }); // 'other' has no validator → skip; must not warn or block. instance.emit('other', { whatever: true }); expect(emits).toHaveLength(1); expect(warnings).toHaveLength(0); }); it('validator throws during emit: STILL sent, fail-open warn, no throw', () => { const { instance, emits } = buildInstance({ events: { scan: throwing } }); expect(() => instance.emit('scan', { code: 1 })).not.toThrow(); expect(emits).toHaveLength(1); expect(warnings).toHaveLength(1); expect(String(warnings[0]?.args[0])).toContain('Failed to run advisory descriptor validation'); }); it('non-conforming action return: respond STILL forwards result + warns (advisory)', () => { const { instance, onHandlers } = buildInstance({ actionReturns: { calibrate: failing } }); const nonConformingResult: TwinMessageResult = { status: TwinMessageResultStatus.Success, message: 'offset=bad', }; let forwarded: TwinMessageResult | undefined; instance.on('calibrate', (_message, respond) => { // The app handles the action and responds with a (non-conforming) result. respond?.(nonConformingResult); }); // Simulate an inbound invocation: the messaging layer hands the app a real respond. const handler = onHandlers.get('calibrate'); expect(handler).toBeDefined(); handler!({ requestId: 'req-1' }, (result) => { forwarded = result; }); // respond forwarded unchanged (routing independent), and a warning was logged. expect(forwarded).toEqual(nonConformingResult); expect(warnings).toHaveLength(1); expect(String(warnings[0]?.args[0])).toContain("actionReturns 'calibrate'"); }); it('action return without a validator for that action: respond forwards, no warning', () => { const { instance, onHandlers } = buildInstance({ actionReturns: { calibrate: failing } }); const result: TwinMessageResult = { status: TwinMessageResultStatus.Success, message: 'ok', }; let forwarded: TwinMessageResult | undefined; instance.on('reset', (_message, respond) => { respond?.(result); }); const handler = onHandlers.get('reset'); handler!({ requestId: 'req-2' }, (received) => { forwarded = received; }); expect(forwarded).toEqual(result); expect(warnings).toHaveLength(0); }); });