136 lines
5.4 KiB
TypeScript
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 });
|
|
}); |