morphit/apps/web/scripts/composite-price-provider-smoke.ts

242 lines
9.2 KiB
TypeScript

#!/usr/bin/env tsx
/**
* composite-price-provider smoke — anchors `createCompositeProvider`.
*
* `apps/web/src/lib/prices/providers/composite.ts` is Phase-3
* scaffolding: `$lib/prices/index.ts` hardcodes `fallbackProvider`
* pending the Phase-3 decision in ADR-0004, so NO production surface
* invokes `createCompositeProvider` yet. The cp197 repo-wide wiring
* audit surfaced it as the single exported source symbol with no
* importer anywhere (runtime, test, OR smoke) — meaning its
* chaining/cache/fallback logic could silently rot before Phase 3
* wires it, with nothing to catch the regression.
*
* Its sibling `coingecko.ts` is already gate-maintained (its COIN_ID
* map is pinned by price-provider-coverage-parity-smoke). This smoke
* gives composite.ts the same kind of guard: it imports the real
* module and asserts every documented semantic, so the file is both
* wired (into the smoke suite) and provably functional. When Phase 3
* lands and wires the composite into the live frontend, this guard is
* already in place.
*
* Documented semantics (from composite.ts) asserted here:
* - provider name is 'composite'
* - empty upstreams → constructor throws
* - first upstream returning a VALID quote wins; later upstreams
* are not called
* - an invalid quote (usd ≤ 0, NaN, Infinity, or non-number) is
* rejected and the chain falls through to the next upstream
* - an upstream that throws is caught and the chain continues
* - all upstreams failing with no cache → propagates an error
* - a successful quote is cached per-symbol for cacheTtlMs, and a
* within-TTL re-read returns the cached quote with its ORIGINAL
* fetchedAt (NOT refreshed) — the "prices updated X ago" honesty
* contract
* - the cache is per-symbol (BTC cache does not serve XMR)
* - after TTL expiry the upstream is queried again
* - if all upstreams fail but a (now-stale) cache entry exists, the
* stale quote is served rather than throwing
*
* Determinism: composite.ts reads `Date.now()` directly (no injected
* clock), so the TTL/expiry/stale paths are exercised by mocking
* `Date.now` with a manually-advanced fake clock — no real-time
* sleeps, so the smoke is stable under triple-pulse.
*/
import {
createCompositeProvider,
type CompositeProviderConfig
} from '../src/lib/prices/providers/composite.ts';
import type { PriceProvider, PriceQuote, PricedSymbol } from '../src/lib/prices/types.ts';
let passed = 0;
let failed = 0;
function pass(name: string): void {
console.log(`${name}`);
passed++;
}
function fail(name: string, detail: string): void {
console.error(`${name}`);
console.error(` ${detail}`);
failed++;
}
function expectEq<T>(name: string, got: T, want: T): void {
if (got === want) pass(name);
else fail(name, `expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
}
async function expectThrows(name: string, fn: () => unknown): Promise<void> {
try {
await fn();
fail(name, 'expected an error to be thrown, but call resolved');
} catch {
pass(name);
}
}
// ── Deterministic clock (composite.ts uses Date.now() directly) ──
let clock = 1_700_000_000_000;
const realDateNow = Date.now;
Date.now = () => clock;
// ── Mock upstream helper: a structurally-valid PriceProvider that
// records its call count and delegates to a responder closure
// (which may return a quote or throw). ──
type Spy = PriceProvider & { calls: number };
function spy(name: string, respond: (symbol: PricedSymbol) => PriceQuote): Spy {
const s: Spy = {
name,
calls: 0,
async getPriceUsd(symbol: PricedSymbol): Promise<PriceQuote> {
s.calls++;
return respond(symbol);
}
};
return s;
}
function q(symbol: PricedSymbol, usd: number, source = 'mock'): PriceQuote {
return { symbol, usd, fetchedAt: Date.now(), source };
}
async function main(): Promise<void> {
console.log('\n── composite-price-provider smoke (cp197 wiring-audit guard) ──\n');
// 1. name
{
const p = createCompositeProvider({ upstreams: [spy('a', (s) => q(s, 1, 'a'))] });
expectEq("provider name is 'composite'", p.name, 'composite');
}
// 2. empty upstreams → throws
{
const cfg = { upstreams: [] } as unknown as CompositeProviderConfig;
try {
createCompositeProvider(cfg);
fail('empty upstreams → constructor throws', 'no error thrown for empty upstreams');
} catch {
pass('empty upstreams → constructor throws');
}
}
// 3. first VALID upstream wins; later upstream not called
{
const a = spy('a', (s) => q(s, 100, 'a'));
const b = spy('b', (s) => q(s, 200, 'b'));
const p = createCompositeProvider({ upstreams: [a, b] });
const res = await p.getPriceUsd('BTC');
expectEq('first valid upstream wins (source)', res.source, 'a');
expectEq('first valid upstream wins (usd)', res.usd, 100);
expectEq('winning upstream queried exactly once', a.calls, 1);
expectEq('later upstream not queried when first wins', b.calls, 0);
}
// 4. invalid quotes are rejected → fall through to next valid upstream
{
const invalids: ReadonlyArray<readonly [string, number]> = [
['usd === 0', 0],
['usd < 0', -5],
['usd === NaN', NaN],
['usd === Infinity', Infinity]
];
for (const [label, badUsd] of invalids) {
const bad = spy('bad', (s) => q(s, badUsd, 'bad'));
const good = spy('good', (s) => q(s, 99, 'good'));
const p = createCompositeProvider({ upstreams: [bad, good] });
const res = await p.getPriceUsd('BTC');
expectEq(`rejects invalid quote (${label}) and falls through`, res.source, 'good');
}
// non-number usd (defends against untyped upstreams)
const nonNum = spy('nonnum', (s) => ({
symbol: s,
usd: '5' as unknown as number,
fetchedAt: Date.now(),
source: 'nonnum'
}));
const good = spy('good', (s) => q(s, 99, 'good'));
const p = createCompositeProvider({ upstreams: [nonNum, good] });
const res = await p.getPriceUsd('BTC');
expectEq('rejects non-number usd and falls through', res.source, 'good');
}
// 5. an upstream that throws is caught → chain continues
{
const boom = spy('boom', () => {
throw new Error('upstream down');
});
const good = spy('good', (s) => q(s, 42, 'good'));
const p = createCompositeProvider({ upstreams: [boom, good] });
const res = await p.getPriceUsd('BTC');
expectEq('throwing upstream is caught, chain continues', res.source, 'good');
expectEq('throwing upstream was actually attempted', boom.calls, 1);
}
// 6. all upstreams fail with no cache → propagates an error
{
const d1 = spy('d1', () => {
throw new Error('x');
});
const d2 = spy('d2', () => {
throw new Error('y');
});
const p = createCompositeProvider({ upstreams: [d1, d2] });
await expectThrows('all upstreams fail + no cache → throws', () => p.getPriceUsd('BTC'));
expectEq('every upstream attempted before throwing', d1.calls + d2.calls, 2);
}
// 7. per-symbol cache hit within TTL: upstream called once; cached
// quote returned with its ORIGINAL fetchedAt (not refreshed)
{
const up = spy('up', (s) => q(s, 50, 'up'));
const p = createCompositeProvider({ upstreams: [up], cacheTtlMs: 60_000 });
const first = await p.getPriceUsd('BTC');
clock += 1_000; // advance 1s, well within the 60s TTL
const second = await p.getPriceUsd('BTC');
expectEq('within-TTL re-read served from cache (one upstream call)', up.calls, 1);
expectEq('cached quote preserves original fetchedAt (not refreshed)', second.fetchedAt, first.fetchedAt);
expectEq('cached quote preserves usd', second.usd, first.usd);
// 8. per-symbol isolation: XMR is NOT served from the BTC cache
const xmr = await p.getPriceUsd('XMR');
expectEq('cache is per-symbol: XMR triggers its own upstream call', up.calls, 2);
expectEq('per-symbol: XMR quote is for XMR', xmr.symbol, 'XMR');
// 9. after TTL expiry, the upstream is queried again
clock += 120_000; // now far past the 60s TTL for BTC
const third = await p.getPriceUsd('BTC');
expectEq('after TTL expiry the upstream is re-queried', up.calls, 3);
expectEq('post-expiry quote carries a fresh fetchedAt', third.fetchedAt, clock);
}
// 10. stale-cache serving: prime cache, expire it, then make the
// upstream fail → the stale quote is served rather than throwing
{
let mode: 'ok' | 'fail' = 'ok';
const flaky = spy('flaky', (s) => {
if (mode === 'fail') throw new Error('down');
return q(s, 77, 'flaky');
});
const p = createCompositeProvider({ upstreams: [flaky], cacheTtlMs: 1_000 });
const primed = await p.getPriceUsd('XMR');
clock += 5_000; // past the 1s TTL → next read is a cache miss
mode = 'fail';
const stale = await p.getPriceUsd('XMR');
expectEq('stale cache served when all upstreams fail', stale.usd, 77);
expectEq('stale-served quote keeps its original fetchedAt', stale.fetchedAt, primed.fetchedAt);
}
const total = passed + failed;
console.log(`\n${passed} passed, ${failed} failed (${total} total)`);
if (failed > 0) {
console.error('\ncomposite-price-provider smoke FAILED');
process.exit(1);
}
console.log(`✓ all ${total} composite-price-provider scenarios passed`);
}
main()
.catch((err) => {
console.error('composite-price-provider smoke crashed:', err);
process.exitCode = 1;
})
.finally(() => {
Date.now = realDateNow;
});