Files
TREK/client/src/components/Budget/useFreezeMissingRates.test.ts
T
Maurice d1dc8542ac fix(costs): leave out an expense no rate is known for instead of counting it 1:1
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.
2026-09-26 13:19:27 +02:00

124 lines
5.3 KiB
TypeScript

// FE-FREEZE-RATES-001 to FE-FREEZE-RATES-006
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import type { BudgetUnconverted } from '@trek/shared'
import { server } from '../../../tests/helpers/msw/server'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildBudgetItem } from '../../../tests/helpers/factories'
import { useTripStore } from '../../store/tripStore'
import { setForcedOffline } from '../../sync/networkMode'
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
import { resetFreezeAttempts, useFreezeMissingRates } from './useFreezeMissingRates'
// The reporter's setup: an AUD trip whose server cannot fetch rates, and a VND bill that
// never froze one. The browser has today's AUD table.
const bill = () => buildBudgetItem({ id: 11, trip_id: 5, name: 'Pho', total_price: 8920000, currency: 'VND', exchange_rate: 1 })
const pending: BudgetUnconverted = { item_ids: [11], settlement_ids: [3], currencies: ['VND'] }
type Props = Parameters<typeof useFreezeMissingRates>[0]
function mountHook(over: Partial<Props> = {}) {
const onHealed = vi.fn()
const props: Props = { tripId: 5, tripCurrency: 'AUD', canEdit: true, unconverted: pending, onHealed, ...over }
const view = renderHook((p: Props) => useFreezeMissingRates(p), { initialProps: props })
return { ...view, onHealed, props }
}
let posted: unknown[] = []
function serveFreeze(reply: () => Response = () => HttpResponse.json({
items: [{ ...bill(), exchange_rate: 18241.3 }],
settlements: [{ id: 3, trip_id: 5, from_user_id: 2, to_user_id: 1, amount: 100000, currency: 'VND', exchange_rate: 18241.3 }],
unresolved: [],
})) {
server.use(http.post('/api/trips/5/budget/freeze-rates', async ({ request }) => {
posted.push(await request.json())
return reply()
}))
}
/** Lets a heal that was never going to send anything run to its end. */
const settle = () => act(async () => { await new Promise(r => setTimeout(r, 30)) })
beforeEach(() => {
resetAllStores()
resetFreezeAttempts()
clearExchangeRateCache()
posted = []
localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() }))
seedStore(useTripStore, { budgetItems: [bill()] })
serveFreeze()
})
afterEach(() => {
setForcedOffline(false)
vi.restoreAllMocks()
})
describe('useFreezeMissingRates', () => {
it('FE-FREEZE-RATES-001: lends the fresh rates for the listed currencies and reloads once rows count', async () => {
const { onHealed } = mountHook()
await waitFor(() => expect(onHealed).toHaveBeenCalledTimes(1))
// Only the currency the settlement is missing goes out, never the whole table.
expect(posted).toEqual([{ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }])
expect(useTripStore.getState().budgetItems[0].exchange_rate).toBe(18241.3)
})
it('FE-FREEZE-RATES-002: asks once per set of rows, however often the settlement is read', async () => {
const { onHealed, props, rerender } = mountHook()
await waitFor(() => expect(onHealed).toHaveBeenCalledTimes(1))
// The same rows in another order, then again after a reload: nothing new to ask.
rerender({ ...props, unconverted: { item_ids: [11], settlement_ids: [3], currencies: ['VND'] } })
rerender({ ...props, unconverted: { settlement_ids: [3], item_ids: [11], currencies: ['VND'] } })
await settle()
expect(posted).toHaveLength(1)
// Another row is another attempt.
rerender({ ...props, unconverted: { item_ids: [11, 12], settlement_ids: [3], currencies: ['VND'] } })
await waitFor(() => expect(posted).toHaveLength(2))
})
it('FE-FREEZE-RATES-003: never asks for a viewer, offline, or with nothing left out', async () => {
mountHook({ canEdit: false })
mountHook({ unconverted: { item_ids: [], settlement_ids: [], currencies: [] } })
mountHook({ unconverted: undefined })
setForcedOffline(true)
mountHook({ unconverted: { item_ids: [21], settlement_ids: [], currencies: ['VND'] } })
await settle()
expect(posted).toEqual([])
})
it('FE-FREEZE-RATES-004: sends nothing when the browser has no rate for the currency either', async () => {
const { onHealed } = mountHook({ unconverted: { item_ids: [31], settlement_ids: [], currencies: ['JPY'] } })
await settle()
expect(posted).toEqual([])
expect(onHealed).not.toHaveBeenCalled()
})
it('FE-FREEZE-RATES-005: does not reload when the server froze nothing', async () => {
serveFreeze(() => HttpResponse.json({ items: [], settlements: [], unresolved: ['VND'] }))
const { onHealed } = mountHook()
await waitFor(() => expect(posted).toHaveLength(1))
await settle()
expect(onHealed).not.toHaveBeenCalled()
})
it('FE-FREEZE-RATES-006: a refused heal is logged, not thrown, and not retried in a loop', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
serveFreeze(() => HttpResponse.json({ error: 'The trip currency changed. Reload and try again.' }, { status: 409 }))
const { onHealed, props, rerender } = mountHook()
await waitFor(() => expect(warn).toHaveBeenCalled())
rerender({ ...props })
await settle()
expect(posted).toHaveLength(1)
expect(onHealed).not.toHaveBeenCalled()
})
})