Skip to content
markpaper

src/errors/sdk.test.ts

v0.3.0 · 6.6 KB

Download file
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);
  });
});
All files