Simpl-Resultat/src/services/transactionService.test.ts
le king fu 4febed9eab feat(data): announce counter-moving writes on a data-change channel
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>
2026-10-08 07:33:47 -04:00

123 lines
4.2 KiB
TypeScript

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([]);
});
});