import { describe, it, expect, vi, beforeEach } from "vitest"; import { shiftMonth, getCartesSnapshot } from "./reportService"; 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("shiftMonth", () => { it("shifts forward within a year", () => { expect(shiftMonth(2026, 1, 2)).toEqual({ year: 2026, month: 3 }); }); it("shifts backward within a year", () => { expect(shiftMonth(2026, 6, -3)).toEqual({ year: 2026, month: 3 }); }); it("wraps around January to the previous year", () => { expect(shiftMonth(2026, 1, -1)).toEqual({ year: 2025, month: 12 }); }); it("wraps past multiple years back", () => { expect(shiftMonth(2026, 4, -24)).toEqual({ year: 2024, month: 4 }); }); it("wraps past year forward", () => { expect(shiftMonth(2025, 11, 3)).toEqual({ year: 2026, month: 2 }); }); }); /** * Dispatch mock SELECT responses based on the SQL fragment being queried. * Each entry returns the canned rows for queries whose text contains `match`. */ function routeSelect(routes: { match: string; rows: unknown[] }[]): void { mockSelect.mockImplementation((sql: string) => { for (const r of routes) { if (sql.includes(r.match)) return Promise.resolve(r.rows); } return Promise.resolve([]); }); } describe("getCartesSnapshot", () => { it("returns zero-filled KPIs when there is no data", async () => { routeSelect([]); const snapshot = await getCartesSnapshot(2026, 3); expect(snapshot.referenceYear).toBe(2026); expect(snapshot.referenceMonth).toBe(3); expect(snapshot.kpis.income.current).toBe(0); expect(snapshot.kpis.expenses.current).toBe(0); expect(snapshot.kpis.net.current).toBe(0); // Savings rate is null (renders as "—") when income is zero. expect(snapshot.kpis.savingsRate.current).toBeNull(); expect(snapshot.kpis.income.sparkline).toHaveLength(13); expect(snapshot.flow12Months).toHaveLength(12); expect(snapshot.topMoversUp).toHaveLength(0); expect(snapshot.topMoversDown).toHaveLength(0); expect(snapshot.budgetAdherence.categoriesTotal).toBe(0); expect(snapshot.seasonality.historicalYears).toHaveLength(0); expect(snapshot.seasonality.historicalAverage).toBeNull(); expect(snapshot.seasonality.deviationPct).toBeNull(); }); it("computes MoM and YoY deltas from a monthly flow stream", async () => { // Reference = 2026-03 routeSelect([ { match: "strftime('%Y-%m', date)", rows: [ { month: "2025-03", income: 3000, expenses: 1800 }, // YoY comparison { month: "2026-02", income: 4000, expenses: 2000 }, // MoM comparison { month: "2026-03", income: 5000, expenses: 2500 }, // Reference ], }, ]); const snapshot = await getCartesSnapshot(2026, 3); expect(snapshot.kpis.income.current).toBe(5000); expect(snapshot.kpis.income.previousMonth).toBe(4000); expect(snapshot.kpis.income.previousYear).toBe(3000); expect(snapshot.kpis.income.deltaMoMAbs).toBe(1000); expect(snapshot.kpis.income.deltaMoMPct).toBe(25); expect(snapshot.kpis.income.deltaYoYAbs).toBe(2000); expect(Math.round(snapshot.kpis.income.deltaYoYPct ?? 0)).toBe(67); expect(snapshot.kpis.expenses.current).toBe(2500); expect(snapshot.kpis.net.current).toBe(2500); expect(snapshot.kpis.savingsRate.current).toBe(50); }); it("January reference month shifts MoM to December of previous year", async () => { routeSelect([ { match: "strftime('%Y-%m', date)", rows: [ { month: "2025-12", income: 2000, expenses: 1000 }, { month: "2026-01", income: 3000, expenses: 1500 }, ], }, ]); const snapshot = await getCartesSnapshot(2026, 1); expect(snapshot.kpis.income.current).toBe(3000); expect(snapshot.kpis.income.previousMonth).toBe(2000); // YoY for January 2026 = January 2025 = no data expect(snapshot.kpis.income.previousYear).toBeNull(); expect(snapshot.kpis.income.deltaYoYAbs).toBeNull(); }); it("savings rate is null when income is zero (no division by zero, renders as — in UI)", async () => { routeSelect([ { match: "strftime('%Y-%m', date)", rows: [ { month: "2026-03", income: 0, expenses: 500 }, ], }, ]); const snapshot = await getCartesSnapshot(2026, 3); expect(snapshot.kpis.savingsRate.current).toBeNull(); expect(snapshot.kpis.income.current).toBe(0); expect(snapshot.kpis.expenses.current).toBe(500); expect(snapshot.kpis.net.current).toBe(-500); }); it("handles less than 13 months of history by filling gaps with zero", async () => { routeSelect([ { match: "strftime('%Y-%m', date)", rows: [ { month: "2026-03", income: 1000, expenses: 400 }, ], }, ]); const snapshot = await getCartesSnapshot(2026, 3); expect(snapshot.kpis.income.sparkline).toHaveLength(13); // First 12 points are zero, last one is 1000 expect(snapshot.kpis.income.sparkline[12].value).toBe(1000); expect(snapshot.kpis.income.sparkline[0].value).toBe(0); // MoM comparison with a missing month returns null (no data for 2026-02) expect(snapshot.kpis.income.previousMonth).toBeNull(); expect(snapshot.kpis.income.deltaMoMAbs).toBeNull(); }); it("computes seasonality only when historical data exists", async () => { routeSelect([ { match: "strftime('%Y-%m', date)", rows: [{ month: "2026-03", income: 3000, expenses: 1500 }], }, { match: "CAST(strftime('%Y', date) AS INTEGER) AS year", rows: [ { year: 2025, amount: 1200 }, { year: 2024, amount: 1000 }, ], }, ]); const snapshot = await getCartesSnapshot(2026, 3); expect(snapshot.seasonality.historicalYears).toHaveLength(2); expect(snapshot.seasonality.historicalAverage).toBe(1100); expect(snapshot.seasonality.referenceAmount).toBe(1500); // (1500 - 1100) / 1100 * 100 ≈ 36.36 expect(Math.round(snapshot.seasonality.deviationPct ?? 0)).toBe(36); }); it("seasonality deviation stays null when there is no historical average", async () => { routeSelect([ { match: "strftime('%Y-%m', date)", rows: [{ month: "2026-03", income: 2000, expenses: 800 }], }, ]); const snapshot = await getCartesSnapshot(2026, 3); expect(snapshot.seasonality.historicalYears).toHaveLength(0); expect(snapshot.seasonality.historicalAverage).toBeNull(); expect(snapshot.seasonality.deviationPct).toBeNull(); }); it("splits top movers by sign and caps each list at 5", async () => { // Seven up-movers, three down-movers — verify we get 5 up and 3 down. // Cumulative totals mirror the monthly ones in this fixture — the Cartes // service only reads the monthly block for top movers, so cumulative // values are inert here. const momRows = [ { category_id: 1, category_name: "C1", category_color: "#000", month_current_total: 200, month_previous_total: 100, cumulative_current_total: 200, cumulative_previous_total: 100 }, { category_id: 2, category_name: "C2", category_color: "#000", month_current_total: 400, month_previous_total: 100, cumulative_current_total: 400, cumulative_previous_total: 100 }, { category_id: 3, category_name: "C3", category_color: "#000", month_current_total: 500, month_previous_total: 100, cumulative_current_total: 500, cumulative_previous_total: 100 }, { category_id: 4, category_name: "C4", category_color: "#000", month_current_total: 700, month_previous_total: 100, cumulative_current_total: 700, cumulative_previous_total: 100 }, { category_id: 5, category_name: "C5", category_color: "#000", month_current_total: 900, month_previous_total: 100, cumulative_current_total: 900, cumulative_previous_total: 100 }, { category_id: 6, category_name: "C6", category_color: "#000", month_current_total: 1100, month_previous_total: 100, cumulative_current_total: 1100, cumulative_previous_total: 100 }, { category_id: 7, category_name: "C7", category_color: "#000", month_current_total: 1300, month_previous_total: 100, cumulative_current_total: 1300, cumulative_previous_total: 100 }, { category_id: 8, category_name: "D1", category_color: "#000", month_current_total: 100, month_previous_total: 500, cumulative_current_total: 100, cumulative_previous_total: 500 }, { category_id: 9, category_name: "D2", category_color: "#000", month_current_total: 100, month_previous_total: 700, cumulative_current_total: 100, cumulative_previous_total: 700 }, { category_id: 10, category_name: "D3", category_color: "#000", month_current_total: 100, month_previous_total: 900, cumulative_current_total: 100, cumulative_previous_total: 900 }, ]; routeSelect([ { match: "strftime('%Y-%m', date)", rows: [{ month: "2026-03", income: 1000, expenses: 500 }], }, { // Matches the getCompareMonthOverMonth SQL pattern. match: "ORDER BY ABS(month_current_total - month_previous_total) DESC", rows: momRows, }, ]); const snapshot = await getCartesSnapshot(2026, 3); expect(snapshot.topMoversUp).toHaveLength(5); expect(snapshot.topMoversDown).toHaveLength(3); // Top up is the biggest delta (C7: +1200) expect(snapshot.topMoversUp[0].categoryName).toBe("C7"); // Top down is the biggest negative delta (D3: -800) expect(snapshot.topMoversDown[0].categoryName).toBe("D3"); }); it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => { // Reference = 2026-03, YTD = Jan + Feb + Mar of 2026. routeSelect([ { match: "strftime('%Y-%m', date)", rows: [ // Previous year YTD: Jan + Feb + Mar 2025 = income 3000, expenses 1500. { month: "2025-01", income: 1000, expenses: 500 }, { month: "2025-02", income: 1000, expenses: 500 }, { month: "2025-03", income: 1000, expenses: 500 }, // Current YTD: Jan + Feb + Mar 2026. { month: "2026-01", income: 2000, expenses: 800 }, { month: "2026-02", income: 2500, expenses: 1000 }, { month: "2026-03", income: 3000, expenses: 1200 }, ], }, ]); const snapshot = await getCartesSnapshot(2026, 3, "ytd"); // Current = sum Jan+Feb+Mar 2026 expect(snapshot.kpis.income.current).toBe(7500); expect(snapshot.kpis.expenses.current).toBe(3000); expect(snapshot.kpis.net.current).toBe(4500); // Savings rate YTD = 4500 / 7500 = 60% expect(snapshot.kpis.savingsRate.current).toBe(60); // MoM in YTD = current YTD vs Jan→Feb 2026 = income 4500, expenses 1800. expect(snapshot.kpis.income.previousMonth).toBe(4500); expect(snapshot.kpis.income.deltaMoMAbs).toBe(3000); expect(snapshot.kpis.expenses.previousMonth).toBe(1800); // YoY in YTD = Jan→Mar 2026 vs Jan→Mar 2025. expect(snapshot.kpis.income.previousYear).toBe(3000); expect(snapshot.kpis.income.deltaYoYAbs).toBe(4500); expect(snapshot.kpis.expenses.previousYear).toBe(1500); }); it("YTD savings rate is null when YTD income is zero", async () => { routeSelect([ { match: "strftime('%Y-%m', date)", rows: [ { month: "2026-01", income: 0, expenses: 200 }, { month: "2026-02", income: 0, expenses: 150 }, { month: "2026-03", income: 0, expenses: 300 }, ], }, ]); const snapshot = await getCartesSnapshot(2026, 3, "ytd"); expect(snapshot.kpis.income.current).toBe(0); expect(snapshot.kpis.expenses.current).toBe(650); expect(snapshot.kpis.savingsRate.current).toBeNull(); }); it("YTD MoM delta is null when reference month is January (no prior YTD window in same year)", async () => { routeSelect([ { match: "strftime('%Y-%m', date)", rows: [ { month: "2025-01", income: 500, expenses: 200 }, { month: "2026-01", income: 1000, expenses: 400 }, ], }, ]); const snapshot = await getCartesSnapshot(2026, 1, "ytd"); expect(snapshot.kpis.income.current).toBe(1000); // MoM previous YTD for January is empty (no prior month in same year). expect(snapshot.kpis.income.previousMonth).toBeNull(); expect(snapshot.kpis.income.deltaMoMAbs).toBeNull(); expect(snapshot.kpis.income.deltaMoMPct).toBeNull(); // YoY still works — Jan 2025. expect(snapshot.kpis.income.previousYear).toBe(500); expect(snapshot.kpis.income.deltaYoYAbs).toBe(500); }); it("YTD YoY delta uses Jan→refMonth of the previous year", async () => { routeSelect([ { match: "strftime('%Y-%m', date)", rows: [ // Prev year YTD Jan→Apr 2025 { month: "2025-01", income: 100, expenses: 50 }, { month: "2025-02", income: 100, expenses: 50 }, { month: "2025-03", income: 100, expenses: 50 }, { month: "2025-04", income: 100, expenses: 50 }, // Prev year outside window — must be ignored. { month: "2025-05", income: 999, expenses: 999 }, // Current YTD Jan→Apr 2026 { month: "2026-01", income: 300, expenses: 100 }, { month: "2026-02", income: 300, expenses: 100 }, { month: "2026-03", income: 300, expenses: 100 }, { month: "2026-04", income: 300, expenses: 100 }, ], }, ]); const snapshot = await getCartesSnapshot(2026, 4, "ytd"); expect(snapshot.kpis.income.current).toBe(1200); expect(snapshot.kpis.income.previousYear).toBe(400); // 4 * 100 expect(snapshot.kpis.expenses.previousYear).toBe(200); // 4 * 50 expect(snapshot.kpis.income.deltaYoYAbs).toBe(800); }); it("defaults to mode=month and produces monthly KPIs (back-compat)", async () => { routeSelect([ { match: "strftime('%Y-%m', date)", rows: [ { month: "2026-01", income: 1000, expenses: 400 }, { month: "2026-02", income: 1000, expenses: 400 }, { month: "2026-03", income: 5000, expenses: 2500 }, ], }, ]); // No mode argument — default "month". const snapshot = await getCartesSnapshot(2026, 3); // Monthly ref only, not YTD sum. expect(snapshot.kpis.income.current).toBe(5000); expect(snapshot.kpis.expenses.current).toBe(2500); }); it("counts budgeted expense categories even though monthBudget is stored signed-negative (#112)", async () => { // Regression: before the fix, `r.monthBudget > 0` rejected every expense // row because budgetService signs expense budgets as negative. The card // then claimed "no budgeted categories" even when the user had set // budgets on expense categories. routeSelect([ { match: "FROM categories WHERE is_active = 1 ORDER BY sort_order", rows: [ { id: 10, name: "Alimentation", parent_id: null, color: "#f59e0b", icon: null, type: "expense", is_active: 1, is_inputable: 1, sort_order: 1, created_at: "", }, { id: 11, name: "Restaurants", parent_id: null, color: "#ef4444", icon: null, type: "expense", is_active: 1, is_inputable: 1, sort_order: 2, created_at: "", }, { id: 12, name: "Loyer", parent_id: null, color: "#6366f1", icon: null, type: "expense", is_active: 1, is_inputable: 1, sort_order: 3, created_at: "", }, ], }, { match: "FROM budget_entries WHERE year", rows: [ { id: 1, category_id: 10, year: 2026, month: 3, amount: 500 }, { id: 2, category_id: 11, year: 2026, month: 3, amount: 200 }, // Category 12 has no budget entry. ], }, { // Matches both the monthly and YTD actuals queries; for this test // the YTD slice can mirror the monthly one. match: "FROM transactions\n WHERE date BETWEEN", rows: [ { category_id: 10, actual: -400 }, // 400 spent against 500 budget -> in target { category_id: 11, actual: -350 }, // 350 spent against 200 budget -> overrun { category_id: 12, actual: -150 }, // no budget, should not appear ], }, ]); const snapshot = await getCartesSnapshot(2026, 3); expect(snapshot.budgetAdherence.categoriesTotal).toBe(2); expect(snapshot.budgetAdherence.categoriesInTarget).toBe(1); expect(snapshot.budgetAdherence.worstOverruns).toHaveLength(1); const [worst] = snapshot.budgetAdherence.worstOverruns; expect(worst.categoryName).toBe("Restaurants"); expect(worst.actual).toBe(350); expect(worst.budget).toBe(200); expect(worst.overrunAbs).toBe(150); expect(worst.overrunPct).toBeCloseTo(75, 5); }); });