Add src/services/dataEvents.ts (ADR 0022): notifyDataChanged() and subscribeDataChanged(), without React, on the model of logService.subscribe. A throwing subscriber is logged and does not stop the others. Notify after every successful write of the tables the getting-started counters read, plus the import folder: setImportFolder, createSource, deleteSource, createImportedFile, updateFileStatus, both import deletions, insertBatch, updateTransactionCategory, applyKeywordWithReassignment (after COMMIT), the four budget writes and runRestore (after COMMIT, one point for the three restore modes). executeImport notifies once more when it ends, succeeded or not. A failed write notifies nothing; transactional writes notify once withTransaction has returned. applyTemplate notifies once for the whole template through a private writeBudgetEntry helper. Each service suite proves the notification after a success and its absence after a failure; executeImport is driven as a plain function with React's hooks stubbed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
8.3 KiB
TypeScript
215 lines
8.3 KiB
TypeScript
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([]);
|
|
});
|
|
});
|