feat!: @rudder/sdk 0.1.0: createClient, idempotent modules.call retries, storage save(type, data), drop resources and projectStorage

This commit is contained in:
edmand46
2026-09-17 15:26:42 +03:00
parent c25b1c2c61
commit 1e4e0d928d
35 changed files with 474 additions and 467 deletions
+93 -60
View File
@@ -1,10 +1,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RudderClient } from '../src/client/RudderClient.js';
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';
@@ -23,22 +24,16 @@ describe('RudderClient', () => {
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('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({ baseUrl: 'https://api.example.com', projectKey: '' });
new RudderClient({ projectKey: '' });
} catch (error) {
caught = error;
}
@@ -122,7 +117,7 @@ describe('transport error mapping', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(null, { status: 204, statusText: 'No Content' }),
));
const result = await client.storage.save([]);
const result = await client.storage.save('t', '{}');
expect(result).toBeUndefined();
});
@@ -135,67 +130,105 @@ describe('transport error mapping', () => {
});
});
describe('modules and resources', () => {
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();
});
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 }),
);
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('quests', 'list', { limit: 10 });
const result = await client.modules.call<{ ok: boolean }>('quests', 'list', { limit: 10 }, {
moduleVersion: '5.0.0',
});
expect(result).toEqual({ result: { ok: true }, log: [] });
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@5.0.0');
expect(headersOf(fetchMock, 0).get('Idempotency-Key')).toMatch(/^[0-9a-f-]{36}$/);
});
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 }));
});
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 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,
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',
});
expect(await handle.delete('row-1')).toBeUndefined();
});
});