import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { RudderClient } from '../src/client/RudderClient.js'; import { RudderError, RudderNetworkError, RudderHttpError, RudderAuthError, 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('throws RudderError with sdk/invalid-options if baseUrl is missing', () => { let caught: unknown; try { new RudderClient({ baseUrl: '', projectKey: 'proj_123' }); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(RudderError); expect((caught as RudderError).code).toBe(SDK_ERROR_INVALID_OPTIONS); expect((caught as Error).message).toContain('baseUrl'); }); it('throws RudderError with sdk/invalid-options if projectKey is missing', () => { let caught: unknown; try { new RudderClient({ baseUrl: 'https://api.example.com', 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.storage.save([]); 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 and resources', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('modules.call posts to /sdk/v1/modules/:module/:fn', async () => { const client = new RudderClient({ baseUrl: 'https://api.example.com', projectKey: 'proj_123', tokenStore: createFakeTokenStore(), }); const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ result: { ok: true }, log: [] }), { status: 200 }), ); vi.stubGlobal('fetch', fetchMock); const result = await client.modules.call('quests', 'list', { limit: 10 }); expect(result).toEqual({ result: { ok: true }, log: [] }); 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 } }); }); it('resources(slug) lists and mutates rows', async () => { const client = new RudderClient({ baseUrl: 'https://api.example.com', projectKey: 'proj_123', tokenStore: createFakeTokenStore(), }); const row = { id: 'row-1', data: { name: 'Sword' }, version: 1 }; const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => { const path = new URL(url).pathname; const method = (init?.method ?? 'GET').toUpperCase(); if (method === 'GET' && path === '/sdk/v1/resources/items') { return Promise.resolve(new Response(JSON.stringify({ items: [row], total: 1 }), { status: 200 })); } if (method === 'GET' && path === '/sdk/v1/resources/items/row-1') { return Promise.resolve(new Response(JSON.stringify(row), { status: 200 })); } if (method === 'POST' && path === '/sdk/v1/resources/items') { return Promise.resolve(new Response(JSON.stringify(row), { status: 200 })); } if (method === 'PATCH' && path === '/sdk/v1/resources/items/row-1') { return Promise.resolve(new Response(JSON.stringify({ ...row, version: 2 }), { status: 200 })); } if (method === 'DELETE' && path === '/sdk/v1/resources/items/row-1') { return Promise.resolve(new Response(null, { status: 204 })); } return Promise.resolve(new Response('unhandled', { status: 500 })); }); vi.stubGlobal('fetch', fetchMock); const handle = client.resources('items'); expect(await handle.list()).toEqual({ items: [row], total: 1 }); expect(await handle.get('row-1')).toEqual(row); expect(await handle.create({ data: { name: 'Sword' } })).toEqual(row); expect(await handle.update('row-1', { data: { name: 'Sword' }, version: 1 })).toEqual({ ...row, version: 2, }); expect(await handle.delete('row-1')).toBeUndefined(); }); });