src/markets/cache.test.ts
v0.2.0 · 5.9 KB
import { describe, expect, it } from 'vitest';
import {
createSymbolsCache,
DEFAULT_SYMBOLS_FAIL_COOLDOWN_MS,
DEFAULT_SYMBOLS_TTL_MS,
FRESH_TTL_MULTIPLIER,
type SymbolsCacheEvent,
SymbolsUnstableError,
} from './cache.js';
import { BTC_RAW, PONS_RAW, symbolsPayload as payload, XRP_RAW } from './fixtures.js';
function clock(start = 0) {
let t = start;
const advance = (ms: number): void => {
t += ms;
};
return { now: () => t, advance };
}
const base = () => payload({ 'BTC-PERP': BTC_RAW, 'XRP-PERP': XRP_RAW });
describe('createSymbolsCache', () => {
it('defaults: TTL 5 min, cooldown 30 s, fresh window 2 TTL', () => {
expect(DEFAULT_SYMBOLS_TTL_MS).toBe(300_000);
expect(DEFAULT_SYMBOLS_FAIL_COOLDOWN_MS).toBe(30_000);
expect(FRESH_TTL_MULTIPLIER).toBe(2);
expect(() => createSymbolsCache({ load: async () => base(), ttlMs: 0 })).toThrow(RangeError);
});
it('loads once within the TTL and single-flights concurrent callers', async () => {
const c = clock();
let loads = 0;
const cache = createSymbolsCache({
load: async () => {
loads++;
return base();
},
now: c.now,
});
expect(cache.peek()).toBeNull();
expect(cache.isFresh()).toBe(false);
const [a, b] = await Promise.all([cache.get(), cache.get()]);
expect(a).toBe(b);
expect(loads).toBe(1);
expect((await cache.resolve('BTC'))?.productId).toBe(2);
expect(await cache.resolve('NOPE')).toBeUndefined();
c.advance(DEFAULT_SYMBOLS_TTL_MS - 1);
await cache.get();
expect(loads).toBe(1);
c.advance(2);
await cache.get();
expect(loads).toBe(2);
expect(cache.status()).toMatchObject({ loaded: true, fresh: true, count: 2, consecutiveUnstable: 0 });
});
it('serves stale on a failed refresh, backs off, and loses freshness after two TTLs', async () => {
const c = clock();
let fail = false;
let loads = 0;
const events: SymbolsCacheEvent[] = [];
const cache = createSymbolsCache({
load: async () => {
loads++;
if (fail) throw new Error('boom');
return base();
},
now: c.now,
onEvent: (e) => events.push(e),
});
const first = await cache.get();
fail = true;
c.advance(DEFAULT_SYMBOLS_TTL_MS + 1);
expect(await cache.get()).toBe(first); // stale served
expect(loads).toBe(2);
expect(cache.status().lastError).toBeInstanceOf(Error);
expect(events.at(-1)).toMatchObject({ type: 'refresh-failed', servingStale: true });
// cooldown: no new attempt for 30 s
c.advance(DEFAULT_SYMBOLS_FAIL_COOLDOWN_MS - 1);
await cache.get();
expect(loads).toBe(2);
c.advance(2);
await cache.get();
expect(loads).toBe(3);
// one failure is forgiven, two TTLs are not
expect(cache.isFresh()).toBe(true);
c.advance(DEFAULT_SYMBOLS_TTL_MS);
expect(cache.isFresh()).toBe(false);
expect(cache.status().fresh).toBe(false);
// a successful refresh restores freshness
fail = false;
c.advance(DEFAULT_SYMBOLS_FAIL_COOLDOWN_MS + 1);
await cache.get();
expect(cache.isFresh()).toBe(true);
expect(cache.status().lastError).toBeUndefined();
});
it('throws on the first load failure (nothing to serve stale)', async () => {
const cache = createSymbolsCache({ load: async () => ({ symbols: {} }) });
await expect(cache.get()).rejects.toThrow(/no perp products/);
expect(cache.peek()).toBeNull();
});
it('rejects an unstable refresh, keeps serving the previous universe and counts the streak', async () => {
const c = clock();
let current: unknown = base();
const events: SymbolsCacheEvent[] = [];
const cache = createSymbolsCache({ load: async () => current, now: c.now, onEvent: (e) => events.push(e) });
const first = await cache.get();
// rename XRP -> XRPX: the old coin drops out of the universe, as after a public market rename
current = payload({ 'BTC-PERP': BTC_RAW, 'XRPX-PERP': { ...XRP_RAW, symbol: 'XRPX-PERP' }, 'PONS-PERP': PONS_RAW });
for (let i = 1; i <= 3; i++) {
c.advance(DEFAULT_SYMBOLS_TTL_MS + DEFAULT_SYMBOLS_FAIL_COOLDOWN_MS + 1);
expect(await cache.get()).toBe(first);
expect(cache.status().consecutiveUnstable).toBe(i);
expect(cache.status().lastError).toBeInstanceOf(SymbolsUnstableError);
}
expect(events.filter((e) => e.type === 'unstable')).toHaveLength(3);
expect(cache.peek()?.byCoin.has('PONS')).toBe(false); // frozen: new listing invisible
});
it('with pinnedCoins a dropped unpinned coin does not freeze the cache', async () => {
const c = clock();
let current: unknown = base();
const events: SymbolsCacheEvent[] = [];
const cache = createSymbolsCache({
load: async () => current,
now: c.now,
pinnedCoins: () => ['BTC'],
onEvent: (e) => events.push(e),
});
await cache.get();
current = payload({ 'BTC-PERP': BTC_RAW, 'XRPX-PERP': { ...XRP_RAW, symbol: 'XRPX-PERP' }, 'PONS-PERP': PONS_RAW });
c.advance(DEFAULT_SYMBOLS_TTL_MS + 1);
const u = await cache.get();
expect(u.byCoin.has('PONS')).toBe(true);
expect(u.byCoin.has('XRP')).toBe(false);
expect(events.at(-1)).toMatchObject({ type: 'refreshed', dropped: ['XRP'], added: ['XRPX', 'PONS'] });
});
it('invalidate forces the next get() to refresh', async () => {
const c = clock();
let loads = 0;
const cache = createSymbolsCache({
load: async () => {
loads++;
return base();
},
now: c.now,
});
await cache.get();
await cache.get();
expect(loads).toBe(1);
cache.invalidate();
await cache.get();
expect(loads).toBe(2);
await cache.get();
expect(loads).toBe(2);
});
it('swallows exceptions from the event handler', async () => {
const cache = createSymbolsCache({
load: async () => base(),
onEvent: () => {
throw new Error('handler');
},
});
await expect(cache.get()).resolves.toBeDefined();
});
});