import { describe, expect, it } from 'bun:test'; import { PeerConnectionManager } from '../peer-connection-manager'; import type { IceServersProvider, PeerConnectionConfig, TwinTransport } from '../types'; /** * Unit tests for iceServersProvider resolution in PeerConnectionManager. * * The provider lets a caller supply the full ICE server list (STUN + TURN with * short-lived credentials) asynchronously at connect time, instead of the static * stun/turn config baked in at construction. We assert that: * - a provider-supplied list reaches the RTCPeerConnection unchanged on the * first (with-STUN) attempt, * - the no-STUN retry keeps only the TURN entries (STUN dropped) so relay * fallback survives, * - the { iceServers, ttlSeconds } result form is accepted, * - the provider is re-invoked (not cached forever) once the credentials are * within the refresh margin of expiry, * - a provider that throws falls back to the static stun/turn config. * * We drive a fake RTCPeerConnection that records each constructor config and * fails createOffer so attemptConnection burns through its first two attempts * (with-STUN then no-STUN) synchronously, then we close to stop the backoff. */ const noopTransport: TwinTransport = { twinId: 'self', sendMessage: async () => {}, subscribe: async () => {}, onMessage: () => {}, offMessage: () => {}, }; /** Minimal RTCPeerConnection stand-in that records the iceServers it was built with. */ function installFakePeerConnection(): { configs: RTCConfiguration[]; restore: () => void } { const configs: RTCConfiguration[] = []; class FakeRTCPeerConnection { connectionState = 'new'; signalingState = 'stable'; iceGatheringState = 'new'; onicecandidate: unknown = null; onicegatheringstatechange: unknown = null; oniceconnectionstatechange: unknown = null; onconnectionstatechange: unknown = null; ondatachannel: unknown = null; ontrack: unknown = null; constructor(config: RTCConfiguration) { configs.push(config); } createDataChannel() { return { binaryType: '' }; } async createOffer(): Promise { throw new Error('fake: createOffer not supported'); } async setLocalDescription(): Promise {} close(): void {} async getStats(): Promise> { return new Map(); } } const original = (globalThis as { RTCPeerConnection?: unknown }).RTCPeerConnection; (globalThis as { RTCPeerConnection?: unknown }).RTCPeerConnection = FakeRTCPeerConnection; return { configs, restore: () => { (globalThis as { RTCPeerConnection?: unknown }).RTCPeerConnection = original; }, }; } function makeConfig(overrides: Partial): PeerConnectionConfig { const base: PeerConnectionConfig = { targetTwinId: 'peer', isInitiator: true, connectionType: 'datachannel', channelPrefix: 'dc-default', useStun: true, stunServers: ['stun:stun.example:3478'], turnServers: [], iceTransportPolicy: 'all', onConnected: () => {}, onDisconnected: () => {}, onError: () => {}, onPeerConnectionCreated: () => {}, }; return Object.assign(base, overrides); } /** Run connect() far enough to capture the first two attempts, then tear down. */ async function captureIceServers(config: PeerConnectionConfig): Promise { const fake = installFakePeerConnection(); const manager = new PeerConnectionManager(config, noopTransport, { connectionTimeout: 15000, initialRetryDelay: 1000, maxRetryDelay: 30000, }); try { manager.connect().catch(() => {}); // rejects once we close — intentional await new Promise((resolve) => setTimeout(resolve, 80)); return fake.configs; } finally { manager.close(); fake.restore(); } } describe('PeerConnectionManager iceServersProvider', () => { it('uses the provider-supplied list on the first attempt and keeps only TURN on the no-STUN retry', async () => { const provided: RTCIceServer[] = [ { urls: 'stun:turn-eu.phystack.com:3478' }, { urls: ['turn:turn-eu.phystack.com:3478?transport=udp', 'turns:turn-eu.phystack.com:5349?transport=tcp'], username: '1700000000:client', credential: 'abc123==', }, ]; const configs = await captureIceServers(makeConfig({ iceServersProvider: () => provided })); expect(configs.length).toBeGreaterThanOrEqual(2); // First attempt (with STUN): full provider list, untouched. expect(configs[0].iceServers).toEqual(provided); // Second attempt (STUN toggled off): STUN-only entry dropped, TURN entry kept. expect(configs[1].iceServers).toEqual([ { urls: ['turn:turn-eu.phystack.com:3478?transport=udp', 'turns:turn-eu.phystack.com:5349?transport=tcp'], username: '1700000000:client', credential: 'abc123==', }, ]); }); it('strips STUN urls from a combined STUN+TURN entry on the no-STUN retry', async () => { const combined: RTCIceServer[] = [ { urls: ['stun:host:3478', 'turn:host:3478'], username: 'user', credential: 'pass' }, ]; const configs = await captureIceServers(makeConfig({ iceServersProvider: () => combined })); expect(configs.length).toBeGreaterThanOrEqual(2); expect(configs[0].iceServers).toEqual(combined); // The bundled stun: URL must be removed, leaving the turn: URL only. expect(configs[1].iceServers).toEqual([{ urls: ['turn:host:3478'], username: 'user', credential: 'pass' }]); }); it('accepts the { iceServers, ttlSeconds } result form', async () => { const iceServers: RTCIceServer[] = [ { urls: ['turn:turn-eu.phystack.com:3478'], username: '1700000000:client', credential: 'xyz==' }, ]; const provider: IceServersProvider = () => ({ iceServers, ttlSeconds: 300 }); const configs = await captureIceServers(makeConfig({ iceServersProvider: provider })); expect(configs.length).toBeGreaterThanOrEqual(1); expect(configs[0].iceServers).toEqual(iceServers); }); it('reuses the cached list across a rapid retry when the TTL is comfortably ahead', async () => { let calls = 0; const provider: IceServersProvider = () => { calls += 1; return { iceServers: [{ urls: ['turn:host:3478'], username: 'u', credential: 'c' }], ttlSeconds: 300 }; }; const configs = await captureIceServers(makeConfig({ iceServersProvider: provider })); // Both attempts happen within ms; a 300s TTL is far from the refresh margin, // so the provider is invoked once and the cache reused (no per-attempt hammering). expect(calls).toBe(1); expect(configs.length).toBeGreaterThanOrEqual(2); }); it('re-invokes the provider when the cached list is due for refresh', async () => { let calls = 0; // ttlSeconds 0 marks the list for immediate refresh, so each attempt re-resolves // (a STUN-only fail-safe self-heals once the relay is configured). const provider: IceServersProvider = () => { calls += 1; return { iceServers: [{ urls: ['turn:host:3478'], username: `u${calls}`, credential: 'c' }], ttlSeconds: 0 }; }; const configs = await captureIceServers(makeConfig({ iceServersProvider: provider })); expect(calls).toBeGreaterThanOrEqual(2); expect(configs.length).toBeGreaterThanOrEqual(2); }); it('falls back to the static stun/turn config when the provider throws', async () => { const configs = await captureIceServers( makeConfig({ stunServers: ['stun:stun.example:3478'], turnServers: [{ urls: 'turn:fallback.example:3478', username: 'user', credential: 'pass' }], iceServersProvider: () => { throw new Error('mint failed'); }, }), ); expect(configs.length).toBeGreaterThanOrEqual(1); // Static path: STUN mapped to {urls} plus the TURN entry. expect(configs[0].iceServers).toEqual([ { urls: 'stun:stun.example:3478' }, { urls: 'turn:fallback.example:3478', username: 'user', credential: 'pass' }, ]); }); });