Files
TREK/client/src/pages/tripPlanner/useBookingExpenseEditor.test.ts
T
Maurice b598b97865 refactor(planner): share the trip sheet wiring between desktop and phone
The phone trip sheet host wrote out by hand three pieces of planner
wiring the desktop planner already had.

useBookingExpenseEditor now holds the expense editor both planners open
over the trip: the booking request state, the receipt fallback, the base
currency, the current user and the save that reloads the budget.
TripPlannerPage and MTripSheets both read it.

applyTripCoverUpdate patches a new trip cover into the store for the
trip form on both sides.

The transport editor and the transit route change moved into
openTransportEditorWith and changeTransitRouteWith, which take the
planner's setters. usePlannerDialogs builds openTransportEditor and
changeTransitRoute on them, and the phone sheet calls the same helpers
on the planner it receives. There are no differences between the two
views to keep as options.
2026-10-09 15:19:51 +02:00

141 lines
5.0 KiB
TypeScript

// FE-TP-BOOKEXP-001 to FE-TP-BOOKEXP-009
//
// The expense editor the trip planner opens over itself, driven straight through
// useBookingExpenseEditor. The desktop planner and the phone trip sheets render the
// same editor from it, so every case holds for both.
import { act, renderHook } from '@testing-library/react';
import { buildBudgetItem } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import type { ExpensePrefill } from '../../components/Budget/CostsPanel';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import { useBookingExpenseEditor } from './useBookingExpenseEditor';
interface Props {
tripCurrency?: string | null;
receiptExpense?: ExpensePrefill | null;
}
const clearReceiptExpense = vi.fn();
function renderEditor(initial: Props = {}) {
return renderHook(
(props: Props) =>
useBookingExpenseEditor({
tripId: 42,
tripCurrency: props.tripCurrency,
receiptExpense: props.receiptExpense ?? null,
clearReceiptExpense,
}),
{ initialProps: initial }
);
}
describe('useBookingExpenseEditor', () => {
beforeEach(() => {
resetAllStores();
clearReceiptExpense.mockClear();
seedStore(useAuthStore, { user: { id: 7, username: 'maurice', email: 'm@example.com' } });
});
it('FE-TP-BOOKEXP-001: no editor until a booking or a receipt asks for one', () => {
const { result } = renderEditor();
expect(result.current.expenseEditor).toBeNull();
});
it('FE-TP-BOOKEXP-002: a booking opens its linked expense for editing and closes it again', () => {
const item = buildBudgetItem({ id: 99 });
const { result } = renderEditor();
act(() => {
result.current.openBookingExpense({ editItem: item });
});
expect(result.current.expenseEditor).toMatchObject({ key: 'booking', editing: item, prefill: undefined });
act(() => {
result.current.expenseEditor!.close();
});
expect(result.current.expenseEditor).toBeNull();
});
it('FE-TP-BOOKEXP-003: a booking without a linked expense opens a new one prefilled', () => {
const { result } = renderEditor();
act(() => {
result.current.openBookingExpense({ prefill: { reservationId: 5, name: 'Train', amount: 30 } });
});
expect(result.current.expenseEditor).toMatchObject({
key: 'booking',
editing: null,
prefill: { reservationId: 5, name: 'Train', amount: 30 },
});
});
it('FE-TP-BOOKEXP-004: a request with neither an item nor a prefill opens nothing', () => {
const { result } = renderEditor();
act(() => {
result.current.openBookingExpense({});
});
expect(result.current.expenseEditor).toBeNull();
});
it('FE-TP-BOOKEXP-005: a scanned receipt opens a new expense and its close clears the receipt', () => {
const { result } = renderEditor({ receiptExpense: { name: 'Lunch', amount: 12 } });
expect(result.current.expenseEditor).toMatchObject({
key: 'receipt',
editing: null,
prefill: { name: 'Lunch', amount: 12 },
});
act(() => {
result.current.expenseEditor!.close();
});
expect(clearReceiptExpense).toHaveBeenCalledTimes(1);
});
it('FE-TP-BOOKEXP-006: a booking request wins over a waiting receipt', () => {
const item = buildBudgetItem({ id: 3 });
const { result } = renderEditor({ receiptExpense: { name: 'Lunch' } });
act(() => {
result.current.openBookingExpense({ editItem: item });
});
expect(result.current.expenseEditor?.key).toBe('booking');
});
it('FE-TP-BOOKEXP-007: the base currency prefers the display setting, then the trip, then EUR', () => {
seedStore(useSettingsStore, { settings: { default_currency: 'usd' } });
const { result, rerender } = renderEditor({ tripCurrency: 'jpy' });
expect(result.current.costsBase).toBe('USD');
act(() => {
seedStore(useSettingsStore, { settings: { default_currency: '' } });
});
rerender({ tripCurrency: 'jpy' });
expect(result.current.costsBase).toBe('JPY');
rerender({ tripCurrency: null });
expect(result.current.costsBase).toBe('EUR');
});
it('FE-TP-BOOKEXP-008: the current user pays, an anonymous session as user -1', () => {
const { result } = renderEditor();
expect(result.current.meId).toBe(7);
act(() => {
seedStore(useAuthStore, { user: null });
});
expect(result.current.meId).toBe(-1);
});
it('FE-TP-BOOKEXP-009: a save closes the editor and reloads the trip budget', () => {
const loadBudgetItems = vi.fn(async () => undefined);
seedStore(useTripStore, { loadBudgetItems });
const { result } = renderEditor();
act(() => {
result.current.openBookingExpense({ prefill: { reservationId: 5 } });
});
act(() => {
result.current.onExpenseSaved();
});
expect(result.current.expenseEditor).toBeNull();
expect(loadBudgetItems).toHaveBeenCalledWith(42);
});
});