import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { RegionInfo, RegionSettings } from '@livekit/protocol'; import { RegionUrlProvider } from './RegionUrlProvider'; import { ConnectionError, ConnectionErrorReason } from './errors'; // Use fake timers for testing auto-refetch vi.useFakeTimers(); // Test helpers function createMockRegionSettings(regions: Array<{ region: string; url: string }>): RegionSettings { return { regions: regions.map((r) => ({ region: r.region, url: r.url, })) as RegionInfo[], } as RegionSettings; } function createMockResponse( status: number, data?: any, headers?: Record, ): Response { const defaultHeaders = new Headers(headers || {}); return { ok: status >= 200 && status < 300, status, statusText: status === 401 ? 'Unauthorized' : status === 500 ? 'Internal Server Error' : 'OK', headers: defaultHeaders, json: vi.fn().mockResolvedValue(data), } as unknown as Response; } describe('RegionUrlProvider', () => { let fetchMock: ReturnType; beforeEach(() => { // Reset the static cache before each test // @ts-ignore - accessing private static field for testing RegionUrlProvider.cache = new Map(); // @ts-ignore - accessing private static field for testing if (RegionUrlProvider.settingsTimeout) { // @ts-ignore clearTimeout(RegionUrlProvider.settingsTimeout); } // Mock fetch fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { vi.clearAllTimers(); vi.restoreAllMocks(); }); describe('constructor and basic methods', () => { it('constructs with valid URL and token', () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'test-token'); expect(provider).toBeDefined(); expect(provider.getServerUrl().toString()).toBe('wss://test.livekit.cloud/'); }); it('updates token correctly', () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'initial-token'); provider.updateToken('new-token'); // Token is private, but we can verify it's used in subsequent calls expect(provider).toBeDefined(); }); it('returns server URL', () => { const url = 'wss://example.livekit.cloud'; const provider = new RegionUrlProvider(url, 'token'); expect(provider.getServerUrl().toString()).toBe('wss://example.livekit.cloud/'); }); it('resets attempted regions', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, { region: 'us-east', url: 'wss://us-east.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); // Get first region const region1 = await provider.getNextBestRegionUrl(); expect(region1).toBe('wss://us-west.livekit.cloud'); // Reset and verify we can get the first region again provider.resetAttempts(); const region2 = await provider.getNextBestRegionUrl(); expect(region2).toBe('wss://us-west.livekit.cloud'); }); }); describe('isCloud', () => { it('returns true for .livekit.cloud domains', () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); expect(provider.isCloud()).toBe(true); }); it('returns true for .livekit.run domains', () => { const provider = new RegionUrlProvider('wss://test.livekit.run', 'token'); expect(provider.isCloud()).toBe(true); }); it('returns false for non-cloud domains', () => { const provider = new RegionUrlProvider('wss://self-hosted.example.com', 'token'); expect(provider.isCloud()).toBe(false); }); it('returns false for localhost', () => { const provider = new RegionUrlProvider('ws://localhost:7880', 'token'); expect(provider.isCloud()).toBe(false); }); }); describe('fetchRegionSettings', () => { it('fetches successfully with valid token and response', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); const result = await provider.fetchRegionSettings(); expect(fetchMock).toHaveBeenCalledWith('https://test.livekit.cloud/settings/regions', { headers: { authorization: 'Bearer token' }, signal: undefined, }); expect(result.regionSettings).toEqual(mockSettings); expect(result.maxAgeInMs).toBe(3600000); }); it('converts wss to https for settings URL', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue(createMockResponse(200, createMockRegionSettings([]))); await provider.fetchRegionSettings(); expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining('https://test.livekit.cloud'), expect.anything(), ); }); it('converts ws to http for settings URL', async () => { const provider = new RegionUrlProvider('ws://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue(createMockResponse(200, createMockRegionSettings([]))); await provider.fetchRegionSettings(); expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining('http://test.livekit.cloud'), expect.anything(), ); }); it('respects abort signal', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const abortController = new AbortController(); fetchMock.mockResolvedValue(createMockResponse(200, createMockRegionSettings([]))); await provider.fetchRegionSettings(abortController.signal); expect(fetchMock).toHaveBeenCalledWith(expect.anything(), { headers: { authorization: 'Bearer token' }, signal: abortController.signal, }); }); it('throws ConnectionError with NotAllowed for 401 response', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue(createMockResponse(401)); const error = await provider.fetchRegionSettings().catch((e) => e); expect(error).toBeInstanceOf(ConnectionError); expect(error).toMatchObject({ reason: ConnectionErrorReason.NotAllowed, status: 401, }); }); it('throws ConnectionError with InternalError for 500 response', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue(createMockResponse(500)); const error = await provider.fetchRegionSettings().catch((e) => e); expect(error).toBeInstanceOf(ConnectionError); expect(error.reason).toBe(ConnectionErrorReason.InternalError); }); it('extracts max-age from Cache-Control header', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue( createMockResponse(200, createMockRegionSettings([]), { 'Cache-Control': 'max-age=7200', }), ); const result = await provider.fetchRegionSettings(); expect(result.maxAgeInMs).toBe(7200000); }); it('uses default max-age when Cache-Control is missing', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue(createMockResponse(200, createMockRegionSettings([]))); const result = await provider.fetchRegionSettings(); expect(result.maxAgeInMs).toBe(5000); // DEFAULT_MAX_AGE_MS }); it('uses default max-age when max-age is not in Cache-Control', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue( createMockResponse(200, createMockRegionSettings([]), { 'Cache-Control': 'public, no-cache', }), ); const result = await provider.fetchRegionSettings(); expect(result.maxAgeInMs).toBe(5000); }); it('sets updatedAtInMs to current time', async () => { const now = Date.now(); vi.setSystemTime(now); const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue(createMockResponse(200, createMockRegionSettings([]))); const result = await provider.fetchRegionSettings(); expect(result.updatedAtInMs).toBe(now); }); }); describe('getNextBestRegionUrl', () => { it('throws error for non-cloud domains', async () => { const provider = new RegionUrlProvider('wss://self-hosted.example.com', 'token'); await expect(provider.getNextBestRegionUrl()).rejects.toThrow( 'region availability is only supported for LiveKit Cloud domains', ); }); it('returns first region on initial call', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, { region: 'us-east', url: 'wss://us-east.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); const region = await provider.getNextBestRegionUrl(); expect(region).toBe('wss://us-west.livekit.cloud'); }); it('returns subsequent regions on repeated calls', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, { region: 'us-east', url: 'wss://us-east.livekit.cloud' }, { region: 'eu-central', url: 'wss://eu-central.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); const region1 = await provider.getNextBestRegionUrl(); const region2 = await provider.getNextBestRegionUrl(); const region3 = await provider.getNextBestRegionUrl(); expect(region1).toBe('wss://us-west.livekit.cloud'); expect(region2).toBe('wss://us-east.livekit.cloud'); expect(region3).toBe('wss://eu-central.livekit.cloud'); }); it('returns null when all regions exhausted', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); await provider.getNextBestRegionUrl(); const region = await provider.getNextBestRegionUrl(); expect(region).toBeNull(); }); it('uses cached settings when available and fresh', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); // First call should fetch await provider.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(1); // Create new provider with same host const provider2 = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); // Second call should use cache await provider2.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new fetch }); it('fetches new settings when cache is expired', async () => { const now = Date.now(); vi.setSystemTime(now); const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=1' }), // 1 second TTL ); // First call await provider.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(1); // Advance time beyond TTL vi.setSystemTime(now + 2000); // Create new provider and call again const provider2 = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); await provider2.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(2); // Should fetch again }); it('fetches new settings when cache is empty', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); await provider.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalled(); }); it('filters out already attempted regions', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, { region: 'us-east', url: 'wss://us-east.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); const region1 = await provider.getNextBestRegionUrl(); const region2 = await provider.getNextBestRegionUrl(); expect(region1).toBe('wss://us-west.livekit.cloud'); expect(region2).toBe('wss://us-east.livekit.cloud'); expect(region1).not.toBe(region2); }); it('works correctly after resetAttempts', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); // Exhaust regions await provider.getNextBestRegionUrl(); let region = await provider.getNextBestRegionUrl(); expect(region).toBeNull(); // Reset and try again provider.resetAttempts(); region = await provider.getNextBestRegionUrl(); expect(region).toBe('wss://us-west.livekit.cloud'); }); it('respects abort signal', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const abortController = new AbortController(); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue(createMockResponse(200, mockSettings)); await provider.getNextBestRegionUrl(abortController.signal); expect(fetchMock).toHaveBeenCalledWith(expect.anything(), { headers: expect.anything(), signal: abortController.signal, }); }); }); describe('caching behavior', () => { it('cache is shared across multiple instances with same hostname', async () => { const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); const provider1 = new RegionUrlProvider('wss://test.livekit.cloud', 'token1'); await provider1.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(1); // Different token, same hostname - should use cache const provider2 = new RegionUrlProvider('wss://test.livekit.cloud', 'token2'); await provider2.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1 }); it('cache is separate for different hostnames', async () => { const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); const provider1 = new RegionUrlProvider('wss://test1.livekit.cloud', 'token'); await provider1.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(1); // Different hostname - should fetch again const provider2 = new RegionUrlProvider('wss://test2.livekit.cloud', 'token'); await provider2.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(2); }); it('cache keys by hostname without port or protocol', async () => { const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); const provider1 = new RegionUrlProvider('wss://test.livekit.cloud:443', 'token'); await provider1.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(1); // Same hostname, different protocol/port - should use cache const provider2 = new RegionUrlProvider('https://test.livekit.cloud', 'token'); await provider2.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(1); }); }); describe('auto-refetch mechanism', () => { it('schedules auto-refetch with correct max-age duration', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=10' }), ); await provider.getNextBestRegionUrl(); // Verify timeout is set (we can't easily check the exact duration without accessing internals) expect(vi.getTimerCount()).toBeGreaterThan(0); }); it('auto-refetch updates cache on success', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const initialSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); const updatedSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, { region: 'us-east', url: 'wss://us-east.livekit.cloud' }, ]); fetchMock .mockResolvedValueOnce( createMockResponse(200, initialSettings, { 'Cache-Control': 'max-age=100' }), ) .mockResolvedValue( createMockResponse(200, updatedSettings, { 'Cache-Control': 'max-age=100' }), ); await provider.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(1); // Advance time to trigger auto-refetch await vi.runOnlyPendingTimersAsync(); // Verify the refetch happened expect(fetchMock).toHaveBeenCalledTimes(2); // Verify cache was updated // @ts-ignore - accessing private cache for testing const cached = RegionUrlProvider.cache.get('test.livekit.cloud'); expect(cached?.regionSettings).toEqual(updatedSettings); }); it('auto-refetch handles errors gracefully', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock .mockResolvedValueOnce( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }), ) .mockRejectedValueOnce(new Error('Fetch failed')) .mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }), ); await provider.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(1); // Advance time to trigger auto-refetch (which will fail) await vi.runOnlyPendingTimersAsync(); // Verify fetch was attempted and failed expect(fetchMock).toHaveBeenCalledTimes(2); // Advance time again to trigger retry after error await vi.runOnlyPendingTimersAsync(); // Verify it retried and succeeded expect(fetchMock).toHaveBeenCalledTimes(3); }); it('clears previous timeout when updating cache', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=10' }), ); await provider.getNextBestRegionUrl(); const firstTimerCount = vi.getTimerCount(); // Update cache again (should clear previous timeout) provider.setServerReportedRegions({ regionSettings: mockSettings, updatedAtInMs: Date.now(), maxAgeInMs: 5000, }); const secondTimerCount = vi.getTimerCount(); // Should still have timers but not accumulating expect(secondTimerCount).toBeGreaterThan(0); expect(secondTimerCount).toBeLessThanOrEqual(firstTimerCount + 1); }); }); describe('setServerReportedRegions', () => { it('stores region settings in cache', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); provider.setServerReportedRegions({ regionSettings: mockSettings, updatedAtInMs: Date.now(), maxAgeInMs: 3600000, }); // Should use cached settings without fetching const region = await provider.getNextBestRegionUrl(); expect(region).toBe('wss://us-west.livekit.cloud'); expect(fetchMock).not.toHaveBeenCalled(); }); it('stores settings with correct fields', () => { const now = Date.now(); vi.setSystemTime(now); const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); provider.setServerReportedRegions({ regionSettings: mockSettings, updatedAtInMs: now, maxAgeInMs: 7200000, }); // @ts-ignore - accessing private cache for testing const cached = RegionUrlProvider.cache.get('test.livekit.cloud'); expect(cached?.regionSettings).toEqual(mockSettings); expect(cached?.updatedAtInMs).toBe(now); expect(cached?.maxAgeInMs).toBe(7200000); }); it('updates existing cache entry', () => { const now = Date.now(); vi.setSystemTime(now); const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const initialSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); const updatedSettings = createMockRegionSettings([ { region: 'us-east', url: 'wss://us-east.livekit.cloud' }, ]); provider.setServerReportedRegions({ regionSettings: initialSettings, updatedAtInMs: now, maxAgeInMs: 5000, }); provider.setServerReportedRegions({ regionSettings: updatedSettings, updatedAtInMs: now + 1000, maxAgeInMs: 10000, }); // @ts-ignore - accessing private cache for testing const cached = RegionUrlProvider.cache.get('test.livekit.cloud'); expect(cached?.regionSettings).toEqual(updatedSettings); expect(cached?.updatedAtInMs).toBe(now + 1000); expect(cached?.maxAgeInMs).toBe(10000); }); it('triggers auto-refetch timeout', () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); const initialTimerCount = vi.getTimerCount(); provider.setServerReportedRegions({ regionSettings: mockSettings, updatedAtInMs: Date.now(), maxAgeInMs: 5000, }); const finalTimerCount = vi.getTimerCount(); expect(finalTimerCount).toBeGreaterThan(initialTimerCount); }); }); describe('edge cases', () => { it('handles empty regions list', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); const region = await provider.getNextBestRegionUrl(); expect(region).toBeNull(); }); it('handles malformed region settings response', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockResolvedValue( createMockResponse(200, { invalid: 'data' }, { 'Cache-Control': 'max-age=3600' }), ); // Should not throw during fetch, but may have issues when accessing regions const result = await provider.fetchRegionSettings(); expect(result).toBeDefined(); }); it('handles network timeout', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); fetchMock.mockRejectedValue(new Error('Network timeout')); await expect(provider.fetchRegionSettings()).rejects.toThrow('Network timeout'); }); it('wraps fetch throw as ConnectionError with ServerUnreachable reason', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); // Simulate fetch itself throwing an error (common in network failures) fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); // Should throw a ConnectionError that can be handled const error = await provider.fetchRegionSettings().catch((e) => e); expect(error).toBeInstanceOf(ConnectionError); expect(error.reason).toBe(ConnectionErrorReason.ServerUnreachable); expect(error.status).toBe(undefined); expect(error.message).toContain('Failed to fetch'); }); it('handles concurrent getNextBestRegionUrl calls', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, { region: 'us-east', url: 'wss://us-east.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); // Make concurrent calls const [region1, region2] = await Promise.all([ provider.getNextBestRegionUrl(), provider.getNextBestRegionUrl(), ]); // Both should return regions (may be same or different depending on timing) expect(region1).toBeTruthy(); expect(region2).toBeTruthy(); }); it('preserves cache when one instance fails to fetch', async () => { const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock .mockResolvedValueOnce( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ) .mockResolvedValueOnce(createMockResponse(500)); // First provider populates cache const provider1 = new RegionUrlProvider('wss://test.livekit.cloud', 'token1'); await provider1.getNextBestRegionUrl(); // Advance time to expire cache vi.setSystemTime(Date.now() + 4000000); // Second provider tries to fetch but fails const provider2 = new RegionUrlProvider('wss://test.livekit.cloud', 'token2'); await expect(provider2.getNextBestRegionUrl()).rejects.toThrow(); // Cache should still be accessible by a third instance if still valid expect(fetchMock).toHaveBeenCalledTimes(2); }); it('handles token update correctly', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'initial-token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=1' }), ); // Initial fetch with initial token await provider.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledWith(expect.anything(), { headers: { authorization: 'Bearer initial-token' }, signal: undefined, }); // Update token provider.updateToken('new-token'); // Expire cache and fetch again vi.setSystemTime(Date.now() + 2000); const provider2 = new RegionUrlProvider('wss://test.livekit.cloud', 'new-token'); await provider2.getNextBestRegionUrl(); // Should use new token expect(fetchMock).toHaveBeenLastCalledWith(expect.anything(), { headers: { authorization: 'Bearer new-token' }, signal: undefined, }); }); it('filters regions with same URL correctly', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west-1', url: 'wss://us-west.livekit.cloud' }, { region: 'us-west-2', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=3600' }), ); const region1 = await provider.getNextBestRegionUrl(); const region2 = await provider.getNextBestRegionUrl(); // First region should be returned, second should be null since it has the same URL expect(region1).toBe('wss://us-west.livekit.cloud'); expect(region2).toBeNull(); // Filtered out because same URL was already attempted }); }); describe('connection tracking and auto-refetch cleanup', () => { beforeEach(() => { // Reset connection tracking maps // @ts-ignore - accessing private static field for testing RegionUrlProvider.connectionTrackers = new Map(); }); it('stops auto-refetch 30s after last connection disconnects', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }), ); // Initial fetch to start auto-refetch await provider.getNextBestRegionUrl(); expect(fetchMock).toHaveBeenCalledTimes(1); const hostname = provider.getServerUrl().hostname; // Simulate connection provider.notifyConnected(); // Verify auto-refetch is running const timersBeforeDisconnect = vi.getTimerCount(); expect(timersBeforeDisconnect).toBeGreaterThan(0); // Simulate disconnect provider.notifyDisconnected(); // Should schedule cleanup timeout (30s) // Advance time by 29s - refetch should still be running vi.advanceTimersByTime(29000); expect(fetchMock).toHaveBeenCalledTimes(1); // No additional fetches // Advance by 1 more second (total 30s) - cleanup should trigger await vi.advanceTimersByTimeAsync(1000); // Auto-refetch should be stopped, so no new timers for refetch // @ts-ignore - accessing private static field for testing const refetchTimeout = RegionUrlProvider.settingsTimeouts.get(hostname); expect(refetchTimeout).toBeUndefined(); }); it('cancels cleanup when reconnecting before 30s delay', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }), ); await provider.getNextBestRegionUrl(); const hostname = provider.getServerUrl().hostname; // Connect and disconnect provider.notifyConnected(); provider.notifyDisconnected(); // Advance time by 15s (less than 30s) vi.advanceTimersByTime(15000); // Reconnect before cleanup triggers provider.notifyConnected(); // @ts-ignore - accessing private static field for testing const tracker = RegionUrlProvider.connectionTrackers.get(hostname); expect(tracker?.cleanupTimeout).toBeUndefined(); // Cleanup should be cancelled // Advance past the original 30s mark vi.advanceTimersByTime(20000); // Auto-refetch should still be running // @ts-ignore - accessing private static field for testing const refetchTimeout = RegionUrlProvider.settingsTimeouts.get(hostname); expect(refetchTimeout).toBeDefined(); }); it('tracks multiple connections correctly', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }), ); await provider.getNextBestRegionUrl(); const hostname = provider.getServerUrl().hostname; // Simulate 3 connections provider.notifyConnected(); provider.notifyConnected(); provider.notifyConnected(); // @ts-ignore - accessing private static field for testing const tracker = RegionUrlProvider.connectionTrackers.get(hostname); expect(tracker?.connectionCount).toBe(3); // Disconnect first connection provider.notifyDisconnected(); // @ts-ignore - accessing private static field for testing expect(tracker?.connectionCount).toBe(2); // Should NOT schedule cleanup yet (still have active connections) expect(tracker?.cleanupTimeout).toBeUndefined(); // Disconnect second connection provider.notifyDisconnected(); // @ts-ignore - accessing private static field for testing expect(tracker?.connectionCount).toBe(1); // Disconnect last connection provider.notifyDisconnected(); // @ts-ignore - accessing private static field for testing expect(tracker?.connectionCount).toBe(0); // NOW cleanup should be scheduled expect(tracker?.cleanupTimeout).toBeDefined(); }); it('handles disconnect without prior connect gracefully', () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const hostname = provider.getServerUrl().hostname; // Disconnect without connect should not throw expect(() => { provider.notifyDisconnected(); }).not.toThrow(); // Should not create a tracker // @ts-ignore - accessing private static field for testing const tracker = RegionUrlProvider.connectionTrackers.get(hostname); expect(tracker).toBeUndefined(); }); it('clears cleanup timeout when scheduling new cleanup', async () => { const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token'); const mockSettings = createMockRegionSettings([ { region: 'us-west', url: 'wss://us-west.livekit.cloud' }, ]); fetchMock.mockResolvedValue( createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }), ); await provider.getNextBestRegionUrl(); const hostname = provider.getServerUrl().hostname; // Connect and disconnect (schedules cleanup) provider.notifyConnected(); provider.notifyDisconnected(); // @ts-ignore - accessing private static field for testing const tracker = RegionUrlProvider.connectionTrackers.get(hostname); const firstCleanupTimeout = tracker?.cleanupTimeout; expect(firstCleanupTimeout).toBeDefined(); // Reconnect and disconnect again (should cancel first and schedule new) provider.notifyConnected(); provider.notifyDisconnected(); const secondCleanupTimeout = tracker?.cleanupTimeout; expect(secondCleanupTimeout).toBeDefined(); expect(secondCleanupTimeout).not.toBe(firstCleanupTimeout); }); }); });