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[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[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[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); }); });