mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
aa1f76ad11
A foreign expense without a frozen rate is converted with today's rate. When the server could not fetch rates itself, it counted the amount 1:1 in the trip currency, although the browser had the rates all along. A VND bill on an AUD trip was settled as millions of AUD. Such a row is now left out of the balances, flows, final budgets, trip totals and per-person summaries. The settlement reports it under `unconverted` and says which currency its figures are in; the trip summary lists the ids and the budget prompt says how many are missing. The browser lends the rates it already holds (`fallback_fx`) when it saves an expense or a settle-up transfer, and to heal existing rows through the new POST .../budget/freeze-rates route, which an editor's Costs view calls for the rows the settlement left out. The server still prefers its own rate, uses a lent one only while it is quoted against the trip currency, and never replaces a frozen or explicit rate. The settlement also takes the browser's display rate (`base_rate`) in place of a display quote the server cannot fetch; the browser leaves out a figure the query would refuse. The browser's own fetch gives up after 10 s. Changing a row's currency while no rate is available no longer keeps the old currency's rate; the row goes back to unfrozen. MCP gets the same through the new freeze_budget_rates tool, which only ever pins the server's own rate.
365 lines
18 KiB
TypeScript
365 lines
18 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { renderHook, waitFor } from '@testing-library/react'
|
|
import { http, HttpResponse } from 'msw'
|
|
import { server } from '../../tests/helpers/msw/server'
|
|
import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates'
|
|
|
|
const FX_URL = 'https://api.frankfurter.dev/v2/rates'
|
|
|
|
// Contract tests for the plain fetcher the PDF export relies on: it must never
|
|
// reject, and "no usable rates" must come back as null (→ breakdown fallback),
|
|
// never as a half-filled object.
|
|
describe('fetchExchangeRates (#1561)', () => {
|
|
beforeEach(() => {
|
|
clearExchangeRateCache()
|
|
})
|
|
|
|
it('fetches, seeds the base self-rate, and caches', async () => {
|
|
let calls = 0
|
|
server.use(http.get(FX_URL, () => {
|
|
calls++
|
|
return HttpResponse.json([{ quote: 'USD', rate: 0.095 }, { quote: 'bogus' }])
|
|
}))
|
|
const rates = await fetchExchangeRates('nok')
|
|
expect(rates).toEqual({ NOK: 1, USD: 0.095 })
|
|
// fresh cache short-circuits the second call
|
|
expect(await fetchExchangeRates('NOK')).toEqual(rates)
|
|
expect(calls).toBe(1)
|
|
})
|
|
|
|
it('returns null on failure with no cache', async () => {
|
|
server.use(http.get(FX_URL, () => HttpResponse.error()))
|
|
expect(await fetchExchangeRates('NOK')).toBeNull()
|
|
})
|
|
|
|
it('returns null for a non-array body', async () => {
|
|
server.use(http.get(FX_URL, () => HttpResponse.json({ message: 'not found' })))
|
|
expect(await fetchExchangeRates('NOK')).toBeNull()
|
|
})
|
|
|
|
it('falls back to a stale localStorage cache when the fetch fails', async () => {
|
|
localStorage.setItem('trek_fx_NOK', JSON.stringify({
|
|
rates: { NOK: 1, USD: 0.1 },
|
|
ts: Date.now() - 24 * 60 * 60 * 1000, // expired
|
|
}))
|
|
server.use(http.get(FX_URL, () => HttpResponse.error()))
|
|
expect(await fetchExchangeRates('NOK')).toEqual({ NOK: 1, USD: 0.1 })
|
|
})
|
|
|
|
// A request the network never answers held the Costs panel's heal for as long as the
|
|
// browser kept the socket open. It gives up after 10 s like any other failure.
|
|
it('gives up on a request that hangs after 10 s and serves the stale cache', async () => {
|
|
vi.useFakeTimers()
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
|
try {
|
|
localStorage.setItem('trek_fx_NOK', JSON.stringify({ rates: { NOK: 1, USD: 0.1 }, ts: Date.now() - 24 * 60 * 60 * 1000 }))
|
|
let signal: AbortSignal | undefined
|
|
fetchSpy.mockImplementation((_input, init) => new Promise((_resolve, reject) => {
|
|
signal = init?.signal ?? undefined
|
|
signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
|
|
}))
|
|
const pending = fetchExchangeRates('NOK')
|
|
await vi.advanceTimersByTimeAsync(9_999)
|
|
expect(signal?.aborted).toBe(false)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(signal?.aborted).toBe(true)
|
|
expect(await pending).toEqual({ NOK: 1, USD: 0.1 })
|
|
} finally {
|
|
fetchSpy.mockRestore()
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
})
|
|
|
|
// The rates the client lends the server to freeze a rate with. Only a fresh table counts:
|
|
// a stale one would pin a figure from another day on the row for good.
|
|
describe('freshRates and fetchFreshRates', () => {
|
|
beforeEach(() => {
|
|
clearExchangeRateCache()
|
|
})
|
|
|
|
it('reads a fresh cache and ignores a stale or missing one', () => {
|
|
expect(freshRates('AUD')).toBeNull()
|
|
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 }))
|
|
expect(freshRates('aud')).toBeNull()
|
|
clearExchangeRateCache()
|
|
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() }))
|
|
expect(freshRates('aud')).toEqual({ AUD: 1, VND: 18241.3 })
|
|
})
|
|
|
|
it('fetches when the cache is stale and gives the new table', async () => {
|
|
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 15000 }, ts: Date.now() - 7 * 60 * 60 * 1000 }))
|
|
server.use(http.get(FX_URL, () => HttpResponse.json([{ quote: 'VND', rate: 18241.3 }])))
|
|
expect(await fetchFreshRates('AUD')).toEqual({ AUD: 1, VND: 18241.3 })
|
|
})
|
|
|
|
it('is null when the fetch fails and only a stale table is left', async () => {
|
|
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 15000 }, ts: Date.now() - 7 * 60 * 60 * 1000 }))
|
|
server.use(http.get(FX_URL, () => HttpResponse.error()))
|
|
expect(await fetchFreshRates('AUD')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('withFallbackFx', () => {
|
|
beforeEach(() => {
|
|
clearExchangeRateCache()
|
|
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61, XAU: 1e-10 }, ts: Date.now() }))
|
|
})
|
|
|
|
it('lends the fresh trip-currency rate for a foreign currency', () => {
|
|
expect(withFallbackFx({ name: 'Pho', currency: 'vnd', total_price: 8920000 }, 'aud')).toEqual({
|
|
name: 'Pho', currency: 'vnd', total_price: 8920000,
|
|
fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } },
|
|
})
|
|
expect(withFallbackFx({ from_user_id: 1, to_user_id: 2, amount: 10, currency: 'EUR' }, 'AUD').fallback_fx)
|
|
.toEqual({ base: 'AUD', rates: { EUR: 0.61 } })
|
|
})
|
|
|
|
it('leaves the data alone when there is nothing to lend', () => {
|
|
const inTrip = { name: 'Tram', currency: 'AUD' }
|
|
expect(withFallbackFx(inTrip, 'AUD')).toBe(inTrip)
|
|
// No currency at all is the trip's own.
|
|
expect(withFallbackFx({ name: 'Tram', currency: null }, 'AUD')).toEqual({ name: 'Tram', currency: null })
|
|
// An explicit rate is the caller's and wins on the server anyway.
|
|
expect(withFallbackFx({ currency: 'VND', exchange_rate: 17000 }, 'AUD')).toEqual({ currency: 'VND', exchange_rate: 17000 })
|
|
// No quote for the currency, no trip currency, or a rate the contract would refuse.
|
|
expect(withFallbackFx({ currency: 'JPY' }, 'AUD')).toEqual({ currency: 'JPY' })
|
|
expect(withFallbackFx({ currency: 'VND' }, null)).toEqual({ currency: 'VND' })
|
|
expect(withFallbackFx({ currency: 'XAU' }, 'AUD')).toEqual({ currency: 'XAU' })
|
|
})
|
|
|
|
it('lends nothing from a stale table', () => {
|
|
clearExchangeRateCache()
|
|
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3 }, ts: Date.now() - 7 * 60 * 60 * 1000 }))
|
|
expect(withFallbackFx({ currency: 'VND' }, 'AUD')).toEqual({ currency: 'VND' })
|
|
})
|
|
})
|
|
|
|
// A cost entered in a foreign currency is money that was actually paid, at a rate that
|
|
// was true that day. Reading it back at today's rate quietly rewrites history, which is
|
|
// what a tester reported after settling up: the figure moved under him.
|
|
describe('convertBooked', () => {
|
|
// Display currency is EUR, so rates are units per 1 EUR. The trip is booked in EUR too
|
|
// unless a test says otherwise.
|
|
const live = (amount: number, from: string | null | undefined): number => {
|
|
const rates: Record<string, number> = { EUR: 1, USD: 2, SEK: 10 }
|
|
const r = rates[(from || 'EUR').toUpperCase()]
|
|
return r && r > 0 ? amount / r : amount
|
|
}
|
|
|
|
it('leaves an amount already in the trip currency to the live step alone', () => {
|
|
// Nothing was ever frozen here, so this is just trip currency to display currency.
|
|
expect(convertBooked(100, null, 1, 'EUR', live)).toBe(100)
|
|
expect(convertBooked(100, 'EUR', 1, 'EUR', live)).toBe(100)
|
|
})
|
|
|
|
it('reads a foreign amount at the rate it was booked at, not at today s', () => {
|
|
// 120 USD booked when a euro bought 1.2 dollars is 100 EUR, and stays 100 EUR even
|
|
// though the live table above says a euro now buys 2.
|
|
expect(convertBooked(120, 'USD', 1.2, 'EUR', live)).toBe(100)
|
|
})
|
|
|
|
it('falls back to live rates for a row that never froze one', () => {
|
|
// Rate 1 is the column default, not a booked rate: rows predating the freeze carry it.
|
|
expect(convertBooked(120, 'USD', 1, 'EUR', live)).toBe(60)
|
|
expect(convertBooked(120, 'USD', undefined, 'EUR', live)).toBe(60)
|
|
expect(convertBooked(120, 'USD', null, 'EUR', live)).toBe(60)
|
|
// A rate that cannot divide is no rate at all.
|
|
expect(convertBooked(120, 'USD', 0, 'EUR', live)).toBe(60)
|
|
expect(convertBooked(120, 'USD', -2, 'EUR', live)).toBe(60)
|
|
})
|
|
|
|
it('goes through the trip currency when the reader displays a third one', () => {
|
|
// 200 SEK booked at 10 SEK per euro is 20 EUR of trip money, which the live step then
|
|
// takes to the display currency. The frozen rate is against the trip, never the display.
|
|
expect(convertBooked(200, 'SEK', 10, 'EUR', live)).toBe(20)
|
|
// Trip in USD, display in EUR: 200 SEK at 5 SEK per dollar is 40 USD, then 20 EUR.
|
|
expect(convertBooked(200, 'SEK', 5, 'USD', live)).toBe(20)
|
|
})
|
|
|
|
it('treats currency case as noise', () => {
|
|
expect(convertBooked(120, 'usd', 1.2, 'eur', live)).toBe(100)
|
|
expect(convertBooked(100, 'eur', 1, 'EUR', live)).toBe(100)
|
|
})
|
|
|
|
// #2525: a euro trip read in dollars, the bill entered in dollars. It was booked at 685.26
|
|
// EUR, and that is what the balances and settle-up net and then show at today's rate. The
|
|
// row has to show the same figure, or paying back the "you lent" it printed reopens the trip.
|
|
it('reads an amount in the display currency through the trip currency too', () => {
|
|
const liveUsd = (amount: number, from: string | null | undefined): number => {
|
|
const rates: Record<string, number> = { USD: 1, EUR: 1 / 1.1398 }
|
|
const r = rates[(from || 'USD').toUpperCase()]
|
|
return r && r > 0 ? amount / r : amount
|
|
}
|
|
// 685.26 EUR, then at today's 1.1398 dollars to the euro.
|
|
expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd)).toBeCloseTo((801.76 / 1.17) * 1.1398, 6)
|
|
expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd)).not.toBeCloseTo(801.76, 1)
|
|
// Without a booked rate there is nothing to go through, and dollars stay dollars.
|
|
expect(convertBooked(801.76, 'USD', 1, 'EUR', liveUsd)).toBe(801.76)
|
|
})
|
|
})
|
|
|
|
describe('bookedInTrip', () => {
|
|
it('gives the trip-currency amount a foreign row was booked at', () => {
|
|
expect(bookedInTrip(801.76, 'USD', 1.17, 'EUR')).toBeCloseTo(685.26, 2)
|
|
expect(bookedInTrip(12000, 'jpy', 170, 'eur')).toBeCloseTo(70.59, 2)
|
|
})
|
|
|
|
it('has nothing to give for a row in the trip currency or one that never froze a rate', () => {
|
|
expect(bookedInTrip(100, 'EUR', 1.17, 'EUR')).toBeNull()
|
|
expect(bookedInTrip(100, null, 1.17, 'EUR')).toBeNull()
|
|
expect(bookedInTrip(100, 'USD', 1, 'EUR')).toBeNull()
|
|
expect(bookedInTrip(100, 'USD', null, 'EUR')).toBeNull()
|
|
expect(bookedInTrip(100, 'USD', 0, 'EUR')).toBeNull()
|
|
})
|
|
})
|
|
|
|
// #2525: the server freezes an entry rate from the trip currency's quote and converts the
|
|
// settlement back with it. Read through the display currency's own quote, a separately
|
|
// rounded figure, a same-day 12,345.67 USD bill on a euro trip came back as $12,346.05.
|
|
describe('useExchangeRates anchored on the trip currency (#2525)', () => {
|
|
beforeEach(() => {
|
|
clearExchangeRateCache()
|
|
})
|
|
|
|
const quotes = (): void => {
|
|
server.use(http.get(FX_URL, ({ request }) => {
|
|
const base = new URL(request.url).searchParams.get('base')
|
|
if (base === 'EUR') return HttpResponse.json([{ quote: 'USD', rate: 1.1398 }, { quote: 'JPY', rate: 170 }])
|
|
if (base === 'USD') return HttpResponse.json([{ quote: 'EUR', rate: 0.87732 }, { quote: 'JPY', rate: 149.1 }])
|
|
return HttpResponse.error()
|
|
}))
|
|
}
|
|
|
|
it('converts the trip currency with its own quote, so a same-day bill reads as typed', async () => {
|
|
quotes()
|
|
const { result } = renderHook(() => useExchangeRates('usd', 'eur'))
|
|
await waitFor(() => expect(result.current.convert(1, 'EUR')).toBe(1.1398))
|
|
const shown = convertBooked(12345.67, 'USD', 1.1398, 'EUR', result.current.convert)
|
|
expect(Math.round(shown * 100)).toBe(1234567)
|
|
expect(Math.round(convertBooked(250, 'USD', 1.1398, 'EUR', result.current.convert) * 100)).toBe(25000)
|
|
// A foreign row that never froze a rate goes through the same quote as well.
|
|
expect(result.current.convert(170, 'JPY')).toBeCloseTo(1.1398, 10)
|
|
// The display currency's own rates stay what the hook hands out.
|
|
expect(result.current.rates).toEqual({ USD: 1, EUR: 0.87732, JPY: 149.1 })
|
|
})
|
|
|
|
it('falls back to the display currency\'s quote while the trip\'s is missing', async () => {
|
|
server.use(http.get(FX_URL, ({ request }) => (new URL(request.url).searchParams.get('base') === 'USD'
|
|
? HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])
|
|
: HttpResponse.error())))
|
|
const { result } = renderHook(() => useExchangeRates('USD', 'EUR'))
|
|
await waitFor(() => expect(result.current.convert(8, 'EUR')).toBe(10))
|
|
expect(result.current.convert(5, 'USD')).toBe(5)
|
|
})
|
|
|
|
it('fetches one set of rates when the trip is in the display currency', async () => {
|
|
let calls = 0
|
|
server.use(http.get(FX_URL, () => { calls++; return HttpResponse.json([{ quote: 'USD', rate: 1.1398 }]) }))
|
|
const { result } = renderHook(() => useExchangeRates('EUR', 'EUR'))
|
|
await waitFor(() => expect(result.current.convert(1.1398, 'USD')).toBe(1))
|
|
expect(calls).toBe(1)
|
|
expect(result.current.displayPerTrip).toBe(1)
|
|
})
|
|
|
|
// What the settlement is asked to label its figures with when the server has no quote
|
|
// (`base_rate`): the same figure `convert` takes a trip amount to the display currency with.
|
|
it('gives the display currency per 1 trip currency from the trip\'s quote', async () => {
|
|
quotes()
|
|
const { result } = renderHook(() => useExchangeRates('USD', 'EUR'))
|
|
await waitFor(() => expect(result.current.displayPerTrip).toBe(1.1398))
|
|
expect(result.current.convert(1, 'EUR')).toBe(result.current.displayPerTrip)
|
|
})
|
|
|
|
it('falls back to the display currency\'s quote for it, and is null without either', async () => {
|
|
server.use(http.get(FX_URL, ({ request }) => (new URL(request.url).searchParams.get('base') === 'USD'
|
|
? HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])
|
|
: HttpResponse.error())))
|
|
const { result } = renderHook(() => useExchangeRates('USD', 'EUR'))
|
|
await waitFor(() => expect(result.current.displayPerTrip).toBe(1.25))
|
|
|
|
clearExchangeRateCache()
|
|
let calls = 0
|
|
server.use(http.get(FX_URL, () => { calls++; return HttpResponse.error() }))
|
|
const none = renderHook(() => useExchangeRates('GBP', 'AUD'))
|
|
await waitFor(() => expect(calls).toBe(2))
|
|
expect(none.result.current.displayPerTrip).toBeNull()
|
|
})
|
|
|
|
// A trip in gold read in rials: 2.6 billion rials to the ounce is past the 1e9 the
|
|
// settlement query takes, and sent as `base_rate` it would fail the whole read with a 400.
|
|
it('is null for a figure the settlement query would refuse, which convert still uses', () => {
|
|
localStorage.setItem('trek_fx_XAU', JSON.stringify({ rates: { XAU: 1, IRR: 2.6e9 }, ts: Date.now() }))
|
|
localStorage.setItem('trek_fx_IRR', JSON.stringify({ rates: { IRR: 1, XAU: 1 / 2.6e9 }, ts: Date.now() }))
|
|
const high = renderHook(() => useExchangeRates('IRR', 'XAU'))
|
|
expect(high.result.current.convert(1, 'XAU')).toBe(2.6e9)
|
|
expect(high.result.current.displayPerTrip).toBeNull()
|
|
// The other way round the figure falls under 1e-9.
|
|
const low = renderHook(() => useExchangeRates('XAU', 'IRR'))
|
|
expect(low.result.current.convert(2.6e9, 'IRR')).toBeCloseTo(1, 10)
|
|
expect(low.result.current.displayPerTrip).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('crossRate', () => {
|
|
it('reads units of one currency per another from rates on any base', () => {
|
|
expect(crossRate({ EUR: 1, USD: 1.25, GBP: 0.8 }, 'USD', 'GBP')).toBe(1.5625)
|
|
expect(crossRate({ EUR: 1, USD: 1.25 }, 'USD', 'EUR')).toBe(1.25)
|
|
})
|
|
|
|
it('has nothing to say without both quotes', () => {
|
|
expect(crossRate(null, 'USD', 'EUR')).toBeNull()
|
|
expect(crossRate({ EUR: 1 }, 'USD', 'EUR')).toBeNull()
|
|
expect(crossRate({ EUR: 0, USD: 1 }, 'USD', 'EUR')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('tripAmountOf', () => {
|
|
// Display USD, trip EUR: one euro buys 1.25 dollars and 100 yen.
|
|
const live = (amount: number, from: string | null | undefined): number =>
|
|
amount * ({ EUR: 1.25, USD: 1, JPY: 0.0125 } as Record<string, number>)[(from || 'USD').toUpperCase()]
|
|
|
|
it('is the booked amount for a row booked through the trip currency', () => {
|
|
expect(tripAmountOf(801.76, 'USD', 1.17, 'EUR', live)).toBeCloseTo(685.26, 2)
|
|
})
|
|
|
|
it('is the amount itself for a row in the trip currency', () => {
|
|
expect(tripAmountOf(100, null, null, 'eur', live)).toBe(100)
|
|
expect(tripAmountOf(100, 'EUR', 1, 'EUR', live)).toBe(100)
|
|
})
|
|
|
|
it('counts a row that never froze a rate at today\'s', () => {
|
|
expect(tripAmountOf(1000, 'JPY', 1, 'EUR', live)).toBeCloseTo(10, 10)
|
|
// Without rates the conversion is the identity, as it is on the server.
|
|
expect(tripAmountOf(1000, 'JPY', null, 'EUR', (a: number) => a)).toBe(1000)
|
|
})
|
|
})
|
|
|
|
describe('convertedLine', () => {
|
|
it('shows the entered amount and the booked trip amount for a bill booked through the trip currency', () => {
|
|
expect(convertedLine(801.76, 'usd', 1.17, 'EUR', 'USD', 791.55)).toEqual({
|
|
entered: { amount: 801.76, currency: 'USD' },
|
|
into: { amount: 801.76 / 1.17, currency: 'EUR' },
|
|
})
|
|
expect(convertedLine(12000, 'JPY', 170, 'EUR', 'USD', 80.46)?.into).toEqual({ amount: 12000 / 170, currency: 'EUR' })
|
|
})
|
|
|
|
it('has no line for a bill in the display currency that still reads as typed', () => {
|
|
expect(convertedLine(12345.67, 'USD', 1.1398, 'EUR', 'USD', 12345.670000000002)).toBeNull()
|
|
// Rounded to the display currency's own decimals.
|
|
expect(convertedLine(1500, 'JPY', 0.0125, 'EUR', 'JPY', 1500.2)).toBeNull()
|
|
})
|
|
|
|
it('has no line for an amount of 0 or a row that was never converted', () => {
|
|
expect(convertedLine(0, 'USD', 1.17, 'EUR', 'USD', 0)).toBeNull()
|
|
expect(convertedLine(0, 'JPY', 170, 'EUR', 'USD', 0)).toEqual({ entered: { amount: 0, currency: 'JPY' }, into: { amount: 0, currency: 'USD' } })
|
|
expect(convertedLine(50, 'USD', 1, 'EUR', 'USD', 50)).toBeNull()
|
|
expect(convertedLine(50, null, null, 'USD', '', 50)).toBeNull()
|
|
})
|
|
|
|
it('goes straight to the display value for a row in the trip currency, or when the viewer reads the trip currency', () => {
|
|
expect(convertedLine(100, null, null, 'EUR', 'USD', 113.98)).toEqual({ entered: { amount: 100, currency: 'EUR' }, into: { amount: 113.98, currency: 'USD' } })
|
|
expect(convertedLine(801.76, 'USD', 1.17, 'EUR', 'EUR', 685.26)).toEqual({ entered: { amount: 801.76, currency: 'USD' }, into: { amount: 685.26, currency: 'EUR' } })
|
|
})
|
|
})
|