import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("./db", () => { const getDb = vi.fn(); return { getDb, withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())), }; }); import { getDb } from "./db"; import { subscribeDataChanged } from "./dataEvents"; import { getTransactionCount, insertBatch, updateTransactionCategory, updateTransactionNotes, } from "./transactionService"; const mockSelect = vi.fn(); const mockExecute = vi.fn(); const mockDb = { select: mockSelect, execute: mockExecute }; /** Number of `execute` calls already made at each data-change notification. */ const notified: number[] = []; let unsubscribe: () => void = () => undefined; beforeEach(() => { vi.mocked(getDb).mockResolvedValue(mockDb as never); mockSelect.mockReset(); mockExecute.mockReset(); notified.length = 0; unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length)); }); afterEach(() => { unsubscribe(); }); describe("getTransactionCount (#351)", () => { it("returns 0 on an empty profile", async () => { mockSelect.mockResolvedValueOnce([{ cnt: 0 }]); await expect(getTransactionCount()).resolves.toBe(0); }); it("returns the unfiltered COUNT(*) of transactions", async () => { mockSelect.mockResolvedValueOnce([{ cnt: 1234 }]); await expect(getTransactionCount()).resolves.toBe(1234); expect(mockSelect).toHaveBeenCalledTimes(1); const sql = String(mockSelect.mock.calls[0][0]); expect(sql).toMatch(/SELECT COUNT\(\*\) AS cnt FROM transactions/); expect(sql).not.toMatch(/WHERE/i); }); it("falls back to 0 when the driver returns no row", async () => { mockSelect.mockResolvedValueOnce([]); await expect(getTransactionCount()).resolves.toBe(0); }); }); describe("updateTransactionCategory (#390)", () => { // tauri-plugin-sql stores a bound JS boolean as the TEXT 'true', which the // getting-started checklist's `is_manually_categorized = 1` never matches. it("binds the manual flag as the integer 1, never as a boolean", async () => { mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateTransactionCategory(5, 12, true); expect(mockExecute).toHaveBeenCalledTimes(1); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params).toEqual([12, 1, 5]); expect(typeof params[1]).toBe("number"); }); it("binds 0 when the category is not set by hand", async () => { mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateTransactionCategory(5, null, false); expect(mockExecute.mock.calls[0][1]).toEqual([null, 0, 5]); }); }); describe("updateTransactionCategory — data-change notification (#396)", () => { it("notifies once after the update", async () => { mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateTransactionCategory(5, 12, true); expect(notified).toEqual([1]); }); it("does not notify when the update fails", async () => { mockExecute.mockRejectedValueOnce(new Error("database is locked")); await expect(updateTransactionCategory(5, 12, true)).rejects.toThrow("database is locked"); expect(notified).toEqual([]); }); it("a note changes no counter: updateTransactionNotes stays silent", async () => { mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateTransactionNotes(5, "loyer"); expect(notified).toEqual([]); }); }); describe("insertBatch — data-change notification (#396)", () => { const row = (i: number) => ({ date: "2026-09-0" + i, description: `Achat ${i}`, amount: -i, source_id: 1, file_id: 2, original_description: `Achat ${i}`, }); it("notifies once, after the last insert", async () => { mockExecute.mockResolvedValue({ rowsAffected: 1 }); await expect(insertBatch([row(1), row(2), row(3)])).resolves.toBe(3); expect(notified).toEqual([3]); }); it("does not notify when an insert fails", async () => { mockExecute .mockResolvedValueOnce({ rowsAffected: 1 }) .mockRejectedValueOnce(new Error("database is locked")); await expect(insertBatch([row(1), row(2), row(3)])).rejects.toThrow("database is locked"); expect(notified).toEqual([]); }); });