Files
rudder-js-sdk/test/RudderClient.test.ts
T

202 lines
7.3 KiB
TypeScript

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();
});
});