Skip to content
markpaper

src/orders/status.test.ts

v0.3.0 · 6.6 KB

Download file
import { describe, expect, it } from 'vitest';
import type { InfoCallOptions, InfoRequest, InfoRequester } from '../transport/types.js';
import { HlOrderError } from './errors.js';
import { fetchOrderStatus, parseOrderStatusAnswer, reconcileByCloid, reconcilePlacement } from './status.js';

const USER = `0x${'AB'.repeat(20)}`;
const CLOID = `0x${'CD'.repeat(16)}`;

function infoSeq(answers: Array<unknown | Error>) {
  const calls: { body: InfoRequest; opts: InfoCallOptions | undefined }[] = [];
  const info = (async (body: InfoRequest, opts?: InfoCallOptions) => {
    calls.push({ body, opts });
    const a = answers[Math.min(calls.length - 1, answers.length - 1)];
    if (a instanceof Error) throw a;
    return a;
  }) as InfoRequester;
  return { info, calls };
}

const found = (status: string, extra: object = {}) => ({
  status: 'order',
  order: {
    order: { coin: 'HYPE', side: 'B', limitPx: '50.0', sz: '0.0', origSz: '1.0', oid: 42, timestamp: 1, cloid: CLOID.toLowerCase(), ...extra },
    status,
    statusTimestamp: 1757000000000,
  },
});

describe('fetchOrderStatus', () => {
  it('queries by oid with weight 2 and a lowercase user', async () => {
    const { info, calls } = infoSeq([found('filled')]);
    const r = await fetchOrderStatus(info, USER, 42);
    expect(calls).toEqual([{ body: { type: 'orderStatus', user: USER.toLowerCase(), oid: 42 }, opts: { weight: 2 } }]);
    expect(r).toMatchObject({
      state: 'found',
      status: 'filled',
      category: 'filled',
      oid: 42,
      cloid: CLOID.toLowerCase(),
      coin: 'HYPE',
      side: 'B',
      limitPx: '50.0',
      sz: '0.0',
      origSz: '1.0',
      statusTimestamp: 1757000000000,
    });
  });

  it('queries by cloid (lowercased) and by a digit-string oid', async () => {
    const a = infoSeq([{ status: 'unknownOid' }]);
    expect(await fetchOrderStatus(a.info, USER, CLOID)).toEqual({ state: 'unknownOid' });
    expect(a.calls[0]?.body.oid).toBe(CLOID.toLowerCase());
    const b = infoSeq([{ status: 'unknownOid' }]);
    await fetchOrderStatus(b.info, USER, '123');
    expect(b.calls[0]?.body.oid).toBe(123);
  });

  it('rejects malformed ids and users before any request', async () => {
    const { info, calls } = infoSeq([{ status: 'unknownOid' }]);
    for (const bad of [0, -1, 1.5, Number.NaN, '0x12', 'abc', '0']) {
      await expect(fetchOrderStatus(info, USER, bad)).rejects.toThrow(HlOrderError);
    }
    await expect(fetchOrderStatus(info, '0x1234', 1)).rejects.toThrow(/user/);
    expect(calls).toHaveLength(0);
  });

  it('categorizes processing statuses', () => {
    const cat = (s: string) => {
      const r = parseOrderStatusAnswer(found(s));
      return r.state === 'found' ? r.category : null;
    };
    expect(cat('open')).toBe('live');
    expect(cat('triggered')).toBe('live');
    expect(cat('filled')).toBe('filled');
    for (const s of ['canceled', 'scheduledCancel', 'reduceOnlyCanceled', 'siblingFilledCanceled', 'marginCanceled']) expect(cat(s)).toBe('canceled');
    for (const s of ['rejected', 'badAloPxRejected', 'perpMarginRejected', 'minTradeNtlRejected']) expect(cat(s)).toBe('rejected');
    expect(cat('somethingNew')).toBe('other');
  });

  it('a failed read is query_failed, never a throw', async () => {
    const err = new Error('HL info HTTP 502');
    const { info } = infoSeq([err]);
    expect(await fetchOrderStatus(info, USER, 1)).toEqual({ state: 'query_failed', message: 'HL info HTTP 502', error: err });
  });

  it('unexpected shapes are invalid_response', () => {
    expect(parseOrderStatusAnswer(null)).toMatchObject({ state: 'invalid_response' });
    expect(parseOrderStatusAnswer({ status: 'what' })).toMatchObject({ state: 'invalid_response' });
    expect(parseOrderStatusAnswer({ status: 'order', order: { status: 'open' } })).toMatchObject({ state: 'invalid_response' });
    expect(parseOrderStatusAnswer({ status: 'order', order: { order: {} } })).toMatchObject({ state: 'invalid_response' });
    expect(parseOrderStatusAnswer(found('open', { oid: 2 ** 53 }))).toMatchObject({ state: 'invalid_response' });
    const noCloid = parseOrderStatusAnswer(found('open', { cloid: null, side: 'X', origSz: undefined }));
    expect(noCloid).toMatchObject({ state: 'found', cloid: null, side: null, origSz: null });
  });
});

describe('reconcileByCloid', () => {
  it('retries unknownOid after the delay and reports found', async () => {
    const { info, calls } = infoSeq([{ status: 'unknownOid' }, found('open')]);
    const sleeps: number[] = [];
    const r = await reconcileByCloid(info, USER, CLOID, { sleep: async (ms) => void sleeps.push(ms) });
    expect(r).toMatchObject({ resolution: 'found', attempts: 2, cloid: CLOID.toLowerCase(), lookup: { category: 'live' } });
    expect(sleeps).toEqual([500]);
    expect(calls).toHaveLength(2);
  });

  it('still unknownOid after all attempts: not_found (not proof)', async () => {
    const { info, calls } = infoSeq([{ status: 'unknownOid' }]);
    const r = await reconcileByCloid(info, USER, CLOID, { attempts: 3, delayMs: 0, sleep: async () => {} });
    expect(r).toEqual({ resolution: 'not_found', cloid: CLOID.toLowerCase(), attempts: 3 });
    expect(calls).toHaveLength(3);
  });

  it('failed reads leave it unresolved; malformed answers stop at once', async () => {
    const failing = infoSeq([new Error('timeout')]);
    expect(await reconcileByCloid(failing.info, USER, CLOID, { sleep: async () => {} })).toMatchObject({
      resolution: 'unresolved',
      attempts: 2,
      lookup: { state: 'query_failed' },
    });
    const weird = infoSeq([{ status: 'nope' }]);
    expect(await reconcileByCloid(weird.info, USER, CLOID, { sleep: async () => {} })).toMatchObject({ resolution: 'unresolved', attempts: 1 });
    expect(weird.calls).toHaveLength(1);
  });

  it('validates inputs', async () => {
    const { info } = infoSeq([{ status: 'unknownOid' }]);
    await expect(reconcileByCloid(info, USER, '0x12')).rejects.toThrow(HlOrderError);
    await expect(reconcileByCloid(info, USER, CLOID, { attempts: 0 })).rejects.toThrow(/attempts/);
    await expect(reconcileByCloid(info, USER, CLOID, { delayMs: -1 })).rejects.toThrow(/delayMs/);
  });

  it('reconcilePlacement checks every cloid in order', async () => {
    const other = `0x${'ef'.repeat(16)}`;
    const { info, calls } = infoSeq([found('filled'), { status: 'unknownOid' }]);
    const r = await reconcilePlacement(info, USER, { reconcileCloids: [CLOID.toLowerCase() as `0x${string}`, other as `0x${string}`] }, {
      attempts: 1,
    });
    expect(r.map((x) => x.resolution)).toEqual(['found', 'not_found']);
    expect(calls.map((c) => c.body.oid)).toEqual([CLOID.toLowerCase(), other]);
  });
});
All files