218 lines
7.4 KiB
TypeScript
218 lines
7.4 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('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: { planStateStore: null, loginEvent: null },
|
|
});
|
|
const internals = client as unknown as {
|
|
scenarioRuntime: unknown;
|
|
ensureScenarioRuntime(): Promise<unknown>;
|
|
};
|
|
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: { planStateStore: null }, // default loginEvent: player_login
|
|
});
|
|
|
|
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({
|
|
plans: [{
|
|
planId: 'plan-1',
|
|
scenarioId: 'scenario-1',
|
|
userId: 'user-1',
|
|
startNodeId: 'start',
|
|
runId: 'run-1',
|
|
nodes: [{ id: 'start', type: 'notification', data: { message: 'Welcome!' } }],
|
|
edges: [],
|
|
boundaryNodes: [],
|
|
}],
|
|
}), { 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();
|
|
});
|
|
});
|