D1 of Max: showsEmptyState of Transactions reads totalInDb === 0 only, like Budget. On an empty profile the card comes up as soon as the count is loaded, instead of the filter bar, the totals and the loading line until the first page is back. A null count (not loaded, or failed) keeps the regular layout. The mount reads of useTransactions and useBudget run under Promise.allSettled: each read that succeeds is dispatched, the first failure becomes the page's error, and a failed count leaves totalInDb null instead of emptying the category, source and account filters. Tests: the reducer test of the loading page rewritten for D1, the page's empty-profile case rendered (card in place of the filter bar, table and pagination), and both hooks' mount reads run through the shared harness. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
4.7 KiB
TypeScript
121 lines
4.7 KiB
TypeScript
// useTransactions — the reads of the mount stand alone (#384).
|
|
//
|
|
// React's hooks come from the shared harness (src/test/hookHarness.ts, #409):
|
|
// the hook runs as a plain function, its reducer is applied, and `commit()`
|
|
// runs its effects (the mount reads, the first page, the search debounce,
|
|
// whose timer `unmount()` clears). The services are mocked. What is pinned:
|
|
// one failed read no longer drops the others, a failed count leaves
|
|
// `totalInDb` unknown (the regular layout, never the "import first" card),
|
|
// and the first failure, in the order categories, sources, count, is the
|
|
// page's error.
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
|
|
const services = vi.hoisted(() => ({
|
|
getAllCategories: vi.fn(),
|
|
getAllImportSources: vi.fn(),
|
|
getTransactionCount: vi.fn(),
|
|
getTransactionPage: vi.fn(),
|
|
}));
|
|
vi.mock("../services/transactionService", () => ({
|
|
...services,
|
|
updateTransactionCategory: vi.fn(),
|
|
updateTransactionNotes: vi.fn(),
|
|
autoCategorizeTransactions: vi.fn(),
|
|
getSplitChildren: vi.fn(),
|
|
saveSplitAdjustment: vi.fn(),
|
|
deleteSplitAdjustment: vi.fn(),
|
|
}));
|
|
vi.mock("../services/categoryService", () => ({ createKeyword: vi.fn() }));
|
|
|
|
import { hookHarness } from "../test/hookHarness";
|
|
import { useTransactions, showsEmptyState } from "./useTransactions";
|
|
import type { Category, ImportSource, TransactionPageResult } from "../shared/types";
|
|
|
|
const harness = hookHarness();
|
|
|
|
const CATEGORIES = [{ id: 1, name: "Épicerie" }] as Category[];
|
|
const SOURCES = [{ id: 7, name: "Desjardins" }] as ImportSource[];
|
|
const PAGE: TransactionPageResult = {
|
|
rows: [],
|
|
totalCount: 0,
|
|
totalAmount: 0,
|
|
incomeTotal: 0,
|
|
expenseTotal: 0,
|
|
};
|
|
|
|
/** Lets the mount reads and the first page settle. */
|
|
const flush = async () => {
|
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
};
|
|
|
|
/** Mounts the hook, runs its effects, lets them settle, and renders again. */
|
|
async function mount() {
|
|
harness.render(() => useTransactions());
|
|
harness.commit();
|
|
await flush();
|
|
return harness.render(() => useTransactions()).state;
|
|
}
|
|
|
|
/** The actions of one type the hook dispatched, in order. */
|
|
const sent = (type: string) =>
|
|
harness.dispatch.mock.calls
|
|
.map(([action]) => action as { type: string; payload?: unknown })
|
|
.filter((a) => a.type === type);
|
|
|
|
beforeEach(() => {
|
|
harness.clear();
|
|
services.getAllCategories.mockReset().mockResolvedValue(CATEGORIES);
|
|
services.getAllImportSources.mockReset().mockResolvedValue(SOURCES);
|
|
services.getTransactionCount.mockReset().mockResolvedValue(0);
|
|
services.getTransactionPage.mockReset().mockResolvedValue(PAGE);
|
|
});
|
|
|
|
afterEach(() => {
|
|
harness.unmount();
|
|
});
|
|
|
|
describe("useTransactions — the reads of the mount (#384)", () => {
|
|
it("all three succeed: categories, sources and the count, no error", async () => {
|
|
const state = await mount();
|
|
expect(state.categories).toBe(CATEGORIES);
|
|
expect(state.sources).toBe(SOURCES);
|
|
expect(state.totalInDb).toBe(0);
|
|
expect(state.error).toBeNull();
|
|
expect(showsEmptyState(state)).toBe(true);
|
|
});
|
|
|
|
it("a failed count still loads the categories and the sources, and leaves the count unknown", async () => {
|
|
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
|
|
const state = await mount();
|
|
expect(state.categories).toBe(CATEGORIES);
|
|
expect(state.sources).toBe(SOURCES);
|
|
expect(state.totalInDb).toBeNull();
|
|
expect(sent("SET_TOTAL_IN_DB")).toEqual([]);
|
|
expect(state.error).toBe("count failed");
|
|
// Unknown count: the regular layout, never the "import first" card.
|
|
expect(showsEmptyState(state)).toBe(false);
|
|
});
|
|
|
|
it("failed categories still load the sources and the count", async () => {
|
|
services.getAllCategories.mockRejectedValue(new Error("categories failed"));
|
|
services.getTransactionCount.mockResolvedValue(12);
|
|
const state = await mount();
|
|
expect(state.categories).toEqual([]);
|
|
expect(state.sources).toBe(SOURCES);
|
|
expect(state.totalInDb).toBe(12);
|
|
expect(state.error).toBe("categories failed");
|
|
});
|
|
|
|
it("several failures: the first one, in the order categories, sources, count, is the error", async () => {
|
|
services.getAllImportSources.mockRejectedValue("sources failed");
|
|
services.getTransactionCount.mockRejectedValue(new Error("count failed"));
|
|
const state = await mount();
|
|
expect(state.categories).toBe(CATEGORIES);
|
|
expect(state.sources).toEqual([]);
|
|
expect(state.totalInDb).toBeNull();
|
|
// A rejection that is not an Error is shown as its text.
|
|
expect(state.error).toBe("sources failed");
|
|
expect(sent("SET_ERROR").filter((a) => a.payload !== null)).toHaveLength(1);
|
|
});
|
|
});
|