// 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 }); });