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
+4 -4
View File
@@ -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
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();
});
});
+2 -2
View File
@@ -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 -1
View File
@@ -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');
});
});
+2 -5
View File
@@ -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();
});
});
+48
View File
@@ -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');
});
});
+20 -34
View File
@@ -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
View File
@@ -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"}}}
+77
View File
@@ -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" }),
},
}
}
+2 -2
View File
@@ -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(),
+9 -68
View File
@@ -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);