235 lines
8.5 KiB
TypeScript
235 lines
8.5 KiB
TypeScript
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.storage.save('t', '{}');
|
|
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<typeof vi.fn>, 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',
|
|
});
|
|
});
|
|
});
|