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('lazy scenario runtime', () => { beforeEach(() => { localStorage.clear(); }); afterEach(() => { vi.unstubAllGlobals(); }); it('does not create the scenario runtime until first use', async () => { const client = new RudderClient({ baseUrl: 'https://api.example.com', projectKey: 'proj_123', runtime: { loginEvent: null }, }); const internals = client as unknown as { scenarioRuntime: unknown; ensureScenarioRuntime(): Promise; }; expect(internals.scenarioRuntime).toBeNull(); await internals.ensureScenarioRuntime(); expect(internals.scenarioRuntime).not.toBeNull(); }); it('delivers effects to handlers subscribed before the runtime is ready', async () => { const client = new RudderClient({ baseUrl: 'https://api.example.com', projectKey: 'proj_123', runtime: {}, }); const notifications: string[] = []; client.effects.onNotification((effect) => { notifications.push(effect.message); return effect.done(); }); vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, init?: RequestInit) => { const path = new URL(url).pathname; if (path === '/sdk/v1/authorization/device') { return Promise.resolve(new Response( JSON.stringify({ accessToken: 'at', refreshToken: 'rt' }), { status: 200 }, )); } if (path === '/sdk/v1/scenarios/trigger') { return Promise.resolve(new Response(JSON.stringify({ effects: [{ runId: 'run-1', scenarioId: 'scenario-1', nodeId: 'start', type: 'notification', data: { message: 'Welcome!' }, }], }), { status: 200 })); } if (path === '/sdk/v1/scenarios/pending') { return Promise.resolve(new Response(JSON.stringify({ effects: [] }), { status: 200 })); } if (path === '/sdk/v1/remote-configs') { return Promise.resolve(new Response(JSON.stringify({ configs: {} }), { status: 200 })); } if (path === '/sdk/v1/stores') { return Promise.resolve(new Response(JSON.stringify({ stores: [], total: 0 }), { status: 200 })); } if (path === '/sdk/v1/catalog') { return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200 })); } if (path === '/sdk/v1/sync') { return Promise.resolve(new Response(JSON.stringify({}), { status: 200 })); } void init; return Promise.resolve(new Response(JSON.stringify({ player: null, wallets: [] }), { status: 200 })); })); await client.auth.loginWithDevice(); await vi.waitFor(() => expect(notifications).toEqual(['Welcome!'])); client.dispose(); }); });