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.
878 lines
44 KiB
TypeScript
878 lines
44 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { http, HttpResponse } from 'msw'
|
||
import MCostsTab from '../../../../src/mobile/screens/trip/tabs/MCostsTab'
|
||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||
import { budgetApi } from '../../../../src/api/client'
|
||
import { localToday } from '../../../../src/components/Planner/today'
|
||
import { resetFreezeAttempts } from '../../../../src/components/Budget/useFreezeMissingRates'
|
||
import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates'
|
||
import { useAuthStore } from '../../../../src/store/authStore'
|
||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||
import type { BudgetItem, Day, Trip } from '../../../../src/types'
|
||
import { buildTrip, buildUser } from '../../../helpers/factories'
|
||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||
import { server } from '../../../helpers/msw/server'
|
||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||
|
||
// FE-MOB-COSTT-001 to FE-MOB-COSTT-049
|
||
|
||
// The add/edit expense sheet is the shared desktop-sized form; the panel only
|
||
// owns when it opens and what happens on save, so it is stubbed here.
|
||
vi.mock('../../../../src/mobile/screens/trip/sheets/MCostSheet', () => ({
|
||
default: ({ base, editing, onClose, onSaved }: {
|
||
base: string
|
||
editing: { id: number } | null
|
||
onClose: () => void
|
||
onSaved: () => void
|
||
}) => (
|
||
<div>
|
||
<span>{editing ? `sheet editing ${editing.id}` : `sheet new ${base}`}</span>
|
||
<button type="button" onClick={onSaved}>sheet save</button>
|
||
<button type="button" onClick={onClose}>sheet close</button>
|
||
</div>
|
||
),
|
||
}))
|
||
|
||
const TRIP = buildTrip({ id: 7, title: 'Tokyo Trip', currency: 'USD' }) as Trip
|
||
|
||
const DAYS = [
|
||
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
|
||
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
|
||
] as unknown as Day[]
|
||
|
||
const MEMBERS = [
|
||
{ id: 1, username: 'Me', avatar_url: null },
|
||
{ id: 2, username: 'Ada', avatar_url: '/uploads/avatars/ada.png' },
|
||
{ id: 3, username: 'Bob', avatar_url: null },
|
||
]
|
||
|
||
const RAMEN = {
|
||
id: 11, trip_id: 7, name: 'Ramen', category: 'food', total_price: 80, currency: 'USD',
|
||
expense_date: '2026-05-02', note: null,
|
||
members: [
|
||
{ user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null },
|
||
{ user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: '/uploads/avatars/ada.png' },
|
||
],
|
||
payers: [{ user_id: 1, amount: 80, username: 'Me' }],
|
||
} as unknown as BudgetItem
|
||
|
||
/** Foreign currency + explicit custom shares — exercises the conversion line. */
|
||
const MUSEUM = {
|
||
id: 12, trip_id: 7, name: 'Museum', category: 'activities', total_price: 30, currency: 'GBP',
|
||
expense_date: '2026-04-30', note: 'Tickets; adult',
|
||
members: [
|
||
{ user_id: 1, paid: 0, username: 'Me', amount: 10, avatar_url: null },
|
||
{ user_id: 3, paid: 0, username: 'Bob', amount: 20, avatar_url: null },
|
||
],
|
||
payers: [{ user_id: 3, amount: 30, username: 'Bob' }],
|
||
} as unknown as BudgetItem
|
||
|
||
/** No currency (falls back to the trip's), no members, no payer → the "unfinished" case. */
|
||
const SOUVENIR = {
|
||
id: 13, trip_id: 7, name: 'Souvenirs', category: 'shopping', total_price: 25, currency: null,
|
||
expense_date: null, note: null, payers: [],
|
||
} as unknown as BudgetItem
|
||
|
||
/** I paid exactly my own share — counts as "mine" but never as "owed". */
|
||
const TAXI = {
|
||
id: 14, trip_id: 7, name: 'Taxi', category: 'transport', total_price: 20, currency: 'USD',
|
||
expense_date: '2026-05-02', note: 'TICKETJSON:{"items":[]}',
|
||
members: [{ user_id: 1, paid: 1, username: 'Me', amount: 20, avatar_url: null }],
|
||
payers: [{ user_id: 1, amount: 20, username: 'Me' }],
|
||
} as unknown as BudgetItem
|
||
|
||
const ITEMS = [RAMEN, MUSEUM, SOUVENIR, TAXI]
|
||
|
||
const SETTLEMENT = {
|
||
balances: [
|
||
{ user_id: 1, username: 'Me', avatar_url: null, balance: 12.5 },
|
||
{ user_id: 2, username: 'Ada', avatar_url: '/uploads/avatars/ada.png', balance: -7.25 },
|
||
],
|
||
flows: [
|
||
{ from: { user_id: 1, username: 'Me' }, to: { user_id: 2, username: 'Ada' }, amount: 40 },
|
||
{ from: { user_id: 3, username: 'Bob' }, to: { user_id: 1, username: 'Me' }, amount: 15 },
|
||
{ from: { user_id: 99, username: 'Ghost' }, to: { user_id: 3, username: 'Bob' }, amount: 5 },
|
||
],
|
||
}
|
||
|
||
let settlementBases: string[] = []
|
||
|
||
function serveSettlement(body: unknown = SETTLEMENT, fail = false) {
|
||
server.use(
|
||
http.get('/api/trips/:id/budget/settlement', ({ request }) => {
|
||
settlementBases.push(new URL(request.url).searchParams.get('base') ?? '')
|
||
return fail ? HttpResponse.error() : HttpResponse.json(body)
|
||
}),
|
||
)
|
||
}
|
||
|
||
function planner(overrides: Partial<TripPlanner> = {}) {
|
||
return buildPlanner({
|
||
tripId: 7,
|
||
trip: TRIP,
|
||
days: DAYS,
|
||
budgetItems: ITEMS,
|
||
tripMembers: MEMBERS as unknown as TripPlanner['tripMembers'],
|
||
...overrides,
|
||
})
|
||
}
|
||
|
||
/** Renders and waits until the settlement response has landed in state. */
|
||
async function renderTab(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
|
||
const view = render(<MCostsTab planner={p} shell={shell} />)
|
||
await screen.findByText('+$12.50')
|
||
return { ...view, planner: p, shell }
|
||
}
|
||
|
||
/** Walks up `n` levels — the panel's blocks are plain divs without test ids. */
|
||
function up(el: HTMLElement, n: number): HTMLElement {
|
||
let node: HTMLElement = el
|
||
for (let i = 0; i < n; i++) node = node.parentElement as HTMLElement
|
||
return node
|
||
}
|
||
|
||
/** The expense card of a row, found from its name. */
|
||
function cardOf(name: string): HTMLElement {
|
||
let el = screen.getByText(name).parentElement
|
||
while (el && !el.className.includes('rounded-2xl')) el = el.parentElement
|
||
if (!el) throw new Error(`no card for ${name}`)
|
||
return el
|
||
}
|
||
|
||
/** Card plus the edit/delete stack that sits next to it. */
|
||
function rowOf(name: string): HTMLElement {
|
||
return up(cardOf(name), 1)
|
||
}
|
||
|
||
describe('MCostsTab', () => {
|
||
beforeEach(() => {
|
||
resetAllStores()
|
||
clearExchangeRateCache()
|
||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'Me' }) })
|
||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, GBP: 0.5 }, ts: Date.now() }))
|
||
localStorage.setItem('trek_fx_GBP', JSON.stringify({ rates: { GBP: 1, USD: 2 }, ts: Date.now() }))
|
||
settlementBases = []
|
||
serveSettlement()
|
||
})
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-001: loads the items and the settlement for the display currency', async () => {
|
||
const { planner: p } = await renderTab()
|
||
expect(p.tripActions.loadBudgetItems).toHaveBeenCalledWith(7)
|
||
expect(settlementBases).toEqual(['USD'])
|
||
})
|
||
|
||
it('FE-MOB-COSTT-002: sums the hero totals across currencies', async () => {
|
||
await renderTab()
|
||
const hero = up(screen.getByText('costs.totalSpend'), 1)
|
||
expect(hero.textContent).toContain('$185.00')
|
||
expect(hero.textContent).toContain('costs.yourShare')
|
||
expect(hero.textContent).toContain('$80.00')
|
||
expect(hero.textContent).toContain('costs.youPaid')
|
||
expect(hero.textContent).toContain('$100.00')
|
||
})
|
||
|
||
it('FE-MOB-COSTT-003: derives owe / owed from the settlement flows', async () => {
|
||
await renderTab()
|
||
expect(up(screen.getByText('costs.youOwe'), 1).textContent).toContain('$40.00')
|
||
expect(up(screen.getByText('costs.youreOwed'), 1).textContent).toContain('$15.00')
|
||
})
|
||
|
||
it('FE-MOB-COSTT-004: reports the unpaid expenses as outstanding', async () => {
|
||
await renderTab()
|
||
const block = up(screen.getByText('costs.outstanding'), 2)
|
||
expect(block.textContent).toContain('1')
|
||
expect(block.textContent).toContain('$25.00')
|
||
})
|
||
|
||
it('FE-MOB-COSTT-005: hides the outstanding block when everything is paid', async () => {
|
||
await renderTab(planner({ budgetItems: [RAMEN, TAXI] }))
|
||
expect(screen.queryByText('costs.outstanding')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-006: lists the settle-up flows and names the participants', async () => {
|
||
await renderTab()
|
||
const settle = up(screen.getByText('costs.settleUp'), 3)
|
||
expect(within(settle).getByText('$40.00')).toBeInTheDocument()
|
||
expect(within(settle).getByText('$15.00')).toBeInTheDocument()
|
||
expect(within(settle).getByText('$5.00')).toBeInTheDocument()
|
||
expect(within(settle).getByText('?')).toBeInTheDocument()
|
||
expect(within(settle).getAllByText('costs.you')).toHaveLength(3)
|
||
expect(within(settle).getAllByText('Ada')).toHaveLength(2)
|
||
})
|
||
|
||
it('FE-MOB-COSTT-007: shows each member balance with its sign, zero for unknown members', async () => {
|
||
await renderTab()
|
||
const settle = up(screen.getByText('costs.settleUp'), 3)
|
||
expect(within(settle).getByText('+$12.50')).toBeInTheDocument()
|
||
expect(within(settle).getByText('−$7.25')).toBeInTheDocument()
|
||
expect(within(settle).getByText('$0.00')).toBeInTheDocument()
|
||
expect(within(settle).getByAltText('')).toHaveAttribute('src', '/uploads/avatars/ada.png')
|
||
})
|
||
|
||
it('FE-MOB-COSTT-008: collapses and reopens the settle-up card', async () => {
|
||
await renderTab()
|
||
// The header is a real <button> now, so Enter/Space activation is the
|
||
// browser's job — no key handler of our own left to exercise.
|
||
const head = screen.getByRole('button', { name: /costs\.settleUp/ })
|
||
expect(head).toHaveAttribute('aria-expanded', 'true')
|
||
fireEvent.click(head)
|
||
expect(screen.queryByText('costs.balances')).not.toBeInTheDocument()
|
||
fireEvent.click(head)
|
||
expect(screen.getByText('costs.balances')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-009: celebrates a settlement without open flows', async () => {
|
||
serveSettlement({ balances: SETTLEMENT.balances, flows: [] })
|
||
await renderTab()
|
||
expect(screen.getByText('costs.everyoneSquare')).toBeInTheDocument()
|
||
expect(screen.getByText('costs.nothingOutstanding')).toBeInTheDocument()
|
||
expect(up(screen.getByText('costs.youOwe'), 1).textContent).toContain('$0.00')
|
||
})
|
||
|
||
it('FE-MOB-COSTT-010: survives a failing settlement request', async () => {
|
||
serveSettlement(null, true)
|
||
render(<MCostsTab planner={planner()} shell={buildShell()} />)
|
||
await waitFor(() => expect(settlementBases).toEqual(['USD']))
|
||
expect(screen.getByText('costs.everyoneSquare')).toBeInTheDocument()
|
||
const settle = up(screen.getByText('costs.settleUp'), 3)
|
||
expect(within(settle).getAllByText('$0.00')).toHaveLength(3)
|
||
})
|
||
|
||
it('FE-MOB-COSTT-011: breaks the spend down by category, largest bar first', async () => {
|
||
await renderTab()
|
||
const block = up(screen.getByText('costs.byCategory'), 1)
|
||
const labels = within(block).getAllByText(/^costs\.cat\./).map(n => n.textContent)
|
||
expect(labels).toEqual(['costs.cat.food', 'costs.cat.activities', 'costs.cat.shopping', 'costs.cat.transport'])
|
||
expect(within(block).getByText('$60.00')).toBeInTheDocument()
|
||
expect(within(block).getByText('$20.00')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-012: shows the empty panel without any expense', async () => {
|
||
await renderTab(planner({ budgetItems: [] }))
|
||
expect(screen.getByText('costs.noCategories')).toBeInTheDocument()
|
||
expect(screen.getByText('costs.emptyText')).toBeInTheDocument()
|
||
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$0.00')
|
||
})
|
||
|
||
it('FE-MOB-COSTT-013: groups the expenses by day with a per-day total', async () => {
|
||
await renderTab()
|
||
expect(screen.getByText('Sat, May 2')).toBeInTheDocument()
|
||
expect(screen.getByText('Thu, Apr 30')).toBeInTheDocument()
|
||
expect(screen.getByText('costs.noDate')).toBeInTheDocument()
|
||
expect(screen.getByText('costs.spent:$100.00')).toBeInTheDocument()
|
||
expect(screen.getByText('costs.spent:$60.00')).toBeInTheDocument()
|
||
expect(screen.getByText('costs.spent:$25.00')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-014: converts a foreign expense and shows both amounts', async () => {
|
||
await renderTab()
|
||
const card = cardOf('Museum')
|
||
expect(within(card).getByText('£30.00 → $60.00')).toBeInTheDocument()
|
||
expect(within(card).getByText('costs.cat.activities')).toBeInTheDocument()
|
||
expect(within(card).getByText('$40.00')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-015: marks an expense nobody paid as unfinished', async () => {
|
||
await renderTab()
|
||
expect(within(cardOf('Souvenirs')).getByText('costs.unfinished')).toBeInTheDocument()
|
||
expect(within(cardOf('Ramen')).queryByText('costs.unfinished')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-016: renders the member chips with their share and paid state', async () => {
|
||
await renderTab()
|
||
const card = cardOf('Ramen')
|
||
const chips = within(card).getAllByRole('button')
|
||
expect(chips).toHaveLength(2)
|
||
expect(chips[0]).toHaveAttribute('aria-pressed', 'true')
|
||
expect(chips[1]).toHaveAttribute('aria-pressed', 'false')
|
||
expect(chips.map(c => c.textContent)).toEqual(['costs.youShort$40.00', '$40.00'])
|
||
})
|
||
|
||
it('FE-MOB-COSTT-017: toggles a member chip to paid', async () => {
|
||
const p = planner()
|
||
await renderTab(p)
|
||
fireEvent.click(within(cardOf('Ramen')).getByRole('button', { pressed: false }))
|
||
await waitFor(() => expect(p.tripActions.toggleBudgetMemberPaid).toHaveBeenCalledWith(7, 11, 2, true))
|
||
})
|
||
|
||
it('FE-MOB-COSTT-018: toasts when the paid toggle fails', async () => {
|
||
const p = planner()
|
||
vi.mocked(p.tripActions.toggleBudgetMemberPaid).mockRejectedValueOnce(new Error('offline'))
|
||
await renderTab(p)
|
||
fireEvent.click(within(cardOf('Ramen')).getByRole('button', { pressed: false }))
|
||
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('common.unknownError'))
|
||
})
|
||
|
||
it('FE-MOB-COSTT-019: filters the list by the search field', async () => {
|
||
await renderTab()
|
||
const search = screen.getByLabelText('costs.searchPlaceholder')
|
||
fireEvent.change(search, { target: { value: 'muse' } })
|
||
expect(screen.getByText('Museum')).toBeInTheDocument()
|
||
expect(screen.queryByText('Ramen')).not.toBeInTheDocument()
|
||
fireEvent.change(search, { target: { value: 'zzz' } })
|
||
expect(screen.getByText('costs.noMatch')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-020: segments the list into mine and owed', async () => {
|
||
await renderTab()
|
||
const mine = screen.getByRole('button', { name: 'costs.filter.mine' })
|
||
fireEvent.click(mine)
|
||
expect(mine).toHaveAttribute('aria-pressed', 'true')
|
||
expect(screen.getByText('Ramen')).toBeInTheDocument()
|
||
expect(screen.getByText('Taxi')).toBeInTheDocument()
|
||
expect(screen.queryByText('Museum')).not.toBeInTheDocument()
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.owed' }))
|
||
expect(screen.getByText('Ramen')).toBeInTheDocument()
|
||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.all' }))
|
||
expect(screen.getByText('Museum')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-021: filters by a category that is actually in use', async () => {
|
||
await renderTab()
|
||
const trigger = screen.getByText('costs.filter.allCategories').closest('button') as HTMLElement
|
||
fireEvent.click(trigger)
|
||
const menu = up(trigger, 1).nextElementSibling as HTMLElement
|
||
const options = within(menu).getAllByRole('button').map(b => b.textContent)
|
||
expect(options).toEqual([
|
||
'costs.filter.allCategories', 'costs.cat.food', 'costs.cat.transport', 'costs.cat.activities', 'costs.cat.shopping',
|
||
])
|
||
|
||
fireEvent.click(within(menu).getByText('costs.cat.activities'))
|
||
expect(screen.getByText('Museum')).toBeInTheDocument()
|
||
expect(screen.queryByText('Ramen')).not.toBeInTheDocument()
|
||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||
|
||
fireEvent.click(trigger)
|
||
fireEvent.click(within(up(trigger, 1).nextElementSibling as HTMLElement).getByText('costs.filter.allCategories'))
|
||
expect(screen.getByText('Ramen')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-022: filters by expense day, labelling known days with their number', async () => {
|
||
await renderTab()
|
||
const trigger = screen.getByText('costs.filter.allDays').closest('button') as HTMLElement
|
||
fireEvent.click(trigger)
|
||
const menu = up(trigger, 1).nextElementSibling as HTMLElement
|
||
expect(within(menu).getAllByRole('button').map(b => b.textContent)).toEqual([
|
||
'costs.filter.allDays', 'Apr 30', 'dayplan.dayN:2 · May 2',
|
||
])
|
||
|
||
fireEvent.click(within(menu).getByText('dayplan.dayN:2 · May 2'))
|
||
expect(screen.getByText('Ramen')).toBeInTheDocument()
|
||
expect(screen.getByText('Taxi')).toBeInTheDocument()
|
||
expect(screen.queryByText('Museum')).not.toBeInTheDocument()
|
||
expect(trigger.textContent).toBe('dayplan.dayN:2 · May 2')
|
||
})
|
||
|
||
it('FE-MOB-COSTT-023: only one filter menu is open at a time', async () => {
|
||
await renderTab()
|
||
const catTrigger = screen.getByText('costs.filter.allCategories').closest('button') as HTMLElement
|
||
const dayTrigger = screen.getByText('costs.filter.allDays').closest('button') as HTMLElement
|
||
fireEvent.click(catTrigger)
|
||
fireEvent.click(dayTrigger)
|
||
expect(catTrigger).toHaveAttribute('aria-expanded', 'false')
|
||
expect(dayTrigger).toHaveAttribute('aria-expanded', 'true')
|
||
})
|
||
|
||
it('FE-MOB-COSTT-024: opens the edit sheet from a row and reloads on save', async () => {
|
||
const p = planner()
|
||
await renderTab(p)
|
||
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.edit' }))
|
||
expect(screen.getByText('sheet editing 11')).toBeInTheDocument()
|
||
|
||
fireEvent.click(screen.getByText('sheet save'))
|
||
expect(screen.queryByText('sheet editing 11')).not.toBeInTheDocument()
|
||
expect(p.tripActions.loadBudgetItems).toHaveBeenCalledTimes(2)
|
||
await waitFor(() => expect(settlementBases).toEqual(['USD', 'USD']))
|
||
})
|
||
|
||
it('FE-MOB-COSTT-025: deletes an expense after confirming and refreshes the settlement', async () => {
|
||
const p = planner()
|
||
await renderTab(p)
|
||
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.delete' }))
|
||
const dialog = screen.getByRole('dialog', { name: 'costs.confirm.deleteTitle' })
|
||
expect(within(dialog).getByText('costs.confirm.deleteBody:Ramen')).toBeInTheDocument()
|
||
fireEvent.click(within(dialog).getByText('common.delete'))
|
||
await waitFor(() => expect(p.tripActions.deleteBudgetItem).toHaveBeenCalledWith(7, 11))
|
||
await waitFor(() => expect(settlementBases).toHaveLength(2))
|
||
})
|
||
|
||
it('FE-MOB-COSTT-026: toasts when the delete fails', async () => {
|
||
const p = planner()
|
||
vi.mocked(p.tripActions.deleteBudgetItem).mockRejectedValueOnce(new Error('offline'))
|
||
await renderTab(p)
|
||
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.delete' }))
|
||
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.delete'))
|
||
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('common.unknownError'))
|
||
})
|
||
|
||
it('FE-MOB-COSTT-027: opens a blank expense sheet when the header raises the add signal', async () => {
|
||
const p = planner()
|
||
const { rerender } = await renderTab(p)
|
||
expect(screen.queryByText('sheet new USD')).not.toBeInTheDocument()
|
||
|
||
rerender(<MCostsTab planner={p} shell={buildShell({ addExpenseSignal: 1 })} />)
|
||
expect(screen.getByText('sheet new USD')).toBeInTheDocument()
|
||
fireEvent.click(screen.getByText('sheet close'))
|
||
expect(screen.queryByText('sheet new USD')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-028: exports the CSV when the header raises the export signal', async () => {
|
||
const blobs: Blob[] = []
|
||
const createObjectURL = vi.fn((b: Blob) => { blobs.push(b); return 'blob:costs' })
|
||
const revokeObjectURL = vi.fn()
|
||
Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: createObjectURL })
|
||
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: revokeObjectURL })
|
||
let downloadName = ''
|
||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||
downloadName = this.download
|
||
})
|
||
|
||
const p = planner()
|
||
const { rerender } = await renderTab(p)
|
||
expect(createObjectURL).not.toHaveBeenCalled()
|
||
|
||
rerender(<MCostsTab planner={p} shell={buildShell({ exportCostsCsvSignal: 1 })} />)
|
||
expect(downloadName).toBe('costs-Tokyo Trip.csv')
|
||
// The blob URL outlives the click by a beat so the download can start.
|
||
expect(revokeObjectURL).not.toHaveBeenCalled()
|
||
await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:costs'))
|
||
|
||
const csv = await blobs[0].text()
|
||
const lines = csv.replace('', '').split('\r\n')
|
||
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (USD);Note')
|
||
expect(lines[1]).toBe(';Souvenirs;costs.cat.shopping;25.00;USD;25.00;')
|
||
expect(lines[2]).toBe('04/30/2026;Museum;costs.cat.activities;30.00;GBP;60.00;"Tickets; adult"')
|
||
// The ticket JSON note never leaves the app as a "note".
|
||
expect(lines[4]).toBe('05/02/2026;Taxi;costs.cat.transport;20.00;USD;20.00;')
|
||
})
|
||
|
||
it('FE-MOB-COSTT-029: records a manual payment and refreshes the settlement', async () => {
|
||
const create = vi.spyOn(budgetApi, 'createSettlement').mockResolvedValue({})
|
||
await renderTab()
|
||
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
|
||
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
|
||
const submit = within(dialog).getByRole('button', { name: 'costs.addPayment' })
|
||
expect(submit).toBeDisabled()
|
||
|
||
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '12,5' } })
|
||
expect(submit).toBeEnabled()
|
||
fireEvent.click(submit)
|
||
expect(create).toHaveBeenCalledWith(7, { from_user_id: 1, to_user_id: 2, amount: 12.5, currency: 'USD', settled_at: localToday() })
|
||
await waitFor(() => expect(settlementBases).toHaveLength(2))
|
||
})
|
||
|
||
it('FE-MOB-COSTT-030: refuses a payment to yourself and toasts a failed one', async () => {
|
||
const create = vi.spyOn(budgetApi, 'createSettlement').mockRejectedValue(new Error('nope'))
|
||
const p = planner()
|
||
await renderTab(p)
|
||
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
|
||
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
|
||
const submit = within(dialog).getByRole('button', { name: 'costs.addPayment' })
|
||
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '5' } })
|
||
|
||
const toGroup = within(dialog).getByText('costs.to').nextElementSibling as HTMLElement
|
||
fireEvent.click(within(toGroup).getByText('costs.you'))
|
||
expect(submit).toBeDisabled()
|
||
|
||
fireEvent.click(within(within(dialog).getByText('costs.to').nextElementSibling as HTMLElement).getByText('Ada'))
|
||
expect(submit).toBeEnabled()
|
||
fireEvent.click(submit)
|
||
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('common.unknownError'))
|
||
expect(create).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('FE-MOB-COSTT-031: a solo trip can never send a payment', async () => {
|
||
await renderTab(planner({ tripMembers: [MEMBERS[0]] as unknown as TripPlanner['tripMembers'] }))
|
||
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
|
||
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
|
||
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '5' } })
|
||
expect(within(dialog).getByRole('button', { name: 'costs.addPayment' })).toBeDisabled()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-032: a read-only member gets no add, edit or delete affordances', async () => {
|
||
await renderTab(planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] }))
|
||
expect(screen.queryByRole('button', { name: 'costs.addPayment' })).not.toBeInTheDocument()
|
||
expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument()
|
||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||
expect(within(cardOf('Ramen')).getByRole('button', { pressed: false })).toBeDisabled()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-033: resets the day filter back to all days', async () => {
|
||
await renderTab()
|
||
const trigger = screen.getByText('costs.filter.allDays').closest('button') as HTMLElement
|
||
fireEvent.click(trigger)
|
||
fireEvent.click(within(up(trigger, 1).nextElementSibling as HTMLElement).getByText('Apr 30'))
|
||
expect(screen.queryByText('Ramen')).not.toBeInTheDocument()
|
||
|
||
fireEvent.click(trigger)
|
||
fireEvent.click(within(up(trigger, 1).nextElementSibling as HTMLElement).getByText('costs.filter.allDays'))
|
||
expect(screen.getByText('Ramen')).toBeInTheDocument()
|
||
expect(trigger.textContent).toBe('costs.filter.allDays')
|
||
})
|
||
|
||
it('FE-MOB-COSTT-034: dismisses the payment sheet without recording anything', async () => {
|
||
const create = vi.spyOn(budgetApi, 'createSettlement')
|
||
await renderTab()
|
||
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
|
||
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
|
||
const fromGroup = within(dialog).getByText('costs.from').nextElementSibling as HTMLElement
|
||
fireEvent.click(within(fromGroup).getByText('Bob'))
|
||
fireEvent.click(within(dialog).getByRole('button', { name: 'common.cancel' }))
|
||
expect(create).not.toHaveBeenCalled()
|
||
expect(settlementBases).toHaveLength(1)
|
||
})
|
||
|
||
it('FE-MOB-COSTT-035: cancelling the confirm keeps the expense', async () => {
|
||
const p = planner()
|
||
await renderTab(p)
|
||
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.delete' }))
|
||
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.cancel'))
|
||
expect(p.tripActions.deleteBudgetItem).not.toHaveBeenCalled()
|
||
expect(screen.getByText('Ramen')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-036: falls back to EUR and a neutral avatar without a user or trip currency', async () => {
|
||
seedStore(useAuthStore, { user: null })
|
||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1 }, ts: Date.now() }))
|
||
const solo = [{ id: 1, username: '', avatar_url: null }] as unknown as TripPlanner['tripMembers']
|
||
render(<MCostsTab planner={planner({ trip: null as unknown as TripPlanner['trip'], tripMembers: solo })} shell={buildShell()} />)
|
||
await screen.findByText('+12,50 €')
|
||
expect(settlementBases).toEqual(['EUR'])
|
||
// Nameless member → the avatar and the flow names both fall back to '?'.
|
||
expect(screen.getAllByText('?').length).toBeGreaterThan(1)
|
||
})
|
||
|
||
it('FE-MOB-COSTT-037: follows the user display currency instead of the trip currency', async () => {
|
||
seedStore(useSettingsStore, {
|
||
settings: { ...useSettingsStore.getState().settings, default_currency: 'GBP' },
|
||
})
|
||
render(<MCostsTab planner={planner()} shell={buildShell()} />)
|
||
await screen.findByText('+£12.50')
|
||
expect(settlementBases).toEqual(['GBP'])
|
||
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('£92.50')
|
||
})
|
||
|
||
it('FE-MOB-COSTT-038: renders a recorded settlement payment inline with the day it was made on', async () => {
|
||
const payment = { id: 501, from_user_id: 1, to_user_id: 2, amount: 25, currency: 'USD', created_at: '2026-04-30T09:00:00Z' }
|
||
serveSettlement({ ...SETTLEMENT, settlements: [payment] })
|
||
await renderTab()
|
||
const group = up(screen.getByText('Thu, Apr 30'), 2)
|
||
expect(within(group).getByText('costs.payment')).toBeInTheDocument()
|
||
expect(within(group).getByText('costs.you → Ada')).toBeInTheDocument()
|
||
expect(within(group).getByText('$25.00')).toBeInTheDocument()
|
||
// The per-day "spent" total only counts expenses, not payments.
|
||
expect(within(group).getByText('costs.spent:$60.00')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-039: a payment on a day with no expense still creates its own ledger group', async () => {
|
||
const payment = { id: 502, from_user_id: 3, to_user_id: 1, amount: 15, currency: 'USD', created_at: '2026-06-15T09:00:00Z' }
|
||
serveSettlement({ ...SETTLEMENT, settlements: [payment] })
|
||
await renderTab()
|
||
expect(screen.getByText('costs.payment')).toBeInTheDocument()
|
||
const row = up(screen.getByText('Bob → costs.you'), 2)
|
||
expect(within(row).getByText('$15.00')).toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-043: shows what the trip costs each traveler and opens the arithmetic on tap', async () => {
|
||
// The rows come from the server in display cents. Bob's museum is a GBP expense
|
||
// the server booked at its frozen rate as $58.00; the live rate this test seeds
|
||
// would make it $60.00, and that figure must never reach the card.
|
||
serveSettlement({
|
||
...SETTLEMENT,
|
||
settlements: [{ id: 5, from_user_id: 3, to_user_id: 1, amount: 10, currency: 'USD', created_at: '2026-05-02 10:00:00' }],
|
||
finalBudgets: [
|
||
{
|
||
user_id: 1, username: 'Me', avatar_url: null, expenses: 100, reimbursed: 10, pending: -25, final: 115,
|
||
sources: {
|
||
fronted: [{ item_id: 11, cents: 8000 }, { item_id: 14, cents: 2000 }],
|
||
moved: [{ settlement_id: 5, from_user_id: 3, to_user_id: 1, cents: 1000 }],
|
||
outstanding: [{ from_user_id: 1, to_user_id: 2, cents: -4000 }, { from_user_id: 3, to_user_id: 1, cents: 1500 }],
|
||
},
|
||
},
|
||
{
|
||
user_id: 3, username: 'Bob', avatar_url: null, expenses: 58, reimbursed: -10, pending: -10, final: 78,
|
||
sources: {
|
||
fronted: [{ item_id: 12, cents: 5800 }],
|
||
moved: [{ settlement_id: 5, from_user_id: 3, to_user_id: 1, cents: -1000 }],
|
||
outstanding: [{ from_user_id: 3, to_user_id: 1, cents: -1500 }, { from_user_id: 99, to_user_id: 3, cents: 500 }],
|
||
},
|
||
},
|
||
],
|
||
})
|
||
await renderTab()
|
||
const card = up(screen.getByText('costs.finalBudget'), 1)
|
||
|
||
// One amount per traveler; Ada, whom the ledger left out, costs nothing.
|
||
expect(within(card).getByText('$115.00')).toBeInTheDocument()
|
||
expect(within(card).getByText('$78.00')).toBeInTheDocument()
|
||
expect(within(card).getByText('$0.00')).toBeInTheDocument()
|
||
expect(within(card).queryByText('costs.finalPending')).not.toBeInTheDocument()
|
||
|
||
const mine = within(card).getByRole('button', { name: /\$115\.00/ })
|
||
fireEvent.click(mine)
|
||
expect(mine).toHaveAttribute('aria-expanded', 'true')
|
||
// Each line signed by what it does to the final, and its rows signed the same
|
||
// way, so they add up to it on sight.
|
||
expect(within(card).getByText('+$100.00')).toBeInTheDocument()
|
||
expect(within(card).getByText('+$80.00')).toBeInTheDocument()
|
||
expect(within(card).getByText('+$20.00')).toBeInTheDocument()
|
||
expect(within(card).getAllByText('−$10.00')).toHaveLength(2)
|
||
expect(within(card).getByText('+$25.00')).toBeInTheDocument()
|
||
expect(within(card).getByText('+$40.00')).toBeInTheDocument()
|
||
expect(within(card).getByText('−$15.00')).toBeInTheDocument()
|
||
expect(within(card).getByText('Ramen')).toBeInTheDocument()
|
||
expect(within(card).getByText('Taxi')).toBeInTheDocument()
|
||
expect(within(card).queryByText('Museum')).not.toBeInTheDocument()
|
||
expect(within(card).getAllByText('Bob → costs.you')).toHaveLength(2)
|
||
expect(within(card).getByText('costs.you → Ada')).toBeInTheDocument()
|
||
|
||
// Opening Bob closes me. His museum row is the server's figure, not a live conversion.
|
||
const bob = within(card).getByRole('button', { name: /\$78\.00/ })
|
||
fireEvent.click(bob)
|
||
expect(mine).toHaveAttribute('aria-expanded', 'false')
|
||
expect(within(card).getAllByText('+$58.00')).toHaveLength(2)
|
||
expect(within(card).queryByText(/60\.00/)).not.toBeInTheDocument()
|
||
// Named by what Bob entered, as the list names it (#2525), never re-converted.
|
||
expect(within(card).getByText('Museum · £30.00')).toBeInTheDocument()
|
||
|
||
fireEvent.click(bob)
|
||
expect(bob).toHaveAttribute('aria-expanded', 'false')
|
||
expect(within(card).queryByText('+$58.00')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-044: a settlement read that fails says so instead of costing everyone nothing', async () => {
|
||
serveSettlement(SETTLEMENT, true)
|
||
render(<MCostsTab planner={planner()} shell={buildShell()} />)
|
||
|
||
const card = up(await screen.findByText('costs.finalBudget'), 1)
|
||
await waitFor(() => expect(within(card).getByText('common.unknownError')).toBeInTheDocument())
|
||
expect(within(card).queryByText('$0.00')).not.toBeInTheDocument()
|
||
expect(within(card).queryByRole('button')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-040: the expense filters apply to payments the same way they do on desktop', async () => {
|
||
const mine = { id: 503, from_user_id: 1, to_user_id: 2, amount: 8, currency: 'USD', created_at: '2026-05-02T09:00:00Z' }
|
||
const others = { id: 504, from_user_id: 2, to_user_id: 3, amount: 9, currency: 'USD', created_at: '2026-05-02T09:00:00Z' }
|
||
serveSettlement({ ...SETTLEMENT, settlements: [mine, others] })
|
||
await renderTab()
|
||
expect(screen.getAllByText('costs.payment')).toHaveLength(2)
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.owed' }))
|
||
expect(screen.queryByText('costs.payment')).not.toBeInTheDocument()
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.mine' }))
|
||
expect(screen.getAllByText('costs.payment')).toHaveLength(1)
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.all' }))
|
||
fireEvent.change(screen.getByLabelText('costs.searchPlaceholder'), { target: { value: 'ramen' } })
|
||
expect(screen.queryByText('costs.payment')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-041: editing a recorded payment opens it pre-filled and saves the change', async () => {
|
||
const payment = { id: 501, from_user_id: 1, to_user_id: 2, amount: 25, currency: 'USD', settled_at: '2026-04-28', created_at: '2026-04-30T09:00:00Z' }
|
||
serveSettlement({ ...SETTLEMENT, settlements: [payment] })
|
||
const update = vi.spyOn(budgetApi, 'updateSettlement').mockResolvedValue({})
|
||
await renderTab()
|
||
|
||
fireEvent.click(within(rowOf('costs.payment')).getByRole('button', { name: 'common.edit' }))
|
||
const dialog = screen.getByRole('dialog', { name: 'costs.editPayment' })
|
||
expect(within(dialog).getByDisplayValue('25.00')).toBeInTheDocument()
|
||
|
||
const submit = within(dialog).getByRole('button', { name: 'common.save' })
|
||
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '30' } })
|
||
fireEvent.click(submit)
|
||
|
||
expect(update).toHaveBeenCalledWith(7, 501, { from_user_id: 1, to_user_id: 2, amount: 30, currency: 'USD', settled_at: '2026-04-28' })
|
||
await waitFor(() => expect(settlementBases).toHaveLength(2))
|
||
})
|
||
|
||
it('FE-MOB-COSTT-042: undoing a payment deletes it outright, no confirmation', async () => {
|
||
const payment = { id: 502, from_user_id: 1, to_user_id: 2, amount: 25, currency: 'USD', created_at: '2026-04-30T09:00:00Z' }
|
||
serveSettlement({ ...SETTLEMENT, settlements: [payment] })
|
||
const del = vi.spyOn(budgetApi, 'deleteSettlement').mockResolvedValue({ success: true })
|
||
await renderTab()
|
||
|
||
fireEvent.click(within(rowOf('costs.payment')).getByRole('button', { name: 'costs.undo' }))
|
||
expect(del).toHaveBeenCalledWith(7, 502)
|
||
await waitFor(() => expect(settlementBases).toHaveLength(2))
|
||
})
|
||
|
||
it('FE-MOB-COSTT-043: editing a payment keeps the currency it was recorded in, not the display currency', async () => {
|
||
// Recorded in pounds while the phone shows dollars: the row says so, the
|
||
// sheet reopens in GBP, and a date-only correction must not re-declare the
|
||
// amount as dollars (the server would freeze a fresh FX rate for it).
|
||
const payment = { id: 505, from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28', created_at: '2026-04-30T09:00:00Z' }
|
||
serveSettlement({ ...SETTLEMENT, settlements: [payment] })
|
||
const update = vi.spyOn(budgetApi, 'updateSettlement').mockResolvedValue({})
|
||
await renderTab()
|
||
|
||
const row = rowOf('costs.payment')
|
||
expect(within(row).getByText('£10.00 → $20.00')).toBeInTheDocument()
|
||
expect(within(row).getByText('$20.00')).toBeInTheDocument()
|
||
|
||
fireEvent.click(within(row).getByRole('button', { name: 'common.edit' }))
|
||
const dialog = screen.getByRole('dialog', { name: 'costs.editPayment' })
|
||
expect(within(dialog).getByDisplayValue('10.00')).toBeInTheDocument()
|
||
expect(within(dialog).getByRole('button', { name: /GBP/ })).toBeInTheDocument()
|
||
|
||
fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' }))
|
||
// The pound rate the phone holds goes along; the server only uses it when the
|
||
// currency changes and it has no rate of its own, so the frozen one stays.
|
||
expect(update).toHaveBeenCalledWith(7, 505, {
|
||
from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28',
|
||
fallback_fx: { base: 'USD', rates: { GBP: 0.5 } },
|
||
})
|
||
})
|
||
|
||
it('FE-MOB-COSTT-045: a bill whose rate moved shows what was entered and what it was booked at (#2525)', async () => {
|
||
// A euro trip read in dollars, the bill entered in dollars at a frozen 1.17 to the
|
||
// euro while today the euro buys 1.1551. It is 685.26 EUR of trip money, $791.55
|
||
// today, and that is what settle-up offers too; the row now says where the
|
||
// 801.76 that was entered went instead of hiding it. Converted with the euro's own
|
||
// quote, not the dollar's separately rounded one.
|
||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
|
||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() }))
|
||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551 }, ts: Date.now() }))
|
||
const hotel = {
|
||
id: 21, trip_id: 7, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17,
|
||
expense_date: '2026-05-01', note: null,
|
||
members: [
|
||
{ user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null },
|
||
{ user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: null },
|
||
],
|
||
payers: [{ user_id: 1, amount: 801.76, username: 'Me' }],
|
||
} as unknown as BudgetItem
|
||
serveSettlement({
|
||
...SETTLEMENT,
|
||
settlements: [
|
||
{ id: 601, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, settled_at: '2026-05-02', created_at: '2026-05-02T09:00:00Z' },
|
||
// A transfer from before they carried a currency was entered in the display currency.
|
||
{ id: 602, from_user_id: 3, to_user_id: 1, amount: 30, currency: null, settled_at: '2026-05-03', created_at: '2026-05-03T09:00:00Z' },
|
||
],
|
||
})
|
||
await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [hotel] }))
|
||
|
||
const card = cardOf('Aparthotel Silver')
|
||
expect(within(card).getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
|
||
expect(within(card).getByText('$791.55')).toBeInTheDocument()
|
||
// Each share is half of the booked euros, the figure the balances net.
|
||
expect(within(card).getAllByText('$395.77')).toHaveLength(2)
|
||
expect(within(card).queryByText('$400.88')).toBeNull()
|
||
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$791.55')
|
||
expect(screen.getByText('costs.spent:$791.55')).toBeInTheDocument()
|
||
|
||
expect(screen.getAllByText('costs.payment')).toHaveLength(2)
|
||
const paid = up(screen.getByText('Ada → costs.you'), 2)
|
||
expect(within(paid).getByText(/^\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||
expect(within(up(screen.getByText('Bob → costs.you'), 2)).getByText('$30.00')).toBeInTheDocument()
|
||
expect(screen.queryByText('$34.65')).toBeNull()
|
||
})
|
||
|
||
it('FE-MOB-COSTT-046: a bill booked at today\'s rate reads exactly as typed (#2525)', async () => {
|
||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } })
|
||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.87732 }, ts: Date.now() }))
|
||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1398 }, ts: Date.now() }))
|
||
const villa = {
|
||
id: 22, trip_id: 7, name: 'Villa', category: 'accommodation', total_price: 12345.67, currency: 'USD', exchange_rate: 1.1398,
|
||
expense_date: '2026-05-01', note: null,
|
||
members: [
|
||
{ user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null },
|
||
{ user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: null },
|
||
],
|
||
payers: [{ user_id: 1, amount: 12345.67, username: 'Me' }],
|
||
} as unknown as BudgetItem
|
||
serveSettlement(SETTLEMENT)
|
||
await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [villa] }))
|
||
|
||
const card = cardOf('Villa')
|
||
// Through the dollar's own quote it read $12,346.05.
|
||
expect(within(card).getByText('$12,345.67')).toBeInTheDocument()
|
||
expect(within(card).queryByText(/^\$12,345\.67 →/)).toBeNull()
|
||
expect(screen.getByText('costs.spent:$12,345.67')).toBeInTheDocument()
|
||
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$12,345.67')
|
||
})
|
||
|
||
// The server could not fetch rates, so the museum's pounds never froze one and the
|
||
// settlement left them out. An editor's phone lends its own rate and reads again.
|
||
it('FE-MOB-COSTT-047: an editor heals a bill the server could not convert and reads the settlement again', async () => {
|
||
resetFreezeAttempts()
|
||
let healed = false
|
||
server.use(
|
||
http.get('/api/trips/:id/budget/settlement', ({ request }) => {
|
||
settlementBases.push(new URL(request.url).searchParams.get('base') ?? '')
|
||
return HttpResponse.json(healed
|
||
? { ...SETTLEMENT, unconverted: { item_ids: [], settlement_ids: [], currencies: [] } }
|
||
: { ...SETTLEMENT, unconverted: { item_ids: [12], settlement_ids: [], currencies: ['GBP'] } })
|
||
}),
|
||
)
|
||
const freeze = vi.spyOn(budgetApi, 'freezeRates').mockImplementation(async () => {
|
||
healed = true
|
||
return { items: [{ ...MUSEUM, exchange_rate: 0.5 }], settlements: [], unresolved: [] }
|
||
})
|
||
await renderTab()
|
||
|
||
await waitFor(() => expect(freeze).toHaveBeenCalledWith(7, { fallback_fx: { base: 'USD', rates: { GBP: 0.5 } } }))
|
||
await waitFor(() => expect(settlementBases).toHaveLength(2))
|
||
expect(freeze).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('FE-MOB-COSTT-048: a read-only member\'s phone never freezes a rate', async () => {
|
||
resetFreezeAttempts()
|
||
serveSettlement({ ...SETTLEMENT, unconverted: { item_ids: [12], settlement_ids: [], currencies: ['GBP'] } })
|
||
const freeze = vi.spyOn(budgetApi, 'freezeRates')
|
||
await renderTab(planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] }))
|
||
|
||
await new Promise(r => setTimeout(r, 50))
|
||
expect(freeze).not.toHaveBeenCalled()
|
||
expect(settlementBases).toHaveLength(1)
|
||
})
|
||
|
||
it('FE-MOB-COSTT-049: asks the settlement in the display currency with the phone\'s own rate for it', async () => {
|
||
seedStore(useSettingsStore, {
|
||
settings: { ...useSettingsStore.getState().settings, default_currency: 'GBP' },
|
||
})
|
||
const rates: (string | null)[] = []
|
||
server.use(
|
||
http.get('/api/trips/:id/budget/settlement', ({ request }) => {
|
||
const url = new URL(request.url)
|
||
settlementBases.push(url.searchParams.get('base') ?? '')
|
||
rates.push(url.searchParams.get('base_rate'))
|
||
return HttpResponse.json(SETTLEMENT)
|
||
}),
|
||
)
|
||
render(<MCostsTab planner={planner()} shell={buildShell()} />)
|
||
await screen.findByText('+£12.50')
|
||
|
||
// Pounds per dollar from the trip currency's own table, what the server labels with
|
||
// when it cannot fetch a quote itself.
|
||
expect(settlementBases).toEqual(['GBP'])
|
||
expect(rates).toEqual(['0.5'])
|
||
})
|
||
|
||
it('FE-MOB-COSTT-044: a payment cannot be saved without a day', async () => {
|
||
// Cleared, the server would store NULL and the ledger would quietly fall
|
||
// back to the day the payment was recorded on.
|
||
const create = vi.spyOn(budgetApi, 'createSettlement').mockResolvedValue({})
|
||
await renderTab()
|
||
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
|
||
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
|
||
const submit = within(dialog).getByRole('button', { name: 'costs.addPayment' })
|
||
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '12,5' } })
|
||
expect(submit).toBeEnabled()
|
||
|
||
fireEvent.click(dialog.querySelector('button[aria-haspopup="dialog"]') as HTMLElement)
|
||
fireEvent.click(screen.getByRole('button', { name: 'Clear date' }))
|
||
expect(submit).toBeDisabled()
|
||
fireEvent.click(submit)
|
||
expect(create).not.toHaveBeenCalled()
|
||
})
|
||
})
|