feat(onboarding): shared getting-started state, live re-read and data-change channel (ADR 0022) #402
18 changed files with 965 additions and 18 deletions
144
src/hooks/useImportWizard.executeImport.test.ts
Normal file
144
src/hooks/useImportWizard.executeImport.test.ts
Normal file
|
|
@ -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<typeof import("react")>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
89
src/services/dataEvents.test.ts
Normal file
89
src/services/dataEvents.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
45
src/services/dataEvents.ts
Normal file
45
src/services/dataEvents.ts
Normal file
|
|
@ -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<DataChangedListener>();
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
): Promise<void> {
|
||||
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(
|
||||
|
|
|
|||
94
src/services/importSourceService.test.ts
Normal file
94
src/services/importSourceService.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ImportSourceInput>
|
||||
|
|
@ -132,4 +140,5 @@ export async function updateSource(
|
|||
export async function deleteSource(id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.execute("DELETE FROM import_sources WHERE id = $1", [id]);
|
||||
notifyDataChanged();
|
||||
}
|
||||
|
|
|
|||
142
src/services/importedFileService.test.ts
Normal file
142
src/services/importedFileService.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ImportedFileWithSource[]> {
|
||||
|
|
@ -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<number> {
|
|||
}
|
||||
await db.execute("DELETE FROM imported_files");
|
||||
await db.execute("DELETE FROM import_sources");
|
||||
notifyDataChanged();
|
||||
return result.rowsAffected;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
65
src/services/userPreferenceService.test.ts
Normal file
65
src/services/userPreferenceService.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string | null> {
|
||||
|
|
@ -35,6 +36,11 @@ export async function getImportFolder(): Promise<string | null> {
|
|||
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<void> {
|
||||
return setPreference("import_folder", path);
|
||||
await setPreference("import_folder", path);
|
||||
notifyDataChanged();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue