Give every report service an optional accountIds?: number[] filter that narrows results to a subset of import sources (transactions.source_id), matched via a parameterized IN (...) clause — one bound placeholder per id, never a joined string (CWE-89). New shared inPlaceholders() helper (src/utils/sqlFilters.ts) generates the placeholder list so the pattern lives in exactly one place. New accountIds param: getCompareMonthOverMonth, getCompareYearOverYear, getBudgetVsActualData, getCartesSnapshot (forwarded to its getCompareMonthOverMonth + getBudgetVsActualData sub-calls, so the Cartes dashboard's top-movers and budget-adherence cards respect an active filter instead of silently ignoring it). Signature change scalar -> plural: getMonthlyTrends, getCategoryOverTime, getExpensesByCategory (sourceId?: number -> accountIds?: number[]). No scalar production caller of these three passed sourceId today, so only the test call-sites needed updating to the array shape. Omitted/empty accountIds adds no clause at all, so every service stays byte-identical to its pre-#273 SQL/results — pinned by a regression test per service (new budgetService.test.ts / dashboardService.test.ts files, extended reportService.test.ts / reportService.cartes.test.ts). This is backend plumbing only: no report page exposes an account filter control yet (follow-up issues #274-#276 add the shared <FilterPanel> and wire it into Trends/Compare/Budget). Resolves #273 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1156 lines
44 KiB
TypeScript
1156 lines
44 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import {
|
|
getMonthlyTrends,
|
|
getCategoryOverTime,
|
|
getHighlights,
|
|
getCompareMonthOverMonth,
|
|
getCompareYearOverYear,
|
|
getCategoryZoom,
|
|
buildCompareTree,
|
|
buildOverTimeTree,
|
|
} from "./reportService";
|
|
import type { CategoryDelta, OverTimeRow } from "../shared/types";
|
|
|
|
// Mock the db module
|
|
vi.mock("./db", () => {
|
|
const getDb = vi.fn();
|
|
return {
|
|
getDb,
|
|
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
|
|
};
|
|
});
|
|
|
|
import { getDb } from "./db";
|
|
|
|
const mockSelect = vi.fn();
|
|
const mockDb = { select: mockSelect };
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
|
mockSelect.mockReset();
|
|
});
|
|
|
|
describe("getMonthlyTrends", () => {
|
|
it("builds query without WHERE clause when no filters are provided (regression: byte-identical to pre-#273)", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getMonthlyTrends();
|
|
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(sql).not.toContain("WHERE");
|
|
expect(params).toEqual([]);
|
|
});
|
|
|
|
it("applies date range filters without an accountIds argument (regression)", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getMonthlyTrends("2025-01-01", "2025-12-31");
|
|
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(sql).toContain("date >= $1");
|
|
expect(sql).toContain("date <= $2");
|
|
expect(sql).not.toContain("source_id");
|
|
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
|
|
});
|
|
|
|
it("an empty accountIds array behaves exactly like no filter", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getMonthlyTrends("2025-01-01", "2025-12-31", []);
|
|
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(sql).not.toContain("source_id");
|
|
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
|
|
});
|
|
|
|
it("applies a parameterized accountIds IN clause, one placeholder per id", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getMonthlyTrends("2025-01-01", "2025-12-31", [3, 7]);
|
|
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(sql).toContain("source_id IN ($3, $4)");
|
|
expect(sql).not.toContain("3,7");
|
|
expect(params).toEqual(["2025-01-01", "2025-12-31", 3, 7]);
|
|
});
|
|
|
|
it("applies the accountIds filter alone (no dates)", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getMonthlyTrends(undefined, undefined, [5]);
|
|
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(sql).toContain("WHERE source_id IN ($1)");
|
|
expect(params).toEqual([5]);
|
|
});
|
|
});
|
|
|
|
describe("getCategoryOverTime", () => {
|
|
it("builds query without WHERE clause when no filters are provided", async () => {
|
|
// First call: top categories, second call: monthly breakdown
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // topCategories
|
|
.mockResolvedValueOnce([]); // monthlyRows
|
|
|
|
await getCategoryOverTime();
|
|
|
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
|
// No WHERE clause should appear (no filters at all)
|
|
expect(topCatSQL).not.toContain("WHERE");
|
|
});
|
|
|
|
it("applies typeFilter via category type when provided", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // topCategories
|
|
.mockResolvedValueOnce([]); // monthlyRows
|
|
|
|
await getCategoryOverTime(undefined, undefined, 50, undefined, "expense");
|
|
|
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
|
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(topCatSQL).toContain("COALESCE(c.type, 'expense')");
|
|
expect(topCatSQL).toContain("WHERE");
|
|
expect(topCatParams[0]).toBe("expense");
|
|
});
|
|
|
|
it("applies income typeFilter correctly", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // topCategories
|
|
.mockResolvedValueOnce([]); // monthlyRows
|
|
|
|
await getCategoryOverTime("2025-01-01", "2025-06-30", 50, undefined, "income");
|
|
|
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
|
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
|
|
expect(topCatParams[0]).toBe("income");
|
|
// Date params follow
|
|
expect(topCatParams[1]).toBe("2025-01-01");
|
|
expect(topCatParams[2]).toBe("2025-06-30");
|
|
});
|
|
|
|
it("applies date range filters", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // topCategories
|
|
.mockResolvedValueOnce([]); // monthlyRows
|
|
|
|
await getCategoryOverTime("2025-01-01", "2025-12-31");
|
|
|
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
|
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(topCatSQL).toContain("t.date >= $1");
|
|
expect(topCatSQL).toContain("t.date <= $2");
|
|
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
|
|
});
|
|
|
|
it("applies accountIds filter (single id)", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // topCategories
|
|
.mockResolvedValueOnce([]); // monthlyRows
|
|
|
|
await getCategoryOverTime(undefined, undefined, 50, [3]);
|
|
|
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
|
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(topCatSQL).toContain("t.source_id IN ($1)");
|
|
expect(topCatParams[0]).toBe(3);
|
|
});
|
|
|
|
it("applies accountIds filter with one bound placeholder per id (never joined into the SQL string)", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // topCategories
|
|
.mockResolvedValueOnce([]); // monthlyRows
|
|
|
|
await getCategoryOverTime(undefined, undefined, 50, [3, 7, 12]);
|
|
|
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
|
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(topCatSQL).toContain("t.source_id IN ($1, $2, $3)");
|
|
expect(topCatSQL).not.toContain("3,7,12");
|
|
// topN ($4) follows the 3 account placeholders.
|
|
expect(topCatParams).toEqual([3, 7, 12, 50]);
|
|
});
|
|
|
|
it("an empty accountIds array behaves exactly like no filter (no clause, no orphan param)", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // topCategories
|
|
.mockResolvedValueOnce([]); // monthlyRows
|
|
|
|
await getCategoryOverTime("2025-01-01", "2025-12-31", 50, []);
|
|
|
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
|
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(topCatSQL).not.toContain("source_id");
|
|
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
|
|
});
|
|
|
|
it("combines typeFilter, date range, and accountIds", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // topCategories
|
|
.mockResolvedValueOnce([]); // monthlyRows
|
|
|
|
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, [2], "expense");
|
|
|
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
|
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
|
|
expect(topCatSQL).toContain("t.date >= $2");
|
|
expect(topCatSQL).toContain("t.date <= $3");
|
|
expect(topCatSQL).toContain("t.source_id IN ($4)");
|
|
expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
|
|
});
|
|
|
|
it("projects category type into the top-N SELECT", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // topCategories
|
|
.mockResolvedValueOnce([]); // monthlyRows
|
|
|
|
await getCategoryOverTime();
|
|
|
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
|
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') AS category_type");
|
|
});
|
|
|
|
it("returns correct structure with categories, data, colors, categoryIds, and types", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([
|
|
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
|
|
{ category_id: 2, category_name: "Salary", category_color: "#00ff00", category_type: "income", total: 200 },
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
|
|
{ month: "2025-01", category_id: 2, category_name: "Salary", total: 100 },
|
|
{ month: "2025-02", category_id: 1, category_name: "Food", total: 200 },
|
|
{ month: "2025-02", category_id: 2, category_name: "Salary", total: 100 },
|
|
]);
|
|
|
|
const result = await getCategoryOverTime("2025-01-01", "2025-02-28", 50, undefined, "expense");
|
|
|
|
expect(result.categories).toEqual(["Food", "Salary"]);
|
|
expect(result.colors).toEqual({ Food: "#ff0000", Salary: "#00ff00" });
|
|
expect(result.categoryIds).toEqual({ Food: 1, Salary: 2 });
|
|
expect(result.types).toEqual({ Food: "expense", Salary: "income" });
|
|
expect(result.data).toHaveLength(2);
|
|
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Salary: 100 });
|
|
expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Salary: 100 });
|
|
});
|
|
|
|
it("defaults a null category type to expense in the types map", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([
|
|
// COALESCE(c.type, 'expense') yields 'expense'; a null slipping through defaults too.
|
|
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: null, total: 500 },
|
|
])
|
|
.mockResolvedValueOnce([{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 }]);
|
|
|
|
const result = await getCategoryOverTime();
|
|
|
|
expect(result.types).toEqual({ Food: "expense" });
|
|
});
|
|
|
|
it("keeps the pivot top-N + Other but the id-keyed tree carries every category (Issue #264)", async () => {
|
|
mockSelect
|
|
// 1. top categories — top-N = 1, so only Food.
|
|
.mockResolvedValueOnce([
|
|
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
|
|
])
|
|
// 2. monthly breakdown — every category (no LIMIT here).
|
|
.mockResolvedValueOnce([
|
|
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
|
|
{ month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
|
|
{ month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 },
|
|
])
|
|
// 3. category metadata for the tree.
|
|
.mockResolvedValueOnce([
|
|
{ id: 1, name: "Food", color: "#ff0000", type: "expense", parent_id: null },
|
|
{ id: 2, name: "Transport", color: "#00ff00", type: "expense", parent_id: null },
|
|
{ id: 3, name: "Entertainment", color: "#0000ff", type: "expense", parent_id: null },
|
|
]);
|
|
|
|
const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense");
|
|
|
|
// Pivot path is UNCHANGED — top-N (Food) plus an "Other" bucket for the rest.
|
|
expect(result.categories).toEqual(["Food", "Other"]);
|
|
expect(result.colors["Other"]).toBe("#9ca3af");
|
|
expect(result.types).toEqual({ Food: "expense" });
|
|
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
|
|
|
|
// Sidecar tree — EVERY category (no top-N, no "Other"), keyed by id.
|
|
const treeNames = result.tree.map((r) => r.categoryName);
|
|
expect(treeNames).toEqual(expect.arrayContaining(["Food", "Transport", "Entertainment"]));
|
|
expect(treeNames).not.toContain("Other");
|
|
const food = result.tree.find((r) => r.categoryId === 1)!;
|
|
expect(food.monthly).toEqual([300]);
|
|
expect(food.total).toBe(300);
|
|
expect(food.category_type).toBe("expense");
|
|
expect(food.is_parent).toBe(false);
|
|
});
|
|
|
|
it("separates homonym categories of different types in the tree (Issue #264)", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // top categories (irrelevant to the tree)
|
|
.mockResolvedValueOnce([
|
|
// Two DIFFERENT categories both named "Divers".
|
|
{ month: "2025-01", category_id: 1, category_name: "Divers", total: 1000 },
|
|
{ month: "2025-01", category_id: 2, category_name: "Divers", total: 300 },
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{ id: 1, name: "Divers", color: null, type: "income", parent_id: null },
|
|
{ id: 2, name: "Divers", color: null, type: "expense", parent_id: null },
|
|
]);
|
|
|
|
const result = await getCategoryOverTime("2025-01-01", "2025-01-31");
|
|
|
|
// The pivot merges them (single "Divers" cell); the tree keeps them apart.
|
|
const incomeDivers = result.tree.find((r) => r.categoryId === 1)!;
|
|
const expenseDivers = result.tree.find((r) => r.categoryId === 2)!;
|
|
expect(incomeDivers.category_type).toBe("income");
|
|
expect(incomeDivers.total).toBe(1000);
|
|
expect(expenseDivers.category_type).toBe("expense");
|
|
expect(expenseDivers.total).toBe(300);
|
|
});
|
|
});
|
|
|
|
describe("getHighlights", () => {
|
|
// Reference month = March 2026, YTD year = 2026, today = 2026-04-14.
|
|
const REF_YEAR = 2026;
|
|
const REF_MONTH = 3;
|
|
const YTD_YEAR = 2026;
|
|
const TODAY = new Date(2026, 3, 14); // April 14, 2026 (month is 0-based here)
|
|
const TODAY_STR = "2026-04-14";
|
|
|
|
function queueEmpty(n: number) {
|
|
for (let i = 0; i < n; i++) {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
}
|
|
}
|
|
|
|
it("computes windows and returns zeroed data on an empty profile", async () => {
|
|
queueEmpty(5); // currentBalance, ytd, series, movers, recent
|
|
|
|
const result = await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 30, 5, 10, TODAY);
|
|
|
|
expect(result.currentMonth).toBe("2026-03");
|
|
expect(result.netBalanceCurrent).toBe(0);
|
|
expect(result.netBalanceYtd).toBe(0);
|
|
expect(result.monthlyBalanceSeries).toHaveLength(12);
|
|
// 12 months ending at the reference month (March 2026), inclusive.
|
|
expect(result.monthlyBalanceSeries[11].month).toBe("2026-03");
|
|
expect(result.monthlyBalanceSeries[0].month).toBe("2025-04");
|
|
expect(result.topMovers).toEqual([]);
|
|
expect(result.topTransactions).toEqual([]);
|
|
});
|
|
|
|
it("parameterises every query with no inlined strings", async () => {
|
|
queueEmpty(5);
|
|
|
|
await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 60, 5, 10, TODAY);
|
|
|
|
for (const call of mockSelect.mock.calls) {
|
|
const sql = call[0] as string;
|
|
const params = call[1] as unknown[];
|
|
expect(sql).not.toContain(`'${TODAY_STR}'`);
|
|
expect(Array.isArray(params)).toBe(true);
|
|
}
|
|
// Reference month range
|
|
const currentParams = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(currentParams[0]).toBe("2026-03-01");
|
|
expect(currentParams[1]).toBe("2026-03-31");
|
|
// YTD spans Jan 1 of ytdYear → today (independent of reference month).
|
|
const ytdParams = mockSelect.mock.calls[1][1] as unknown[];
|
|
expect(ytdParams[0]).toBe("2026-01-01");
|
|
expect(ytdParams[1]).toBe(TODAY_STR);
|
|
});
|
|
|
|
it("uses a 60-day window ending today for top transactions when requested", async () => {
|
|
queueEmpty(5);
|
|
|
|
await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 60, 5, 10, TODAY);
|
|
|
|
const recentParams = mockSelect.mock.calls[4][1] as unknown[];
|
|
// 60-day window ending today (2026-04-14): start = 2026-04-14 - 59 days = 2026-02-14.
|
|
expect(recentParams[0]).toBe("2026-02-14");
|
|
expect(recentParams[1]).toBe(TODAY_STR);
|
|
expect(recentParams[2]).toBe(10);
|
|
});
|
|
|
|
it("computes top movers against the reference month's previous month", async () => {
|
|
queueEmpty(3); // current balance, ytd, series
|
|
mockSelect
|
|
.mockResolvedValueOnce([
|
|
{
|
|
category_id: 1,
|
|
category_name: "Restaurants",
|
|
category_color: "#f97316",
|
|
current_total: 240,
|
|
previous_total: 200,
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([]); // recent
|
|
|
|
await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 30, 5, 10, TODAY);
|
|
|
|
const moversParams = mockSelect.mock.calls[3][1] as unknown[];
|
|
// Reference month = March 2026, previous = February 2026.
|
|
expect(moversParams[0]).toBe("2026-03-01");
|
|
expect(moversParams[1]).toBe("2026-03-31");
|
|
expect(moversParams[2]).toBe("2026-02-01");
|
|
expect(moversParams[3]).toBe("2026-02-28");
|
|
});
|
|
|
|
it("wraps January reference month back to December of the previous year for top movers", async () => {
|
|
queueEmpty(5);
|
|
|
|
await getHighlights(2026, 1, 2026, 30, 5, 10, new Date(2026, 0, 10));
|
|
|
|
const moversParams = mockSelect.mock.calls[3][1] as unknown[];
|
|
expect(moversParams[0]).toBe("2026-01-01");
|
|
expect(moversParams[1]).toBe("2026-01-31");
|
|
expect(moversParams[2]).toBe("2025-12-01");
|
|
expect(moversParams[3]).toBe("2025-12-31");
|
|
});
|
|
|
|
it("computes deltaAbs and deltaPct from movers rows", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([{ net: -500 }]) // current balance
|
|
.mockResolvedValueOnce([{ net: -1800 }]) // ytd
|
|
.mockResolvedValueOnce([
|
|
{ month: "2026-03", net: -500 },
|
|
{ month: "2026-02", net: -400 },
|
|
]) // series
|
|
.mockResolvedValueOnce([
|
|
{
|
|
category_id: 1,
|
|
category_name: "Restaurants",
|
|
category_color: "#f97316",
|
|
current_total: 240,
|
|
previous_total: 200,
|
|
},
|
|
{
|
|
category_id: 2,
|
|
category_name: "Groceries",
|
|
category_color: "#10b981",
|
|
current_total: 85,
|
|
previous_total: 170,
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([]); // recent
|
|
|
|
const result = await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 30, 5, 10, TODAY);
|
|
|
|
expect(result.netBalanceCurrent).toBe(-500);
|
|
expect(result.netBalanceYtd).toBe(-1800);
|
|
expect(result.topMovers).toHaveLength(2);
|
|
expect(result.topMovers[0]).toMatchObject({
|
|
categoryName: "Restaurants",
|
|
currentAmount: 240,
|
|
previousAmount: 200,
|
|
deltaAbs: 40,
|
|
});
|
|
expect(result.topMovers[0].deltaPct).toBeCloseTo(20, 4);
|
|
expect(result.topMovers[1].deltaAbs).toBe(-85);
|
|
expect(result.topMovers[1].deltaPct).toBeCloseTo(-50, 4);
|
|
});
|
|
|
|
it("returns deltaPct=null when previous month total is zero", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([{ net: 0 }])
|
|
.mockResolvedValueOnce([{ net: 0 }])
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
category_id: 3,
|
|
category_name: "New expense",
|
|
category_color: "#3b82f6",
|
|
current_total: 120,
|
|
previous_total: 0,
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([]);
|
|
|
|
const result = await getHighlights(REF_YEAR, REF_MONTH, YTD_YEAR, 30, 5, 10, TODAY);
|
|
|
|
expect(result.topMovers[0].deltaPct).toBeNull();
|
|
expect(result.topMovers[0].deltaAbs).toBe(120);
|
|
});
|
|
});
|
|
|
|
describe("getCompareMonthOverMonth", () => {
|
|
it("passes monthly and cumulative boundaries as parameters", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getCompareMonthOverMonth(2026, 4);
|
|
|
|
// Two queries now: the delta aggregation (call 0, unchanged) + the category
|
|
// metadata for the hierarchy (call 1, Issue #247).
|
|
expect(mockSelect).toHaveBeenCalledTimes(2);
|
|
expect(mockSelect.mock.calls[1][0]).toContain("FROM categories");
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(sql).toContain("$1");
|
|
expect(sql).toContain("$8");
|
|
// Monthly current ($1, $2), monthly previous ($3, $4),
|
|
// cumulative current Jan→ref ($5, $6), cumulative previous Jan→prev ($7, $8).
|
|
expect(params).toEqual([
|
|
"2026-04-01", "2026-04-30",
|
|
"2026-03-01", "2026-03-31",
|
|
"2026-01-01", "2026-04-30",
|
|
"2026-01-01", "2026-03-31",
|
|
]);
|
|
expect(sql).not.toContain("'2026");
|
|
});
|
|
|
|
it("wraps to december of previous year when target month is january", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getCompareMonthOverMonth(2026, 1);
|
|
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
// Cumulative-previous window lives entirely in 2025 (Jan → Dec), since the
|
|
// previous month (Dec 2025) belongs to the prior calendar year.
|
|
expect(params).toEqual([
|
|
"2026-01-01", "2026-01-31",
|
|
"2025-12-01", "2025-12-31",
|
|
"2026-01-01", "2026-01-31",
|
|
"2025-01-01", "2025-12-31",
|
|
]);
|
|
});
|
|
|
|
it("converts raw rows into CategoryDelta with monthly and cumulative deltas", async () => {
|
|
mockSelect.mockResolvedValueOnce([
|
|
{
|
|
category_id: 1,
|
|
category_name: "Groceries",
|
|
category_color: "#10b981",
|
|
month_current_total: 500,
|
|
month_previous_total: 400,
|
|
cumulative_current_total: 2000,
|
|
cumulative_previous_total: 1500,
|
|
},
|
|
{
|
|
category_id: 2,
|
|
category_name: "Restaurants",
|
|
category_color: "#f97316",
|
|
month_current_total: 120,
|
|
month_previous_total: 0,
|
|
cumulative_current_total: 300,
|
|
cumulative_previous_total: 180,
|
|
},
|
|
]);
|
|
|
|
const result = await getCompareMonthOverMonth(2026, 4);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toMatchObject({
|
|
categoryName: "Groceries",
|
|
currentAmount: 500,
|
|
previousAmount: 400,
|
|
deltaAbs: 100,
|
|
cumulativeCurrentAmount: 2000,
|
|
cumulativePreviousAmount: 1500,
|
|
cumulativeDeltaAbs: 500,
|
|
});
|
|
expect(result[0].deltaPct).toBeCloseTo(25, 4);
|
|
expect(result[0].cumulativeDeltaPct).toBeCloseTo((500 / 1500) * 100, 4);
|
|
expect(result[1].deltaPct).toBeNull(); // monthly previous = 0
|
|
expect(result[1].cumulativeDeltaPct).toBeCloseTo(((300 - 180) / 180) * 100, 4);
|
|
});
|
|
|
|
it("yields null deltaPct for both blocks when previous is zero", async () => {
|
|
mockSelect.mockResolvedValueOnce([
|
|
{
|
|
category_id: 3,
|
|
category_name: "New",
|
|
category_color: "#3b82f6",
|
|
month_current_total: 50,
|
|
month_previous_total: 0,
|
|
cumulative_current_total: 80,
|
|
cumulative_previous_total: 0,
|
|
},
|
|
]);
|
|
|
|
const result = await getCompareMonthOverMonth(2026, 4);
|
|
|
|
expect(result[0].deltaPct).toBeNull();
|
|
expect(result[0].cumulativeDeltaPct).toBeNull();
|
|
expect(result[0].deltaAbs).toBe(50);
|
|
expect(result[0].cumulativeDeltaAbs).toBe(80);
|
|
});
|
|
|
|
it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getCompareMonthOverMonth(2026, 4);
|
|
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(sql).not.toContain("source_id");
|
|
expect(params).toHaveLength(8);
|
|
});
|
|
|
|
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getCompareMonthOverMonth(2026, 4, []);
|
|
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(sql).not.toContain("source_id");
|
|
expect(params).toHaveLength(8);
|
|
});
|
|
|
|
it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getCompareMonthOverMonth(2026, 4, [3, 7]);
|
|
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(sql).toContain("AND t.source_id IN ($9, $10)");
|
|
expect(sql).not.toContain("3,7");
|
|
expect(params).toEqual([
|
|
"2026-04-01", "2026-04-30",
|
|
"2026-03-01", "2026-03-31",
|
|
"2026-01-01", "2026-04-30",
|
|
"2026-01-01", "2026-03-31",
|
|
3, 7,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("getCompareYearOverYear", () => {
|
|
it("compares a single month YoY and the YTD window through that month", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getCompareYearOverYear(2026, 4);
|
|
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(params).toEqual([
|
|
"2026-04-01", "2026-04-30",
|
|
"2025-04-01", "2025-04-30",
|
|
"2026-01-01", "2026-04-30",
|
|
"2025-01-01", "2025-04-30",
|
|
]);
|
|
});
|
|
|
|
it("defaults to december when no reference month is supplied", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getCompareYearOverYear(2026);
|
|
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(params).toEqual([
|
|
"2026-12-01", "2026-12-31",
|
|
"2025-12-01", "2025-12-31",
|
|
"2026-01-01", "2026-12-31",
|
|
"2025-01-01", "2025-12-31",
|
|
]);
|
|
});
|
|
|
|
it("populates both blocks for a YoY row", async () => {
|
|
mockSelect.mockResolvedValueOnce([
|
|
{
|
|
category_id: 1,
|
|
category_name: "Groceries",
|
|
category_color: "#10b981",
|
|
month_current_total: 600,
|
|
month_previous_total: 400,
|
|
cumulative_current_total: 2500,
|
|
cumulative_previous_total: 1600,
|
|
},
|
|
]);
|
|
|
|
const result = await getCompareYearOverYear(2026, 4);
|
|
|
|
expect(result[0]).toMatchObject({
|
|
currentAmount: 600,
|
|
previousAmount: 400,
|
|
deltaAbs: 200,
|
|
cumulativeCurrentAmount: 2500,
|
|
cumulativePreviousAmount: 1600,
|
|
cumulativeDeltaAbs: 900,
|
|
});
|
|
expect(result[0].deltaPct).toBeCloseTo(50, 4);
|
|
expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4);
|
|
});
|
|
|
|
it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getCompareYearOverYear(2026, 4);
|
|
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(sql).not.toContain("source_id");
|
|
expect(params).toHaveLength(8);
|
|
});
|
|
|
|
it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getCompareYearOverYear(2026, 4, [9]);
|
|
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(sql).toContain("AND t.source_id IN ($9)");
|
|
expect(params).toEqual([
|
|
"2026-04-01", "2026-04-30",
|
|
"2025-04-01", "2025-04-30",
|
|
"2026-01-01", "2026-04-30",
|
|
"2025-01-01", "2025-04-30",
|
|
9,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("real-vs-real compare — transfer netting (Issue #243)", () => {
|
|
// A `transfer` category (e.g. "Paiement CC") is a money move, not spending:
|
|
// its debit and matching credit must cancel to ~0 in the real-vs-real
|
|
// compare. Ordinary categories keep the expense-report behavior (only
|
|
// outflows, summed as positive magnitudes). The netting lives entirely in
|
|
// the SQL (COMPARE_DELTA_SQL): a signed SUM(t.amount) for `transfer`,
|
|
// ABS(t.amount) filtered on amount < 0 for plain expenses. Income, like
|
|
// transfers, is a signed SUM (Issue #253); the WHERE is broadened so
|
|
// income/transfer credits are not dropped before they can count or cancel.
|
|
//
|
|
// There is no runnable SQLite in these unit tests (tauri-plugin-sql only
|
|
// exists inside the Tauri WebView, and CI runs Node 20 → no node:sqlite), so
|
|
// the netting is pinned two ways: (1) a reference-semantics check over
|
|
// concrete debit/credit rows that reproduces the exact per-row rule the SQL
|
|
// encodes, and (2) structural assertions that the real query on BOTH compare
|
|
// functions carries that rule and the broadened WHERE.
|
|
|
|
/** Per-row contribution to a bucket — the exact rule COMPARE_DELTA_SQL encodes. */
|
|
function bucketContribution(amount: number, type: string | null): number {
|
|
const t = type ?? "expense";
|
|
return t === "transfer" || t === "income"
|
|
? amount // signed → transfer debit/credit cancel; income credits kept as revenue (#253)
|
|
: amount < 0
|
|
? Math.abs(amount) // expense outflow as a positive magnitude
|
|
: 0; // other (expense) credits dropped — the report stays outflow-only for them
|
|
}
|
|
|
|
it("nets a balanced transfer to ~0 while an expense keeps its sum of outflows", () => {
|
|
// "Paiement CC" (transfer): -500 out of chequing, +500 onto the card.
|
|
const transfer = [-500, 500].reduce((s, a) => s + bucketContribution(a, "transfer"), 0);
|
|
expect(transfer).toBeCloseTo(0, 6);
|
|
|
|
// "Épicerie" (expense): two debits + one refund credit that must NOT net it down.
|
|
const expense = [-120, -80, 60].reduce((s, a) => s + bucketContribution(a, "expense"), 0);
|
|
expect(expense).toBe(200);
|
|
|
|
// Uncategorized (c.type NULL) defaults to expense behavior.
|
|
const uncategorized = [-40, 15].reduce((s, a) => s + bucketContribution(a, null), 0);
|
|
expect(uncategorized).toBe(40);
|
|
|
|
// Income (paie + a small correction) is a signed SUM — credits are kept as
|
|
// revenue (Issue #253), not dropped like non-transfer expense credits.
|
|
const income = [4200, 150, -50].reduce((s, a) => s + bucketContribution(a, "income"), 0);
|
|
expect(income).toBe(4300);
|
|
});
|
|
|
|
it("getCompareMonthOverMonth SQL nets transfers (signed) but keeps ABS for other types", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getCompareMonthOverMonth(2026, 4);
|
|
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
// transfer → signed t.amount; every other type → ABS(t.amount)
|
|
expect(sql).toContain(
|
|
"COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount)",
|
|
);
|
|
// WHERE broadened so transfer credits survive the expense (amount < 0) filter
|
|
expect(sql).toContain(
|
|
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))",
|
|
);
|
|
// still four date buckets
|
|
expect(sql).toContain("month_current_total");
|
|
expect(sql).toContain("cumulative_previous_total");
|
|
});
|
|
|
|
it("getCompareYearOverYear applies the identical netting contract (both tabs fixed)", async () => {
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getCompareYearOverYear(2026, 4);
|
|
|
|
const sql = mockSelect.mock.calls[0][0] as string;
|
|
expect(sql).toContain(
|
|
"COALESCE(c.type, 'expense') IN ('transfer', 'income') THEN t.amount ELSE ABS(t.amount)",
|
|
);
|
|
expect(sql).toContain(
|
|
"WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') IN ('transfer', 'income'))",
|
|
);
|
|
});
|
|
|
|
it("keeps a netted transfer at 0 through the pipeline while an expense is unchanged", async () => {
|
|
// Rows as the netted SQL returns them: the balanced transfer arrives at 0,
|
|
// the ordinary expense keeps its outflow sum. Assert nothing downstream
|
|
// re-inflates the transfer.
|
|
mockSelect.mockResolvedValueOnce([
|
|
{
|
|
category_id: 5,
|
|
category_name: "Épicerie",
|
|
category_color: "#10b981",
|
|
month_current_total: 200,
|
|
month_previous_total: 150,
|
|
cumulative_current_total: 800,
|
|
cumulative_previous_total: 600,
|
|
},
|
|
{
|
|
category_id: 71,
|
|
category_name: "Paiement CC",
|
|
category_color: "#9ca3af",
|
|
month_current_total: 0,
|
|
month_previous_total: 0,
|
|
cumulative_current_total: 0,
|
|
cumulative_previous_total: 0,
|
|
},
|
|
]);
|
|
|
|
const result = await getCompareMonthOverMonth(2026, 4);
|
|
const transferRow = result.find((r) => r.categoryName === "Paiement CC")!;
|
|
const expenseRow = result.find((r) => r.categoryName === "Épicerie")!;
|
|
|
|
expect(transferRow.currentAmount).toBe(0);
|
|
expect(transferRow.previousAmount).toBe(0);
|
|
expect(transferRow.deltaAbs).toBe(0);
|
|
expect(transferRow.cumulativeCurrentAmount).toBe(0);
|
|
// Ordinary expense keeps EXACTLY its sum of outflows.
|
|
expect(expenseRow.currentAmount).toBe(200);
|
|
expect(expenseRow.previousAmount).toBe(150);
|
|
});
|
|
});
|
|
|
|
describe("getCategoryZoom", () => {
|
|
it("uses a bounded recursive CTE when including subcategories", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // transactions
|
|
.mockResolvedValueOnce([{ rollup: 0 }]) // rollup
|
|
.mockResolvedValueOnce([]) // byChild
|
|
.mockResolvedValueOnce([]); // evolution
|
|
|
|
await getCategoryZoom(42, "2026-01-01", "2026-12-31", true);
|
|
|
|
const txSql = mockSelect.mock.calls[0][0] as string;
|
|
expect(txSql).toContain("WITH RECURSIVE cat_tree");
|
|
expect(txSql).toContain("WHERE ct.depth < 5");
|
|
const txParams = mockSelect.mock.calls[0][1] as unknown[];
|
|
expect(txParams).toEqual([42, "2026-01-01", "2026-12-31"]);
|
|
});
|
|
|
|
it("terminates on a cyclic category tree because of the depth cap", async () => {
|
|
// Simulate a cyclic parent_id chain. Since the mocked db.select simply
|
|
// returns our canned values, the real cycle guard (depth < 5) is what we
|
|
// can assert: the CTE must include the bound.
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // transactions
|
|
.mockResolvedValueOnce([{ rollup: 0 }]) // rollup
|
|
.mockResolvedValueOnce([]) // byChild
|
|
.mockResolvedValueOnce([]); // evolution
|
|
|
|
await expect(
|
|
getCategoryZoom(1, "2026-01-01", "2026-01-31", true),
|
|
).resolves.toBeDefined();
|
|
|
|
// Every recursive query sent must contain the depth guard.
|
|
for (const call of mockSelect.mock.calls) {
|
|
const sql = call[0] as string;
|
|
if (sql.includes("cat_tree")) {
|
|
expect(sql).toContain("ct.depth < 5");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("issues a direct-only query when includeSubcategories is false", async () => {
|
|
mockSelect
|
|
.mockResolvedValueOnce([]) // transactions
|
|
.mockResolvedValueOnce([{ rollup: 0 }]); // rollup — no byChild / evolution recursive here
|
|
// evolution is still queried with categoryId = direct
|
|
mockSelect.mockResolvedValueOnce([]);
|
|
|
|
await getCategoryZoom(7, "2026-01-01", "2026-12-31", false);
|
|
|
|
const txSql = mockSelect.mock.calls[0][0] as string;
|
|
expect(txSql).not.toContain("cat_tree");
|
|
expect(txSql).toContain("t.category_id = $1");
|
|
});
|
|
});
|
|
|
|
describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
|
|
// Flat leaf, mirroring what rowsToDeltas returns from COMPARE_DELTA_SQL.
|
|
function leaf(
|
|
categoryId: number | null,
|
|
categoryName: string,
|
|
mc: number,
|
|
mp: number,
|
|
cc = mc,
|
|
cp = mp,
|
|
): CategoryDelta {
|
|
return {
|
|
categoryId,
|
|
categoryName,
|
|
categoryColor: "#000",
|
|
previousAmount: mp,
|
|
currentAmount: mc,
|
|
deltaAbs: mc - mp,
|
|
deltaPct: mp !== 0 ? ((mc - mp) / mp) * 100 : null,
|
|
cumulativePreviousAmount: cp,
|
|
cumulativeCurrentAmount: cc,
|
|
cumulativeDeltaAbs: cc - cp,
|
|
cumulativeDeltaPct: cp !== 0 ? ((cc - cp) / cp) * 100 : null,
|
|
};
|
|
}
|
|
|
|
const cats = [
|
|
{ id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
|
|
{ id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
|
|
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
|
|
{ id: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
|
|
{ id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 },
|
|
{ id: 8, name: "Revenus", color: null, type: "income", parent_id: null },
|
|
{ id: 80, name: "Paie", color: null, type: "income", parent_id: 8 },
|
|
] as Parameters<typeof buildCompareTree>[1];
|
|
|
|
it("nests leaves under a parent subtotal equal to the sum of its children", () => {
|
|
const rows = buildCompareTree(
|
|
[leaf(22, "Épicerie", 500, 400, 2000, 1500), leaf(24, "Restaurant", 120, 200, 300, 500)],
|
|
cats,
|
|
);
|
|
|
|
// Parent subtotal on top, then children ordered by |monthly delta| desc.
|
|
expect(rows.map((r) => r.categoryName)).toEqual(["Dépenses", "Épicerie", "Restaurant"]);
|
|
|
|
const parent = rows[0];
|
|
expect(parent.is_parent).toBe(true);
|
|
expect(parent.categoryId).toBe(2);
|
|
expect(parent.depth).toBe(0);
|
|
expect(parent.category_type).toBe("expense");
|
|
// Subtotal = sum of children (the group's net).
|
|
expect(parent.currentAmount).toBe(620);
|
|
expect(parent.previousAmount).toBe(600);
|
|
expect(parent.deltaAbs).toBe(20);
|
|
expect(parent.cumulativeCurrentAmount).toBe(2300);
|
|
expect(parent.cumulativePreviousAmount).toBe(2000);
|
|
expect(parent.cumulativeDeltaAbs).toBe(300);
|
|
|
|
// Children are leaves at depth 1 with UNCHANGED values (no regression).
|
|
const epicerie = rows.find((r) => r.categoryName === "Épicerie")!;
|
|
expect(epicerie.is_parent).toBeFalsy();
|
|
expect(epicerie.depth).toBe(1);
|
|
expect(epicerie.parent_id).toBe(2);
|
|
expect(epicerie.currentAmount).toBe(500);
|
|
expect(epicerie.previousAmount).toBe(400);
|
|
expect(epicerie.deltaAbs).toBe(100);
|
|
});
|
|
|
|
it("keeps a balanced transfer group netted to 0 at the subtotal (preserves #243)", () => {
|
|
const rows = buildCompareTree([leaf(50, "REER", 0, 0, 0, 0)], cats);
|
|
const parent = rows.find((r) => r.categoryId === 5)!;
|
|
expect(parent.is_parent).toBe(true);
|
|
expect(parent.category_type).toBe("transfer");
|
|
// The netted-to-zero transfer stays 0 through the subtotal — not re-inflated.
|
|
expect(parent.currentAmount).toBe(0);
|
|
expect(parent.previousAmount).toBe(0);
|
|
expect(parent.deltaAbs).toBe(0);
|
|
});
|
|
|
|
it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
|
|
const rows = buildCompareTree(
|
|
[leaf(80, "Paie", 4200, 4000), leaf(22, "Épicerie", 500, 400), leaf(50, "REER", 0, 0)],
|
|
cats,
|
|
);
|
|
const types = rows.map((r) => r.category_type);
|
|
// Income section first, then expenses, then transfers (Issue #253).
|
|
expect(types).toEqual(["income", "income", "expense", "expense", "transfer", "transfer"]);
|
|
});
|
|
|
|
it("preserves grand-total invariance vs the flat leaves", () => {
|
|
const flat = [
|
|
leaf(22, "Épicerie", 500, 400, 2000, 1500),
|
|
leaf(24, "Restaurant", 120, 200, 300, 500),
|
|
leaf(50, "REER", 0, 0, 0, 0),
|
|
];
|
|
const rows = buildCompareTree(flat, cats);
|
|
const sumLeaves = (rs: CategoryDelta[]) =>
|
|
rs.filter((r) => !r.is_parent).reduce((s, r) => s + r.currentAmount, 0);
|
|
expect(sumLeaves(rows)).toBe(sumLeaves(flat));
|
|
});
|
|
|
|
it("surfaces an uncategorized (null id) row as a top-level leaf", () => {
|
|
const rows = buildCompareTree([leaf(null, "Uncategorized", 90, 40)], cats);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].categoryName).toBe("Uncategorized");
|
|
expect(rows[0].is_parent).toBeFalsy();
|
|
expect(rows[0].depth).toBe(0);
|
|
expect(rows[0].currentAmount).toBe(90);
|
|
});
|
|
|
|
it("keeps a leaf whose category was soft-deleted (still in the metadata)", () => {
|
|
// A single leaf with no siblings collapses to just that leaf (no lone subtotal).
|
|
const rows = buildCompareTree([leaf(22, "Épicerie", 75, 60)], cats);
|
|
const names = rows.map((r) => r.categoryName);
|
|
expect(names).toContain("Épicerie");
|
|
// A parent with exactly one contributing child still gets a subtotal row.
|
|
const parent = rows.find((r) => r.is_parent);
|
|
expect(parent?.currentAmount).toBe(75);
|
|
});
|
|
});
|
|
|
|
describe("buildOverTimeTree — hierarchical trends (Issue #264)", () => {
|
|
/** Flat id-keyed leaf, mirroring what getCategoryOverTime feeds the builder. */
|
|
function otLeaf(categoryId: number | null, categoryName: string, monthly: number[]): OverTimeRow {
|
|
return {
|
|
categoryId,
|
|
categoryName,
|
|
categoryColor: "#000",
|
|
monthly,
|
|
total: monthly.reduce((s, v) => s + v, 0),
|
|
parent_id: null,
|
|
is_parent: false,
|
|
depth: 0,
|
|
category_type: "expense",
|
|
};
|
|
}
|
|
|
|
const cats = [
|
|
{ id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
|
|
{ id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
|
|
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
|
|
{ id: 8, name: "Revenus", color: null, type: "income", parent_id: null },
|
|
{ id: 80, name: "Paie", color: null, type: "income", parent_id: 8 },
|
|
{ id: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
|
|
{ id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 },
|
|
] as Parameters<typeof buildOverTimeTree>[1];
|
|
|
|
it("nests leaves under a parent subtotal equal to the per-month sum of its children", () => {
|
|
const rows = buildOverTimeTree(
|
|
[otLeaf(22, "Épicerie", [500, 400]), otLeaf(24, "Restaurant", [120, 200])],
|
|
cats,
|
|
2,
|
|
);
|
|
|
|
// Parent subtotal on top, then children ordered by |total| desc.
|
|
expect(rows.map((r) => r.categoryName)).toEqual(["Dépenses", "Épicerie", "Restaurant"]);
|
|
|
|
const parent = rows[0];
|
|
expect(parent.is_parent).toBe(true);
|
|
expect(parent.categoryId).toBe(2);
|
|
expect(parent.depth).toBe(0);
|
|
expect(parent.category_type).toBe("expense");
|
|
// Subtotal series is index-aligned and equals the per-month child sums.
|
|
expect(parent.monthly).toEqual([620, 600]);
|
|
expect(parent.total).toBe(1220);
|
|
|
|
const epicerie = rows.find((r) => r.categoryName === "Épicerie")!;
|
|
expect(epicerie.is_parent).toBe(false);
|
|
expect(epicerie.depth).toBe(1);
|
|
expect(epicerie.parent_id).toBe(2);
|
|
expect(epicerie.monthly).toEqual([500, 400]);
|
|
});
|
|
|
|
it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
|
|
const rows = buildOverTimeTree(
|
|
[otLeaf(80, "Paie", [4200]), otLeaf(22, "Épicerie", [500]), otLeaf(50, "REER", [0])],
|
|
cats,
|
|
1,
|
|
);
|
|
// Each leaf sits under a parent, so every section yields [subtotal, leaf].
|
|
expect(rows.map((r) => r.category_type)).toEqual([
|
|
"income",
|
|
"income",
|
|
"expense",
|
|
"expense",
|
|
"transfer",
|
|
"transfer",
|
|
]);
|
|
});
|
|
|
|
it("preserves grand-total invariance vs the flat leaves", () => {
|
|
const flat = [
|
|
otLeaf(22, "Épicerie", [500, 400]),
|
|
otLeaf(24, "Restaurant", [120, 200]),
|
|
otLeaf(50, "REER", [0, 0]),
|
|
];
|
|
const rows = buildOverTimeTree(flat, cats, 2);
|
|
const sumLeaves = (rs: OverTimeRow[]) =>
|
|
rs.filter((r) => !r.is_parent).reduce((s, r) => s + r.total, 0);
|
|
expect(sumLeaves(rows)).toBe(sumLeaves(flat));
|
|
});
|
|
|
|
it("surfaces an uncategorized (null id) leaf as a top-level row", () => {
|
|
const rows = buildOverTimeTree([otLeaf(null, "Uncategorized", [90])], cats, 1);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].categoryName).toBe("Uncategorized");
|
|
expect(rows[0].is_parent).toBe(false);
|
|
expect(rows[0].depth).toBe(0);
|
|
expect(rows[0].monthly).toEqual([90]);
|
|
});
|
|
|
|
it("terminates on a cyclic parent_id chain (A → B → A) without overflowing", () => {
|
|
const cyclic = [
|
|
{ id: 1, name: "A", color: null, type: "expense", parent_id: 2 },
|
|
{ id: 2, name: "B", color: null, type: "expense", parent_id: 1 },
|
|
] as Parameters<typeof buildOverTimeTree>[1];
|
|
|
|
// The relevant-ancestor walk and buildNode both cap at MAX_TREE_DEPTH, so a
|
|
// corrupted cycle returns (no root emerges) instead of recursing forever —
|
|
// .not.toThrow() catches a "Maximum call stack" RangeError if it overflowed.
|
|
let rows: OverTimeRow[] = [];
|
|
expect(() => {
|
|
rows = buildOverTimeTree([otLeaf(1, "A", [100])], cyclic, 1);
|
|
}).not.toThrow();
|
|
expect(Array.isArray(rows)).toBe(true);
|
|
for (const r of rows) expect(r.depth).toBeLessThanOrEqual(5);
|
|
});
|
|
});
|
|
|
|
describe("getCompareMonthOverMonth — wires the hierarchy (Issue #247)", () => {
|
|
it("returns parent subtotal rows when category metadata is available", async () => {
|
|
mockSelect
|
|
// 1. COMPARE_DELTA_SQL — two sibling leaves under parent id 2.
|
|
.mockResolvedValueOnce([
|
|
{
|
|
category_id: 22,
|
|
category_name: "Épicerie",
|
|
category_color: "#10b981",
|
|
month_current_total: 500,
|
|
month_previous_total: 400,
|
|
cumulative_current_total: 2000,
|
|
cumulative_previous_total: 1500,
|
|
},
|
|
{
|
|
category_id: 24,
|
|
category_name: "Restaurant",
|
|
category_color: "#f97316",
|
|
month_current_total: 120,
|
|
month_previous_total: 200,
|
|
cumulative_current_total: 300,
|
|
cumulative_previous_total: 500,
|
|
},
|
|
])
|
|
// 2. COMPARE_CATEGORIES_SQL — the hierarchy.
|
|
.mockResolvedValueOnce([
|
|
{ id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
|
|
{ id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
|
|
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
|
|
]);
|
|
|
|
const result = await getCompareMonthOverMonth(2026, 4);
|
|
|
|
// Delta query stays call 0 (netting SQL + params assertions elsewhere rely on it).
|
|
expect(mockSelect.mock.calls[0][0]).toContain("month_current_total");
|
|
expect(mockSelect.mock.calls[1][0]).toContain("FROM categories");
|
|
|
|
const parent = result.find((r) => r.is_parent);
|
|
expect(parent).toBeDefined();
|
|
expect(parent!.categoryId).toBe(2);
|
|
expect(parent!.currentAmount).toBe(620);
|
|
// Leaves survive unchanged.
|
|
expect(result.find((r) => r.categoryName === "Épicerie")!.currentAmount).toBe(500);
|
|
});
|
|
});
|