Files
xo-stack/packages/stack/tests/rates/rates-coinbase.spec.ts
T

136 lines
5.4 KiB
TypeScript

// Tests for CoinbaseRates: verifies public endpoints and conversion logic using stubs
import { describe, it, expect, vi } from 'vitest';
import { Satoshis } from '@xocash/primitives';
import {
RatesCoinbase,
type CoinbaseProduct,
} from '~/src/index.js';
// CoinbaseRates implements BaseRates via Coinbase Exchange REST API
describe('CoinbaseRates', () => {
it('listProducts should fetch and return product list', async () => {
// Arrange: prepare a fake product list and stub fetcher to return it
const sampleProducts: CoinbaseProduct[] = [
{
id: 'BCH-USD',
base_currency: 'BCH',
quote_currency: 'USD',
base_min_size: '0.00000001',
base_max_size: '1000000',
quote_increment: '0.01',
}
];
const fetcher = vi.fn().mockResolvedValue({
ok: true,
json: async () => sampleProducts
} as any);
// Act: instantiate and call listProducts()
const rates = RatesCoinbase.from('https://api.exchange.coinbase.com', 1000, fetcher);
const products = await rates.listProducts();
// Assert: fetcher was invoked on the correct endpoint and data matches
expect(fetcher).toHaveBeenCalledWith('https://api.exchange.coinbase.com/products');
expect(products).toEqual(sampleProducts);
});
it('getRate should fetch direct pair and cache result', async () => {
// Arrange: stub ticker endpoint to return price '2' for direct pair
const tickerResponse = { ok: true, json: async () => ({ price: '2' }) } as any;
const fetcher = vi.fn().mockResolvedValue(tickerResponse);
const rates = RatesCoinbase.from('https://api.exchange.coinbase.com', 1000, fetcher);
// Act: first call should invoke fetcher for the direct BCH-USD ticker
const rate1 = await rates.getRate('USD');
// Assert: parsed value is correct and fetcher was called against the correct URL
expect(rate1).toBe(0.5);
expect(fetcher).toHaveBeenCalledWith(
'https://api.exchange.coinbase.com/products/USD-BCH/ticker',
);
// Act: second call should hit the cache (no new network call)
const rate2 = await rates.getRate('USD');
// Assert: value unchanged and fetcher not called again
expect(rate2).toBe(0.5);
expect(fetcher).toHaveBeenCalledTimes(1);
});
it('getRate should fallback to inverse when direct fails', async () => {
// Arrange: make the direct FETCH fail, then succeed for inverse fetch
const error = new Error('not found');
const inverseResponse = { ok: true, json: async () => ({ price: '4' }) } as any;
const fetcher = vi.fn()
.mockRejectedValueOnce(error) // direct USD-BCH will throw
.mockResolvedValueOnce(inverseResponse); // then BCH-USD returns price
const rates = RatesCoinbase.from('https://api.exchange.coinbase.com', 1000, fetcher);
// Act: calling getRate on currencies that require inversion
const rate = await rates.getRate('USD');
// Assert: fetcher first called with USD-BCH then BCH-USD, and final rate is inverted correctly
expect(fetcher).toHaveBeenNthCalledWith(
1,
'https://api.exchange.coinbase.com/products/USD-BCH/ticker'
);
expect(fetcher).toHaveBeenNthCalledWith(
2,
'https://api.exchange.coinbase.com/products/BCH-USD/ticker'
);
expect(rate).toBe(4);
});
it('toBCH and fromBCH should convert amounts using getRate', async () => {
// Arrange: create rates instance and stub getRate to return a deterministic factor
const tickerResponse = { ok: true, json: async () => ({ price: '2' }) } as any;
const fetcher = vi.fn().mockResolvedValue(tickerResponse);
const rates = RatesCoinbase.from('https://api.exchange.coinbase.com', 1000, fetcher);
// Value returned is 1 usd = 0.5 bch
vi.spyOn(rates, 'getRate').mockResolvedValue(2 as any);
// Act: convert 2 units of foreign currency to BCH
const satoshis = await rates.toBCH(2, 'USD');
// Assert: 2 / rate (2) => 1 BCH
expect(satoshis.toBCH()).toBe(1);
// Act: convert 2 BCH back to foreign currency
const foreignAmount = await rates.fromBCH(Satoshis.fromBCH(2), 'USD');
// Assert: 2 BCH * rate (2) => 4 units of foreign currency
expect(foreignAmount).toBe(4);
});
// --- Integration tests against actual Coinbase API (no mocking) ---
it('integration: listProducts should return a non-empty array', async () => {
// Use default fetcher to call real /products endpoint
const rates = RatesCoinbase.from();
const products = await rates.listProducts();
// Expect an array of products and at least one result
expect(Array.isArray(products)).toBe(true);
expect(products.length).toBeGreaterThan(0);
});
it('integration: getRate BCH->USD should yield a positive number', async () => {
// Call real ticker endpoint
const rates = RatesCoinbase.from();
const rate = await rates.getRate('USD');
// Expect a numeric rate greater than zero
expect(typeof rate).toBe('number');
expect(rate).toBeGreaterThan(0);
});
it('integration: toBCH/fromBCH roundtrip with USD should work', async () => {
// Convert 1 USD to BCH (satoshis) then back to USD
const rates = RatesCoinbase.from();
const sats = await rates.toBCH(1, 'USD');
expect(sats.toBigInt()).toBeGreaterThan(BigInt(0));
const back = await rates.fromBCH(sats, 'USD');
expect(back).toBeGreaterThan(0);
expect(back).toBeCloseTo(1.00, 3);
}, { timeout: 20000 });
});