Refactor rates to use traits for historical data and be more event based. Add Bitpay.
This commit is contained in:
@@ -1,136 +0,0 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Satoshis } from '@xocash/primitives';
|
||||
|
||||
import {
|
||||
RatesComposite,
|
||||
BaseRates,
|
||||
RatesCoinbase,
|
||||
} from '~/src/index.js';
|
||||
|
||||
/**
|
||||
* A simple stub that implements BaseRates and returns fixed values.
|
||||
*/
|
||||
class StubRate extends BaseRates {
|
||||
constructor(private factor: number) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {}
|
||||
public async stop(): Promise<void> {}
|
||||
|
||||
/** Return a fixed rate factor */
|
||||
public async getRate(): Promise<number> {
|
||||
return this.factor;
|
||||
}
|
||||
|
||||
/** toBCH returns Satoshis.fromBCH(amount * factor) */
|
||||
public async toBCH(amount: number): Promise<Satoshis> {
|
||||
return Satoshis.fromBCH(amount / this.factor);
|
||||
}
|
||||
|
||||
/** fromBCH returns (BCH amount) * factor */
|
||||
public async fromBCH(amount: Satoshis): Promise<number> {
|
||||
return amount.toBCH() * this.factor;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unit tests with in-memory stub rates ---
|
||||
describe('RatesComposite (unit) with two stub rates', () => {
|
||||
// Use 99 and 101 so average will be exactly 100
|
||||
const stub1 = new StubRate(99);
|
||||
const stub2 = new StubRate(101);
|
||||
const composite = new RatesComposite([stub1, stub2]);
|
||||
|
||||
it('getRate should average the two rates to 100', async () => {
|
||||
// Act: composite over stub rates
|
||||
const avg = await composite.getRate('ANY');
|
||||
// Assert: (99 + 101) / 2 = 100
|
||||
expect(avg).toBeCloseTo(100, 3);
|
||||
});
|
||||
|
||||
it('toBCH should average returned BCH to 100 BCH', async () => {
|
||||
// amount=1, stub1 gives 99 BCH, stub2 gives 101 BCH -> avg = 100 BCH
|
||||
const sats = await composite.toBCH(1, 'ANY');
|
||||
|
||||
// Expect 1 USD = 1/100 BCH
|
||||
expect(sats.toBCH()).toBeCloseTo(0.01, 3);
|
||||
});
|
||||
|
||||
it('fromBCH should average returned foreign value to 100', async () => {
|
||||
// 1 BCH => stub1 returns 99, stub2 returns 101 -> avg = 100
|
||||
const value = await composite.fromBCH(Satoshis.fromBCH(1), 'ANY');
|
||||
expect(value).toBeCloseTo(100, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RatesComposite (unit) with two very different stub rates', () => {
|
||||
const stub1 = new StubRate(100);
|
||||
const stub2 = new StubRate(600);
|
||||
const composite = new RatesComposite([stub1, stub2]);
|
||||
|
||||
it('toBCH should average returned BCH to 100 BCH', async () => {
|
||||
// amount=1, stub1 gives 100 per BCH, stub2 gives 600 per BCH -> avg = 350 per BCH
|
||||
const sats = await composite.toBCH(1, 'ANY');
|
||||
|
||||
// Expect 1 USD = 1/350 BCH
|
||||
expect(sats.toBCH()).toBeCloseTo(0.00285, 4);
|
||||
});
|
||||
|
||||
it('fromBCH should average returned foreign value to 100', async () => {
|
||||
// 1 BCH => stub1 returns 100, stub2 returns 600 -> avg = 350
|
||||
const value = await composite.fromBCH(Satoshis.fromBCH(1), 'ANY');
|
||||
expect(value).toBeCloseTo(350, 3);
|
||||
});
|
||||
|
||||
it('composite toBCH/fromBCH roundtrip USD', async () => {
|
||||
const sats = await composite.toBCH(1, 'USD');
|
||||
expect(sats.toBigInt()).toBeGreaterThan(BigInt(0));
|
||||
|
||||
const back = await composite.fromBCH(sats, 'USD');
|
||||
expect(back).toBeGreaterThan(0);
|
||||
expect(back).toBeCloseTo(1.00, 3);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
// --- Integration tests combining CoinbaseRates and GpOraclesRates ---
|
||||
describe('AvgCompositeRates (integration) combining Coinbase & OracleRates', () => {
|
||||
it('integration: composite BCH->USD from Coinbase+Oracle should yield > 0', async () => {
|
||||
const cb = RatesCoinbase.from();
|
||||
const mockRate = new StubRate(99);
|
||||
|
||||
const composite = new RatesComposite([cb, mockRate]);
|
||||
await composite.start();
|
||||
|
||||
const rate = await composite.getRate('USD');
|
||||
|
||||
await composite.stop();
|
||||
expect(rate).toBeGreaterThan(0);
|
||||
}, 20000);
|
||||
|
||||
it('integration: composite toBCH/fromBCH roundtrip USD', async () => {
|
||||
const cb = RatesCoinbase.from();
|
||||
const mockRate = new StubRate(100);
|
||||
|
||||
const composite = new RatesComposite([cb, mockRate]);
|
||||
await composite.start();
|
||||
|
||||
const sats = await composite.toBCH(1, 'USD');
|
||||
expect(sats.toBigInt()).toBeGreaterThan(BigInt(0));
|
||||
|
||||
const back = await composite.fromBCH(sats, 'USD');
|
||||
expect(back).toBeGreaterThan(0);
|
||||
expect(back).toBeCloseTo(1.00, 3);
|
||||
|
||||
await composite.stop();
|
||||
}, 20000);
|
||||
});
|
||||
Reference in New Issue
Block a user