import { CbeExchangeProvider, ExchangeService } from '@edr/api-common'; /** * The CBE feed quotes every currency it publishes against ETB in one fetch — * this is a fixture of that shape (trimmed to USD + DJF, the two the app * actually reads). Verified live against the real feed on 2026-09-04. */ const CBE_FIXTURE = [ { Date: '2026-09-04', ExchangeRate: [ { transactionalSelling: 163.4365, transactionalBuying: 160.2319, currency: { CurrencyCode: 'USD' }, }, { transactionalSelling: 0.9203, transactionalBuying: 0.9022, currency: { CurrencyCode: 'DJF' }, }, // CBE publishes 0 for a currency it isn't quoting cash-selling that // day — must not be picked up as a usable rate. { transactionalSelling: 0, currency: { CurrencyCode: 'ZZZ' } }, ], }, ]; function mockFetchOnce(payload: unknown): jest.Mock { const fn = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(payload), }); (global as unknown as { fetch: typeof fetch }).fetch = fn as never; return fn; } describe('CbeExchangeProvider — multi-currency', () => { it('parses every quoted currency out of one fetch, not just USD', async () => { const fetchMock = mockFetchOnce(CBE_FIXTURE); const provider = new CbeExchangeProvider({}); const usdToEtb = await provider.getBaseRate({ from: 'USD', to: 'ETB' }); const djfToEtb = await provider.getBaseRate({ from: 'DJF', to: 'ETB' }); expect(usdToEtb).toBeCloseTo(163.4365); expect(djfToEtb).toBeCloseTo(0.9203); // Both rates came from the SAME cached fetch — one HTTP call serves // every currency, not one per currency. expect(fetchMock).toHaveBeenCalledTimes(1); }); it('skips a currency CBE reports as 0 (unquoted that day) — throws with no fallback configured', async () => { mockFetchOnce(CBE_FIXTURE); const provider = new CbeExchangeProvider({}); await expect(provider.getBaseRate({ from: 'ZZZ' as never, to: 'ETB' })).rejects.toThrow( /No CBE rate available for ZZZ/, ); }); it('only ever answers for X→ETB — everything else is derived upstream', async () => { mockFetchOnce(CBE_FIXTURE); const provider = new CbeExchangeProvider({}); await expect(provider.getBaseRate({ from: 'ETB', to: 'USD' })).resolves.toBeNull(); await expect(provider.getBaseRate({ from: 'USD', to: 'DJF' })).resolves.toBeNull(); }); }); describe('ExchangeService — USD↔DJF pivot', () => { it('derives USD→DJF by pivoting through ETB, the provider’s base currency', async () => { mockFetchOnce(CBE_FIXTURE); const service = new ExchangeService({}); const rate = await service.getRate('USD', 'DJF'); // 163.4365 / 0.9203 — same arithmetic as converting via ETB by hand. expect(rate).toBeCloseTo(163.4365 / 0.9203, 4); expect(rate).toBeCloseTo(177.59, 1); }); it('derives the inverse, DJF→USD, from the same pivot', async () => { mockFetchOnce(CBE_FIXTURE); const service = new ExchangeService({}); const rate = await service.getRate('DJF', 'USD'); expect(rate).toBeCloseTo(0.9203 / 163.4365, 6); }); it('getRateTable resolves every supported currency into the target in one call', async () => { mockFetchOnce(CBE_FIXTURE); const service = new ExchangeService({}); const fx = await service.getRateTable('DJF'); expect(fx.DJF).toBe(1); expect(fx.USD).toBeCloseTo(163.4365 / 0.9203, 4); expect(fx.ETB).toBeCloseTo(1 / 0.9203, 4); }); });