src/rest/client.test.ts
v0.2.1 · 8 KB
import { describe, expect, it } from 'vitest';
import {
accountPayload,
activeOrdersText,
FIXTURE_L1_ADDRESS,
ORDER_ID_A,
orderBookDetailsPayload,
orderBooksPayload,
} from '../testing/fixtures.js';
import { createAuthTokenCache } from './authToken.js';
import { createRestClient, instanceOf, type ReadAttemptEvent, resolveBaseUrl } from './client.js';
import { LighterHttpError, LighterReadError, LighterTransportError } from './errors.js';
import { type FetchLike, INSTANCE_URLS } from './types.js';
interface Call {
url: string;
headers: Record<string, string>;
}
type Step = { status: number; body: string } | { throws: string } | { hang: true };
/** Scripted fetch: one step per call, in order; the last step repeats. */
function scripted(steps: Step[]): { fetch: FetchLike; calls: Call[] } {
const calls: Call[] = [];
const fetch: FetchLike = async (url, init) => {
calls.push({ url, headers: (init.headers as Record<string, string>) ?? {} });
const step = steps[Math.min(calls.length - 1, steps.length - 1)] as Step;
if ('throws' in step) throw new Error(step.throws);
if ('hang' in step) {
return new Promise<Response>((_, reject) => {
init.signal?.addEventListener('abort', () => reject(init.signal?.reason ?? new Error('aborted')), {
once: true,
});
});
}
return new Response(step.body, { status: step.status });
};
return { fetch, calls };
}
const noSleep = async () => {};
describe('resolveBaseUrl / instanceOf', () => {
it('accepts instance names and URLs, strips trailing slashes', () => {
expect(resolveBaseUrl('robinhoodchain')).toBe(INSTANCE_URLS.robinhoodchain);
expect(resolveBaseUrl('mainnet')).toBe(INSTANCE_URLS.mainnet);
expect(resolveBaseUrl('https://example.com/')).toBe('https://example.com');
expect(() => resolveBaseUrl('not a url')).toThrow(/invalid Lighter baseUrl/);
expect(() => resolveBaseUrl('ftp://example.com')).toThrow(/protocol/);
expect(instanceOf('https://api.rh.lighter.xyz/')).toBe('robinhoodchain');
expect(instanceOf('https://example.com')).toBeUndefined();
});
});
describe('createRestClient', () => {
it('decodes the four public/private reads', async () => {
const s = scripted([
{ status: 200, body: JSON.stringify(orderBooksPayload()) },
{ status: 200, body: JSON.stringify(orderBookDetailsPayload()) },
{ status: 200, body: JSON.stringify(accountPayload()) },
{ status: 200, body: activeOrdersText() },
]);
const authToken = createAuthTokenCache({ getToken: async () => 'tok-1' });
const client = createRestClient({ baseUrl: 'robinhoodchain', fetch: s.fetch, authToken, sleep: noSleep });
expect(client.baseUrl).toBe(INSTANCE_URLS.robinhoodchain);
expect((await client.orderBooks()).map((r) => r.symbol)).toEqual(['ETH', 'ETH/USDG', 'LIT', 'CRWV']);
expect((await client.orderBookDetails()).get('ETH')?.marketId).toBe(32);
expect((await client.account(12345)).l1Address).toBe(FIXTURE_L1_ADDRESS);
const orders = await client.accountActiveOrders(12345, { symbolByMarketId: new Map([[32, 'ETH']]) });
expect(orders[0]?.orderId).toBe(ORDER_ID_A);
expect(orders[0]?.symbol).toBe('ETH');
expect(s.calls.map((c) => c.url)).toEqual([
`${INSTANCE_URLS.robinhoodchain}/api/v1/orderBooks`,
`${INSTANCE_URLS.robinhoodchain}/api/v1/orderBookDetails`,
`${INSTANCE_URLS.robinhoodchain}/api/v1/account?by=index&value=12345`,
`${INSTANCE_URLS.robinhoodchain}/api/v1/accountActiveOrders?account_index=12345`,
]);
// public reads carry no token; the private one carries it without a Bearer prefix
expect(s.calls[0]?.headers.authorization).toBeUndefined();
expect(s.calls[3]?.headers.authorization).toBe('tok-1');
});
it('adds market_id when asked and refuses a private read without a token cache', async () => {
const s = scripted([{ status: 200, body: '{"orders":[]}' }]);
const withToken = createRestClient({
baseUrl: 'mainnet',
fetch: s.fetch,
authToken: createAuthTokenCache({ getToken: async () => 't' }),
});
await withToken.accountActiveOrders(7, { marketIndex: 3 });
expect(s.calls[0]?.url).toContain('account_index=7&market_id=3');
const noToken = createRestClient({ baseUrl: 'mainnet', fetch: s.fetch });
await expect(noToken.accountActiveOrders(7)).rejects.toThrow(/authToken/);
});
it('does not retry 4xx, 429 included', async () => {
const s = scripted([{ status: 429, body: 'slow down' }]);
const client = createRestClient({ baseUrl: 'mainnet', fetch: s.fetch, sleep: noSleep });
await expect(client.orderBooks()).rejects.toBeInstanceOf(LighterHttpError);
expect(s.calls).toHaveLength(1);
const s404 = scripted([{ status: 404, body: '' }]);
const c404 = createRestClient({ baseUrl: 'mainnet', fetch: s404.fetch, sleep: noSleep });
await expect(c404.account(1)).rejects.toMatchObject({ status: 404, path: '/api/v1/account?by=index&value=1' });
expect(s404.calls).toHaveLength(1);
});
it('retries 5xx and network errors with a growing pause, then throws', async () => {
const pauses: number[] = [];
const events: ReadAttemptEvent[] = [];
const s = scripted([
{ status: 503, body: 'x' },
{ throws: 'ECONNRESET' },
{ status: 200, body: JSON.stringify(orderBooksPayload()) },
]);
const client = createRestClient({
baseUrl: 'mainnet',
fetch: s.fetch,
sleep: async (ms) => {
pauses.push(ms);
},
onAttempt: (e) => events.push(e),
});
expect((await client.orderBooks()).length).toBe(4);
expect(pauses).toEqual([400, 800]);
expect(events.map((e) => [e.attempt, e.ok, e.status])).toEqual([
[1, false, 503],
[2, false, undefined],
[3, true, 200],
]);
const dead = scripted([{ throws: 'ECONNREFUSED' }]);
const c2 = createRestClient({ baseUrl: 'mainnet', fetch: dead.fetch, sleep: noSleep, attempts: 2 });
await expect(c2.orderBooks()).rejects.toBeInstanceOf(LighterTransportError);
expect(dead.calls).toHaveLength(2);
const always5xx = scripted([{ status: 502, body: 'bad gateway' }]);
const c3 = createRestClient({ baseUrl: 'mainnet', fetch: always5xx.fetch, sleep: noSleep, attempts: 2 });
await expect(c3.orderBooks()).rejects.toMatchObject({ status: 502 });
});
it('refreshes the token on 401/403 and retries', async () => {
let n = 0;
const authToken = createAuthTokenCache({ getToken: async () => `tok-${++n}` });
const s = scripted([
{ status: 401, body: '' },
{ status: 200, body: '{"orders":[]}' },
]);
const client = createRestClient({ baseUrl: 'mainnet', fetch: s.fetch, authToken, sleep: noSleep });
expect(await client.accountActiveOrders(1)).toEqual([]);
expect(s.calls.map((c) => c.headers.authorization)).toEqual(['tok-1', 'tok-2']);
});
it('times out an attempt and retries; the caller signal aborts without retry', async () => {
const s = scripted([{ hang: true }, { status: 200, body: JSON.stringify(orderBooksPayload()) }]);
const client = createRestClient({ baseUrl: 'mainnet', fetch: s.fetch, timeoutMs: 20, sleep: noSleep });
expect((await client.orderBooks()).length).toBe(4);
expect(s.calls).toHaveLength(2);
const hang = scripted([{ hang: true }]);
const c2 = createRestClient({ baseUrl: 'mainnet', fetch: hang.fetch, timeoutMs: 1000, sleep: noSleep });
const ctl = new AbortController();
const p = c2.get('/api/v1/orderBooks', { signal: ctl.signal });
ctl.abort(new Error('caller cancelled'));
await expect(p).rejects.toThrow('caller cancelled');
expect(hang.calls).toHaveLength(1);
});
it('surfaces fail-closed decoding errors unchanged', async () => {
const s = scripted([{ status: 200, body: '{"accounts":[]}' }]);
const client = createRestClient({ baseUrl: 'mainnet', fetch: s.fetch, sleep: noSleep });
await expect(client.account(1)).rejects.toBeInstanceOf(LighterReadError);
expect(s.calls).toHaveLength(1);
});
it('validates the timeout', () => {
expect(() => createRestClient({ baseUrl: 'mainnet', timeoutMs: 0 })).toThrow(RangeError);
});
});