src/markets/cache.test.ts
v0.2.1 · 5.4 KB
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);
});
});