mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
cfdbfb49c0
Every Costs write went straight to the API, so offline an expense failed with
"Unknown error" and was lost. Expenses (add, edit, delete, split, paid tick,
reorder) now go through budgetRepo and the mutation queue like places and
packing items: a temporary row offline, held behind an older queued write
online. The replay reads the expense from `item` only, so the split answer
({ members, item }) keeps the local payers and the paid answer ({ member })
never lands in the expense table. An expense linked to a place or booking
created offline is sent with the real id once that one has synced.
Payments are queued without a local row and a hint says they show once
sent; the ledger reads the balances again when the queue is through, and
offline it shows the last balances it read (new Dexie table, v10). Receipts
need an upload, so an offline save with new ones is stopped with a hint.
Refs #2601
258 lines
9.2 KiB
TypeScript
258 lines
9.2 KiB
TypeScript
import { act, renderHook } from '@testing-library/react';
|
|
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
|
|
|
import { budgetApi } from '../../api/client';
|
|
import { clearAll, offlineDb } from '../../db/offlineDb';
|
|
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
|
import { localToday } from '../Planner/today';
|
|
import type { CostsSettlement } from './costsModel';
|
|
import { useSettlementForm } from './useSettlementForm';
|
|
|
|
// FE-BUDGET-SETTLEFORM-001 to FE-BUDGET-SETTLEFORM-011
|
|
|
|
vi.mock('../../api/client', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../api/client')>()),
|
|
budgetApi: { createSettlement: vi.fn(), updateSettlement: vi.fn() },
|
|
}));
|
|
|
|
const PEOPLE = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
|
const RECORDED: CostsSettlement = {
|
|
id: 30,
|
|
from_user_id: 2,
|
|
to_user_id: 3,
|
|
amount: 4.9,
|
|
currency: 'usd',
|
|
created_at: '2026-07-01T10:00:00Z',
|
|
settled_at: '2026-06-30',
|
|
note: 'cash',
|
|
};
|
|
|
|
let onSaved: Mock<() => void>;
|
|
let toastError: Mock<(message: string) => void>;
|
|
let toastInfo: Mock<(message: string) => void>;
|
|
|
|
interface Props {
|
|
editing: CostsSettlement | null;
|
|
open?: boolean;
|
|
}
|
|
|
|
function setup(initial: Props, oneSaveAtATime?: boolean) {
|
|
return renderHook(
|
|
({ editing, open }: Props) =>
|
|
useSettlementForm({
|
|
tripId: 7,
|
|
tripCurrency: 'EUR',
|
|
base: 'EUR',
|
|
people: PEOPLE,
|
|
me: 1,
|
|
editing,
|
|
open,
|
|
t: (key: string) => key,
|
|
toast: { error: toastError, info: toastInfo },
|
|
onSaved,
|
|
oneSaveAtATime,
|
|
}),
|
|
{ initialProps: initial }
|
|
);
|
|
}
|
|
|
|
const createMock = () => vi.mocked(budgetApi.createSettlement);
|
|
const updateMock = () => vi.mocked(budgetApi.updateSettlement);
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
clearExchangeRateCache();
|
|
onSaved = vi.fn<() => void>();
|
|
toastError = vi.fn<(message: string) => void>();
|
|
toastInfo = vi.fn<(message: string) => void>();
|
|
createMock().mockResolvedValue({});
|
|
updateMock().mockResolvedValue({});
|
|
});
|
|
|
|
describe('useSettlementForm: where the desktop dialog starts', () => {
|
|
it('FE-BUDGET-SETTLEFORM-001: a new payment runs from me to the first other traveller, today, in the display currency', () => {
|
|
const { result } = setup({ editing: null });
|
|
expect(result.current.fromId).toBe(1);
|
|
expect(result.current.toId).toBe(2);
|
|
expect(result.current.amount).toBe('');
|
|
expect(result.current.currency).toBe('EUR');
|
|
expect(result.current.day).toBe(localToday());
|
|
expect(result.current.note).toBe('');
|
|
expect(result.current.valid).toBe(false);
|
|
});
|
|
|
|
it('FE-BUDGET-SETTLEFORM-002: a reopened payment keeps its people, its currency decimals, its day and its note (#2175)', () => {
|
|
const { result } = setup({ editing: RECORDED });
|
|
expect(result.current.fromId).toBe(2);
|
|
expect(result.current.toId).toBe(3);
|
|
expect(result.current.amount).toBe('4.90');
|
|
expect(result.current.currency).toBe('USD');
|
|
expect(result.current.day).toBe('2026-06-30');
|
|
expect(result.current.note).toBe('cash');
|
|
expect(result.current.valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('useSettlementForm: where the phone sheet starts', () => {
|
|
it('FE-BUDGET-SETTLEFORM-003: the sheet starts blank while closed and fills in from the payment when it opens', () => {
|
|
const { result, rerender } = setup({ editing: RECORDED, open: false });
|
|
expect(result.current.fromId).toBe(1);
|
|
expect(result.current.amount).toBe('');
|
|
expect(result.current.currency).toBe('EUR');
|
|
|
|
rerender({ editing: RECORDED, open: true });
|
|
expect(result.current.fromId).toBe(2);
|
|
expect(result.current.toId).toBe(3);
|
|
expect(result.current.amount).toBe('4.90');
|
|
expect(result.current.currency).toBe('USD');
|
|
expect(result.current.day).toBe('2026-06-30');
|
|
expect(result.current.note).toBe('cash');
|
|
});
|
|
|
|
it('FE-BUDGET-SETTLEFORM-004: reopening for a new payment starts over from the defaults', () => {
|
|
const { result, rerender } = setup({ editing: RECORDED, open: true });
|
|
act(() => result.current.setNote('typed'));
|
|
rerender({ editing: null, open: false });
|
|
rerender({ editing: null, open: true });
|
|
expect(result.current.fromId).toBe(1);
|
|
expect(result.current.toId).toBe(2);
|
|
expect(result.current.amount).toBe('');
|
|
expect(result.current.currency).toBe('EUR');
|
|
expect(result.current.day).toBe(localToday());
|
|
expect(result.current.note).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('useSettlementForm: saving', () => {
|
|
it('FE-BUDGET-SETTLEFORM-005: records a new payment, reading a comma as the decimal mark, and trims the note', async () => {
|
|
const { result } = setup({ editing: null });
|
|
act(() => {
|
|
result.current.setAmount('12,5');
|
|
result.current.setNote(' dinner ');
|
|
result.current.setDay('2026-07-03');
|
|
});
|
|
expect(result.current.valid).toBe(true);
|
|
await act(async () => {
|
|
await result.current.save();
|
|
});
|
|
expect(createMock()).toHaveBeenCalledWith(7, {
|
|
from_user_id: 1,
|
|
to_user_id: 2,
|
|
amount: 12.5,
|
|
currency: 'EUR',
|
|
settled_at: '2026-07-03',
|
|
note: 'dinner',
|
|
});
|
|
expect(onSaved).toHaveBeenCalledTimes(1);
|
|
expect(result.current.saving).toBe(false);
|
|
});
|
|
|
|
it('FE-BUDGET-SETTLEFORM-006: an edited payment is updated in place, an empty note goes as null', async () => {
|
|
const { result } = setup({ editing: RECORDED });
|
|
act(() => result.current.setNote(' '));
|
|
await act(async () => {
|
|
await result.current.save();
|
|
});
|
|
expect(updateMock()).toHaveBeenCalledWith(7, 30, {
|
|
from_user_id: 2,
|
|
to_user_id: 3,
|
|
amount: 4.9,
|
|
currency: 'USD',
|
|
settled_at: '2026-06-30',
|
|
note: null,
|
|
});
|
|
expect(createMock()).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-BUDGET-SETTLEFORM-007: nothing is sent while the form is invalid', async () => {
|
|
const { result } = setup({ editing: null });
|
|
act(() => {
|
|
result.current.setAmount('10');
|
|
result.current.setToId(1);
|
|
});
|
|
expect(result.current.valid).toBe(false);
|
|
await act(async () => {
|
|
await result.current.save();
|
|
});
|
|
act(() => {
|
|
result.current.setToId(2);
|
|
result.current.setDay('');
|
|
});
|
|
await act(async () => {
|
|
await result.current.save();
|
|
});
|
|
expect(createMock()).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-BUDGET-SETTLEFORM-008: a failed save toasts and leaves the form open', async () => {
|
|
createMock().mockRejectedValueOnce(new Error('nope'));
|
|
const { result } = setup({ editing: null });
|
|
act(() => result.current.setAmount('3'));
|
|
await act(async () => {
|
|
await result.current.save();
|
|
});
|
|
expect(toastError).toHaveBeenCalledWith('common.unknownError');
|
|
expect(onSaved).not.toHaveBeenCalled();
|
|
expect(result.current.saving).toBe(false);
|
|
});
|
|
|
|
it('FE-BUDGET-SETTLEFORM-009: the phone ignores a second save while one is out, the desktop sends both', async () => {
|
|
const run = async (oneSaveAtATime: boolean) => {
|
|
createMock().mockClear();
|
|
const pending: (() => void)[] = [];
|
|
createMock().mockImplementation(() => new Promise((resolve) => pending.push(() => resolve({}))));
|
|
const { result } = setup({ editing: null, open: true }, oneSaveAtATime);
|
|
act(() => result.current.setAmount('3'));
|
|
let first: Promise<void> = Promise.resolve();
|
|
act(() => {
|
|
first = result.current.save();
|
|
});
|
|
expect(result.current.saving).toBe(true);
|
|
await act(async () => {
|
|
void result.current.save();
|
|
});
|
|
const calls = createMock().mock.calls.length;
|
|
await act(async () => {
|
|
pending.forEach((resolve) => resolve());
|
|
await first;
|
|
});
|
|
return calls;
|
|
};
|
|
expect(await run(true)).toBe(1);
|
|
expect(await run(false)).toBe(2);
|
|
});
|
|
|
|
it('FE-BUDGET-SETTLEFORM-010: the amount takes only the decimals its currency has (#2604)', () => {
|
|
const { result } = setup({ editing: null });
|
|
act(() => result.current.setAmount('12,345'));
|
|
expect(result.current.amount).toBe('12.34');
|
|
act(() => result.current.setCurrency('JPY'));
|
|
expect(result.current.amount).toBe('12');
|
|
act(() => result.current.setAmount('1000.5'));
|
|
expect(result.current.amount).toBe('1000.');
|
|
});
|
|
});
|
|
|
|
describe('useSettlementForm: offline (#2601)', () => {
|
|
it('FE-BUDGET-SETTLEFORM-011: offline a payment is queued with a hint instead of failing', async () => {
|
|
await clearAll();
|
|
Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true });
|
|
try {
|
|
const { result } = setup({ editing: null });
|
|
act(() => result.current.setAmount('8'));
|
|
await act(async () => {
|
|
await result.current.save();
|
|
});
|
|
expect(createMock()).not.toHaveBeenCalled();
|
|
expect(toastError).not.toHaveBeenCalled();
|
|
expect(toastInfo).toHaveBeenCalledWith('costs.paymentQueued');
|
|
expect(onSaved).toHaveBeenCalledTimes(1);
|
|
const [queued] = await offlineDb.mutationQueue.toArray();
|
|
expect(queued).toMatchObject({ method: 'POST', url: '/trips/7/budget/settlements' });
|
|
expect(queued.body).toMatchObject({ from_user_id: 1, to_user_id: 2, amount: 8 });
|
|
} finally {
|
|
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
|
}
|
|
});
|
|
});
|