import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import type { Socket, ManagerOptions, SocketOptions } from 'socket.io-client'; import { CloudAppConnection } from '../cloud-app-connection.service'; import type { CloudAppFetch } from '../cloud-app-token.service'; /** * Minimal Socket.IO mock — enough to: * - capture handshake `auth` * - fire arbitrary server-side events on demand (`emitFromServer`) * - record client-emitted events (`emittedToServer`) * - simulate the inner Manager's `reconnect_attempt` for the auth-stale path */ class MockSocket { public auth: Record; public io = { listeners: new Map void>>() } as any; public listeners = new Map void>>(); public emittedToServer: Array<{ event: string; payload: unknown }> = []; public connected = false; public disconnectCalled = 0; constructor( public url: string, opts: Partial, ) { this.auth = { ...((opts.auth as Record) || {}) }; this.io.on = (event: string, listener: (...args: any[]) => void) => { const list = this.io.listeners.get(event) ?? []; list.push(listener); this.io.listeners.set(event, list); }; } on(event: string, listener: (...args: any[]) => void): this { const list = this.listeners.get(event) ?? []; list.push(listener); this.listeners.set(event, list); return this; } off(event: string, listener: (...args: any[]) => void): this { const list = this.listeners.get(event) ?? []; this.listeners.set( event, list.filter((handler) => handler !== listener), ); return this; } emit(event: string, payload?: unknown): this { this.emittedToServer.push({ event, payload }); return this; } disconnect(): this { this.disconnectCalled += 1; this.connected = false; return this; } /** Test helper — fire a server-originated event into the client. */ emitFromServer(event: string, ...args: unknown[]): void { (this.listeners.get(event) ?? []).forEach((listener) => listener(...args)); } emitFromManager(event: string, ...args: unknown[]): void { (this.io.listeners.get(event) ?? []).forEach((listener: any) => listener(...args)); } } const TEST_API = 'http://localhost:14080'; const TEST_PHYHUB = 'http://localhost:14400'; const FUTURE_EXPIRY_MS = 15 * 60 * 1000; const okJson = (body: unknown): Response => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' }, }); let originalSetTimeout: typeof setTimeout; let scheduledTimers: Array<{ delay: number; fn: () => void }> = []; beforeEach(() => { scheduledTimers = []; originalSetTimeout = globalThis.setTimeout; // Capture timers without actually scheduling them so the tests are // deterministic and don't have to wait. Returns an integer handle. globalThis.setTimeout = ((fn: () => void, delay: number) => { const handle = scheduledTimers.length + 1; scheduledTimers.push({ delay, fn }); return handle as unknown as ReturnType; }) as typeof setTimeout; }); afterEach(() => { globalThis.setTimeout = originalSetTimeout; }); describe('CloudAppConnection.connect()', () => { it('passes cloudAppJwt in the Socket.IO handshake auth', async () => { const expiresAt = new Date(Date.now() + FUTURE_EXPIRY_MS).toISOString(); const fakeFetch: CloudAppFetch = () => Promise.resolve(okJson({ token: 'jwt-handshake', expiresAt })); let captured: MockSocket | null = null; const conn = new CloudAppConnection({ appRegistrationId: 'app-1', appSecret: 's', coreApiUrl: TEST_API, phyhubUrl: TEST_PHYHUB, fetch: fakeFetch, ioFactory: (url, opts) => { captured = new MockSocket(url, opts); // Resolve `connect()` by firing `cloudAppAuthenticated` async. originalSetTimeout( () => captured!.emitFromServer('cloudAppAuthenticated', { status: 'success', twins: [] }), 0, ); return captured as unknown as Socket; }, }); await conn.connect(); expect(captured).not.toBeNull(); expect(captured!.url).toBe(TEST_PHYHUB); expect(captured!.auth.cloudAppJwt).toBe('jwt-handshake'); }); it('rejects when the token endpoint returns 401', async () => { const fakeFetch: CloudAppFetch = () => Promise.resolve(new Response('nope', { status: 401 })); const conn = new CloudAppConnection({ appRegistrationId: 'app-1', appSecret: 'wrong', coreApiUrl: TEST_API, phyhubUrl: TEST_PHYHUB, fetch: fakeFetch, // ioFactory should never be reached when auth fails up-front. ioFactory: () => { throw new Error('ioFactory should not be called when token exchange fails'); }, }); let caught: unknown; try { await conn.connect(); } catch (error) { caught = error; } expect((caught as Error).message).toContain('invalid credentials'); }); it('schedules a refresh ~refreshLeadMs before expiry', async () => { const expiresAt = new Date(Date.now() + FUTURE_EXPIRY_MS).toISOString(); const fakeFetch: CloudAppFetch = () => Promise.resolve(okJson({ token: 'jwt-1', expiresAt })); const refreshLeadMs = 60_000; const conn = new CloudAppConnection({ appRegistrationId: 'app', appSecret: 's', coreApiUrl: TEST_API, phyhubUrl: TEST_PHYHUB, refreshLeadMs, fetch: fakeFetch, ioFactory: (url, opts) => { const socket = new MockSocket(url, opts); originalSetTimeout(() => socket.emitFromServer('cloudAppAuthenticated', { status: 'success', twins: [] }), 0); return socket as unknown as Socket; }, }); await conn.connect(); // Exactly one timer should be scheduled, with a delay close to // FUTURE_EXPIRY_MS - refreshLeadMs. expect(scheduledTimers.length).toBe(1); const expectedDelay = FUTURE_EXPIRY_MS - refreshLeadMs; // Allow a generous slop for clock drift between Date.now() reads. const actualDelay = scheduledTimers[0]!.delay; expect(Math.abs(actualDelay - expectedDelay)).toBeLessThan(2_000); }); it('refresh swaps the cached token and patches socket.auth in place', async () => { const firstExpiry = new Date(Date.now() + FUTURE_EXPIRY_MS).toISOString(); const secondExpiry = new Date(Date.now() + 2 * FUTURE_EXPIRY_MS).toISOString(); let callCount = 0; const fakeFetch: CloudAppFetch = () => { callCount += 1; const body = callCount === 1 ? { token: 'jwt-1', expiresAt: firstExpiry } : { token: 'jwt-2', expiresAt: secondExpiry }; return Promise.resolve(okJson(body)); }; let captured: MockSocket | null = null; const conn = new CloudAppConnection({ appRegistrationId: 'app', appSecret: 's', coreApiUrl: TEST_API, phyhubUrl: TEST_PHYHUB, fetch: fakeFetch, ioFactory: (url, opts) => { captured = new MockSocket(url, opts); originalSetTimeout( () => captured!.emitFromServer('cloudAppAuthenticated', { status: 'success', twins: [] }), 0, ); return captured as unknown as Socket; }, }); await conn.connect(); expect(conn.getToken()?.token).toBe('jwt-1'); expect(captured!.auth.cloudAppJwt).toBe('jwt-1'); // Trigger the refresh manually by invoking the captured timer body. expect(scheduledTimers.length).toBe(1); await scheduledTimers[0]!.fn(); // setTimeout in runRefresh's success path schedules another refresh — let // a microtask flush so the swap happens before we assert. await new Promise((resolve) => originalSetTimeout(resolve, 0)); expect(callCount).toBe(2); expect(conn.getToken()?.token).toBe('jwt-2'); expect(captured!.auth.cloudAppJwt).toBe('jwt-2'); }); it('reconnect_attempt re-exchanges the JWT when cached token is near expiry', async () => { // Issue a token that expires almost immediately so refreshIfStale fires. const expiresAt = new Date(Date.now() + 5_000).toISOString(); const refreshedAt = new Date(Date.now() + FUTURE_EXPIRY_MS).toISOString(); let callCount = 0; const fakeFetch: CloudAppFetch = () => { callCount += 1; const body = callCount === 1 ? { token: 'jwt-near-expiry', expiresAt } : { token: 'jwt-fresh', expiresAt: refreshedAt }; return Promise.resolve(okJson(body)); }; let captured: MockSocket | null = null; const conn = new CloudAppConnection({ appRegistrationId: 'app', appSecret: 's', coreApiUrl: TEST_API, phyhubUrl: TEST_PHYHUB, refreshLeadMs: 60_000, fetch: fakeFetch, ioFactory: (url, opts) => { captured = new MockSocket(url, opts); originalSetTimeout( () => captured!.emitFromServer('cloudAppAuthenticated', { status: 'success', twins: [] }), 0, ); return captured as unknown as Socket; }, }); await conn.connect(); expect(conn.getToken()?.token).toBe('jwt-near-expiry'); // Simulate the Manager firing a reconnect attempt. captured!.emitFromManager('reconnect_attempt', 1); // Flush the microtask the handler queued. await new Promise((resolve) => originalSetTimeout(resolve, 0)); await new Promise((resolve) => originalSetTimeout(resolve, 0)); expect(callCount).toBe(2); expect(conn.getToken()?.token).toBe('jwt-fresh'); expect(captured!.auth.cloudAppJwt).toBe('jwt-fresh'); }); it('disconnect() stops refresh timer and tears down the socket', async () => { const expiresAt = new Date(Date.now() + FUTURE_EXPIRY_MS).toISOString(); const fakeFetch: CloudAppFetch = () => Promise.resolve(okJson({ token: 'jwt-1', expiresAt })); let captured: MockSocket | null = null; const conn = new CloudAppConnection({ appRegistrationId: 'app', appSecret: 's', coreApiUrl: TEST_API, phyhubUrl: TEST_PHYHUB, fetch: fakeFetch, ioFactory: (url, opts) => { captured = new MockSocket(url, opts); originalSetTimeout( () => captured!.emitFromServer('cloudAppAuthenticated', { status: 'success', twins: [] }), 0, ); return captured as unknown as Socket; }, }); await conn.connect(); conn.disconnect(); expect(captured!.disconnectCalled).toBe(1); expect(conn.getToken()).toBeNull(); expect(conn.getSocket()).toBeNull(); }); });