Added Rates Service: Base, Coinbase, Composite
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// 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 });
|
||||
});
|
||||
Reference in New Issue
Block a user