morphit/apps/indexer/test/lib/feeAmountCalc.test.ts

204 lines
6.7 KiB
TypeScript

/**
* Morphit indexer — feeAmountCalc tests.
*
* Covers both pure-math (`computeFeeAmounts`) and the
* Coingecko fetch wrapper (`fetchBtcXmrPricesFromCoingecko`)
* with a stubbed `fetch`.
*/
import { describe, expect, it, vi } from 'vitest';
import {
computeFeeAmounts,
fetchBtcXmrPricesFromCoingecko
} from '../../src/lib/feeAmountCalc';
describe('computeFeeAmounts — pure math', () => {
it('canonical defaults: $0.25 target at $60K BTC / $320 XMR', () => {
const result = computeFeeAmounts(0.25, { btcUsd: 60_000, xmrUsd: 320 });
// 0.25 / 60_000 * 1e8 = 416.666... → 417 (Math.round)
expect(result.btcSatoshis).toBe(417);
// 0.25 / 320 * 1e12 = 781_250_000
expect(result.xmrPiconero).toBe(781_250_000);
});
it('zero target → zero amounts', () => {
const result = computeFeeAmounts(0, { btcUsd: 60_000, xmrUsd: 320 });
expect(result.btcSatoshis).toBe(0);
expect(result.xmrPiconero).toBe(0);
});
it('high target ($1.00) scales linearly', () => {
const result = computeFeeAmounts(1.0, { btcUsd: 60_000, xmrUsd: 320 });
// 1.0 / 60_000 * 1e8 = 1666.666... → 1667
expect(result.btcSatoshis).toBe(1667);
// 1.0 / 320 * 1e12 = 3_125_000_000
expect(result.xmrPiconero).toBe(3_125_000_000);
});
it('higher BTC price → fewer sats for same USD target', () => {
const lo = computeFeeAmounts(0.25, { btcUsd: 30_000, xmrUsd: 320 });
const hi = computeFeeAmounts(0.25, { btcUsd: 120_000, xmrUsd: 320 });
expect(hi.btcSatoshis).toBeLessThan(lo.btcSatoshis);
// XMR side unaffected when only BTC price changes
expect(lo.xmrPiconero).toBe(hi.xmrPiconero);
});
});
describe('fetchBtcXmrPricesFromCoingecko', () => {
function mockFetch(opts: {
body?: unknown;
ok?: boolean;
status?: number;
throwsBeforeFetch?: Error;
}): typeof fetch {
return vi.fn(async () => {
if (opts.throwsBeforeFetch) throw opts.throwsBeforeFetch;
return {
ok: opts.ok ?? true,
status: opts.status ?? 200,
json: async () => opts.body
} as Response;
}) as unknown as typeof fetch;
}
it('happy path: valid response → prices returned', async () => {
const fetchImpl = mockFetch({
body: {
bitcoin: { usd: 60_123.45 },
monero: { usd: 320.67 }
}
});
const prices = await fetchBtcXmrPricesFromCoingecko(fetchImpl);
expect(prices.btcUsd).toBe(60_123.45);
expect(prices.xmrUsd).toBe(320.67);
});
it('non-2xx HTTP → throws with status', async () => {
const fetchImpl = mockFetch({ ok: false, status: 503 });
await expect(fetchBtcXmrPricesFromCoingecko(fetchImpl)).rejects.toThrow(
/HTTP 503/
);
});
it('network failure → throws with "coingecko unreachable"', async () => {
const fetchImpl = mockFetch({
throwsBeforeFetch: new Error('ENOTFOUND api.coingecko.com')
});
await expect(fetchBtcXmrPricesFromCoingecko(fetchImpl)).rejects.toThrow(
/coingecko unreachable/
);
});
it('missing bitcoin field → throws', async () => {
const fetchImpl = mockFetch({
body: { monero: { usd: 320 } }
});
await expect(fetchBtcXmrPricesFromCoingecko(fetchImpl)).rejects.toThrow(
/BTC\/USD not a number or numeric string/
);
});
it('zero BTC price → throws (avoids divide-by-zero downstream)', async () => {
const fetchImpl = mockFetch({
body: { bitcoin: { usd: 0 }, monero: { usd: 320 } }
});
await expect(fetchBtcXmrPricesFromCoingecko(fetchImpl)).rejects.toThrow(
/BTC\/USD missing or invalid/
);
});
it('negative XMR price → throws', async () => {
const fetchImpl = mockFetch({
body: { bitcoin: { usd: 60_000 }, monero: { usd: -1 } }
});
await expect(fetchBtcXmrPricesFromCoingecko(fetchImpl)).rejects.toThrow(
/XMR\/USD missing or invalid/
);
});
it('string price → coerced and accepted (Coingecko sometimes returns strings)', async () => {
const fetchImpl = mockFetch({
body: { bitcoin: { usd: '60000' }, monero: { usd: '320' } }
});
const prices = await fetchBtcXmrPricesFromCoingecko(fetchImpl);
expect(prices.btcUsd).toBe(60_000);
expect(prices.xmrUsd).toBe(320);
});
it('garbage string price → throws (Part 112 hardening)', async () => {
const fetchImpl = mockFetch({
body: { bitcoin: { usd: 'not-a-number' }, monero: { usd: 320 } }
});
await expect(fetchBtcXmrPricesFromCoingecko(fetchImpl)).rejects.toThrow(
/BTC\/USD not a numeric string/
);
});
// Part 112 hardening — pre-tightening, these would have
// coerced through `Number()` to surprising values
// (`Number(null) === 0`, `Number(true) === 1`,
// `Number([42]) === 42`, etc) and only `0` would have been
// caught by the downstream `<= 0` gate. Post-tightening,
// all are rejected with a type-explicit error.
it('null price → throws (Part 112 hardening)', async () => {
const fetchImpl = mockFetch({
body: { bitcoin: { usd: null }, monero: { usd: 320 } }
});
await expect(fetchBtcXmrPricesFromCoingecko(fetchImpl)).rejects.toThrow(
/BTC\/USD not a number or numeric string/
);
});
it('boolean price → throws (Part 112 hardening)', async () => {
const fetchImpl = mockFetch({
body: { bitcoin: { usd: true }, monero: { usd: 320 } }
});
await expect(fetchBtcXmrPricesFromCoingecko(fetchImpl)).rejects.toThrow(
/BTC\/USD not a number or numeric string/
);
});
it('array price → throws (Part 112 hardening)', async () => {
const fetchImpl = mockFetch({
body: { bitcoin: { usd: [60_000] }, monero: { usd: 320 } }
});
await expect(fetchBtcXmrPricesFromCoingecko(fetchImpl)).rejects.toThrow(
/BTC\/USD not a number or numeric string/
);
});
it('object price → throws (Part 112 hardening)', async () => {
const fetchImpl = mockFetch({
body: { bitcoin: { usd: { value: 60_000 } }, monero: { usd: 320 } }
});
await expect(fetchBtcXmrPricesFromCoingecko(fetchImpl)).rejects.toThrow(
/BTC\/USD not a number or numeric string/
);
});
it('empty string price → throws (Part 112 hardening, would have coerced to 0)', async () => {
const fetchImpl = mockFetch({
body: { bitcoin: { usd: '' }, monero: { usd: 320 } }
});
await expect(fetchBtcXmrPricesFromCoingecko(fetchImpl)).rejects.toThrow(
/BTC\/USD not a numeric string/
);
});
it('whitespace-padded numeric string → throws (Part 112 hardening, was tolerated by Number())', async () => {
const fetchImpl = mockFetch({
body: { bitcoin: { usd: ' 60000 ' }, monero: { usd: 320 } }
});
await expect(fetchBtcXmrPricesFromCoingecko(fetchImpl)).rejects.toThrow(
/BTC\/USD not a numeric string/
);
});
it('exponential notation accepted (scientific JSON numbers are legal)', async () => {
const fetchImpl = mockFetch({
body: { bitcoin: { usd: '6e4' }, monero: { usd: 320 } }
});
const prices = await fetchBtcXmrPricesFromCoingecko(fetchImpl);
expect(prices.btcUsd).toBe(60_000);
});
});