diff --git a/client/src/components/Budget/CostsPanel.filtering.test.tsx b/client/src/components/Budget/CostsPanel.filtering.test.tsx new file mode 100644 index 000000000..fe073c96e --- /dev/null +++ b/client/src/components/Budget/CostsPanel.filtering.test.tsx @@ -0,0 +1,170 @@ +// FE-W5COSTS-014 to FE-W5COSTS-019 and FE-W5COSTS-076: filtering the Costs ledger +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { buildBudgetItem, buildTrip, buildUser } from '../../../tests/helpers/factories'; +import { server } from '../../../tests/helpers/msw/server'; +import { fireEvent, render, screen, within } from '../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useAuthStore } from '../../store/authStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import { useTripStore } from '../../store/tripStore'; +import type { BudgetItem } from '../../types'; +import CostsPanel from './CostsPanel'; + +const tripMembers = [ + { id: 1, username: 'alice', avatar_url: null }, + { id: 2, username: 'bob', avatar_url: null }, +]; + +type Payment = { + id: number; + from_user_id: number; + to_user_id: number; + amount: number; + created_at?: string; + settled_at?: string | null; +}; +type MemberFixture = { user_id: number; username?: string }; +const expense = (over: Partial> & { members?: MemberFixture[] }): BudgetItem => + ({ ...buildBudgetItem({ trip_id: 1 }), payers: [], members: [], ...over }) as unknown as BudgetItem; + +function mount(items: BudgetItem[], settlement: { settlements?: Payment[] } = {}) { + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items })), + http.get('/api/trips/1/budget/settlement', () => + HttpResponse.json({ balances: [], flows: [], settlements: [], ...settlement }) + ) + ); + return render(); +} + +// Alice is the signed-in user; the euro display currency keeps FX an identity. +function seedAlice() { + resetAllStores(); + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) }); + seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true }); + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }); +} + +const dinner = () => + expense({ + id: 101, + name: 'Dinner', + category: 'food', + total_price: 90, + expense_date: '2025-06-15', + payers: [{ user_id: 1, amount: 90 }], + members: [ + { user_id: 1, username: 'alice' }, + { user_id: 2, username: 'bob' }, + ], + }); +const taxi = () => + expense({ + id: 102, + name: 'Taxi', + category: 'transport', + total_price: 30, + expense_date: '2025-06-16', + payers: [{ user_id: 2, amount: 30 }], + members: [ + { user_id: 1, username: 'alice' }, + { user_id: 2, username: 'bob' }, + ], + }); + +describe('CostsPanel — filtering the ledger', () => { + beforeEach(seedAlice); + + const payment: Payment = { id: 7, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00' }; + // The payment comes with the settlement, which can arrive after the expenses: a filter is + // only tested against it once both are on screen. + const ledgerLoaded = async () => { + await screen.findByText('Taxi'); + await screen.findByText('Payment'); + }; + + it('FE-W5COSTS-014: "Paid by me" keeps only the expenses I fronted', async () => { + mount([dinner(), taxi()], { settlements: [payment] }); + + await ledgerLoaded(); + fireEvent.click(screen.getByRole('button', { name: 'Filter' })); + fireEvent.click(screen.getByRole('button', { name: 'Paid by me' })); + + expect(screen.getByText('Dinner')).toBeInTheDocument(); + expect(screen.queryByText('Taxi')).not.toBeInTheDocument(); + // A transfer I'm part of stays visible under "mine". + expect(screen.getByText('Payment')).toBeInTheDocument(); + }); + + it('FE-W5COSTS-015: "I\'m owed" keeps the expenses I am net positive on and drops payments', async () => { + mount([dinner(), taxi()], { settlements: [payment] }); + + await ledgerLoaded(); + fireEvent.click(screen.getByRole('button', { name: 'Filter' })); + fireEvent.click(screen.getByRole('button', { name: "I'm owed" })); + + expect(screen.getByText('Dinner')).toBeInTheDocument(); + expect(screen.queryByText('Taxi')).not.toBeInTheDocument(); + expect(screen.queryByText('Payment')).not.toBeInTheDocument(); + }); + + it('FE-W5COSTS-016: the category filter narrows the ledger and hides payments', async () => { + mount([dinner(), taxi()], { settlements: [payment] }); + + await ledgerLoaded(); + fireEvent.click(screen.getByRole('button', { name: 'Filter' })); + fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Transport' })); + + expect(screen.getByText('Taxi')).toBeInTheDocument(); + expect(screen.queryByText('Dinner')).not.toBeInTheDocument(); + expect(screen.queryByText('Payment')).not.toBeInTheDocument(); + }); + + it('FE-W5COSTS-017: picking a single day banners it with that day’s total', async () => { + mount([dinner(), taxi()], { settlements: [payment] }); + + await ledgerLoaded(); + fireEvent.click(screen.getByRole('button', { name: 'Filter' })); + fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' })); + + expect(screen.getByText('Sunday, June 15')).toBeInTheDocument(); + expect(screen.getByText('1 expense')).toBeInTheDocument(); + expect(screen.getByText('Dinner')).toBeInTheDocument(); + expect(screen.queryByText('Taxi')).not.toBeInTheDocument(); + // The 16th carries the payment, so it is filtered out with the day too. + expect(screen.queryByText('Payment')).not.toBeInTheDocument(); + }); + + it('FE-W5COSTS-076: a payment groups by its own settled day, not the day it was recorded', async () => { + // Recorded (created_at) on the 16th, but settled on the 15th — the ledger + // must follow settled_at, the same way it already follows expense_date over + // an expense's own created_at. + mount([dinner(), taxi()], { settlements: [{ ...payment, settled_at: '2025-06-15' }] }); + + await ledgerLoaded(); + fireEvent.click(screen.getByRole('button', { name: 'Filter' })); + fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' })); + + expect(screen.getByText('Dinner')).toBeInTheDocument(); + expect(screen.queryByText('Taxi')).not.toBeInTheDocument(); + expect(screen.getByText('Payment')).toBeInTheDocument(); + }); + + it('FE-W5COSTS-018: expenses without a date are grouped under "No date"', async () => { + mount([expense({ id: 120, name: 'Souvenirs', category: 'shopping', total_price: 12, expense_date: null })]); + + expect(await screen.findByText('No date')).toBeInTheDocument(); + }); + + it('FE-W5COSTS-019: a search with no hits shows the no-match copy', async () => { + const user = userEvent.setup(); + mount([dinner()]); + + await screen.findByText('Dinner'); + await user.type(screen.getByPlaceholderText('Search expenses…'), 'zzz'); + + expect(screen.getByText('No expenses match your search.')).toBeInTheDocument(); + expect(screen.queryByText('Dinner')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Budget/CostsPanel.formerParticipants.test.tsx b/client/src/components/Budget/CostsPanel.formerParticipants.test.tsx new file mode 100644 index 000000000..9c7970b7b --- /dev/null +++ b/client/src/components/Budget/CostsPanel.formerParticipants.test.tsx @@ -0,0 +1,68 @@ +// FE-W5COSTS-097: someone who left the trip in the Costs panel (#2604) +import { http, HttpResponse } from 'msw'; +import { buildBudgetItem, buildTrip, buildUser } from '../../../tests/helpers/factories'; +import { server } from '../../../tests/helpers/msw/server'; +import { render, screen, waitFor } from '../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { clearExchangeRateCache } from '../../hooks/useExchangeRates'; +import { useAuthStore } from '../../store/authStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import { useTripStore } from '../../store/tripStore'; +import CostsPanel from './CostsPanel'; +import { resetFreezeAttempts } from './useFreezeMissingRates'; + +const tripMembers = [ + { id: 1, username: 'alice', avatar_url: null }, + { id: 2, username: 'bob', avatar_url: null }, +]; + +// Alice signed in on an AUD trip whose browser holds an AUD quote: the setup this case +// ran under beside the bills the server cannot convert in CostsPanel.test.tsx. +function seedAudTrip() { + resetAllStores(); + seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true }); + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'AUD' } }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'AUD' }) }); + clearExchangeRateCache(); + resetFreezeAttempts(); + localStorage.setItem('trek_fx_AUD', JSON.stringify({ rates: { AUD: 1, VND: 18241.3, EUR: 0.61 }, ts: Date.now() })); +} + +describe('CostsPanel: someone who left the trip (#2604)', () => { + beforeEach(seedAudTrip); + afterEach(clearExchangeRateCache); + + it('FE-W5COSTS-097: someone who left the trip is named and keeps their balance in the list (#2604)', async () => { + // Carol was removed from the trip; her payer row stays on the expense and the + // server still nets her into the balances. + const item = { + ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Boat' }), + total_price: 90, + payers: [{ user_id: 3, amount: 90, username: 'carol' }], + members: [ + { user_id: 1, paid: 0, amount: null, username: 'alice' }, + { user_id: 2, paid: 0, amount: null, username: 'bob' }, + { user_id: 3, paid: 0, amount: null, username: 'carol' }, + ], + }; + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), + http.get('/api/trips/1/budget/settlement', () => + HttpResponse.json({ + balances: [ + { user_id: 1, username: 'alice', avatar_url: null, balance: -30 }, + { user_id: 2, username: 'bob', avatar_url: null, balance: -30 }, + { user_id: 3, username: 'carol', avatar_url: null, balance: 60 }, + ], + flows: [], + settlements: [], + }) + ) + ); + render(); + + await screen.findByText('Boat'); + await waitFor(() => expect(screen.getAllByText('carol').length).toBeGreaterThan(0)); + expect(screen.queryByText('?')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Budget/CostsPanel.summary.test.tsx b/client/src/components/Budget/CostsPanel.summary.test.tsx index eba1d89cd..c2cd31250 100644 --- a/client/src/components/Budget/CostsPanel.summary.test.tsx +++ b/client/src/components/Budget/CostsPanel.summary.test.tsx @@ -60,7 +60,8 @@ describe('CostsPanel: summary footers and won amounts (#2604)', () => { mount([owedByBob()], { flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 20 }], }); - await screen.findByText('Museum'); + // The flow comes with the settlement, which can arrive after the expense. + await screen.findByRole('button', { name: /^Settle: bob → you/ }); expect(screen.getByRole('region', { name: 'Outstanding amount' })).not.toHaveTextContent("You're all settled up"); }); @@ -136,7 +137,7 @@ describe('CostsPanel: summary footers and won amounts (#2604)', () => { flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 20 }], }); await screen.findByText('Museum'); - expect(screen.getByRole('button', { name: /^Settle: bob → you, 20,00\s€$/ })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /^Settle: bob → you, 20,00\s€$/ })).toBeInTheDocument(); expect(screen.getAllByText('bob → you').length).toBeGreaterThan(0); }); }); diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index e727b3115..6a7d8f2d4 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -1094,102 +1094,12 @@ describe('CostsPanel — settle up', () => { await screen.findByText('You owe') const card = screen.getByRole('region', { name: 'You owe' }) as HTMLElement - expect(within(card).getByText('To')).toBeInTheDocument() + expect(await within(card).findByText('To')).toBeInTheDocument() expect(within(card).getByText('bob')).toBeInTheDocument() expect(screen.getByText('Nothing owed to you')).toBeInTheDocument() }) }) -describe('CostsPanel — filtering the ledger', () => { - beforeEach(seedAlice) - - const payment: Payment = { id: 7, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00' } - - it('FE-W5COSTS-014: "Paid by me" keeps only the expenses I fronted', async () => { - mount([dinner(), taxi()], { settlements: [payment] }) - - await screen.findByText('Taxi') - fireEvent.click(screen.getByRole('button', { name: 'Filter' })) - fireEvent.click(screen.getByRole('button', { name: 'Paid by me' })) - - expect(screen.getByText('Dinner')).toBeInTheDocument() - expect(screen.queryByText('Taxi')).not.toBeInTheDocument() - // A transfer I'm part of stays visible under "mine". - expect(screen.getByText('Payment')).toBeInTheDocument() - }) - - it('FE-W5COSTS-015: "I\'m owed" keeps the expenses I am net positive on and drops payments', async () => { - mount([dinner(), taxi()], { settlements: [payment] }) - - await screen.findByText('Taxi') - fireEvent.click(screen.getByRole('button', { name: 'Filter' })) - fireEvent.click(screen.getByRole('button', { name: "I'm owed" })) - - expect(screen.getByText('Dinner')).toBeInTheDocument() - expect(screen.queryByText('Taxi')).not.toBeInTheDocument() - expect(screen.queryByText('Payment')).not.toBeInTheDocument() - }) - - it('FE-W5COSTS-016: the category filter narrows the ledger and hides payments', async () => { - mount([dinner(), taxi()], { settlements: [payment] }) - - await screen.findByText('Taxi') - fireEvent.click(screen.getByRole('button', { name: 'Filter' })) - fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Transport' })) - - expect(screen.getByText('Taxi')).toBeInTheDocument() - expect(screen.queryByText('Dinner')).not.toBeInTheDocument() - expect(screen.queryByText('Payment')).not.toBeInTheDocument() - }) - - it('FE-W5COSTS-017: picking a single day banners it with that day’s total', async () => { - mount([dinner(), taxi()], { settlements: [payment] }) - - await screen.findByText('Taxi') - fireEvent.click(screen.getByRole('button', { name: 'Filter' })) - fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' })) - - expect(screen.getByText('Sunday, June 15')).toBeInTheDocument() - expect(screen.getByText('1 expense')).toBeInTheDocument() - expect(screen.getByText('Dinner')).toBeInTheDocument() - expect(screen.queryByText('Taxi')).not.toBeInTheDocument() - // The 16th carries the payment, so it is filtered out with the day too. - expect(screen.queryByText('Payment')).not.toBeInTheDocument() - }) - - it('FE-W5COSTS-076: a payment groups by its own settled day, not the day it was recorded', async () => { - // Recorded (created_at) on the 16th, but settled on the 15th — the ledger - // must follow settled_at, the same way it already follows expense_date over - // an expense's own created_at. - mount([dinner(), taxi()], { settlements: [{ ...payment, settled_at: '2025-06-15' }] }) - - await screen.findByText('Taxi') - fireEvent.click(screen.getByRole('button', { name: 'Filter' })) - fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' })) - - expect(screen.getByText('Dinner')).toBeInTheDocument() - expect(screen.queryByText('Taxi')).not.toBeInTheDocument() - expect(screen.getByText('Payment')).toBeInTheDocument() - }) - - it('FE-W5COSTS-018: expenses without a date are grouped under "No date"', async () => { - mount([expense({ id: 120, name: 'Souvenirs', category: 'shopping', total_price: 12, expense_date: null })]) - - expect(await screen.findByText('No date')).toBeInTheDocument() - }) - - it('FE-W5COSTS-019: a search with no hits shows the no-match copy', async () => { - const user = userEvent.setup() - mount([dinner()]) - - await screen.findByText('Dinner') - await user.type(screen.getByPlaceholderText('Search expenses…'), 'zzz') - - expect(screen.getByText('No expenses match your search.')).toBeInTheDocument() - expect(screen.queryByText('Dinner')).not.toBeInTheDocument() - }) -}) - describe('CostsPanel — expense rows', () => { beforeEach(() => { seedAlice() @@ -1222,7 +1132,7 @@ describe('CostsPanel — expense rows', () => { await screen.findByText('Diner') expect(screen.getByText(/\$100\.00 → 50,00 €/)).toBeInTheDocument() // The payer chip and the settlement row are converted the same way. - expect(screen.getByText(/\$20\.00 → 10,00 €/)).toBeInTheDocument() + expect(await screen.findByText(/\$20\.00 → 10,00 €/)).toBeInTheDocument() }) it('FE-W5COSTS-078: a booked rate outlives the live one, for the expense and for the transfer', async () => { @@ -1242,7 +1152,7 @@ describe('CostsPanel — expense rows', () => { expect(screen.getByText(/\$120\.00 → 100,00 €/)).toBeInTheDocument() expect(screen.queryByText(/60,00 €/)).toBeNull() // The settled transfer is read back the same way: 24 USD booked at 1.2 is 20 euro. - expect(screen.getByText(/\$24\.00 → 20,00 €/)).toBeInTheDocument() + expect(await screen.findByText(/\$24\.00 → 20,00 €/)).toBeInTheDocument() }) it('FE-W5COSTS-022: deleting an expense removes it, and a failure is reported', async () => { @@ -2527,7 +2437,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => { }) await screen.findByText('Aparthotel Silver') - expect(screen.getByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument() + expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument() // The hotel's split chips can carry the same figure, so look past them to the payment. const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]')) expect(outsideChips('$395.77')).toHaveLength(1) @@ -2777,33 +2687,4 @@ describe('CostsPanel: a bill the server cannot convert', () => { // currency when it has no quote of its own. expect(asked.every(a => a.base === 'IRR' && a.baseRate === null)).toBe(true) }) - - it('FE-W5COSTS-097: someone who left the trip is named and keeps their balance in the list (#2604)', async () => { - // Carol was removed from the trip; her payer row stays on the expense and the - // server still nets her into the balances. - const item = { - ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Boat' }), total_price: 90, - payers: [{ user_id: 3, amount: 90, username: 'carol' }], - members: [{ user_id: 1, paid: 0, amount: null, username: 'alice' }, { user_id: 2, paid: 0, amount: null, username: 'bob' }, { user_id: 3, paid: 0, amount: null, username: 'carol' }], - } - server.use( - http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), - http.get('/api/trips/1/budget/settlement', () => - HttpResponse.json({ - balances: [ - { user_id: 1, username: 'alice', avatar_url: null, balance: -30 }, - { user_id: 2, username: 'bob', avatar_url: null, balance: -30 }, - { user_id: 3, username: 'carol', avatar_url: null, balance: 60 }, - ], - flows: [], - settlements: [], - }) - ), - ) - render() - - await screen.findByText('Boat') - await waitFor(() => expect(screen.getAllByText('carol').length).toBeGreaterThan(0)) - expect(screen.queryByText('?')).not.toBeInTheDocument() - }) })