Skip to content
markpaper

src/rest/client.test.ts

v0.2.1 · 8 KB

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