import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { getBudgetVsActualData, getActualTotalsForYear, upsertBudgetEntry, deleteBudgetEntry, upsertBudgetEntriesForYear, applyTemplate, saveAsTemplate, } from "./budgetService"; import { subscribeDataChanged } from "./dataEvents"; vi.mock("./db", () => { const getDb = vi.fn(); return { getDb }; }); import { getDb } from "./db"; const mockSelect = vi.fn(); const mockExecute = vi.fn(); const mockDb = { select: mockSelect, execute: mockExecute }; beforeEach(() => { vi.mocked(getDb).mockResolvedValue(mockDb as never); mockSelect.mockReset(); mockExecute.mockReset(); }); // getBudgetVsActualData fans out to 4 parallel selects (getAllActiveCategories, // getBudgetEntriesForYear, and getActualsByCategoryRange for the month + YTD // windows). Only the two `getActualsByCategoryRange` calls query `transactions` // and are eligible for the accountIds filter — budgets are not tied to an // account, so the categories/entries queries must never carry the clause. describe("getBudgetVsActualData — accountIds filter (Issue #273)", () => { it("without accountIds, the actuals queries carry no source_id clause (regression)", async () => { mockSelect.mockImplementation(() => Promise.resolve([])); await getBudgetVsActualData(2026, 3); const actualsCalls = mockSelect.mock.calls.filter(([sql]) => (sql as string).includes("FROM transactions"), ); expect(actualsCalls.length).toBe(2); // month + YTD for (const [sql, params] of actualsCalls) { expect(sql as string).not.toContain("source_id"); expect(params as unknown[]).toHaveLength(2); // dateFrom, dateTo only } }); it("an empty accountIds array behaves exactly like no filter (regression)", async () => { mockSelect.mockImplementation(() => Promise.resolve([])); await getBudgetVsActualData(2026, 3, []); const actualsCalls = mockSelect.mock.calls.filter(([sql]) => (sql as string).includes("FROM transactions"), ); for (const [sql, params] of actualsCalls) { expect(sql as string).not.toContain("source_id"); expect(params as unknown[]).toHaveLength(2); } }); it("applies a parameterized accountIds IN clause to both the month and YTD actuals queries", async () => { mockSelect.mockImplementation(() => Promise.resolve([])); await getBudgetVsActualData(2026, 3, [4, 9]); const actualsCalls = mockSelect.mock.calls.filter(([sql]) => (sql as string).includes("FROM transactions"), ); expect(actualsCalls.length).toBe(2); for (const [sql, params] of actualsCalls) { expect(sql as string).toContain("source_id IN ($3, $4)"); expect(sql as string).not.toContain("4,9"); expect(params as unknown[]).toEqual(expect.arrayContaining([4, 9])); } }); it("never adds the filter to the categories/budget-entries queries (a budget is not tied to an account)", async () => { mockSelect.mockImplementation(() => Promise.resolve([])); await getBudgetVsActualData(2026, 3, [4]); const categoriesCall = mockSelect.mock.calls.find(([sql]) => (sql as string).includes("FROM categories"), )!; const budgetEntriesCall = mockSelect.mock.calls.find(([sql]) => (sql as string).includes("FROM budget_entries"), )!; expect(categoriesCall[0]).not.toContain("source_id"); expect(budgetEntriesCall[0]).not.toContain("source_id"); }); }); // getActualTotalsForYear is the previous-year actuals reference column // consumed by useBudget (Issue #276) — a thin wrapper over the same // getActualsByCategoryRange helper, so it gets the same accountIds pass-through. describe("getActualTotalsForYear — accountIds filter (Issue #276)", () => { it("without accountIds, the query carries no source_id clause (regression)", async () => { mockSelect.mockImplementation(() => Promise.resolve([])); await getActualTotalsForYear(2025); expect(mockSelect).toHaveBeenCalledTimes(1); const [sql, params] = mockSelect.mock.calls[0]; expect(sql as string).not.toContain("source_id"); expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31"]); }); it("an empty accountIds array behaves exactly like no filter (regression)", async () => { mockSelect.mockImplementation(() => Promise.resolve([])); await getActualTotalsForYear(2025, []); const [sql, params] = mockSelect.mock.calls[0]; expect(sql as string).not.toContain("source_id"); expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31"]); }); it("applies a parameterized accountIds IN clause", async () => { mockSelect.mockImplementation(() => Promise.resolve([])); await getActualTotalsForYear(2025, [4, 9]); const [sql, params] = mockSelect.mock.calls[0]; expect(sql as string).toContain("source_id IN ($3, $4)"); expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31", 4, 9]); }); }); describe("budget writes — data-change notification (#396)", () => { /** Number of `execute` calls already made at each data-change notification. */ const notified: number[] = []; let unsubscribe: () => void = () => undefined; beforeEach(() => { notified.length = 0; unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length)); }); afterEach(() => { unsubscribe(); }); it("upsertBudgetEntry notifies once after the write, and not when it fails", async () => { mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await upsertBudgetEntry(4, 2026, 10, 250); expect(notified).toEqual([1]); mockExecute.mockRejectedValueOnce(new Error("database is locked")); await expect(upsertBudgetEntry(4, 2026, 11, 250)).rejects.toThrow("database is locked"); expect(notified).toEqual([1]); }); it("deleteBudgetEntry notifies once after the delete, and not when it fails", async () => { mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await deleteBudgetEntry(4, 2026, 10); expect(notified).toEqual([1]); mockExecute.mockRejectedValueOnce(new Error("database is locked")); await expect(deleteBudgetEntry(4, 2026, 10)).rejects.toThrow("database is locked"); expect(notified).toEqual([1]); }); it("upsertBudgetEntriesForYear notifies once, after the twelve months", async () => { mockExecute.mockResolvedValue({ rowsAffected: 1 }); await upsertBudgetEntriesForYear(4, 2026, [100, 0, 100, 0, 100, 0, 100, 0, 100, 0, 100, 0]); expect(mockExecute).toHaveBeenCalledTimes(12); expect(notified).toEqual([12]); }); it("upsertBudgetEntriesForYear does not notify when a month fails", async () => { mockExecute .mockResolvedValueOnce({ rowsAffected: 1 }) .mockRejectedValueOnce(new Error("database is locked")); await expect(upsertBudgetEntriesForYear(4, 2026, Array(12).fill(100))).rejects.toThrow( "database is locked", ); expect(notified).toEqual([]); }); it("applyTemplate notifies once, after the template's last entry", async () => { mockSelect.mockResolvedValueOnce([ { id: 1, template_id: 7, category_id: 4, amount: 100 }, { id: 2, template_id: 7, category_id: 5, amount: 200 }, { id: 3, template_id: 7, category_id: 6, amount: 300 }, ]); mockExecute.mockResolvedValue({ rowsAffected: 1 }); await applyTemplate(7, 2026, 10); expect(mockExecute).toHaveBeenCalledTimes(3); expect(mockExecute.mock.calls.map((c) => (c[1] as unknown[])[0])).toEqual([4, 5, 6]); expect(mockExecute.mock.calls[0][1]).toEqual([4, 2026, 10, 100, null]); expect(notified).toEqual([3]); }); it("applyTemplate does not notify when an entry fails", async () => { mockSelect.mockResolvedValueOnce([ { id: 1, template_id: 7, category_id: 4, amount: 100 }, { id: 2, template_id: 7, category_id: 5, amount: 200 }, ]); mockExecute .mockResolvedValueOnce({ rowsAffected: 1 }) .mockRejectedValueOnce(new Error("database is locked")); await expect(applyTemplate(7, 2026, 10)).rejects.toThrow("database is locked"); expect(notified).toEqual([]); }); it("saving a template changes no counter: saveAsTemplate stays silent", async () => { mockExecute.mockResolvedValue({ rowsAffected: 1, lastInsertId: 9 }); await saveAsTemplate("Mensuel", undefined, [{ category_id: 4, amount: 100 }]); expect(notified).toEqual([]); }); });