src/rest/rest.test.ts
v0.1.0 · 4.2 KB
import { describe, expect, it } from 'vitest';
import { FIXTURE_ACCOUNT, FIXTURE_KEYS } from '../testing/fixtures.js';
import {
computeClockSkew,
createRestClient,
decodeQfexOrderbook,
isCdnCached,
isCdnEdgeRejection,
parseRetryAfterMs,
} from './index.js';
const response = (status: number, text: string, headers: Record<string, string> = {}) => ({
status,
headers: { get: (key: string) => headers[key] ?? null },
text: async () => text,
});
function client(sequence: Array<ReturnType<typeof response> | Error>) {
let calls = 0;
let now = 1_820_000_000_000;
const rest = createRestClient({
baseUrl: 'mainnet',
fetch: async () => {
calls++;
const next = sequence.shift();
if (next instanceof Error) throw next;
if (!next) throw new Error('No response');
return next;
},
keys: FIXTURE_KEYS,
restMaxRps: 9,
rateLimitDefaultBackoffMs: 370,
rateBackoff: { minMs: 10, maxMs: 8000 },
retryDelayMs: () => 0,
readTries: 2,
timeoutMs: 90,
maxWaitMs: 5000,
skewHistoryLimit: 7,
readOnly: false,
now: () => now,
sleep: async (ms) => {
now += ms;
},
});
return { rest, calls: () => calls };
}
describe('REST retries and exact reads', () => {
it('retries reads but keeps uncertain writes distinct and unretried', async () => {
const read = client([response(503, 'Unavailable'), response(200, '{"value":9007199254741009}')]);
expect(await read.rest.publicGet('/refdata')).toEqual({ value: '9007199254741009' });
expect(read.calls()).toBe(2);
const write = client([response(503, 'Unavailable'), response(200, '{}')]);
await expect(
write.rest.userPost('/user/leverage', FIXTURE_ACCOUNT, { symbol: 'BTC-USD', leverage: 8 }),
).rejects.toMatchObject({ kind: 'server', outcomeUnknown: true });
expect(write.calls()).toBe(1);
});
it('refreshes a nonce once for signature failure, not forbidden keys', async () => {
const test = client([response(401, 'invalid signature'), response(200, '{}')]);
await test.rest.userGet('/user/positions', FIXTURE_ACCOUNT);
expect(test.calls()).toBe(2);
const rejected = client([response(403, 'invalid credentials')]);
await expect(rejected.rest.userGet('/user/positions', FIXTURE_ACCOUNT)).rejects.toMatchObject({ kind: 'auth' });
expect(rejected.calls()).toBe(1);
});
it('does not treat edge HTML as a revoked key or signed cached data as fresh', async () => {
expect(isCdnEdgeRejection('text/html', 'Forbidden')).toBe(true);
expect(isCdnCached('Hit from cloudfront', '0')).toBe(true);
const test = client([response(200, '{}', { age: '9' }), response(200, '{}', { age: '0' })]);
await test.rest.userGet('/user/positions', FIXTURE_ACCOUNT);
expect(test.calls()).toBe(2);
});
it('parses retry-after units and captures a skew interval', () => {
expect(parseRetryAfterMs(null, 'retry after 350ms', 0, 900, { minMs: 10, maxMs: 8000 })).toBe(350);
expect(parseRetryAfterMs('2', null, 0, 900, { minMs: 10, maxMs: 8000 })).toBe(2000);
const skew = computeClockSkew('Tue, 01 Jan 2030 00:00:00 GMT', 1_893_456_000_030, 1_893_456_000_170)!;
expect(skew.loMs).toBe(-170);
expect(skew.hiMs).toBe(970);
});
it('masks configured secrets in structured problems and error bodies', async () => {
const test = client([
response(
400,
JSON.stringify({ detail: 'Rejected ' + FIXTURE_KEYS.secret, errors: [{ message: FIXTURE_KEYS.secret }] }),
),
]);
try {
await test.rest.userGet('/user/positions', FIXTURE_ACCOUNT);
throw new Error('Expected request rejection');
} catch (error) {
expect(String(error)).not.toContain(FIXTURE_KEYS.secret);
expect(JSON.stringify(error)).not.toContain(FIXTURE_KEYS.secret);
}
});
it('drops zero padding, sorts the book, and preserves exact literals', () => {
const book = decodeQfexOrderbook(
{
ticker_id: 'BTC-USD',
timestamp: '99',
bids: [
['120.25', '0'],
['119.5', '0.38'],
],
asks: [
['122', '0.4'],
['121.5', '0.6'],
],
},
'BTC-USD',
);
expect(book.zeroLevelsDropped).toBe(1);
expect(book.asks[0]?.px).toBe('121.5');
});
});