import { describe, expect, it } from 'bun:test'; import { MultiTransportFallback, TransferTransportError, type ChunkAck, type ITransferTransport, type TransferResumeState, } from '../src/index.js'; class StubTransport implements ITransferTransport { callCount = 0; constructor( private readonly behavior: () => 'ok' | 'transport-error' | 'other-error', ) {} async probe(): Promise { this.callCount++; const verdict = this.behavior(); if (verdict === 'transport-error') throw new TransferTransportError('probe failed'); if (verdict === 'other-error') throw new Error('other'); } async sendChunk( _peer: string, _streamId: string, _laneId: number, seq: number | bigint, ): Promise { this.callCount++; const verdict = this.behavior(); if (verdict === 'transport-error') throw new TransferTransportError('send failed'); if (verdict === 'other-error') throw new Error('other'); return { lastSeq: typeof seq === 'bigint' ? Number(seq) : seq }; } async fetchResumeState(): Promise { this.callCount++; const verdict = this.behavior(); if (verdict === 'transport-error') throw new TransferTransportError('resume failed'); return null; } } describe('MultiTransportFallback', () => { it('uses the primary transport when probe succeeds', async () => { const primary = new StubTransport(() => 'ok'); const secondary = new StubTransport(() => 'ok'); const tertiary = new StubTransport(() => 'ok'); const fb = new MultiTransportFallback([ { name: 'webrtc', transport: primary }, { name: 'ws', transport: secondary }, { name: 'http', transport: tertiary }, ]); await fb.probe('bob'); expect(fb.activeName).toBe('webrtc'); expect(primary.callCount).toBe(1); expect(secondary.callCount).toBe(0); }); it('demotes through layers on transport errors', async () => { const primary = new StubTransport(() => 'transport-error'); const secondary = new StubTransport(() => 'transport-error'); const tertiary = new StubTransport(() => 'ok'); const fb = new MultiTransportFallback([ { name: 'webrtc', transport: primary }, { name: 'ws', transport: secondary }, { name: 'http', transport: tertiary }, ]); const switches: Array<{ from: string; to: string }> = []; fb.onSwitch((from, to) => switches.push({ from, to })); await fb.probe('bob'); expect(fb.activeName).toBe('http'); expect(switches).toEqual([ { from: 'webrtc', to: 'ws' }, { from: 'ws', to: 'http' }, ]); expect(fb.failures).toHaveLength(2); }); it('throws if every layer fails', async () => { const fb = new MultiTransportFallback([ { name: 'a', transport: new StubTransport(() => 'transport-error') }, { name: 'b', transport: new StubTransport(() => 'transport-error') }, ]); await expect(fb.probe('bob')).rejects.toThrow(); }); it('does NOT demote on non-transport errors', async () => { const primary = new StubTransport(() => 'other-error'); const fb = new MultiTransportFallback([ { name: 'p', transport: primary }, { name: 's', transport: new StubTransport(() => 'ok') }, ]); await expect(fb.probe('bob')).rejects.toThrow(/other/); // We did NOT advance — non-transport errors are caller bugs. expect(fb.activeName).toBe('p'); }); it('sticks to the demoted layer for sendChunk after probe failure', async () => { const primary = new StubTransport(() => 'transport-error'); const secondary = new StubTransport(() => 'ok'); const fb = new MultiTransportFallback([ { name: 'p', transport: primary }, { name: 's', transport: secondary }, ]); await fb.probe('bob'); expect(fb.activeName).toBe('s'); // primary not called again const before = primary.callCount; await fb.sendChunk('bob', 'sid', 0, 0n, new Uint8Array(8)); expect(primary.callCount).toBe(before); expect(secondary.callCount).toBeGreaterThan(0); }); it('rejects empty transport list', () => { expect(() => new MultiTransportFallback([])).toThrow(); }); });