Repository Public
1// v10: the price feed helper. A refused request must not blank the prices for the whole TTL (the cause of « Prix désactivés »2// and of the missing daily change on a fresh stack), concurrent callers must share one request, and a stale value is served3// while the feed is down. Pure: a scripted fetch and a manual clock.4import { describe, expect, it } from 'vitest';5import { createGecko } from '../src/gecko.ts';67function feed(script: (number | unknown)[]) {8 const calls: string[] = [];9 const fetchJson = async (url: string) => { calls.push(url); const next = script.length > 1 ? script.shift() : script[0];10 return typeof next === 'number' ? { ok: false, status: next, json: async () => ({}) } : { ok: true, status: 200, json: async () => next }; };11 return { calls, fetchJson };12}1314describe('CoinGecko reads', () => {15 it('a refused request is retried once at once; the value is then cached for its TTL', async () => {16 let t = 1_000_000; const f = feed([429, [{ id: 'bitcoin' }]]), gecko = createGecko(f.fetchJson, { now: () => t, pauseMs: 1 });17 expect(await gecko('markets:usd', 240_000, '/coins/markets?x')).toEqual([{ id: 'bitcoin' }]);18 expect(f.calls).toHaveLength(2); expect(f.calls[0]).toBe('https://api.coingecko.com/api/v3/coins/markets?x');19 t += 239_000; await gecko('markets:usd', 240_000, '/coins/markets?x'); expect(f.calls).toHaveLength(2);20 t += 2_000; await gecko('markets:usd', 240_000, '/coins/markets?x'); expect(f.calls).toHaveLength(3);21 });22 it('a failure with no earlier value answers null for 15 s only — not for the TTL — then asks again', async () => {23 let t = 0; const f = feed([429, 429, 429, 429, [{ id: 'bitcoin' }]]), gecko = createGecko(f.fetchJson, { now: () => t, pauseMs: 1 });24 expect(await gecko('k', 240_000, '/p')).toBeNull(); expect(f.calls).toHaveLength(2); // the call and its one retry25 t += 14_000; expect(await gecko('k', 240_000, '/p')).toBeNull(); expect(f.calls).toHaveLength(2); // not hammered meanwhile26 t += 2_000; expect(await gecko('k', 240_000, '/p')).toBeNull(); expect(f.calls).toHaveLength(4);27 t += 16_000; expect(await gecko('k', 240_000, '/p')).toEqual([{ id: 'bitcoin' }]); // 32 s after the first refusal, not 240 s28 });29 it('the last good value is served while the feed is down', async () => {30 let t = 0; const f = feed([{ v: 1 }, 500, 500, 500, 500, { v: 2 }]), gecko = createGecko(f.fetchJson, { now: () => t, pauseMs: 1 });31 expect(await gecko('k', 60_000, '/p')).toEqual({ v: 1 });32 t += 61_000; expect(await gecko('k', 60_000, '/p')).toEqual({ v: 1 });33 t += 16_000; expect(await gecko('k', 60_000, '/p')).toEqual({ v: 1 });34 t += 16_000; expect(await gecko('k', 60_000, '/p')).toEqual({ v: 2 });35 });36 it('concurrent callers of one key share one request; different keys do not wait for each other', async () => {37 const f = feed([{ v: 1 }]), gecko = createGecko(f.fetchJson, { pauseMs: 1 });38 const [a, b, c] = await Promise.all([gecko('k', 1000, '/p'), gecko('k', 1000, '/p'), gecko('other', 1000, '/q')]);39 expect(a).toEqual({ v: 1 }); expect(b).toEqual({ v: 1 }); expect(c).toEqual({ v: 1 });40 expect(f.calls).toHaveLength(2);41 });42 it('a network error is a failure like another', async () => {43 const gecko = createGecko(async () => { throw new Error('ECONNRESET'); }, { pauseMs: 1 });44 expect(await gecko('k', 1000, '/p')).toBeNull();45 });46});47