Skip to content
markpaper

src/markets/cache.test.ts

v0.2.1 · 5.4 KB

Download file
import { describe, expect, it } from 'vitest';
import { orderBookDetailsPayload } from '../testing/fixtures.js';
import { assertMarketsSane, createMarketCache, type MarketCacheEvent } from './cache.js';
import { parseOrderBookDetails } from './parse.js';
import { LighterMetaError } from './types.js';

function clock(start = 1_000_000) {
  let t = start;
  return { now: () => t, advance: (ms: number) => (t += ms) };
}

describe('assertMarketsSane', () => {
  const before = parseOrderBookDetails(orderBookDetailsPayload());

  it('accepts a refresh that drops a market outside the working set', () => {
    const payload = orderBookDetailsPayload();
    (payload.order_book_details as unknown[]).splice(3, 1); // drop CRWV
    const after = parseOrderBookDetails(payload);
    expect(() => assertMarketsSane(before, after, ['ETH'])).not.toThrow();
  });

  it('rejects a refresh that drops or re-ids a working-set market', () => {
    const payload = orderBookDetailsPayload();
    (payload.order_book_details as unknown[]).splice(3, 1); // drop CRWV
    const after = parseOrderBookDetails(payload);
    expect(() => assertMarketsSane(before, after, ['CRWV'])).toThrow(LighterMetaError);

    const reId = orderBookDetailsPayload();
    (reId.order_book_details as Array<Record<string, unknown>>)[0] = {
      ...((reId.order_book_details as Array<Record<string, unknown>>)[0] as Record<string, unknown>),
      market_id: 33,
    };
    expect(() => assertMarketsSane(before, parseOrderBookDetails(reId), ['ETH'])).toThrow(/market_id/);

    const reDec = orderBookDetailsPayload();
    (reDec.order_book_details as Array<Record<string, unknown>>)[0] = {
      ...((reDec.order_book_details as Array<Record<string, unknown>>)[0] as Record<string, unknown>),
      supported_size_decimals: 3,
    };
    expect(() => assertMarketsSane(before, parseOrderBookDetails(reDec), ['ETH'])).toThrow(/decimals/);
  });

  it('is a no-op without a previous map', () => {
    expect(() => assertMarketsSane(null, before, ['ETH'])).not.toThrow();
  });
});

describe('createMarketCache', () => {
  it('fetches once within the TTL and refreshes after it', async () => {
    const c = clock();
    let calls = 0;
    const cache = createMarketCache({
      fetchDetails: async () => {
        calls++;
        return orderBookDetailsPayload();
      },
      ttlMs: 1000,
      now: c.now,
    });
    await cache.get();
    await cache.get();
    expect(calls).toBe(1);
    c.advance(1001);
    await cache.get();
    expect(calls).toBe(2);
    expect(cache.peek()?.ageMs).toBe(0);
  });

  it('single-flights concurrent refreshes', async () => {
    let calls = 0;
    let release: () => void = () => {};
    const gate = new Promise<void>((r) => {
      release = r;
    });
    const cache = createMarketCache({
      fetchDetails: async () => {
        calls++;
        await gate;
        return orderBookDetailsPayload();
      },
    });
    const a = cache.get();
    const b = cache.get();
    release();
    expect((await a).size).toBe(4);
    expect((await b).size).toBe(4);
    expect(calls).toBe(1);
  });

  it('serves the stale map on a transport error and backs off', async () => {
    const c = clock();
    const events: MarketCacheEvent[] = [];
    let fail = false;
    let calls = 0;
    const cache = createMarketCache({
      fetchDetails: async () => {
        calls++;
        if (fail) throw new Error('HTTP 503');
        return orderBookDetailsPayload();
      },
      ttlMs: 1000,
      failedBackoffMs: 500,
      now: c.now,
      onEvent: (e) => events.push(e),
    });
    await cache.get();
    fail = true;
    c.advance(1001);
    const stale = await cache.get();
    expect(stale.size).toBe(4);
    expect(events.at(-1)).toMatchObject({ type: 'stale', error: 'HTTP 503', ageMs: 1001 });
    c.advance(100);
    await cache.get();
    expect(calls).toBe(2); // within the backoff: no new attempt
    c.advance(500);
    await cache.get();
    expect(calls).toBe(3);
  });

  it('rejects a refresh that loses a working-set market and keeps the old map', async () => {
    const events: MarketCacheEvent[] = [];
    const c = clock();
    let dropped = false;
    const cache = createMarketCache({
      fetchDetails: async () => {
        const p = orderBookDetailsPayload();
        if (dropped) (p.order_book_details as unknown[]).splice(3, 1);
        return p;
      },
      ttlMs: 10,
      failedBackoffMs: 100,
      now: c.now,
      onEvent: (e) => events.push(e),
    });
    await cache.get();
    cache.noteInterest('CRWV');
    dropped = true;
    c.advance(11);
    const m = await cache.get();
    expect(m.has('CRWV')).toBe(true);
    expect(events.at(-1)).toMatchObject({ type: 'rejected' });
    expect(cache.workingSet()).toEqual(['CRWV']);
    cache.forgetInterest('CRWV');
    c.advance(1000);
    expect((await cache.get()).has('CRWV')).toBe(false);
  });

  it('throws when there is no map at all', async () => {
    const cache = createMarketCache({
      fetchDetails: async () => {
        throw new Error('down');
      },
    });
    await expect(cache.get()).rejects.toThrow('down');
    expect(cache.peek()).toBeNull();
  });

  it('invalidate forces a refresh; market() looks up one symbol', async () => {
    let calls = 0;
    const cache = createMarketCache({
      fetchDetails: async () => {
        calls++;
        return orderBookDetailsPayload();
      },
    });
    expect((await cache.market('ETH'))?.marketId).toBe(32);
    cache.invalidate();
    await cache.get();
    expect(calls).toBe(2);
  });
});
All files