import { describe, expect, it } from 'bun:test'; import { assertWebSessionCodeTarget, createWebSessionCode, guardWithTimeout, requestWebSessionCode, resolveWebSessionEndpointId, WebSessionCodeConfigError, WebSessionCodeIssuer, CREATE_WEB_SESSION_CODE_EVENT, type WebSessionCodeAckPayload, type WebSessionCodeClientAdapter, type WebSessionCodeState, type WebSessionCodeTimers, } from '../web-session-code.service'; /** Timer seam capture — nothing is actually scheduled, tests fire entries by hand. */ interface ScheduledTimer { callback: () => void; delayMs: number; cancelled: boolean; fired: boolean; } class FakeTimers implements WebSessionCodeTimers { public scheduled: ScheduledTimer[] = []; schedule(callback: () => void, delayMs: number): unknown { const entry: ScheduledTimer = { callback, delayMs, cancelled: false, fired: false }; this.scheduled.push(entry); return entry; } cancel(handle: unknown): void { (handle as ScheduledTimer).cancelled = true; } pending(): ScheduledTimer[] { return this.scheduled.filter((entry) => !entry.cancelled && !entry.fired); } fire(entry: ScheduledTimer): void { entry.fired = true; entry.callback(); } } class FakeSocket { public connectListeners: Array<() => void> = []; on(event: string, listener: () => void): this { if (event === 'connect') { this.connectListeners.push(listener); } return this; } off(event: string, listener: () => void): this { if (event === 'connect') { this.connectListeners = this.connectListeners.filter((registered) => registered !== listener); } return this; } fireConnect(): void { this.connectListeners.forEach((listener) => listener()); } } interface RecordedMint { method: string; payload: unknown; ack: (response: WebSessionCodeAckPayload) => void; } class FakeAdapter implements WebSessionCodeClientAdapter { public mintCalls: RecordedMint[] = []; public twinRequests: string[] = []; public ensureConnectionCalls = 0; public socket: FakeSocket | null = new FakeSocket(); /** Responds to each emit; null keeps the ack pending (timeout scenarios). */ public ackResponder: ((callIndex: number) => WebSessionCodeAckPayload | null) | null = null; public twinResponder: (twinId: string) => Promise<{ deviceId: string; type: string }> = () => Promise.resolve({ deviceId: 'endpoint-uuid-1', type: 'Web' }); ensureConnection(): Promise { this.ensureConnectionCalls += 1; return Promise.resolve(); } emit(method: string, payload: unknown, ack: (response: WebSessionCodeAckPayload) => void): void { this.mintCalls.push({ method, payload, ack }); if (this.ackResponder) { const response = this.ackResponder(this.mintCalls.length); if (response !== null) { ack(response); } } } getTwinById(twinId: string): Promise<{ deviceId: string; type: string }> { this.twinRequests.push(twinId); return this.twinResponder(twinId); } getSocket(): FakeSocket | null { return this.socket; } } const SUCCESS_ACK: WebSessionCodeAckPayload = { status: 'success', message: 'Created web session code', code: 'ABCDEFGHJK', url: 'https://w-qa.omborigrid.com/office-remote/#code=ABCDEFGHJK', expiresIn: 300, }; const SILENT_LOGGER = { info: () => undefined, warn: () => undefined, error: () => undefined }; const flushAsync = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); describe('assertWebSessionCodeTarget', () => { it('throws when both twinId and endpointId are given', () => { expect(() => assertWebSessionCodeTarget({ twinId: 'twin-1', endpointId: 'endpoint-1' })).toThrow( 'exactly one of twinId or endpointId', ); }); it('throws when neither twinId nor endpointId is given', () => { expect(() => assertWebSessionCodeTarget({})).toThrow('exactly one of twinId or endpointId'); }); }); describe('resolveWebSessionEndpointId', () => { it('passes an explicit endpointId through without a twin lookup', async () => { const adapter = new FakeAdapter(); const endpointId = await resolveWebSessionEndpointId(adapter, { endpointId: 'endpoint-uuid-9' }); expect(endpointId).toBe('endpoint-uuid-9'); expect(adapter.twinRequests).toEqual([]); }); it('resolves a twinId to the Web twin deviceId', async () => { const adapter = new FakeAdapter(); const endpointId = await resolveWebSessionEndpointId(adapter, { twinId: 'twin-1' }); expect(endpointId).toBe('endpoint-uuid-1'); expect(adapter.twinRequests).toEqual(['twin-1']); }); it('rejects a non-Web twin with a terminal config error', async () => { const adapter = new FakeAdapter(); adapter.twinResponder = () => Promise.resolve({ deviceId: 'device-1', type: 'Peripheral' }); await expect(resolveWebSessionEndpointId(adapter, { twinId: 'twin-1' })).rejects.toBeInstanceOf( WebSessionCodeConfigError, ); }); }); describe('requestWebSessionCode', () => { it('emits createWebSessionCode with the endpointId and resolves the ack', async () => { const adapter = new FakeAdapter(); adapter.ackResponder = () => SUCCESS_ACK; const timers = new FakeTimers(); const issued = await requestWebSessionCode(adapter, 'endpoint-uuid-1', 15_000, timers); expect(adapter.mintCalls).toHaveLength(1); expect(adapter.mintCalls[0].method).toBe(CREATE_WEB_SESSION_CODE_EVENT); expect(adapter.mintCalls[0].payload).toEqual({ data: { endpointId: 'endpoint-uuid-1' } }); expect(issued.code).toBe('ABCDEFGHJK'); expect(issued.url).toBe('https://w-qa.omborigrid.com/office-remote/#code=ABCDEFGHJK'); expect(issued.expiresIn).toBe(300); expect(issued.endpointId).toBe('endpoint-uuid-1'); expect(issued.expiresAt.getTime()).toBeGreaterThan(Date.now()); // The ack-timeout guard is cancelled once the ack settles. expect(timers.pending()).toHaveLength(0); }); it('rejects on an error ack with the server message', async () => { const adapter = new FakeAdapter(); adapter.ackResponder = () => ({ status: 'error', message: 'Web endpoint not found or unauthorized' }); await expect(requestWebSessionCode(adapter, 'endpoint-uuid-1', 15_000, new FakeTimers())).rejects.toThrow( 'Web endpoint not found or unauthorized', ); }); it('rejects on a malformed ack', async () => { const adapter = new FakeAdapter(); adapter.ackResponder = () => ({ status: 'success' }); await expect(requestWebSessionCode(adapter, 'endpoint-uuid-1', 15_000, new FakeTimers())).rejects.toThrow( 'malformed ack', ); }); it('rejects on timeout and ignores a late ack', async () => { const adapter = new FakeAdapter(); const timers = new FakeTimers(); const request = requestWebSessionCode(adapter, 'endpoint-uuid-1', 15_000, timers); expect(timers.pending()).toHaveLength(1); expect(timers.pending()[0].delayMs).toBe(15_000); timers.fire(timers.pending()[0]); await expect(request).rejects.toThrow('Timed out after 15000ms'); // A late ack after the timeout must not throw or resolve anything. adapter.mintCalls[0].ack(SUCCESS_ACK); }); }); describe('guardWithTimeout', () => { it('rejects on timeout and silently discards a late settlement', async () => { const timers = new FakeTimers(); let rejectLate: (error: Error) => void = () => undefined; const hungOperation = new Promise((_, reject) => { rejectLate = reject; }); const guarded = guardWithTimeout(hungOperation, 15_000, timers, 'the web endpoint twin lookup'); expect(timers.pending()).toHaveLength(1); timers.fire(timers.pending()[0]); await expect(guarded).rejects.toThrow('Timed out after 15000ms waiting for the web endpoint twin lookup'); // A late rejection of the underlying promise must be swallowed, not // surface as an unhandled rejection. rejectLate(new Error('late socket error')); await flushAsync(); }); it('cancels the timeout when the operation settles first', async () => { const timers = new FakeTimers(); const result = await guardWithTimeout(Promise.resolve('done'), 15_000, timers, 'the hub connection'); expect(result).toBe('done'); expect(timers.pending()).toHaveLength(0); }); }); describe('createWebSessionCode (one-shot)', () => { it('ensures the connection, resolves the twin, and mints once', async () => { const adapter = new FakeAdapter(); adapter.ackResponder = () => SUCCESS_ACK; const issued = await createWebSessionCode(adapter, { twinId: 'twin-1' }, new FakeTimers()); expect(adapter.ensureConnectionCalls).toBe(1); expect(adapter.twinRequests).toEqual(['twin-1']); expect(issued.endpointId).toBe('endpoint-uuid-1'); }); }); interface IssuerFixture { adapter: FakeAdapter; timers: FakeTimers; states: WebSessionCodeState[]; issuer: WebSessionCodeIssuer; } const startIssuer = async ( configure?: (adapter: FakeAdapter) => void, listenerOverride?: (state: WebSessionCodeState, states: WebSessionCodeState[]) => void, ): Promise => { const adapter = new FakeAdapter(); adapter.ackResponder = () => SUCCESS_ACK; if (configure) { configure(adapter); } const timers = new FakeTimers(); const states: WebSessionCodeState[] = []; const listener = (state: WebSessionCodeState): void => { states.push(state); if (listenerOverride) { listenerOverride(state, states); } }; const issuer = new WebSessionCodeIssuer( adapter, { twinId: 'twin-1', logger: SILENT_LOGGER }, listener, timers, () => 0, ); issuer.start(); await flushAsync(); return { adapter, timers, states, issuer }; }; describe('WebSessionCodeIssuer', () => { it('delivers the initial code as an active state through the listener', async () => { const { states } = await startIssuer(); expect(states).toHaveLength(1); const state = states[0]; expect(state.status).toBe('active'); if (state.status === 'active') { expect(state.url).toBe(SUCCESS_ACK.url as string); expect(state.code).toBe('ABCDEFGHJK'); expect(state.endpointId).toBe('endpoint-uuid-1'); } }); it('schedules the rotation at the renew fraction of the code TTL and re-mints on fire', async () => { const { adapter, timers, states } = await startIssuer(); // random() is pinned to 0 → no jitter: 300s * 0.8 = 240_000ms. const rotation = timers.pending(); expect(rotation).toHaveLength(1); expect(rotation[0].delayMs).toBe(240_000); timers.fire(rotation[0]); await flushAsync(); expect(adapter.mintCalls).toHaveLength(2); expect(states).toHaveLength(2); expect(states[1].status).toBe('active'); // The twin is resolved once and the endpointId cached. expect(adapter.twinRequests).toEqual(['twin-1']); }); it('reports unavailable with exponential backoff on mint failures and recovers', async () => { let failuresToServe = 2; const { timers, states } = await startIssuer((fixtureAdapter) => { fixtureAdapter.ackResponder = () => { if (failuresToServe > 0) { failuresToServe -= 1; return { status: 'error', message: 'Web endpoint not found or unauthorized' }; } return SUCCESS_ACK; }; }); expect(states).toHaveLength(1); expect(states[0].status).toBe('unavailable'); if (states[0].status === 'unavailable') { expect(states[0].retryAt).not.toBeNull(); } const firstRetry = timers.pending(); expect(firstRetry).toHaveLength(1); expect(firstRetry[0].delayMs).toBe(5_000); timers.fire(firstRetry[0]); await flushAsync(); expect(states).toHaveLength(2); const secondRetry = timers.pending(); expect(secondRetry[0].delayMs).toBe(10_000); timers.fire(secondRetry[0]); await flushAsync(); expect(states).toHaveLength(3); expect(states[2].status).toBe('active'); // Backoff resets after a success: the pending timer is a rotation, not a retry. expect(timers.pending()[0].delayMs).toBe(240_000); }); it('stops permanently on a non-Web twin with retryAt null and no timers', async () => { const { timers, states } = await startIssuer((fixtureAdapter) => { fixtureAdapter.twinResponder = () => Promise.resolve({ deviceId: 'device-1', type: 'Screen' }); }); expect(states).toHaveLength(1); expect(states[0].status).toBe('unavailable'); if (states[0].status === 'unavailable') { expect(states[0].retryAt).toBeNull(); expect(states[0].message).toContain('not a Web twin'); } expect(timers.pending()).toHaveLength(0); }); it('refresh() cancels the rotation timer and mints immediately', async () => { const { adapter, timers, states, issuer } = await startIssuer(); const rotationBeforeRefresh = timers.pending()[0]; issuer.refresh(); await flushAsync(); expect(rotationBeforeRefresh.cancelled).toBe(true); expect(adapter.mintCalls).toHaveLength(2); expect(states).toHaveLength(2); }); it('re-mints when the socket reconnects', async () => { const { adapter, states } = await startIssuer(); adapter.socket!.fireConnect(); await flushAsync(); expect(adapter.mintCalls).toHaveLength(2); expect(states).toHaveLength(2); }); it('stop() cancels timers, detaches the reconnect listener, and silences further states', async () => { const { adapter, timers, states, issuer } = await startIssuer(); const rotation = timers.pending()[0]; issuer.stop(); expect(rotation.cancelled).toBe(true); expect(adapter.socket!.connectListeners).toHaveLength(0); adapter.socket!.fireConnect(); issuer.refresh(); await flushAsync(); expect(adapter.mintCalls).toHaveLength(1); expect(states).toHaveLength(1); }); it('survives a throwing listener and keeps rotating', async () => { const { timers, states } = await startIssuer(undefined, () => { throw new Error('listener exploded'); }); expect(states).toHaveLength(1); const rotation = timers.pending(); expect(rotation).toHaveLength(1); timers.fire(rotation[0]); await flushAsync(); expect(states).toHaveLength(2); }); it('times out a hung twin lookup and recovers on the retry', async () => { let twinLookupHangs = true; const { adapter, timers, states } = await startIssuer((fixtureAdapter) => { fixtureAdapter.twinResponder = () => { if (twinLookupHangs) { // A dropped getTwinById ack: the promise never settles. return new Promise(() => undefined); } return Promise.resolve({ deviceId: 'endpoint-uuid-1', type: 'Web' }); }; }); // The resolution guard must be live — firing it turns the hang into the // normal unavailable + retry path instead of a permanently dead // subscription (mintInFlight held forever). const resolutionGuard = timers.pending(); expect(resolutionGuard).toHaveLength(1); expect(resolutionGuard[0].delayMs).toBe(15_000); timers.fire(resolutionGuard[0]); await flushAsync(); expect(states).toHaveLength(1); expect(states[0].status).toBe('unavailable'); if (states[0].status === 'unavailable') { expect(states[0].message).toContain('web endpoint twin lookup'); expect(states[0].retryAt).not.toBeNull(); } twinLookupHangs = false; const retry = timers.pending(); expect(retry).toHaveLength(1); timers.fire(retry[0]); await flushAsync(); expect(states).toHaveLength(2); expect(states[1].status).toBe('active'); expect(adapter.mintCalls).toHaveLength(1); }); it('keeps a single mint in flight when refresh is spammed', async () => { const adapter = new FakeAdapter(); // Never ack — the mint stays pending. adapter.ackResponder = null; const timers = new FakeTimers(); const states: WebSessionCodeState[] = []; const issuer = new WebSessionCodeIssuer( adapter, { endpointId: 'endpoint-uuid-1', logger: SILENT_LOGGER }, (state) => states.push(state), timers, () => 0, ); issuer.start(); await flushAsync(); issuer.refresh(); issuer.refresh(); await flushAsync(); expect(adapter.mintCalls).toHaveLength(1); issuer.stop(); }); });