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>
123 lines
4.2 KiB
TypeScript
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([]);
|
|
});
|
|
});
|