import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { RudderClient, createClient } from '../src/client/RudderClient.js'; import { RudderError, RudderNetworkError, RudderHttpError, RudderAuthError, RudderModuleError, SDK_ERROR_INVALID_OPTIONS, } from '../src/client/RudderError.js'; import { createFakeTokenStore } from './helpers/FakeTokenStore.js'; describe('RudderClient', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('constructs with valid options', () => { const client = new RudderClient({ baseUrl: 'https://api.example.com', projectKey: 'proj_123', tokenStore: createFakeTokenStore(), }); expect(client.auth).toBeDefined(); }); it('defaults baseUrl and exposes createClient', () => { const client = createClient({ projectKey: 'proj_123', tokenStore: createFakeTokenStore() }); expect(client).toBeInstanceOf(RudderClient); expect(client.options.baseUrl).toBe('https://api.rudder.build'); }); it('throws RudderError with sdk/invalid-options if projectKey is missing', () => { let caught: unknown; try { new RudderClient({ projectKey: '' }); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(RudderError); expect((caught as RudderError).code).toBe(SDK_ERROR_INVALID_OPTIONS); expect((caught as Error).message).toContain('projectKey'); }); it('creates a default localStorage-backed token store when tokenStore is omitted', () => { localStorage.clear(); const client = new RudderClient({ baseUrl: 'https://api.example.com', projectKey: 'proj_123', }); client.options.tokenStore.saveTokens('at', 'rt'); expect(localStorage.getItem('rudder_access_token')).toBe('at'); expect(client.options.tokenStore.getAccessToken()).toBe('at'); client.options.tokenStore.clear(); }); it('falls back to an in-memory token store when localStorage is unavailable', () => { vi.stubGlobal('localStorage', undefined); const client = new RudderClient({ baseUrl: 'https://api.example.com', projectKey: 'proj_123', }); client.options.tokenStore.saveTokens('at', 'rt'); expect(client.options.tokenStore.getAccessToken()).toBe('at'); expect(client.options.tokenStore.getRefreshToken()).toBe('rt'); client.options.tokenStore.clear(); expect(client.options.tokenStore.getAccessToken()).toBeNull(); }); }); describe('transport error mapping', () => { let client: RudderClient; beforeEach(() => { client = new RudderClient({ baseUrl: 'https://api.example.com', projectKey: 'proj_123', tokenStore: createFakeTokenStore(), }); }); it('throws RudderNetworkError with the underlying error as cause', async () => { const underlying = new Error('Network down'); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(underlying)); let caught: unknown; await client.player.reload().catch((error) => { caught = error; }); expect(caught).toBeInstanceOf(RudderNetworkError); expect((caught as RudderNetworkError).cause).toBe(underlying); }); it('throws RudderAuthError on 401', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }), )); await expect(client.player.reload()) .rejects.toThrow(RudderAuthError); }); it('throws RudderHttpError with the server error code on non-401 HTTP error', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ code: 'run_expired', error: 'gone' }), { status: 410, statusText: 'Gone', }), )); let caught: unknown; await client.player.reload().catch((error) => { caught = error; }); expect(caught).toBeInstanceOf(RudderHttpError); expect((caught as RudderHttpError).code).toBe('run_expired'); }); it('returns undefined for 204 No Content', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(null, { status: 204, statusText: 'No Content' }), )); const result = await client.auth.linkWithGoogle('id-token'); expect(result).toBeUndefined(); }); it('returns parsed JSON for 200 responses', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(JSON.stringify({ player: { id: 'player-1' }, wallets: [] }), { status: 200 }), )); const result = await client.player.reload(); expect(result).toEqual({ player: { id: 'player-1' }, wallets: [] }); }); }); describe('modules.call', () => { let client: RudderClient; beforeEach(() => { vi.useFakeTimers(); client = createClient({ baseUrl: 'https://api.example.com', projectKey: 'proj_123', tokenStore: createFakeTokenStore(), }); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); const respond = (status: number, body: unknown) => new Response(JSON.stringify(body), { status }); function headersOf(fetchMock: ReturnType, n: number): Headers { return new Headers(fetchMock.mock.calls[n][1].headers); } it('posts args, sends the version header, and returns result', async () => { const fetchMock = vi.fn().mockResolvedValue(respond(200, { result: { ok: true } })); vi.stubGlobal('fetch', fetchMock); const result = await client.modules.call<{ ok: boolean }>('quests', 'list', { limit: 10 }, { moduleVersion: '0.1.0', }); expect(result).toEqual({ ok: true }); const [url, init] = fetchMock.mock.calls[0]; expect(new URL(String(url)).pathname).toBe('/sdk/v1/modules/quests/list'); expect(JSON.parse(String(init.body))).toEqual({ args: { limit: 10 } }); expect(headersOf(fetchMock, 0).get('X-Rudder-Module-Version')).toBe('quests@0.1.0'); expect(headersOf(fetchMock, 0).get('Idempotency-Key')).toMatch(/^[0-9a-f-]{36}$/); }); it('sends {} when args are omitted and no version header without moduleVersion', async () => { const fetchMock = vi.fn().mockResolvedValue(respond(200, { result: 1 })); vi.stubGlobal('fetch', fetchMock); await client.modules.call('m', 'f'); expect(JSON.parse(String(fetchMock.mock.calls[0][1].body))).toEqual({ args: {} }); expect(headersOf(fetchMock, 0).has('X-Rudder-Module-Version')).toBe(false); }); it('retries network errors, 5xx and 409 conflict with the same Idempotency-Key', async () => { const fetchMock = vi .fn() .mockRejectedValueOnce(new Error('offline')) .mockResolvedValueOnce(respond(409, { code: 'conflict', error: 'busy' })) .mockResolvedValueOnce(respond(200, { result: 'done' })); vi.stubGlobal('fetch', fetchMock); const pending = client.modules.call('m', 'f', {}); await vi.runAllTimersAsync(); expect(await pending).toBe('done'); expect(fetchMock).toHaveBeenCalledTimes(3); const key = headersOf(fetchMock, 0).get('Idempotency-Key'); expect(headersOf(fetchMock, 1).get('Idempotency-Key')).toBe(key); expect(headersOf(fetchMock, 2).get('Idempotency-Key')).toBe(key); fetchMock.mockResolvedValueOnce(respond(200, { result: 'again' })); await client.modules.call('m', 'f', {}); expect(headersOf(fetchMock, 3).get('Idempotency-Key')).not.toBe(key); }); it('gives up after 2 retries on 5xx', async () => { const fetchMock = vi.fn().mockImplementation(async () => respond(503, { error: 'down' })); vi.stubGlobal('fetch', fetchMock); const pending = client.modules.call('m', 'f').catch((e) => e); await vi.runAllTimersAsync(); const error = await pending; expect(error).toBeInstanceOf(RudderHttpError); expect((error as RudderHttpError).status).toBe(503); expect(fetchMock).toHaveBeenCalledTimes(3); }); it('does not retry other 4xx, including 409 with another code', async () => { const fetchMock = vi.fn().mockResolvedValue(respond(409, { code: 'version_conflict' })); vi.stubGlobal('fetch', fetchMock); await expect(client.modules.call('m', 'f')).rejects.toBeInstanceOf(RudderHttpError); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('maps 422 to RudderModuleError', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue( respond(422, { error: 'not enough gold', code: 'insufficient_funds', requestId: 'req-1' }), )); const error = await client.modules.call('store', 'buy').catch((e) => e); expect(error).toBeInstanceOf(RudderModuleError); expect(error).toMatchObject({ status: 422, code: 'insufficient_funds', message: 'not enough gold', requestId: 'req-1', }); }); });