Simpl-Resultat/src/services/categoryService.test.ts
le king fu 8a9d9909f9
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m9s
test: pin the reattachment count to active descendants (#393)
A deactivated child of the reattached category is not counted in the
number of categories put back, which the Journal line reports.

Resolves #393

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:28:45 -04:00

397 lines
16 KiB
TypeScript

// 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<unknown>) => {
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> = {}): 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<number, number> = {}) {
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<unknown>): Promise<CategoryHierarchyError> {
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, counting its active descendants only", async () => {
const db = useFakeDb([
row(1, null),
row(2, null, { is_active: 0 }),
row(20, 2),
row(21, 20),
row(22, 20, { is_active: 0 }),
]);
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);
});
});