Simpl-Resultat/src/services/reportService.test.ts
le king fu 4a93c60ea7 feat(reports): plumb accountIds[] account filter into 7 report services
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>
2026-07-11 15:15:37 -04:00

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