diff --git a/src/hooks/useImportWizard.executeImport.test.ts b/src/hooks/useImportWizard.executeImport.test.ts new file mode 100644 index 0000000..abb04a8 --- /dev/null +++ b/src/hooks/useImportWizard.executeImport.test.ts @@ -0,0 +1,144 @@ +// useImportWizard — `executeImport` announces its end on the data channel (#396). +// +// No renderer in this project, and the reducer alone cannot reach the import: +// `executeImport` is a callback of the hook. React's three hooks used by +// `useImportWizard` are stubbed so the hook runs as a plain function on a +// prepared state, and the services it calls are mocked. What is pinned here is +// the ADR 0022 contract: one last notification once the import is over, +// succeeded or not, after its inserts and file statuses. + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const harness = vi.hoisted(() => ({ + state: null as unknown, + dispatch: vi.fn(), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useReducer: () => [harness.state, harness.dispatch], + useCallback: (fn: unknown) => fn, + useEffect: () => undefined, + }; +}); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => "hash-of-file") })); +vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() })); +vi.mock("../services/dataEvents", () => ({ + notifyDataChanged: vi.fn(), + subscribeDataChanged: vi.fn(() => () => undefined), +})); +vi.mock("../services/userPreferenceService", () => ({ + getImportFolder: vi.fn(async () => null), + setImportFolder: vi.fn(), +})); +vi.mock("../services/importSourceService", () => ({ + getAllSources: vi.fn(async () => []), + getSourceByName: vi.fn(async () => null), + createSource: vi.fn(async () => 7), + updateSource: vi.fn(async () => undefined), +})); +vi.mock("../services/importedFileService", () => ({ + existsByHash: vi.fn(async () => null), + createImportedFile: vi.fn(async () => 11), + updateFileStatus: vi.fn(async () => undefined), + getFilesBySourceId: vi.fn(async () => []), +})); +vi.mock("../services/transactionService", () => ({ + insertBatch: vi.fn(async (rows: unknown[]) => rows.length), + findDuplicates: vi.fn(async () => []), +})); +vi.mock("../services/categorizationService", () => ({ + categorizeBatch: vi.fn(async (descriptions: string[]) => + descriptions.map(() => ({ category_id: null, supplier_id: null })), + ), +})); +vi.mock("../services/importConfigTemplateService", () => ({ + getAllTemplates: vi.fn(async () => []), + createTemplate: vi.fn(), + updateTemplate: vi.fn(), + deleteTemplate: vi.fn(), +})); + +import { useImportWizard, initialWizardState, type WizardState } from "./useImportWizard"; +import { notifyDataChanged } from "../services/dataEvents"; +import { createSource } from "../services/importSourceService"; +import { createImportedFile, updateFileStatus } from "../services/importedFileService"; +import { insertBatch } from "../services/transactionService"; +import type { ParsedRow } from "../shared/types"; + +const rows: ParsedRow[] = [1, 2].map((i) => ({ + rowIndex: i, + raw: [`2026-09-0${i}`, `Achat ${i}`, `-${i}`], + parsed: { date: `2026-09-0${i}`, description: `Achat ${i}`, amount: -i }, + sourceFilename: "releve.csv", +})); + +/** The wizard on its "confirm" step, ready to import two rows of one file. */ +const readyToImport = (): WizardState => ({ + ...initialWizardState, + step: "confirm", + sourceConfig: { ...initialWizardState.sourceConfig, name: "Desjardins" }, + selectedFiles: [ + { filename: "releve.csv", file_path: "/releves/Desjardins/releve.csv", size_bytes: 10, modified_at: "" }, + ], + parsedPreview: rows, + previewHeaders: ["date", "description", "amount"], + duplicateResult: { fileAlreadyImported: false, duplicateRows: [], newRows: rows }, + excludedDuplicateIndices: new Set(), +}); + +/** The order in which a mock was first called, to compare with the notification. */ +const order = (mock: unknown): number => vi.mocked(mock as () => void).mock.invocationCallOrder[0]; + +beforeEach(() => { + vi.mocked(notifyDataChanged).mockClear(); + vi.mocked(createSource).mockClear(); + vi.mocked(createImportedFile).mockClear(); + vi.mocked(updateFileStatus).mockClear(); + vi.mocked(insertBatch).mockClear(); + harness.dispatch.mockClear(); + harness.state = readyToImport(); +}); + +describe("executeImport — final data-change notification (#396)", () => { + it("notifies once at the end of a successful import, after the inserts", async () => { + await useImportWizard().executeImport(); + + expect(insertBatch).toHaveBeenCalledTimes(1); + expect(notifyDataChanged).toHaveBeenCalledTimes(1); + expect(order(notifyDataChanged)).toBeGreaterThan(order(insertBatch)); + expect(harness.dispatch).toHaveBeenCalledWith({ type: "SET_STEP", payload: "report" }); + }); + + it("notifies once at the end when the inserts fail, after the file statuses are settled", async () => { + vi.mocked(insertBatch).mockRejectedValueOnce(new Error("database is locked")); + + await useImportWizard().executeImport(); + + expect(updateFileStatus).toHaveBeenCalledWith(11, "error", 0, "Error: database is locked"); + expect(notifyDataChanged).toHaveBeenCalledTimes(1); + expect(order(notifyDataChanged)).toBeGreaterThan(order(updateFileStatus)); + }); + + it("notifies once at the end when the import fails before any insert", async () => { + vi.mocked(createSource).mockRejectedValueOnce(new Error("CHECK constraint failed")); + + await useImportWizard().executeImport(); + + expect(insertBatch).not.toHaveBeenCalled(); + expect(harness.dispatch).toHaveBeenCalledWith({ type: "SET_STEP", payload: "confirm" }); + expect(notifyDataChanged).toHaveBeenCalledTimes(1); + }); + + it("does not notify when there is nothing to import (no duplicate check yet)", async () => { + harness.state = { ...readyToImport(), duplicateResult: null }; + + await useImportWizard().executeImport(); + + expect(createSource).not.toHaveBeenCalled(); + expect(notifyDataChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useImportWizard.ts b/src/hooks/useImportWizard.ts index 7f1a54b..65b8ace 100644 --- a/src/hooks/useImportWizard.ts +++ b/src/hooks/useImportWizard.ts @@ -33,6 +33,7 @@ import { findDuplicates, } from "../services/transactionService"; import { categorizeBatch } from "../services/categorizationService"; +import { notifyDataChanged } from "../services/dataEvents"; import { getAllTemplates, createTemplate, @@ -1156,6 +1157,11 @@ export function useImportWizard() { payload: e instanceof Error ? e.message : String(e), }); dispatch({ type: "SET_STEP", payload: "confirm" }); + } finally { + // One more announcement once the import is over, succeeded or not, so + // the last re-read of the getting-started path sees its final state: + // inserts done, file statuses settled (ADR 0022). + notifyDataChanged(); } }, [ state.duplicateResult, diff --git a/src/services/budgetService.test.ts b/src/services/budgetService.test.ts index 1fb8d64..d22e0f1 100644 --- a/src/services/budgetService.test.ts +++ b/src/services/budgetService.test.ts @@ -1,5 +1,14 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getBudgetVsActualData, getActualTotalsForYear } from "./budgetService"; +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(); @@ -9,11 +18,13 @@ vi.mock("./db", () => { import { getDb } from "./db"; const mockSelect = vi.fn(); -const mockDb = { select: mockSelect }; +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, @@ -118,3 +129,87 @@ describe("getActualTotalsForYear — accountIds filter (Issue #276)", () => { 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([]); + }); +}); diff --git a/src/services/budgetService.ts b/src/services/budgetService.ts index 13d9f41..9bc2586 100644 --- a/src/services/budgetService.ts +++ b/src/services/budgetService.ts @@ -1,4 +1,6 @@ +import type Database from "@tauri-apps/plugin-sql"; import { getDb } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import { inPlaceholders } from "../utils/sqlFilters"; import type { Category, @@ -40,6 +42,26 @@ export async function getBudgetEntriesForMonth( ); } +// Every budget write below is announced on the data channel once it succeeded: +// the number of budget entries is a getting-started counter (ADR 0022). + +/** The UPSERT behind `upsertBudgetEntry` and `applyTemplate`, without the announcement. */ +async function writeBudgetEntry( + db: Database, + categoryId: number, + year: number, + month: number, + amount: number, + notes?: string +): Promise { + await db.execute( + `INSERT INTO budget_entries (category_id, year, month, amount, notes) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT(category_id, year, month) DO UPDATE SET amount = $4, notes = $5, updated_at = CURRENT_TIMESTAMP`, + [categoryId, year, month, amount, notes || null] + ); +} + export async function upsertBudgetEntry( categoryId: number, year: number, @@ -48,12 +70,8 @@ export async function upsertBudgetEntry( notes?: string ): Promise { const db = await getDb(); - await db.execute( - `INSERT INTO budget_entries (category_id, year, month, amount, notes) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT(category_id, year, month) DO UPDATE SET amount = $4, notes = $5, updated_at = CURRENT_TIMESTAMP`, - [categoryId, year, month, amount, notes || null] - ); + await writeBudgetEntry(db, categoryId, year, month, amount, notes); + notifyDataChanged(); } export async function deleteBudgetEntry( @@ -66,6 +84,7 @@ export async function deleteBudgetEntry( "DELETE FROM budget_entries WHERE category_id = $1 AND year = $2 AND month = $3", [categoryId, year, month] ); + notifyDataChanged(); } export async function getActualsByCategory( @@ -116,6 +135,7 @@ export async function upsertBudgetEntriesForYear( ); } } + notifyDataChanged(); } // Templates @@ -165,9 +185,12 @@ export async function applyTemplate( month: number ): Promise { const entries = await getTemplateEntries(templateId); + const db = await getDb(); for (const entry of entries) { - await upsertBudgetEntry(entry.category_id, year, month, entry.amount); + await writeBudgetEntry(db, entry.category_id, year, month, entry.amount); } + // One announcement for the whole template, after its last entry. + notifyDataChanged(); } export async function deleteTemplate(templateId: number): Promise { diff --git a/src/services/categorizationService.test.ts b/src/services/categorizationService.test.ts index 4fbdf9c..4c96bf4 100644 --- a/src/services/categorizationService.test.ts +++ b/src/services/categorizationService.test.ts @@ -1,10 +1,11 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { validateKeyword, previewKeywordMatches, applyKeywordWithReassignment, KEYWORD_MAX_LENGTH, } from "./categorizationService"; +import { subscribeDataChanged } from "./dataEvents"; vi.mock("./db", () => { const getDb = vi.fn(); @@ -18,7 +19,7 @@ vi.mock("./db", () => { }; }); -import { getDb } from "./db"; +import { getDb, withTransaction } from "./db"; const mockSelect = vi.fn(); const mockExecute = vi.fn(); @@ -180,3 +181,63 @@ describe("applyKeywordWithReassignment", () => { expect(mockExecute).not.toHaveBeenCalled(); }); }); + +describe("applyKeywordWithReassignment — data-change notification (#396)", () => { + const input = { + keyword: "METRO", + categoryId: 3, + transactionIds: [1, 2], + allowReplaceExisting: false, + }; + + /** At each notification: the last statement issued, and whether the DB lock was held. */ + const seen: Array<{ last: string; inLock: boolean }> = []; + let inLock = false; + let unsubscribe: () => void = () => undefined; + + beforeEach(() => { + seen.length = 0; + inLock = false; + vi.mocked(withTransaction).mockImplementation(async (fn) => { + inLock = true; + try { + return await fn((await getDb()) as never); + } finally { + inLock = false; + } + }); + unsubscribe = subscribeDataChanged(() => { + const calls = mockExecute.mock.calls; + seen.push({ last: String(calls[calls.length - 1]?.[0]).trim(), inLock }); + }); + }); + + afterEach(() => { + unsubscribe(); + vi.mocked(withTransaction).mockImplementation(async (fn) => fn((await getDb()) as never)); + }); + + it("notifies once, after the COMMIT and once the DB lock is released", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute.mockResolvedValue({ lastInsertId: 77, rowsAffected: 1 }); + await applyKeywordWithReassignment(input); + expect(seen).toEqual([{ last: "COMMIT", inLock: false }]); + }); + + it("does not notify after a ROLLBACK", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute + .mockResolvedValueOnce(undefined) // BEGIN + .mockResolvedValueOnce({ lastInsertId: 77 }) // INSERT keywords + .mockRejectedValueOnce(new Error("disk full")); // UPDATE transactions fails + await expect(applyKeywordWithReassignment(input)).rejects.toThrow("disk full"); + expect(seen).toEqual([]); + }); + + it("does not notify for a refused keyword", async () => { + await expect(applyKeywordWithReassignment({ ...input, keyword: "a" })).rejects.toThrow( + "invalid_keyword:tooShort", + ); + expect(seen).toEqual([]); + }); +}); diff --git a/src/services/categorizationService.ts b/src/services/categorizationService.ts index e678a91..5e42dde 100644 --- a/src/services/categorizationService.ts +++ b/src/services/categorizationService.ts @@ -1,4 +1,5 @@ import { getDb, withTransaction } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import type Database from "@tauri-apps/plugin-sql"; import type { Keyword, RecentTransaction } from "../shared/types"; @@ -223,9 +224,14 @@ export async function applyKeywordWithReassignment( } const keyword = validation.value; - return withTransaction((db) => + const result = await withTransaction((db) => applyKeywordInTransaction(db, input, keyword), ); + // Reached only after the COMMIT (a ROLLBACK rethrows), and once the DB lock + // is released: the recategorised transactions carry the manual flag that the + // "categorize by hand" step counts (ADR 0022). + notifyDataChanged(); + return result; } async function applyKeywordInTransaction( diff --git a/src/services/dataEvents.test.ts b/src/services/dataEvents.test.ts new file mode 100644 index 0000000..601c810 --- /dev/null +++ b/src/services/dataEvents.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("./logService", () => ({ + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), +})); + +import { logError } from "./logService"; +import { notifyDataChanged, subscribeDataChanged } from "./dataEvents"; + +// The channel is module state: every test unsubscribes what it subscribed. +const cleanups: Array<() => void> = []; +function subscribe(listener: () => void): () => void { + const unsubscribe = subscribeDataChanged(listener); + cleanups.push(unsubscribe); + return unsubscribe; +} + +beforeEach(() => { + vi.mocked(logError).mockReset(); +}); + +afterEach(() => { + cleanups.splice(0).forEach((unsubscribe) => unsubscribe()); +}); + +describe("dataEvents (#396)", () => { + it("a subscriber receives each notification", () => { + const listener = vi.fn(); + subscribe(listener); + notifyDataChanged(); + notifyDataChanged(); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("an unsubscribed listener no longer receives", () => { + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + notifyDataChanged(); + unsubscribe(); + notifyDataChanged(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("unsubscribing one listener leaves the others subscribed", () => { + const first = vi.fn(); + const second = vi.fn(); + const unsubscribeFirst = subscribe(first); + subscribe(second); + unsubscribeFirst(); + notifyDataChanged(); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it("a subscriber that throws does not stop the others, and notify never throws", () => { + const before = vi.fn(); + const after = vi.fn(); + subscribe(before); + subscribe(() => { + throw new Error("listener bug"); + }); + subscribe(after); + + expect(() => notifyDataChanged()).not.toThrow(); + expect(before).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledTimes(1); + expect(vi.mocked(logError).mock.calls[0]).toEqual([ + "Data events: a subscriber failed", + "listener bug", + ]); + }); + + it("a listener that unsubscribes while being called does not skip the next one", () => { + const next = vi.fn(); + const unsubscribeSelf: { fn: () => void } = { fn: () => undefined }; + unsubscribeSelf.fn = subscribe(() => unsubscribeSelf.fn()); + subscribe(next); + notifyDataChanged(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("notifies nobody without subscribers", () => { + expect(() => notifyDataChanged()).not.toThrow(); + expect(logError).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/dataEvents.ts b/src/services/dataEvents.ts new file mode 100644 index 0000000..9fbb443 --- /dev/null +++ b/src/services/dataEvents.ts @@ -0,0 +1,45 @@ +import { logError } from "./logService"; + +/** + * Data-change channel (#396, ADR 0022). + * + * The writes that may move a counter read elsewhere (the getting-started path + * reads `COUNTERS_SQL` and the import folder) call `notifyDataChanged()` once + * they have succeeded. The message only says "data that counters read may have + * changed": it carries no payload, and the data layer does not know who + * listens. Subscribers re-read on their own terms (the onboarding provider + * waits 150 ms to group a burst, then ignores any stale answer). + * + * Modeled on `logService.subscribe`, without React. + */ + +type DataChangedListener = () => void; + +const listeners = new Set(); + +/** + * Tells every subscriber that data read by counters may have changed. Never + * throws: a subscriber that fails is logged and the others still run, so a + * write that succeeded is never reported as failed because of a listener. + */ +export function notifyDataChanged(): void { + // Iterate over a copy: a listener may unsubscribe while being called. + for (const listener of [...listeners]) { + try { + listener(); + } catch (e) { + logError( + "Data events: a subscriber failed", + e instanceof Error ? e.message : String(e), + ); + } + } +} + +/** Subscribes to data changes. Returns the function that unsubscribes. */ +export function subscribeDataChanged(listener: DataChangedListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/src/services/dataExportService.test.ts b/src/services/dataExportService.test.ts index 3e101bf..1702c35 100644 --- a/src/services/dataExportService.test.ts +++ b/src/services/dataExportService.test.ts @@ -21,7 +21,7 @@ * transaction exists to survive. */ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; vi.mock("./db", () => { const getDb = vi.fn(); @@ -49,6 +49,7 @@ import { type ExportImportSource, type ExportImportTemplate, } from "./dataExportService"; +import { subscribeDataChanged } from "./dataEvents"; import { formatFromRow, FORMAT_FIELD_PAIRS } from "../utils/importFormat"; import type { Category } from "../shared/types"; import fr from "../i18n/locales/fr.json"; @@ -762,3 +763,80 @@ describe("a restore writes the manual flag as an integer (#390)", () => { expect(transactions.map((t) => t.is_manually_categorized)).toEqual([1, 1, 0]); }); }); + +// --------------------------------------------------------------------------- +// 8. A committed restore is announced on the data channel (#396, ADR 0022) +// --------------------------------------------------------------------------- + +describe("a committed restore is announced on the data channel (#396)", () => { + /** The statements already issued at each notification. */ + const seen: string[][] = []; + let unsubscribe: () => void = () => undefined; + + beforeEach(() => { + seen.length = 0; + unsubscribe = subscribeDataChanged(() => seen.push([...db.log])); + }); + + afterEach(() => { + unsubscribe(); + }); + + it("transactions with categories: once, after the COMMIT", async () => { + await importTransactionsWithCategories( + envelopeData({ transactions: [transactionFixture(1)] }), + "backup.json" + ); + expect(seen).toHaveLength(1); + expect(seen[0][seen[0].length - 1]).toBe("COMMIT"); + }); + + it("transactions only: once, after the COMMIT", async () => { + await importTransactionsOnly(envelopeData({ transactions: [transactionFixture(1)] }), "backup.json"); + expect(seen).toHaveLength(1); + expect(seen[0][seen[0].length - 1]).toBe("COMMIT"); + }); + + it("categories only: once, after the COMMIT", async () => { + await importCategoriesOnly({ categories: CATEGORY_FIXTURES, suppliers: [], keywords: [] }); + expect(seen).toHaveLength(1); + expect(seen[0][seen[0].length - 1]).toBe("COMMIT"); + }); + + it("a restore rolled back announces nothing", async () => { + let txInserts = 0; + wire( + makeFakeDb((sql) => { + if (!/INSERT INTO transactions/.test(sql)) return false; + return ++txInserts === 2; + }) + ); + await expect( + importTransactionsOnly( + envelopeData({ transactions: [transactionFixture(1), transactionFixture(2)] }), + "backup.json" + ) + ).rejects.toThrow("boom"); + expect(db.log).toContain("ROLLBACK"); + expect(seen).toEqual([]); + }); + + it("a failed COMMIT announces nothing", async () => { + wire(makeFakeDb((sql) => sql.trim() === "COMMIT")); + await expect( + importTransactionsOnly(envelopeData({ transactions: [transactionFixture(1)] }), "backup.json") + ).rejects.toThrow("boom"); + expect(seen).toEqual([]); + }); + + it("a backup refused at the boundary announces nothing", async () => { + await expect( + importTransactionsWithCategories( + envelopeData({ import_sources: [{ ...SOURCE, amount_mode: "nonsense" as never }] }), + "backup.json" + ) + ).rejects.toThrow(); + expect(db.log).toEqual([]); + expect(seen).toEqual([]); + }); +}); diff --git a/src/services/dataExportService.ts b/src/services/dataExportService.ts index bd3ef9a..5663f39 100644 --- a/src/services/dataExportService.ts +++ b/src/services/dataExportService.ts @@ -1,5 +1,6 @@ import type Database from "@tauri-apps/plugin-sql"; import { getDb, withTransaction } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import Papa from "papaparse"; import { toSqlFlag } from "../utils/sqlFlag"; import type { @@ -414,11 +415,16 @@ const HOST_SOURCE_MAPPING = JSON.stringify({ * to go back to. `withTransaction` holds the single DB lock for the whole * BEGIN..COMMIT so the statements cannot be split across pooled connections * (see `db.ts`); the BEGIN/COMMIT/ROLLBACK themselves are ours to issue. + * + * A committed restore replaces the data every getting-started counter reads, + * so it is announced on the data channel: one point for the three restore + * modes, reached only after the COMMIT (a rollback rethrows) and once the DB + * lock is released (ADR 0022). */ async function runRestore( body: (db: Database) => Promise ): Promise { - return withTransaction(async (db) => { + await withTransaction(async (db) => { await db.execute("BEGIN"); let open = true; try { @@ -436,6 +442,7 @@ async function runRestore( throw e; } }); + notifyDataChanged(); } async function restoreCategories( diff --git a/src/services/importSourceService.test.ts b/src/services/importSourceService.test.ts new file mode 100644 index 0000000..801e170 --- /dev/null +++ b/src/services/importSourceService.test.ts @@ -0,0 +1,94 @@ +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 { + createSource, + deleteSource, + updateSource, + type ImportSourceInput, +} from "./importSourceService"; + +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(); +}); + +const input: ImportSourceInput = { + name: "Desjardins", + date_format: "YYYY-MM-DD", + delimiter: ",", + encoding: "utf-8", + column_mapping: '{"date":0,"description":1,"amount":2}', + skip_lines: 0, + has_header: 1, + amount_mode: "single", + sign_convention: "negative_expense", +}; + +describe("createSource — data-change notification (#396)", () => { + it("notifies once after the insert", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 4 }); + await expect(createSource(input)).resolves.toBe(4); + expect(notified).toEqual([1]); + }); + + it("notifies after an upsert on an existing name, before the lookup that only reads", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 0 }); + mockSelect.mockResolvedValueOnce([{ id: 9, name: "Desjardins" }]); + await expect(createSource(input)).resolves.toBe(9); + expect(notified).toEqual([1]); + }); + + it("does not notify when the write fails", async () => { + mockExecute.mockRejectedValueOnce(new Error("CHECK constraint failed")); + await expect(createSource(input)).rejects.toThrow("CHECK constraint failed"); + expect(notified).toEqual([]); + }); +}); + +describe("deleteSource — data-change notification (#396)", () => { + it("notifies once after the delete", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await deleteSource(4); + expect(notified).toEqual([1]); + }); + + it("does not notify when the delete fails", async () => { + mockExecute.mockRejectedValueOnce(new Error("FOREIGN KEY constraint failed")); + await expect(deleteSource(4)).rejects.toThrow("FOREIGN KEY constraint failed"); + expect(notified).toEqual([]); + }); +}); + +describe("updateSource — no notification (#396)", () => { + it("changes no counter, so it stays silent", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateSource(4, { name: "Desjardins chèques" }); + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(notified).toEqual([]); + }); +}); diff --git a/src/services/importSourceService.ts b/src/services/importSourceService.ts index 4493249..8e5ba76 100644 --- a/src/services/importSourceService.ts +++ b/src/services/importSourceService.ts @@ -1,4 +1,5 @@ import { getDb } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import type { ImportFormatRow, ImportSource } from "../shared/types"; /** @@ -85,12 +86,19 @@ export async function createSource( source.template_id ?? null, ] ); + // The number of sources is a getting-started counter (ADR 0022). Announced + // right after the write: the lookup below only reads. + notifyDataChanged(); // On conflict, lastInsertId may be 0 — look up the existing row if (result.lastInsertId) return result.lastInsertId as number; const existing = await getSourceByName(source.name); return existing!.id; } +/** + * Edits a source in place. No data-change notification (ADR 0022): it changes + * no counter, the number of sources stays the same. + */ export async function updateSource( id: number, source: Partial @@ -132,4 +140,5 @@ export async function updateSource( export async function deleteSource(id: number): Promise { const db = await getDb(); await db.execute("DELETE FROM import_sources WHERE id = $1", [id]); + notifyDataChanged(); } diff --git a/src/services/importedFileService.test.ts b/src/services/importedFileService.test.ts new file mode 100644 index 0000000..110013d --- /dev/null +++ b/src/services/importedFileService.test.ts @@ -0,0 +1,142 @@ +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 { TransactionLinkedToBalanceError } from "./transactionService"; +import { + createImportedFile, + deleteAllImportsWithTransactions, + deleteImportWithTransactions, + updateFileStatus, +} from "./importedFileService"; + +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(); +}); + +const file = { + source_id: 3, + filename: "releve-2026-09.csv", + file_hash: "abc", + row_count: 12, + status: "completed", +}; + +const linkedRow = { transaction_id: 1, account_id: 2, account_name: "CELI", direction: "in" as const }; + +describe("createImportedFile — data-change notification (#396)", () => { + it("notifies once after inserting a new file record", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 21 }); + await expect(createImportedFile(file)).resolves.toBe(21); + expect(notified).toEqual([1]); + }); + + it("notifies once after updating the record of a file imported again", async () => { + mockSelect.mockResolvedValueOnce([{ id: 8 }]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await expect(createImportedFile(file)).resolves.toBe(8); + expect(String(mockExecute.mock.calls[0][0])).toContain("UPDATE imported_files"); + expect(notified).toEqual([1]); + }); + + it("does not notify when the write fails", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute.mockRejectedValueOnce(new Error("database is locked")); + await expect(createImportedFile(file)).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); +}); + +describe("updateFileStatus — data-change notification (#396)", () => { + it("notifies once after the update", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateFileStatus(8, "error", 0, "insert failed"); + expect(notified).toEqual([1]); + }); + + it("does not notify when the update fails", async () => { + mockExecute.mockRejectedValueOnce(new Error("database is locked")); + await expect(updateFileStatus(8, "error")).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); +}); + +describe("deleteImportWithTransactions — data-change notification (#396)", () => { + it("notifies once, after the last delete (orphaned source included)", async () => { + mockSelect + .mockResolvedValueOnce([{ source_id: 3 }]) // source of the file + .mockResolvedValueOnce([]) // no linked transfer + .mockResolvedValueOnce([{ cnt: 0 }]); // no file left for the source + mockExecute.mockResolvedValue({ rowsAffected: 12 }); + await expect(deleteImportWithTransactions(8)).resolves.toBe(12); + expect(mockExecute).toHaveBeenCalledTimes(3); + expect(notified).toEqual([3]); + }); + + it("does not notify when the pre-flight refuses (transactions linked to the Balance sheet)", async () => { + mockSelect.mockResolvedValueOnce([{ source_id: 3 }]).mockResolvedValueOnce([linkedRow]); + await expect(deleteImportWithTransactions(8)).rejects.toBeInstanceOf( + TransactionLinkedToBalanceError, + ); + expect(mockExecute).not.toHaveBeenCalled(); + expect(notified).toEqual([]); + }); + + it("does not notify when a delete fails", async () => { + mockSelect.mockResolvedValueOnce([{ source_id: 3 }]).mockResolvedValueOnce([]); + mockExecute + .mockResolvedValueOnce({ rowsAffected: 12 }) + .mockRejectedValueOnce(new Error("database is locked")); + await expect(deleteImportWithTransactions(8)).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); +}); + +describe("deleteAllImportsWithTransactions — data-change notification (#396)", () => { + it("notifies once, after the three deletes", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute.mockResolvedValue({ rowsAffected: 40 }); + await expect(deleteAllImportsWithTransactions()).resolves.toBe(40); + expect(notified).toEqual([3]); + }); + + it("does not notify when the pre-flight refuses", async () => { + mockSelect.mockResolvedValueOnce([linkedRow]); + await expect(deleteAllImportsWithTransactions()).rejects.toBeInstanceOf( + TransactionLinkedToBalanceError, + ); + expect(notified).toEqual([]); + }); + + it("does not notify when a delete fails", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute.mockRejectedValueOnce(new Error("database is locked")); + await expect(deleteAllImportsWithTransactions()).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); +}); diff --git a/src/services/importedFileService.ts b/src/services/importedFileService.ts index 57f7a32..a559164 100644 --- a/src/services/importedFileService.ts +++ b/src/services/importedFileService.ts @@ -1,4 +1,5 @@ import { getDb } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import { isLinkedTransactionFkError } from "./balance.service"; import { TransactionLinkedToBalanceError } from "./transactionService"; import type { ImportedFile, ImportedFileWithSource } from "../shared/types"; @@ -38,11 +39,14 @@ export async function createImportedFile(file: { "SELECT id FROM imported_files WHERE source_id = $1 AND filename = $2", [file.source_id, file.filename] ); + // Both branches write the status that the "import a first file" step counts + // (`status = 'completed'`), so each one is announced (ADR 0022). if (existing.length > 0) { await db.execute( `UPDATE imported_files SET file_hash = $1, row_count = $2, status = $3, notes = $4, import_date = CURRENT_TIMESTAMP WHERE id = $5`, [file.file_hash, file.row_count, file.status, file.notes || null, existing[0].id] ); + notifyDataChanged(); return existing[0].id; } @@ -58,6 +62,7 @@ export async function createImportedFile(file: { file.notes || null, ] ); + notifyDataChanged(); return result.lastInsertId as number; } @@ -72,6 +77,7 @@ export async function updateFileStatus( `UPDATE imported_files SET status = $1, row_count = COALESCE($2, row_count), notes = COALESCE($3, notes) WHERE id = $4`, [status, rowCount ?? null, notes ?? null, id] ); + notifyDataChanged(); } export async function getAllImportedFiles(): Promise { @@ -142,6 +148,9 @@ export async function deleteImportWithTransactions( } } + // Announced once every delete went through; a refused or failed delete + // throws above and announces nothing (ADR 0022). + notifyDataChanged(); return result.rowsAffected; } @@ -175,5 +184,6 @@ export async function deleteAllImportsWithTransactions(): Promise { } await db.execute("DELETE FROM imported_files"); await db.execute("DELETE FROM import_sources"); + notifyDataChanged(); return result.rowsAffected; } diff --git a/src/services/transactionService.test.ts b/src/services/transactionService.test.ts index 317fb95..fbc6a1b 100644 --- a/src/services/transactionService.test.ts +++ b/src/services/transactionService.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("./db", () => { const getDb = vi.fn(); @@ -9,16 +9,32 @@ vi.mock("./db", () => { }); import { getDb } from "./db"; -import { getTransactionCount, updateTransactionCategory } from "./transactionService"; +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)", () => { @@ -60,3 +76,48 @@ describe("updateTransactionCategory (#390)", () => { 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([]); + }); +}); diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 0de93e0..5d98036 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -1,4 +1,5 @@ import { getDb } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import { categorizeBatch } from "./categorizationService"; import { isLinkedTransactionFkError, @@ -136,6 +137,9 @@ export async function insertBatch( onProgress(insertedCount); } + // Once, after the last insert: without it, the last re-read of an import + // could run before its transactions exist (ADR 0022). + notifyDataChanged(); return insertedCount; } @@ -306,6 +310,8 @@ export async function updateTransactionCategory( `UPDATE transactions SET category_id = $1, is_manually_categorized = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`, [categoryId, toSqlFlag(isManual), txId] ); + // The manual flag is the "categorize by hand" step (ADR 0022). + notifyDataChanged(); } export async function updateTransactionNotes( diff --git a/src/services/userPreferenceService.test.ts b/src/services/userPreferenceService.test.ts new file mode 100644 index 0000000..d75e1c5 --- /dev/null +++ b/src/services/userPreferenceService.test.ts @@ -0,0 +1,65 @@ +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 { + deletePreference, + getImportFolder, + setImportFolder, + setPreference, +} from "./userPreferenceService"; + +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("setImportFolder — data-change notification (#396)", () => { + it("writes the import_folder preference, then notifies once", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await setImportFolder("/home/max/releves"); + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(mockExecute.mock.calls[0][1]).toEqual(["import_folder", "/home/max/releves"]); + expect(notified).toEqual([1]); + }); + + it("does not notify when the write fails", async () => { + mockExecute.mockRejectedValueOnce(new Error("database is locked")); + await expect(setImportFolder("/home/max/releves")).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); +}); + +describe("generic preferences do not notify (#396)", () => { + it("setPreference, deletePreference and reads stay silent", async () => { + mockExecute.mockResolvedValue({ rowsAffected: 1 }); + mockSelect.mockResolvedValue([]); + await setPreference("budget-grid-expanded", "[]"); + await deletePreference("budget-grid-expanded"); + await getImportFolder(); + expect(notified).toEqual([]); + }); +}); diff --git a/src/services/userPreferenceService.ts b/src/services/userPreferenceService.ts index f8bf7bf..0d2d876 100644 --- a/src/services/userPreferenceService.ts +++ b/src/services/userPreferenceService.ts @@ -1,4 +1,5 @@ import { getDb } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import type { UserPreference } from "../shared/types"; export async function getPreference(key: string): Promise { @@ -35,6 +36,11 @@ export async function getImportFolder(): Promise { return getPreference("import_folder"); } +/** + * Stores the import folder. The getting-started path counts it as a step, so + * a successful write is announced on the data channel (ADR 0022). + */ export async function setImportFolder(path: string): Promise { - return setPreference("import_folder", path); + await setPreference("import_folder", path); + notifyDataChanged(); }