feat!: @rudder/sdk 0.1.0: createClient, idempotent modules.call retries, storage save(type, data), drop resources and projectStorage
This commit is contained in:
@@ -19,9 +19,7 @@ describe('public API surface', () => {
|
||||
'PlayerDomain',
|
||||
'CatalogDomain',
|
||||
'StorageDomain',
|
||||
'ProjectStorageDomain',
|
||||
'ModulesDomain',
|
||||
'ResourcesDomain',
|
||||
]) {
|
||||
expect(name in sdk).toBe(false);
|
||||
}
|
||||
@@ -40,6 +38,8 @@ describe('public API surface', () => {
|
||||
it('exposes the expected value exports', () => {
|
||||
for (const name of [
|
||||
'RudderClient',
|
||||
'createClient',
|
||||
'RudderModuleError',
|
||||
'RudderError',
|
||||
'RudderNetworkError',
|
||||
'RudderHttpError',
|
||||
@@ -67,10 +67,10 @@ describe('public API surface', () => {
|
||||
expect(client.player).toBeDefined();
|
||||
expect(client.catalog).toBeDefined();
|
||||
expect(client.storage).toBeDefined();
|
||||
expect(client.projectStorage).toBeDefined();
|
||||
expect(client.modules).toBeDefined();
|
||||
expect(typeof client.modules.call).toBe('function');
|
||||
expect(typeof client.resources).toBe('function');
|
||||
expect('resources' in client).toBe(false);
|
||||
expect('projectStorage' in client).toBe(false);
|
||||
|
||||
expect('battlePass' in client).toBe(false);
|
||||
expect('quests' in client).toBe(false);
|
||||
|
||||
+93
-60
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Test-side helpers: load the seed artifact and build SDK clients against prod.
|
||||
|
||||
import { RudderClient } from '../../../src/client/RudderClient.js';
|
||||
import { RudderClient, createClient } from '../../../src/client/RudderClient.js';
|
||||
import type { TokenStore } from '../../../src/token/TokenStore.js';
|
||||
import { readArtifact } from './artifact.js';
|
||||
import type { SeedArtifact } from './types.js';
|
||||
@@ -41,7 +41,7 @@ export function createInMemoryTokenStore(): TokenStore {
|
||||
export function makeProdClient(
|
||||
artifact: SeedArtifact = loadArtifact(),
|
||||
): RudderClient {
|
||||
return new RudderClient({
|
||||
return createClient({
|
||||
baseUrl: artifact.baseUrl,
|
||||
projectKey: artifact.projectKey,
|
||||
tokenStore: createInMemoryTokenStore(),
|
||||
|
||||
@@ -2,10 +2,11 @@ import { describe, it, expect } from 'vitest';
|
||||
import { freshPlayer } from './_setup/harness.js';
|
||||
|
||||
describe('e2e-prod: player', () => {
|
||||
it('device login yields a profile with a player id and wallets array', async () => {
|
||||
it('device login yields a profile with a player id wallets array and counters', async () => {
|
||||
const client = await freshPlayer();
|
||||
const profile = await client.player.reload();
|
||||
expect(profile.player?.id).toBeTruthy();
|
||||
expect(Array.isArray(profile.wallets)).toBe(true);
|
||||
expect(profile).toHaveProperty('counters');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,18 +5,15 @@ describe('e2e-prod: storage', () => {
|
||||
it('save -> get -> delete round-trip', async () => {
|
||||
const client = await freshPlayer();
|
||||
const payload = JSON.stringify({ level: 7 });
|
||||
const item = { id: 'slot_1', type: 'savegame', data: payload };
|
||||
|
||||
await client.storage.save([item]);
|
||||
await client.storage.save('savegame', payload);
|
||||
|
||||
// The server may assign its own item id, so match on type + data, not the client id.
|
||||
// v0.2 fetches the whole storage; filtering by type is client-side.
|
||||
const got = await client.storage.reload();
|
||||
expect(got.items?.some((i) => i.type === 'savegame' && i.data === payload)).toBe(true);
|
||||
|
||||
await client.storage.delete('savegame');
|
||||
|
||||
const after = await client.storage.reload();
|
||||
expect(after.items?.some((i) => i.data === payload)).toBeFalsy();
|
||||
expect(after.items?.some((i) => i.type === 'savegame')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createTestClient } from '../helpers/createClient.js';
|
||||
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||
// Checked-in output of `rudder client generate --lang ts` for
|
||||
// test/fixtures/leaderboards.manifest.json; `npm run typecheck` proves the
|
||||
// generated factory accepts RudderClient.
|
||||
import { modules, type LeaderboardsSubmitError } from '../fixtures/rudder.modules.js';
|
||||
import { RudderModuleError } from '../../src/client/RudderError.js';
|
||||
|
||||
describe('E2E: generated module client', () => {
|
||||
let gateway: FakeGateway;
|
||||
|
||||
beforeEach(() => {
|
||||
gateway = createFakeGateway({
|
||||
moduleCalls: new Map([
|
||||
['leaderboards/top', { result: { entries: [{ playerId: 'p1', score: 120 }] } }],
|
||||
]),
|
||||
});
|
||||
gateway.install();
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('calls through the generated factory with typed results', async () => {
|
||||
const client = createTestClient();
|
||||
await client.auth.loginWithDevice();
|
||||
const m = modules(client);
|
||||
|
||||
const top = await m.leaderboards.top({ slug: 'weekly', limit: 10 });
|
||||
const score: number = top.entries[0].score;
|
||||
expect(score).toBe(120);
|
||||
|
||||
// @ts-expect-error score is required by the generated args type
|
||||
await m.leaderboards.submit({ slug: 'weekly' });
|
||||
|
||||
const call = gateway.recorded.find((r) => r.path === '/sdk/v1/modules/leaderboards/top');
|
||||
expect(call?.body).toEqual({ args: { slug: 'weekly', limit: 10 } });
|
||||
expect(call?.headers.get('X-Rudder-Module-Version')).toBe('leaderboards@5.0.0');
|
||||
});
|
||||
|
||||
it('narrows module errors to the generated code union', () => {
|
||||
const error = new RudderModuleError<LeaderboardsSubmitError>('', '', {
|
||||
code: 'leaderboard_not_found',
|
||||
});
|
||||
const code: LeaderboardsSubmitError = error.code;
|
||||
expect(code).toBe('leaderboard_not_found');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { RudderClient } from '../../src/client/RudderClient.js';
|
||||
import { createFakeTokenStore } from '../helpers/FakeTokenStore.js';
|
||||
import { createTestClient } from '../helpers/createClient.js';
|
||||
import { createFakeGateway, type FakeGateway } from '../helpers/fakeGateway.js';
|
||||
|
||||
function makeClient(): RudderClient {
|
||||
return new RudderClient({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
});
|
||||
}
|
||||
|
||||
describe('E2E: player storage', () => {
|
||||
let gateway: FakeGateway;
|
||||
let client: RudderClient;
|
||||
@@ -18,7 +10,7 @@ describe('E2E: player storage', () => {
|
||||
beforeEach(() => {
|
||||
gateway = createFakeGateway();
|
||||
gateway.install();
|
||||
client = makeClient();
|
||||
client = createTestClient();
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
@@ -26,50 +18,44 @@ describe('E2E: player storage', () => {
|
||||
it('saves items then reads them back (round-trip through the gateway)', async () => {
|
||||
await client.auth.loginWithDevice();
|
||||
|
||||
await client.storage.save([
|
||||
{ id: 'save_1', type: 'progress', data: JSON.stringify({ level: 7, gold: 1200 }) },
|
||||
{ id: 'settings', type: 'prefs', data: JSON.stringify({ sound: false }) },
|
||||
]);
|
||||
await client.storage.save('progress', JSON.stringify({ level: 7, gold: 1200 }));
|
||||
await client.storage.save('prefs', JSON.stringify({ sound: false }));
|
||||
|
||||
const all = await client.storage.reload();
|
||||
expect(all.items).toHaveLength(2);
|
||||
|
||||
const progress = all.items!.find((i) => i.id === 'save_1');
|
||||
const progress = all.items!.find((i) => i.type === 'progress');
|
||||
expect(JSON.parse(progress!.data!)).toEqual({ level: 7, gold: 1200 });
|
||||
});
|
||||
|
||||
it('filters by type', async () => {
|
||||
await client.auth.loginWithDevice();
|
||||
await client.storage.save([
|
||||
{ id: 'a', type: 'progress', data: '{}' },
|
||||
{ id: 'b', type: 'prefs', data: '{}' },
|
||||
]);
|
||||
|
||||
const all = await client.storage.reload();
|
||||
const prefs = (all.items ?? []).filter((i) => i.type === 'prefs');
|
||||
expect(prefs).toHaveLength(1);
|
||||
expect(prefs[0].id).toBe('b');
|
||||
const put = gateway.recorded.find((r) => r.method === 'PUT' && r.path === '/sdk/v1/storage');
|
||||
expect(put?.body).toEqual({ items: [{ type: 'progress', data: '{"level":7,"gold":1200}' }] });
|
||||
|
||||
const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
|
||||
expect(getReq?.query.get('limit')).toBe('100');
|
||||
});
|
||||
|
||||
it('deletes a type and the items are gone', async () => {
|
||||
it('save overwrites the item of the same type', async () => {
|
||||
await client.auth.loginWithDevice();
|
||||
await client.storage.save([
|
||||
{ id: 'a', type: 'progress', data: '{}' },
|
||||
{ id: 'b', type: 'prefs', data: '{}' },
|
||||
]);
|
||||
await client.storage.save('progress', '1');
|
||||
await client.storage.save('progress', '2');
|
||||
const all = await client.storage.reload();
|
||||
expect(all.items?.map((i) => i.data)).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('deletes a type and the item is gone', async () => {
|
||||
await client.auth.loginWithDevice();
|
||||
await client.storage.save('progress', '{}');
|
||||
await client.storage.save('prefs', '{}');
|
||||
|
||||
await client.storage.delete('progress');
|
||||
|
||||
const remaining = await client.storage.reload();
|
||||
expect(remaining.items?.map((i) => i.id)).toEqual(['b']);
|
||||
expect(remaining.items?.map((i) => i.type)).toEqual(['prefs']);
|
||||
});
|
||||
|
||||
it('storage writes carry the auth token', async () => {
|
||||
const token = (await client.auth.loginWithDevice()).accessToken;
|
||||
await client.storage.save([{ id: 'a', type: 't', data: '{}' }]);
|
||||
await client.storage.save('t', '{}');
|
||||
|
||||
const put = gateway.recorded.find((r) => r.method === 'PUT' && r.path === '/sdk/v1/storage');
|
||||
expect(put?.authToken).toBe(token);
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{"slug":"leaderboards","version":"5.0.0","functions":{
|
||||
"submit":{"trigger":"client","input":{"type":"object","required":["slug","score"],"properties":{"slug":{"type":"string"},"score":{"type":"integer"},"mode":{"type":"string","enum":["best","last"]}}},
|
||||
"output":{"type":"object","required":["rank"],"properties":{"rank":{"type":"integer"}}},"errors":["leaderboard_not_found"]},
|
||||
"top":{"trigger":"client","input":{"type":"object","required":["slug"],"properties":{"slug":{"type":"string"},"limit":{"type":"integer"}}},
|
||||
"output":{"type":"object","required":["entries"],"properties":{"entries":{"type":"array","items":{"type":"object","required":["playerId","score"],"properties":{"playerId":{"type":"string"},"score":{"type":"number"}}}}}}},
|
||||
"reset":{"trigger":"schedule"}}}
|
||||
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
// Code generated by rudder client generate. DO NOT EDIT.
|
||||
//
|
||||
// Modules:
|
||||
// leaderboards@5.0.0
|
||||
|
||||
export interface RudderModuleCaller {
|
||||
modules: {
|
||||
call<T>(module: string, fn: string, args: unknown, options?: { moduleVersion?: string }): Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
export type RudderKernelError =
|
||||
| "call_depth_exceeded"
|
||||
| "conflict"
|
||||
| "counter_only_increment"
|
||||
| "dependency_not_declared"
|
||||
| "forbidden"
|
||||
| "function_not_callable"
|
||||
| "function_not_found"
|
||||
| "insufficient_funds"
|
||||
| "internal"
|
||||
| "invalid_parameters"
|
||||
| "module_not_installed"
|
||||
| "player_not_found"
|
||||
| "player_required"
|
||||
| "reserved_event"
|
||||
| "resource_invalid_field"
|
||||
| "resource_invalid_ref"
|
||||
| "resource_not_found"
|
||||
| "row_not_found"
|
||||
| "row_referenced"
|
||||
| "storage_key_limit_exceeded"
|
||||
| "unique_violation"
|
||||
| "unknown_operation"
|
||||
| "version_conflict"
|
||||
|
||||
export interface LeaderboardsSubmitArgs {
|
||||
mode?: "best" | "last"
|
||||
score: number
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface LeaderboardsSubmitResult {
|
||||
rank: number
|
||||
}
|
||||
|
||||
export type LeaderboardsSubmitError =
|
||||
| RudderKernelError
|
||||
| "leaderboard_not_found"
|
||||
|
||||
export interface LeaderboardsTopArgs {
|
||||
limit?: number
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface LeaderboardsTopResultEntriesItem {
|
||||
playerId: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface LeaderboardsTopResult {
|
||||
entries: Array<LeaderboardsTopResultEntriesItem>
|
||||
}
|
||||
|
||||
export type LeaderboardsTopError =
|
||||
| RudderKernelError
|
||||
|
||||
export function modules(client: RudderModuleCaller) {
|
||||
return {
|
||||
leaderboards: {
|
||||
submit: (args: LeaderboardsSubmitArgs) =>
|
||||
client.modules.call<LeaderboardsSubmitResult>("leaderboards", "submit", args, { moduleVersion: "5.0.0" }),
|
||||
top: (args: LeaderboardsTopArgs) =>
|
||||
client.modules.call<LeaderboardsTopResult>("leaderboards", "top", args, { moduleVersion: "5.0.0" }),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { RudderClient } from '../../src/client/RudderClient.js';
|
||||
import { RudderClient, createClient } from '../../src/client/RudderClient.js';
|
||||
import type { RudderClientOptions } from '../../src/client/RudderClientOptions.js';
|
||||
import { createFakeTokenStore } from './FakeTokenStore.js';
|
||||
|
||||
/** Creates a RudderClient with test-friendly defaults and a fake token store. */
|
||||
export function createTestClient(overrides?: Partial<RudderClientOptions>): RudderClient {
|
||||
return new RudderClient({
|
||||
return createClient({
|
||||
baseUrl: 'https://api.test.rudder.build',
|
||||
projectKey: 'test-project-key',
|
||||
tokenStore: createFakeTokenStore(),
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { StorageItem } from '../../src/generated/storage.js';
|
||||
import type { ResourceRow } from '../../src/generated/resources.js';
|
||||
import type { CallModuleFunctionClientResponse } from '../../src/generated/modules.js';
|
||||
import type { CatalogCurrency } from '../../src/generated/catalog.js';
|
||||
import type { ProjectStorageItem } from '../../src/generated/project-storage.js';
|
||||
|
||||
export interface RecordedRequest {
|
||||
method: string;
|
||||
@@ -11,14 +9,14 @@ export interface RecordedRequest {
|
||||
query: URLSearchParams;
|
||||
body: unknown;
|
||||
authToken: string | null;
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
export interface FakeGatewayState {
|
||||
storage: Map<string, StorageItem>;
|
||||
projectStorage: Map<string, ProjectStorageItem>;
|
||||
currencies: CatalogCurrency[];
|
||||
resources: Map<string, Map<string, ResourceRow>>;
|
||||
moduleCalls: CallModuleFunctionClientResponse;
|
||||
/** Response per `module/fn`; falls back to `{ result: {} }`. */
|
||||
moduleCalls: Map<string, CallModuleFunctionClientResponse>;
|
||||
}
|
||||
|
||||
export interface FakeGateway {
|
||||
@@ -39,10 +37,8 @@ const noContent = (): Response => new Response(null, { status: 204 });
|
||||
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
|
||||
const state: FakeGatewayState = {
|
||||
storage: seed?.storage ?? new Map(),
|
||||
projectStorage: seed?.projectStorage ?? new Map(),
|
||||
currencies: seed?.currencies ?? [],
|
||||
resources: seed?.resources ?? new Map(),
|
||||
moduleCalls: seed?.moduleCalls ?? { result: {} },
|
||||
moduleCalls: seed?.moduleCalls ?? new Map(),
|
||||
};
|
||||
|
||||
const recorded: RecordedRequest[] = [];
|
||||
@@ -72,49 +68,7 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
||||
|
||||
const moduleMatch = path.match(/^\/sdk\/v1\/modules\/([^/]+)\/([^/]+)$/);
|
||||
if (method === 'POST' && moduleMatch) {
|
||||
return json(state.moduleCalls);
|
||||
}
|
||||
|
||||
const resourceItemMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)\/([^/]+)$/);
|
||||
const resourceListMatch = path.match(/^\/sdk\/v1\/resources\/([^/]+)$/);
|
||||
if (resourceItemMatch) {
|
||||
const slug = decodeURIComponent(resourceItemMatch[1]);
|
||||
const id = decodeURIComponent(resourceItemMatch[2]);
|
||||
const rows = state.resources.get(slug) ?? new Map();
|
||||
if (method === 'GET') {
|
||||
const row = rows.get(id);
|
||||
return row ? json(row) : json({ error: 'not found' }, 404);
|
||||
}
|
||||
if (method === 'PATCH') {
|
||||
const existing = rows.get(id);
|
||||
if (!existing) return json({ error: 'not found' }, 404);
|
||||
const next = { ...existing, ...(body as ResourceRow), id };
|
||||
rows.set(id, next);
|
||||
state.resources.set(slug, rows);
|
||||
return json(next);
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
rows.delete(id);
|
||||
state.resources.set(slug, rows);
|
||||
return noContent();
|
||||
}
|
||||
}
|
||||
if (resourceListMatch) {
|
||||
const slug = decodeURIComponent(resourceListMatch[1]);
|
||||
const rows = state.resources.get(slug) ?? new Map();
|
||||
if (method === 'GET') {
|
||||
return json({ items: [...rows.values()], total: rows.size });
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const created: ResourceRow = {
|
||||
id: (body as ResourceRow).id ?? `row-${rows.size + 1}`,
|
||||
data: (body as ResourceRow).data,
|
||||
version: 1,
|
||||
};
|
||||
rows.set(created.id!, created);
|
||||
state.resources.set(slug, rows);
|
||||
return json(created);
|
||||
}
|
||||
return json(state.moduleCalls.get(`${moduleMatch[1]}/${moduleMatch[2]}`) ?? { result: {} });
|
||||
}
|
||||
|
||||
if (path === '/sdk/v1/storage') {
|
||||
@@ -128,28 +82,14 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
||||
if (method === 'PUT') {
|
||||
const items = (body as { items?: StorageItem[] }).items ?? [];
|
||||
for (const item of items) {
|
||||
if (item.id) state.storage.set(item.id, item);
|
||||
if (item.type) state.storage.set(item.type, { id: `id-${item.type}`, ...item });
|
||||
}
|
||||
return noContent();
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
const type = query.get('type');
|
||||
for (const [id, item] of state.storage) {
|
||||
if (!type || item.type === type) state.storage.delete(id);
|
||||
}
|
||||
return noContent();
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/sdk/v1/project-storage') {
|
||||
if (method === 'GET') {
|
||||
return json({ items: [...state.projectStorage.values()], nextCursor: '' });
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
const items = (body as { items?: ProjectStorageItem[] }).items ?? [];
|
||||
for (const item of items) {
|
||||
if (item.id) state.projectStorage.set(item.id, item);
|
||||
}
|
||||
if (type) state.storage.delete(type);
|
||||
else state.storage.clear();
|
||||
return noContent();
|
||||
}
|
||||
}
|
||||
@@ -170,6 +110,7 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
||||
query: url.searchParams,
|
||||
body: init?.body ? JSON.parse(init.body as string) : undefined,
|
||||
authToken: auth ? auth.replace(/^Bearer\s+/, '') : null,
|
||||
headers,
|
||||
};
|
||||
recorded.push(req);
|
||||
return handle(req);
|
||||
|
||||
Reference in New Issue
Block a user