diff --git a/src/components/categories/HierarchyCheckModal.render.test.tsx b/src/components/categories/HierarchyCheckModal.render.test.tsx
new file mode 100644
index 0000000..c783b7d
--- /dev/null
+++ b/src/components/categories/HierarchyCheckModal.render.test.tsx
@@ -0,0 +1,123 @@
+// HierarchyCheckModal — what the « Vérifier la hiérarchie » window shows (#393).
+// No jsdom: react-dom/server renders in vitest's node environment. A closed
+// CategoryCombobox renders its input only, without its portal (#429).
+import { describe, it, expect, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
+
+import HierarchyCheckModal from "./HierarchyCheckModal";
+import type { CategoryTreeNode } from "../../shared/types";
+import type { OrphanCategory } from "../../utils/categoryHierarchy";
+
+const node = (id: number, parent_id: number | null, name: string, i18n_key: string | null = null): CategoryTreeNode => ({
+ id,
+ name,
+ parent_id,
+ color: null,
+ icon: null,
+ type: "expense",
+ is_active: true,
+ is_inputable: true,
+ sort_order: id,
+ keyword_count: 0,
+ i18n_key,
+ children: [],
+});
+
+// 1 is a root; 3 <-> 5 is a cycle; 20's parent is gone.
+const CATEGORIES = [
+ node(1, null, "Logement", "categoriesSeed.housing"),
+ node(3, 5, "Épicerie"),
+ node(5, 3, "Alimentation"),
+ node(20, 99, "Cadeaux"),
+];
+const ORPHANS: OrphanCategory[] = [
+ { id: 3, reason: "cycle" },
+ { id: 20, reason: "missing_parent" },
+];
+
+const render = (props: { orphans: OrphanCategory[] | null; error?: string | null; isSaving?: boolean }) =>
+ renderToStaticMarkup(
+ ,
+ );
+
+const comboboxes = (html: string) => html.match(/]*role="combobox"[^>]*>/g) ?? [];
+
+describe("HierarchyCheckModal (#393)", () => {
+ it("is a modal dialog named after the button, with its explanation and a close button", () => {
+ const html = render({ orphans: [] });
+ expect(html).toContain('role="dialog"');
+ expect(html).toContain('aria-modal="true"');
+ expect(html).toContain('aria-label="t(categories.hierarchy.check)"');
+ expect(html).toContain("t(categories.hierarchy.intro)");
+ expect(html).toContain("t(common.close)");
+ });
+
+ it("says the check runs while the orphans are not known", () => {
+ const html = render({ orphans: null });
+ expect(html).toContain("t(categories.hierarchy.checking)");
+ expect(html).not.toContain("t(categories.hierarchy.none)");
+ expect(comboboxes(html)).toHaveLength(0);
+ });
+
+ it("says « aucune » for a sound hierarchy, with no row", () => {
+ const html = render({ orphans: [] });
+ expect(html).toContain("t(categories.hierarchy.none)");
+ expect(html).not.toContain("categories.hierarchy.count");
+ expect(html).not.toContain("t(categories.hierarchy.reattach)");
+ });
+
+ it("counts the orphans, and gives each row its name, its reason, a new parent and « Rattacher »", () => {
+ const html = render({ orphans: ORPHANS });
+ expect(html).toContain("t(categories.hierarchy.count[count=2])");
+ expect(html).toContain(">Épicerie
");
+ expect(html).toContain(">Cadeaux");
+ expect(html).toContain("t(categories.hierarchy.reasons.cycle)");
+ expect(html).toContain("t(categories.hierarchy.reasons.missing_parent)");
+ expect(html.split("t(categories.hierarchy.reattach)")).toHaveLength(3);
+ const inputs = comboboxes(html);
+ expect(inputs).toHaveLength(2);
+ expect(inputs[0]).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Épicerie])"');
+ expect(inputs[1]).toContain('aria-label="t(categories.hierarchy.newParentFor[name=Cadeaux])"');
+ });
+
+ it("preselects the root on every row", () => {
+ for (const input of comboboxes(render({ orphans: ORPHANS }))) {
+ expect(input).toContain('value="t(categories.hierarchy.root)"');
+ }
+ });
+
+ it("names a seed category in the app's language", () => {
+ const html = render({ orphans: [{ id: 1, reason: "inactive_parent" }] });
+ expect(html).toContain(">t(categoriesSeed.housing[defaultValue=Logement])");
+ expect(html).toContain("t(categories.hierarchy.reasons.inactive_parent)");
+ });
+
+ it("shows the translated error above the list, as an alert", () => {
+ const html = render({ orphans: ORPHANS, error: "Ce parent a des transactions" });
+ expect(html).toContain('role="alert"');
+ expect(html.indexOf("Ce parent a des transactions")).toBeLessThan(html.indexOf("categories.hierarchy.count"));
+ });
+
+ it("shows a failed check alone, without the « checking » line", () => {
+ const html = render({ orphans: null, error: "database is locked" });
+ expect(html).toContain("database is locked");
+ expect(html).not.toContain("t(categories.hierarchy.checking)");
+ });
+
+ it("disables « Rattacher » while a reattachment runs", () => {
+ const html = render({ orphans: ORPHANS, isSaving: true });
+ const buttons = html.match(/`);
+ expect(end).toBeGreaterThan(-1);
+ const start = html.lastIndexOf("", start) + 1);
+ };
+
+ it("sits between « All keywords » and « Reset », styled as « Reset »", () => {
+ const html = render();
+ const allKeywords = html.indexOf("t(categories.allKeywords)");
+ const check = html.indexOf("t(categories.hierarchy.check)");
+ const reset = html.indexOf("t(categories.reinitialize)");
+ expect(allKeywords).toBeLessThan(check);
+ expect(check).toBeLessThan(reset);
+ const classOf = (tag: string) => tag.match(/class="([^"]*)"/)?.[1];
+ expect(classOf(buttonTag(html, "t(categories.hierarchy.check)"))).toBe(
+ classOf(buttonTag(html, "t(categories.reinitialize)")),
+ );
+ });
+
+ it("is disabled while a save runs, as « Reset »", () => {
+ harness.state = { isSaving: true };
+ expect(buttonTag(render(), "t(categories.hierarchy.check)")).toContain('disabled=""');
+ });
+
+ it("opens the window only while the check is open", () => {
+ expect(render()).not.toContain('role="dialog"');
+ harness.state = { hierarchyCheck: { orphans: [], error: null } };
+ const html = render();
+ expect(html).toContain('role="dialog"');
+ expect(html).toContain("t(categories.hierarchy.none)");
+ });
+
+ it("translates a refusal in the window by its key", () => {
+ harness.state = {
+ hierarchyCheck: { orphans: [], error: { key: "categories.hierarchy.errors.too_deep" } },
+ };
+ expect(render()).toContain("t(categories.hierarchy.errors.too_deep)");
+ });
+
+ it("translates a refused save of the form by its key, and shows any other failure as is", () => {
+ harness.state = { error: { key: "categories.hierarchy.errors.parent_has_transactions" } };
+ expect(render()).toContain(">t(categories.hierarchy.errors.parent_has_transactions)");
+ harness.state = { error: { message: "database is locked" } };
+ expect(render()).toContain(">database is locked");
+ });
+});
diff --git a/src/pages/CategoriesPage.tsx b/src/pages/CategoriesPage.tsx
index 54f489e..f620503 100644
--- a/src/pages/CategoriesPage.tsx
+++ b/src/pages/CategoriesPage.tsx
@@ -1,12 +1,13 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
-import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react";
+import { Plus, RotateCcw, List, AlertTriangle, ListTree } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
-import { useCategories } from "../hooks/useCategories";
+import { useCategories, categoriesErrorText } from "../hooks/useCategories";
import CategoryTree from "../components/categories/CategoryTree";
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
+import HierarchyCheckModal from "../components/categories/HierarchyCheckModal";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { useOnboarding } from "../contexts/OnboardingContext";
import {
@@ -35,6 +36,9 @@ export default function CategoriesPage() {
removeKeyword,
reinitializeCategories,
moveCategory,
+ checkHierarchy,
+ reattachOrphan,
+ closeHierarchyCheck,
} = useCategories();
// The getting-started card (#432) points at "All keywords" while it is on
@@ -79,6 +83,14 @@ export default function CategoriesPage() {
{t("categories.allKeywords")}
+
+
+ {t("categories.hierarchy.check")}
+
setShowReinitConfirm(true)}
disabled={state.isSaving}
@@ -99,7 +111,7 @@ export default function CategoriesPage() {
{state.error && (
- {state.error}
+ {categoriesErrorText(state.error, t)}
)}
@@ -141,6 +153,17 @@ export default function CategoriesPage() {
)}
+ {state.hierarchyCheck && (
+
+ )}
+
{/* Reinitialize confirmation modal */}
{showReinitConfirm && (
diff --git a/src/services/categoryService.test.ts b/src/services/categoryService.test.ts
new file mode 100644
index 0000000..1a0bb8a
--- /dev/null
+++ b/src/services/categoryService.test.ts
@@ -0,0 +1,391 @@
+// categoryService — the hierarchy rules at the write (#393): a new parent is
+// checked by `updateCategory` and `reattachCategory`, the reads and the writes
+// run in one transaction through its handle, the orphan diagnostic reads every
+// category, and `getCategoryDepth` ends on a cycle.
+//
+// The base is a fake: a `categories` table in memory, the direct transaction
+// count of each category, BEGIN/COMMIT/ROLLBACK. `withTransaction` hands the
+// callback its own handle; the global connection of `getDb()` throws while a
+// transaction runs, as the real lock would deadlock (docs/architecture.md,
+// « Accès DB et transactions »).
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const fake = vi.hoisted(() => ({
+ inTransaction: false,
+ global: null as unknown,
+ handle: null as unknown,
+}));
+
+vi.mock("./db", () => ({
+ getDb: vi.fn(async () => fake.global),
+ withTransaction: vi.fn(async (fn: (db: unknown) => Promise
) => {
+ fake.inTransaction = true;
+ try {
+ return await fn(fake.handle);
+ } finally {
+ fake.inTransaction = false;
+ }
+ }),
+}));
+
+import { withTransaction } from "./db";
+import {
+ CategoryHierarchyError,
+ categoryHierarchyErrorKey,
+ createCategory,
+ findOrphanCategories,
+ getCategoryDepth,
+ reattachCategory,
+ updateCategory,
+} from "./categoryService";
+import { buildTree } from "../hooks/useCategories";
+import type { CategoryTreeNode } from "../shared/types";
+
+interface Row {
+ id: number;
+ name: string;
+ parent_id: number | null;
+ is_active: number;
+ is_inputable: number;
+}
+
+const row = (id: number, parent_id: number | null, extra: Partial = {}): Row => ({
+ id,
+ name: `c${id}`,
+ parent_id,
+ is_active: 1,
+ is_inputable: 1,
+ ...extra,
+});
+
+const HIERARCHY_SQL = "SELECT id, parent_id, is_active FROM categories";
+
+/**
+ * The categories table and the direct transaction counts. Every statement is
+ * logged with the connection it went through ("handle" or "global").
+ */
+function useFakeDb(initial: Row[], transactions: Record = {}) {
+ let rows = initial.map((r) => ({ ...r }));
+ let beforeTransaction: Row[] | null = null;
+ const log: Array<{ via: "handle" | "global"; sql: string; params: unknown[] }> = [];
+
+ const select = async (sql: string, params: unknown[] = []) => {
+ if (sql === HIERARCHY_SQL) {
+ return rows.map(({ id, parent_id, is_active }) => ({ id, parent_id, is_active }));
+ }
+ if (sql.includes("COUNT(*) AS cnt FROM transactions WHERE category_id = $1")) {
+ return [{ cnt: transactions[params[0] as number] ?? 0 }];
+ }
+ if (sql.includes("SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1")) {
+ const found = rows.find((r) => r.id === params[0] && r.is_active === 1);
+ return found ? [{ parent_id: found.parent_id }] : [];
+ }
+ throw new Error(`unexpected SELECT: ${sql}`);
+ };
+
+ const execute = async (sql: string, params: unknown[] = []) => {
+ if (sql === "BEGIN") {
+ beforeTransaction = rows.map((r) => ({ ...r }));
+ } else if (sql === "COMMIT") {
+ beforeTransaction = null;
+ } else if (sql === "ROLLBACK") {
+ if (beforeTransaction) rows = beforeTransaction;
+ beforeTransaction = null;
+ } else if (sql.startsWith("UPDATE categories SET name = $1")) {
+ const [name, , , parent_id, is_inputable, , id] = params as [string, string, string, number | null, number, number, number];
+ rows = rows.map((r) => (r.id === id ? { ...r, name, parent_id, is_inputable } : r));
+ } else if (sql.startsWith("UPDATE categories SET parent_id = $1 WHERE id = $2")) {
+ const [parent_id, id] = params as [number | null, number];
+ rows = rows.map((r) => (r.id === id ? { ...r, parent_id } : r));
+ } else if (sql.startsWith("UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1")) {
+ rows = rows.map((r) => (r.id === params[0] && r.is_inputable === 1 ? { ...r, is_inputable: 0 } : r));
+ } else if (sql.startsWith("INSERT INTO categories")) {
+ const [name, , , parent_id, is_inputable] = params as [string, string, string, number | null, number];
+ const id = Math.max(0, ...rows.map((r) => r.id)) + 1;
+ rows = [...rows, { id, name, parent_id, is_active: 1, is_inputable }];
+ return { rowsAffected: 1, lastInsertId: id };
+ } else {
+ throw new Error(`unexpected execute: ${sql}`);
+ }
+ return { rowsAffected: 1 };
+ };
+
+ const connection = (via: "handle" | "global") => ({
+ select: vi.fn(async (sql: string, params?: unknown[]) => {
+ if (via === "global" && fake.inTransaction) throw new Error("getDb() inside a transaction");
+ log.push({ via, sql, params: params ?? [] });
+ return select(sql, params);
+ }),
+ execute: vi.fn(async (sql: string, params?: unknown[]) => {
+ if (via === "global" && fake.inTransaction) throw new Error("getDb() inside a transaction");
+ log.push({ via, sql, params: params ?? [] });
+ return execute(sql, params);
+ }),
+ });
+ fake.global = connection("global");
+ fake.handle = connection("handle");
+
+ return {
+ rows: () => rows,
+ log,
+ /** The statements that wrote, in order (BEGIN/COMMIT/ROLLBACK included). */
+ writes: () => log.filter((l) => !l.sql.startsWith("SELECT")).map((l) => l.sql.split(" WHERE")[0]),
+ };
+}
+
+const form = (parent_id: number | null, extra: { name?: string; is_inputable?: boolean } = {}) => ({
+ name: extra.name ?? "Renamed",
+ type: "expense",
+ color: "#4A90A4",
+ parent_id,
+ is_inputable: extra.is_inputable ?? true,
+ sort_order: 1,
+});
+
+/** What `updateCategory` or `reattachCategory` rejected with. */
+async function refusal(promise: Promise): Promise {
+ const error = await promise.then(
+ () => null,
+ (e: unknown) => e,
+ );
+ expect(error).toBeInstanceOf(CategoryHierarchyError);
+ return error as CategoryHierarchyError;
+}
+
+/** The tree of the Categories page, built from the active rows as the page loads them. */
+const visibleIds = (rows: Row[]): number[] => {
+ const flat: CategoryTreeNode[] = rows
+ .filter((r) => r.is_active === 1)
+ .map((r) => ({
+ id: r.id,
+ name: r.name,
+ parent_id: r.parent_id,
+ color: null,
+ icon: null,
+ type: "expense",
+ is_active: true,
+ is_inputable: r.is_inputable === 1,
+ sort_order: r.id,
+ keyword_count: 0,
+ i18n_key: null,
+ children: [],
+ }));
+ const ids: number[] = [];
+ const walk = (nodes: CategoryTreeNode[]) => {
+ for (const node of nodes) {
+ ids.push(node.id);
+ walk(node.children);
+ }
+ };
+ walk(buildTree(flat));
+ return ids.sort((a, b) => a - b);
+};
+
+// 1 -> 10 -> 100, 1 -> 11; 2 -> 20.
+const TREE = [row(1, null), row(10, 1), row(100, 10), row(11, 1), row(2, null), row(20, 2)];
+
+beforeEach(() => {
+ fake.inTransaction = false;
+ vi.mocked(withTransaction).mockClear();
+});
+
+describe("updateCategory — regression of #393: A -> B -> A", () => {
+ it("refuses to put a category under its own child, writes nothing, rolls back", async () => {
+ // A (1) is a root, B (2) its child. Editing A with parent B closed the
+ // loop in one save and hid both everywhere.
+ const db = useFakeDb([row(1, null), row(2, 1)]);
+ const error = await refusal(updateCategory(1, form(2)));
+ expect(error.code).toBe("cycle");
+ expect(db.rows().find((r) => r.id === 1)?.parent_id).toBeNull();
+ expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
+ expect(visibleIds(db.rows())).toEqual([1, 2]);
+ });
+
+ it("refuses the category itself as its parent", async () => {
+ useFakeDb(TREE);
+ expect((await refusal(updateCategory(11, form(11)))).code).toBe("self_parent");
+ });
+
+ it("refuses a grandchild as the parent", async () => {
+ useFakeDb(TREE);
+ expect((await refusal(updateCategory(1, form(100)))).code).toBe("cycle");
+ });
+
+ it("refuses a fourth level, counting the sub-tree of the category", async () => {
+ const db = useFakeDb(TREE);
+ // Root 2 carries 20: under 10 (level 1), 20 would land at level 3.
+ expect((await refusal(updateCategory(2, form(10)))).code).toBe("too_deep");
+ // A leaf under 100 (level 2) would be level 3.
+ expect((await refusal(updateCategory(20, form(100)))).code).toBe("too_deep");
+ expect(db.writes()).toEqual(["BEGIN", "ROLLBACK", "BEGIN", "ROLLBACK"]);
+ });
+
+ it("refuses a new parent that holds transactions of its own", async () => {
+ const db = useFakeDb(TREE, { 11: 3 });
+ expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_transactions");
+ expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(2);
+ expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(1);
+ expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
+ });
+
+ it("moves a category under a valid parent, which stops taking input, in one transaction through its handle", async () => {
+ const db = useFakeDb(TREE);
+ await updateCategory(20, form(11, { is_inputable: false }));
+ expect(db.writes()).toEqual([
+ "BEGIN",
+ "UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6",
+ "UPDATE categories SET is_inputable = 0",
+ "COMMIT",
+ ]);
+ // Every statement, reads included, went through the transaction's handle.
+ expect(db.log.every((l) => l.via === "handle")).toBe(true);
+ expect(withTransaction).toHaveBeenCalledTimes(1);
+ const moved = db.rows().find((r) => r.id === 20);
+ expect(moved?.parent_id).toBe(11);
+ expect(moved?.name).toBe("Renamed");
+ // The flag is bound as the integer 0, never the boolean false.
+ const update = db.log.find((l) => l.sql.startsWith("UPDATE categories SET name"));
+ expect(update?.params[4]).toBe(0);
+ expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(0);
+ });
+
+ it("accepts the root, without touching any other category", async () => {
+ const db = useFakeDb(TREE);
+ await updateCategory(10, form(null));
+ expect(db.rows().find((r) => r.id === 10)?.parent_id).toBeNull();
+ expect(db.writes()).toEqual([
+ "BEGIN",
+ "UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6",
+ "COMMIT",
+ ]);
+ });
+
+ it("does not check an unchanged parent: a rename under a parent with transactions or at a fourth level is saved", async () => {
+ // Stored before the rules: 2 holds transactions and has a child; 1000 sits at level 3.
+ const db = useFakeDb([...TREE, row(1000, 100)], { 2: 5 });
+ await updateCategory(20, form(2, { name: "Rent" }));
+ await updateCategory(1000, form(100, { name: "Deep" }));
+ expect(db.rows().find((r) => r.id === 20)?.name).toBe("Rent");
+ expect(db.rows().find((r) => r.id === 1000)?.name).toBe("Deep");
+ expect(db.log.some((l) => l.sql.includes("FROM transactions"))).toBe(false);
+ expect(db.writes().filter((w) => w === "UPDATE categories SET is_inputable = 0")).toEqual([]);
+ });
+
+ it("refuses a deactivated parent with a plain error, out of reach of the pickers", async () => {
+ const db = useFakeDb([...TREE, row(3, null, { is_active: 0 })]);
+ const error = await updateCategory(20, form(3)).then(
+ () => null,
+ (e: unknown) => e,
+ );
+ expect(error).toBeInstanceOf(Error);
+ expect(error).not.toBeInstanceOf(CategoryHierarchyError);
+ expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
+ });
+});
+
+describe("CategoryHierarchyError", () => {
+ it("carries its code and names the i18n key of its message", () => {
+ const error = new CategoryHierarchyError("too_deep");
+ expect(error).toBeInstanceOf(Error);
+ expect(error.name).toBe("CategoryHierarchyError");
+ expect(error.code).toBe("too_deep");
+ expect(categoryHierarchyErrorKey("parent_has_transactions")).toBe(
+ "categories.hierarchy.errors.parent_has_transactions",
+ );
+ });
+});
+
+describe("findOrphanCategories (#393)", () => {
+ it("reads every category with the dedicated query, inactive ones included", async () => {
+ const db = useFakeDb([row(1, null), row(2, null, { is_active: 0 }), row(20, 2), row(30, 99)]);
+ expect(await findOrphanCategories()).toEqual([
+ { id: 20, reason: "inactive_parent" },
+ { id: 30, reason: "missing_parent" },
+ ]);
+ expect(db.log.map((l) => l.sql)).toEqual([HIERARCHY_SQL]);
+ });
+});
+
+describe("reattachCategory on rows already in a cycle (#393)", () => {
+ it("a two-member cycle: found by the diagnostic, broken at the root, back in the tree", async () => {
+ // 5 <-> 3, 8 under 5: none of them is reachable from a root.
+ const db = useFakeDb([row(1, null), row(5, 3), row(3, 5), row(8, 5)]);
+ expect(visibleIds(db.rows())).toEqual([1]);
+ expect(await findOrphanCategories()).toEqual([{ id: 3, reason: "cycle" }]);
+
+ expect(await reattachCategory(3, null)).toBe(3);
+
+ expect(db.rows().find((r) => r.id === 3)?.parent_id).toBeNull();
+ expect(await findOrphanCategories()).toEqual([]);
+ expect(visibleIds(db.rows())).toEqual([1, 3, 5, 8]);
+ expect(db.log.filter((l) => l.via === "global").map((l) => l.sql)).toEqual([HIERARCHY_SQL, HIERARCHY_SQL]);
+ });
+
+ it("a three-member cycle: too deep under a root, broken at the root, back in the tree", async () => {
+ // 6 -> 4 -> 9 -> 6, nothing under them. Broken at 4, the branch is 4 -> 6 -> 9:
+ // three levels, so it fits at the root and not under root 1.
+ const db = useFakeDb([row(1, null), row(6, 4), row(4, 9), row(9, 6)]);
+ expect(await findOrphanCategories()).toEqual([{ id: 4, reason: "cycle" }]);
+ // Under root 1 the branch (4, 6, 9) would reach level 3: refused, nothing written.
+ expect((await refusal(reattachCategory(4, 1))).code).toBe("too_deep");
+ expect(await findOrphanCategories()).toEqual([{ id: 4, reason: "cycle" }]);
+
+ expect(await reattachCategory(4, null)).toBe(3);
+ expect(await findOrphanCategories()).toEqual([]);
+ expect(visibleIds(db.rows())).toEqual([1, 4, 6, 9]);
+ });
+
+ it("moves a cycle member under a sound parent, which stops taking input", async () => {
+ const db = useFakeDb([row(1, null), row(5, 3), row(3, 5)]);
+ expect(await reattachCategory(3, 1)).toBe(2);
+ expect(db.rows().find((r) => r.id === 3)?.parent_id).toBe(1);
+ expect(db.rows().find((r) => r.id === 1)?.is_inputable).toBe(0);
+ expect(db.writes()).toEqual([
+ "BEGIN",
+ "UPDATE categories SET parent_id = $1",
+ "UPDATE categories SET is_inputable = 0",
+ "COMMIT",
+ ]);
+ expect(db.log.every((l) => l.via === "handle")).toBe(true);
+ expect(visibleIds(db.rows())).toEqual([1, 3, 5]);
+ });
+
+ it("refuses the other member of the cycle and a parent with transactions, writing nothing", async () => {
+ const db = useFakeDb([row(1, null), row(5, 3), row(3, 5)], { 1: 2 });
+ expect((await refusal(reattachCategory(3, 5))).code).toBe("cycle");
+ expect((await refusal(reattachCategory(3, 1))).code).toBe("parent_has_transactions");
+ expect(db.rows().find((r) => r.id === 3)?.parent_id).toBe(5);
+ expect(db.writes()).toEqual(["BEGIN", "ROLLBACK", "BEGIN", "ROLLBACK"]);
+ });
+
+ it("reattaches a category whose parent was deleted", async () => {
+ const db = useFakeDb([row(1, null), row(2, null, { is_active: 0 }), row(20, 2), row(21, 20)]);
+ expect(await reattachCategory(20, 1)).toBe(2);
+ expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(1);
+ expect(await findOrphanCategories()).toEqual([]);
+ });
+});
+
+describe("getCategoryDepth (#393)", () => {
+ it("counts the active ancestors", async () => {
+ useFakeDb(TREE);
+ expect(await getCategoryDepth(1)).toBe(0);
+ expect(await getCategoryDepth(10)).toBe(1);
+ expect(await getCategoryDepth(100)).toBe(2);
+ });
+
+ it("ends on a cycle and on a category that is its own parent: no depth", async () => {
+ useFakeDb([row(5, 3), row(3, 5), row(8, 5), row(7, 7)]);
+ expect(await getCategoryDepth(5)).toBe(Number.POSITIVE_INFINITY);
+ expect(await getCategoryDepth(8)).toBe(Number.POSITIVE_INFINITY);
+ expect(await getCategoryDepth(7)).toBe(Number.POSITIVE_INFINITY);
+ });
+
+ it("so createCategory refuses a cycle member as the parent", async () => {
+ const db = useFakeDb([row(5, 3), row(3, 5)]);
+ await expect(
+ createCategory({ name: "New", type: "expense", color: "#000", parent_id: 5, is_inputable: true, sort_order: 1 }),
+ ).rejects.toThrow("maximum depth");
+ expect(db.rows()).toHaveLength(2);
+ });
+});
diff --git a/src/services/categoryService.ts b/src/services/categoryService.ts
index 976bf2c..a73bfaa 100644
--- a/src/services/categoryService.ts
+++ b/src/services/categoryService.ts
@@ -1,5 +1,112 @@
-import { getDb } from "./db";
+import type Database from "@tauri-apps/plugin-sql";
+import { getDb, withTransaction } from "./db";
import type { Keyword } from "../shared/types";
+import { toSqlFlag } from "../utils/sqlFlag";
+import {
+ descendantsOf,
+ findOrphans,
+ isActiveRow,
+ parentChangeRefusal,
+ type HierarchyRow,
+ type OrphanCategory,
+} from "../utils/categoryHierarchy";
+
+/** Why a new parent is refused (#393); each code has its message under `categories.hierarchy.errors`. */
+export type CategoryHierarchyErrorCode =
+ | "self_parent"
+ | "cycle"
+ | "too_deep"
+ | "parent_has_transactions";
+
+/**
+ * Thrown when `updateCategory` or `reattachCategory` refuses a new parent
+ * (#393), before any write. The UI shows the translation of its code
+ * (`categoryHierarchyErrorKey`), never `message`, which is for the tests and
+ * the developer console.
+ */
+export class CategoryHierarchyError extends Error {
+ readonly code: CategoryHierarchyErrorCode;
+ constructor(code: CategoryHierarchyErrorCode, message?: string) {
+ super(message ?? `New parent refused by the category hierarchy rules: ${code}`);
+ this.name = "CategoryHierarchyError";
+ this.code = code;
+ }
+}
+
+/** The i18n key of a refusal's message. */
+export function categoryHierarchyErrorKey(code: CategoryHierarchyErrorCode): string {
+ return `categories.hierarchy.errors.${code}`;
+}
+
+/**
+ * Every category, active or not, with what the hierarchy rules read (#393).
+ * The other category queries keep active rows only, which would turn a
+ * deactivated parent into a missing one.
+ */
+const HIERARCHY_ROWS_SQL = "SELECT id, parent_id, is_active FROM categories";
+
+async function readHierarchyRows(db: Database): Promise {
+ return db.select(HIERARCHY_ROWS_SQL);
+}
+
+/** The orphan categories of the profile, one per group to reattach (`findOrphans`, #393). */
+export async function findOrphanCategories(): Promise {
+ const db = await getDb();
+ return findOrphans(await readHierarchyRows(db));
+}
+
+/** A category that receives a child stops taking input, as in `createCategory`. */
+async function markAsParent(db: Database, parentId: number): Promise {
+ await db.execute(
+ `UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1`,
+ [parentId]
+ );
+}
+
+/**
+ * Checks moving category `id` under `parentId` (#393), on rows read in the
+ * caller's transaction and through its handle: the hierarchy rules of
+ * `parentChangeRefusal`, then a parent that holds transactions of its own is
+ * refused (once a parent, it would stop taking input). The root is always
+ * accepted. Throws before any write.
+ */
+async function checkNewParent(
+ db: Database,
+ rows: HierarchyRow[],
+ id: number,
+ parentId: number | null
+): Promise {
+ if (parentId === null) return;
+ const active = rows.filter(isActiveRow);
+ // Unreachable from the UI: both pickers list active categories only.
+ if (parentId !== id && !active.some((r) => r.id === parentId)) {
+ throw new Error("Parent category not found or inactive");
+ }
+ const refusal = parentChangeRefusal(active, id, parentId);
+ if (refusal !== null) throw new CategoryHierarchyError(refusal);
+ const used = await db.select>(
+ `SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`,
+ [parentId]
+ );
+ if ((used[0]?.cnt ?? 0) > 0) throw new CategoryHierarchyError("parent_has_transactions");
+}
+
+/** Runs `body` between BEGIN and COMMIT on the transaction handle, ROLLBACK on failure. */
+async function inTransaction(db: Database, body: () => Promise): Promise {
+ await db.execute("BEGIN");
+ try {
+ const result = await body();
+ await db.execute("COMMIT");
+ return result;
+ } catch (e) {
+ try {
+ await db.execute("ROLLBACK");
+ } catch {
+ // Keep the original error: it is the one that explains the failure.
+ }
+ throw e;
+ }
+}
interface CategoryRow {
id: number;
@@ -27,11 +134,20 @@ export async function getAllCategoriesWithCounts(): Promise {
);
}
+/**
+ * Number of active ancestors of a category: 0 for a root. A category caught in
+ * a `parent_id` cycle, or under one, has no depth: the walk keeps the ids it
+ * has seen and returns `Infinity` on the first one seen twice (#393), which
+ * `createCategory` refuses as a parent.
+ */
export async function getCategoryDepth(categoryId: number): Promise {
const db = await getDb();
let depth = 0;
let currentId: number | null = categoryId;
+ const visited = new Set();
while (currentId !== null) {
+ if (visited.has(currentId)) return Number.POSITIVE_INFINITY;
+ visited.add(currentId);
const parentRows: Array<{ parent_id: number | null }> = await db.select>(
`SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1`,
[currentId]
@@ -68,15 +184,22 @@ export async function createCategory(data: {
// Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0
if (data.parent_id !== null) {
- await db.execute(
- `UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1`,
- [data.parent_id]
- );
+ await markAsParent(db, data.parent_id);
}
return result.lastInsertId as number;
}
+/**
+ * Saves the category form. A new parent goes through the hierarchy rules
+ * (#393, `checkNewParent`): `CategoryHierarchyError` on a refusal, nothing
+ * written. The reads and the writes (the row, then the new parent's
+ * `is_inputable`) run in one transaction, through its handle.
+ *
+ * An unchanged parent is not checked: a name or a colour saved on a category
+ * stored before these rules (a fourth level, a parent with transactions) must
+ * not be refused.
+ */
export async function updateCategory(
id: number,
data: {
@@ -88,10 +211,43 @@ export async function updateCategory(
sort_order: number;
}
): Promise {
- const db = await getDb();
- await db.execute(
- `UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6 WHERE id = $7`,
- [data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order, id]
+ const parentId = data.parent_id ?? null;
+ await withTransaction((db) =>
+ inTransaction(db, async () => {
+ const rows = await readHierarchyRows(db);
+ const current = rows.find((r) => r.id === id);
+ if (current === undefined) throw new Error("Category not found");
+ const parentChanged = (current.parent_id ?? null) !== parentId;
+ if (parentChanged) await checkNewParent(db, rows, id, parentId);
+ await db.execute(
+ `UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6 WHERE id = $7`,
+ [data.name, data.type, data.color, parentId, toSqlFlag(data.is_inputable), data.sort_order, id]
+ );
+ if (parentChanged && parentId !== null) await markAsParent(db, parentId);
+ })
+ );
+}
+
+/**
+ * Moves a category under `parentId`, or to the root with `null`: the repair
+ * of the « Vérifier la hiérarchie » window (#393). Same rules as a parent
+ * change in `updateCategory`, always checked, in one transaction through its
+ * handle; only `parent_id` changes, and the new parent's `is_inputable`.
+ *
+ * Returns the number of categories put back in place: the category and every
+ * active category under it (for a member of a cycle, the whole cycle and what
+ * hangs under it).
+ */
+export async function reattachCategory(id: number, parentId: number | null): Promise {
+ return withTransaction((db) =>
+ inTransaction(db, async () => {
+ const rows = await readHierarchyRows(db);
+ if (!rows.some((r) => r.id === id)) throw new Error("Category not found");
+ await checkNewParent(db, rows, id, parentId);
+ await db.execute(`UPDATE categories SET parent_id = $1 WHERE id = $2`, [parentId, id]);
+ if (parentId !== null) await markAsParent(db, parentId);
+ return 1 + descendantsOf(rows.filter(isActiveRow), id).size;
+ })
);
}
@@ -140,6 +296,13 @@ export async function fixDuplicateSortOrders(): Promise {
}
}
+/**
+ * Writes the order and the parent of every category after a drag-and-drop in
+ * the tree. Unlike `updateCategory`, no hierarchy check here (#393, out of its
+ * scope): its only caller, `moveCategory` of `useCategories`, cannot drop a
+ * category inside its own sub-tree (the drop target is not found, nothing is
+ * written).
+ */
export async function updateCategorySortOrders(
updates: Array<{ id: number; sort_order: number; parent_id: number | null }>
): Promise {