src/errors/sdk.test.ts
v0.3.0 · 6.6 KB
import { describe, expect, it } from 'vitest';
import {
causeChain,
exchangeErrorBody,
extractPartialBatch,
findApiRequestError,
invokeAsync,
isUserRejectedSignature,
isValidationError,
isWalletError,
} from './sdk.js';
/** Minimal stand-in for the SDK class: recognized by name, not by instanceof. */
function apiRequestError(response: unknown, message = 'api error'): Error {
return Object.assign(new Error(message), { name: 'ApiRequestError', response });
}
const partialBody = {
status: 'ok',
response: {
type: 'order',
data: {
statuses: [
{ resting: { oid: 1 } },
{ error: 'Order has invalid price.' },
{ filled: { oid: 3, totalSz: '1', avgPx: '2' } },
],
},
},
};
describe('causeChain', () => {
it('walks up to 6 causes and stops on cycles', () => {
let e: Error = new Error('root');
for (let i = 0; i < 10; i++) e = new Error(`wrap ${i}`, { cause: e });
expect(causeChain(e)).toHaveLength(7);
const a: { cause?: unknown } = {};
const b = { cause: a };
a.cause = b;
expect(causeChain(a)).toHaveLength(2);
expect(causeChain(undefined)).toEqual([]);
});
});
describe('exchangeErrorBody', () => {
it('returns the body of an ApiRequestError, also when wrapped', () => {
const err = apiRequestError({ status: 'err', response: 'Insufficient margin' });
expect(exchangeErrorBody(err)).toEqual({ status: 'err', response: 'Insufficient margin' });
const wrapped = new Error('retry exhausted', { cause: err });
expect(exchangeErrorBody(wrapped)).toEqual({ status: 'err', response: 'Insufficient margin' });
expect(findApiRequestError(wrapped)).toBe(err);
});
it('returns null for other errors and malformed bodies', () => {
expect(exchangeErrorBody(new Error('x'))).toBeNull();
expect(exchangeErrorBody(apiRequestError(undefined))).toBeNull();
expect(exchangeErrorBody(apiRequestError({ status: 1 }))).toBeNull();
expect(exchangeErrorBody(Object.assign(new Error('x'), { name: 'HttpRequestError', response: { status: 500 } }))).toBeNull();
expect(exchangeErrorBody(null)).toBeNull();
});
});
describe('extractPartialBatch', () => {
it('recovers placed oids from a batch the SDK threw on', () => {
const batch = extractPartialBatch(apiRequestError(partialBody, 'order 1: Order has invalid price.'));
expect(batch).not.toBeNull();
expect(batch?.kind).toBe('order');
if (batch?.kind !== 'order') return;
expect(batch.responseType).toBe('order');
expect(batch.placedOids).toEqual([1, 3]);
expect(batch.results[1]).toMatchObject({ status: 'error', kind: 'invalidPrice' });
expect(batch.needsReconcile).toBe(false);
});
it('passes requested sizes through for the overfill check', () => {
const batch = extractPartialBatch(apiRequestError(partialBody), { requestedSizes: ['1', '1', '0.5'] });
if (batch?.kind !== 'order') throw new Error('expected order batch');
expect(batch.results[2]).toMatchObject({ status: 'invalid', reason: 'overfill' });
expect(batch.placedOids).toEqual([1]);
expect(batch.needsReconcile).toBe(true);
});
it('parses cancel batches as cancel statuses', () => {
const body = {
status: 'ok',
response: { type: 'cancel', data: { statuses: ['success', { error: 'Order was never placed, already canceled, or filled.' }, { error: 'boom' }] } },
};
const batch = extractPartialBatch(apiRequestError(body));
expect(batch?.kind).toBe('cancel');
if (batch?.kind !== 'cancel') return;
expect(batch.results.map((r) => r.status)).toEqual(['success', 'alreadyGone', 'error']);
});
it('returns null for whole-action rejections, TWAP errors and non-SDK errors', () => {
expect(extractPartialBatch(apiRequestError({ status: 'err', response: 'x' }))).toBeNull();
expect(extractPartialBatch(apiRequestError({ status: 'ok', response: { type: 'twapOrder', data: { status: { error: 'x' } } } }))).toBeNull();
expect(extractPartialBatch(new Error('timeout'))).toBeNull();
});
it('an empty or oddly wrapped statuses array is still a batch that needs reconciling, never "rejected"', () => {
const empty = extractPartialBatch(apiRequestError({ status: 'ok', response: { type: 'order', data: { statuses: [] } } }), {
expectedCount: 2,
});
expect(empty).toMatchObject({ kind: 'order', placedOids: [], needsReconcile: true });
if (empty?.kind !== 'order') throw new Error('expected order batch');
expect(empty.results.map((r) => r.status)).toEqual(['invalid', 'invalid']);
const noStatus = extractPartialBatch(
apiRequestError({ response: { type: 'order', data: { statuses: [{ resting: { oid: 5 } }, { error: 'x' }] } } }),
);
// No outer `status`: the SDK still throws for this body (its bulk check ignores `status`), oids are kept.
expect(noStatus).toMatchObject({ kind: 'order', placedOids: [5], needsReconcile: true });
const weird = extractPartialBatch(
apiRequestError({ status: 'unknown', response: { type: 'cancel', data: { statuses: ['success', { error: 'x' }] } } }),
);
expect(weird).toMatchObject({ kind: 'cancel', needsReconcile: true });
});
});
describe('SDK error predicates', () => {
it('recognizes ValidationError and AbstractWalletError by name', () => {
expect(isValidationError(Object.assign(new Error('Invalid length'), { name: 'ValidationError' }))).toBe(true);
expect(isValidationError(new TypeError('x'))).toBe(false);
const wallet = Object.assign(new Error('sign failed'), { name: 'AbstractWalletError' });
expect(isWalletError(new Error('outer', { cause: wallet }))).toBe(true);
expect(isWalletError(new Error('x'))).toBe(false);
});
it('detects a declined wallet signature', () => {
expect(isUserRejectedSignature({ code: 4001, message: 'rejected' })).toBe(true);
expect(isUserRejectedSignature(new Error('outer', { cause: new Error('User denied message signature') }))).toBe(true);
expect(isUserRejectedSignature(new Error('User rejected the request.'))).toBe(true);
expect(isUserRejectedSignature(new Error('insufficient funds'))).toBe(false);
expect(isUserRejectedSignature('user denied')).toBe(true);
});
});
describe('invokeAsync', () => {
it('turns a synchronous throw into a rejection that .catch() sees', async () => {
const syncThrower = (): Promise<number> => {
throw Object.assign(new Error('Invalid length'), { name: 'ValidationError' });
};
expect(() => syncThrower().catch(() => 0)).toThrow('Invalid length');
await expect(invokeAsync(syncThrower)).rejects.toThrow('Invalid length');
await expect(invokeAsync(() => Promise.resolve(5))).resolves.toBe(5);
await expect(invokeAsync(() => 7)).resolves.toBe(7);
});
});