import { describe, it, expect, vi, beforeEach } from "vitest"; import { getCategoryOverTime, getHighlights, getCompareMonthOverMonth, getCompareYearOverYear, getCategoryZoom, } from "./reportService"; // Mock the db module vi.mock("./db", () => ({ getDb: vi.fn(), })); import { getDb } from "./db"; const mockSelect = vi.fn(); const mockDb = { select: mockSelect }; beforeEach(() => { vi.mocked(getDb).mockResolvedValue(mockDb as never); mockSelect.mockReset(); }); 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 sourceId filter", 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"); expect(topCatParams[0]).toBe(3); }); it("combines typeFilter, date range, and sourceId", 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 = $4"); expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]); }); it("returns correct structure with categories, data, colors, and categoryIds", async () => { mockSelect .mockResolvedValueOnce([ { category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 }, { category_id: 2, category_name: "Transport", category_color: "#00ff00", total: 200 }, ]) .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-02", category_id: 1, category_name: "Food", total: 200 }, { month: "2025-02", category_id: 2, category_name: "Transport", total: 100 }, ]); const result = await getCategoryOverTime("2025-01-01", "2025-02-28", 50, undefined, "expense"); expect(result.categories).toEqual(["Food", "Transport"]); expect(result.colors).toEqual({ Food: "#ff0000", Transport: "#00ff00" }); expect(result.categoryIds).toEqual({ Food: 1, Transport: 2 }); expect(result.data).toHaveLength(2); expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Transport: 100 }); expect(result.data[1]).toEqual({ month: "2025-02", Food: 200, Transport: 100 }); }); it("groups non-top-N categories into Other", async () => { mockSelect .mockResolvedValueOnce([ { category_id: 1, category_name: "Food", category_color: "#ff0000", total: 500 }, ]) .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 }, ]); const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense"); expect(result.categories).toEqual(["Food", "Other"]); expect(result.colors["Other"]).toBe("#9ca3af"); expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 }); }); }); 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); expect(mockSelect).toHaveBeenCalledTimes(1); 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); }); }); 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); }); }); 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"); }); });