test(costs): the ledger cases wait for the settlement, and the filter and former member cases get files of their own

This commit is contained in:
Maurice
2026-10-12 21:14:05 +00:00
parent b21f9d6739
commit 64e4c34472
4 changed files with 245 additions and 125 deletions
@@ -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<Omit<BudgetItem, 'members'>> & { 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(<CostsPanel tripId={1} tripMembers={tripMembers} />);
}
// 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();
});
});
@@ -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(<CostsPanel tripId={1} tripMembers={tripMembers} />);
await screen.findByText('Boat');
await waitFor(() => expect(screen.getAllByText('carol').length).toBeGreaterThan(0));
expect(screen.queryByText('?')).not.toBeInTheDocument();
});
});
@@ -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);
});
});
@@ -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(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Boat')
await waitFor(() => expect(screen.getAllByText('carol').length).toBeGreaterThan(0))
expect(screen.queryByText('?')).not.toBeInTheDocument()
})
})