Decision of Max of 2026-10-20 (review 25921): transactions, non-zero budget amounts and active keywords are checked only while the target still takes input (is_inputable = 1, the condition of markAsParent), that is when it really becomes a parent and stops taking input. An existing parent, no longer inputable, accepts a new or reattached subcategory whatever it holds, as in every released version: the refusal protected nothing there. All three codes and all three entry points follow the rule; the hierarchy rows now carry is_inputable, and createCategory reads them through its transaction's handle. Also pins a negative amount, in the grid and in a template, as a refusal (review 25921, R1/R2): the grid stores what is typed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
607 lines
27 KiB
TypeScript
607 lines
27 KiB
TypeScript
// categoryService — the hierarchy rules at the write (#393): a new parent is
|
|
// checked by `updateCategory` and `reattachCategory`, a parent's content by
|
|
// `createCategory` too, 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 and BEGIN/COMMIT/ROLLBACK.
|
|
// What a parent holds (its transactions, budget amounts and keywords) lives in
|
|
// a real SQLite database (`node:sqlite`, built into Node, no package), whose
|
|
// tables are created by their CREATE TABLE of the schema of a new profile
|
|
// (`consolidated_schema.sql`): the checks' SQL runs as written, so a condition
|
|
// dropped from a query (`amount <> 0`, `is_active = 1`) turns a test red.
|
|
// `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";
|
|
import { readFileSync } from "fs";
|
|
import { resolve } from "path";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import fr from "../i18n/locales/fr.json";
|
|
import en from "../i18n/locales/en.json";
|
|
|
|
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 {
|
|
CATEGORY_HIERARCHY_ERROR_CODES,
|
|
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, is_inputable FROM categories";
|
|
|
|
/** What categories hold in their own name, for the checks of a parent's content. */
|
|
interface Content {
|
|
/** Budget amounts of a year, as [category, amount]. */
|
|
budget?: Array<[number, number]>;
|
|
/** Amounts of budget templates, as [category, amount]. */
|
|
templates?: Array<[number, number]>;
|
|
/** Keywords, as [category, is_active]. */
|
|
keywords?: Array<[number, 0 | 1]>;
|
|
}
|
|
|
|
/** The tables the content checks read. */
|
|
const CONTENT_TABLES = ["transactions", "budget_entries", "budget_template_entries", "keywords"];
|
|
const CONTENT_SQL = /\bFROM (transactions|budget_entries|budget_template_entries|keywords)\b/;
|
|
|
|
const SCHEMA = readFileSync(
|
|
resolve(import.meta.dirname, "../../src-tauri/src/database/consolidated_schema.sql"),
|
|
"utf-8",
|
|
);
|
|
|
|
/** The CREATE TABLE of `table` in the schema of a new profile. */
|
|
function createTableSql(table: string): string {
|
|
const found = SCHEMA.match(new RegExp(`CREATE TABLE IF NOT EXISTS ${table} \\([\\s\\S]*?\\n\\);`));
|
|
if (!found) throw new Error(`no CREATE TABLE for ${table} in consolidated_schema.sql`);
|
|
return found[0];
|
|
}
|
|
|
|
/**
|
|
* A SQLite database with the real content tables (no categories table: the
|
|
* foreign keys are off) holding `transactions` direct transactions per
|
|
* category and `content`.
|
|
*/
|
|
function contentDb(transactions: Record<number, number>, content: Content): DatabaseSync {
|
|
const db = new DatabaseSync(":memory:", { enableForeignKeyConstraints: false });
|
|
for (const table of CONTENT_TABLES) db.exec(createTableSql(table));
|
|
const tx = db.prepare("INSERT INTO transactions (date, description, amount, category_id) VALUES ('2026-09-01', 'tx', -10, ?)");
|
|
for (const [category, count] of Object.entries(transactions)) {
|
|
for (let i = 0; i < count; i++) tx.run(Number(category));
|
|
}
|
|
const budget = db.prepare("INSERT INTO budget_entries (category_id, year, month, amount) VALUES (?, 2026, ?, ?)");
|
|
(content.budget ?? []).forEach(([category, amount], i) => budget.run(category, (i % 12) + 1, amount));
|
|
const template = db.prepare("INSERT INTO budget_template_entries (template_id, category_id, amount) VALUES (?, ?, ?)");
|
|
(content.templates ?? []).forEach(([category, amount], i) => template.run(i + 1, category, amount));
|
|
const keyword = db.prepare("INSERT INTO keywords (keyword, category_id, priority, is_active) VALUES (?, ?, 0, ?)");
|
|
(content.keywords ?? []).forEach(([category, active], i) => keyword.run(`KW${i}`, category, active));
|
|
return db;
|
|
}
|
|
|
|
/**
|
|
* The categories table, and what categories hold. Every statement is logged
|
|
* with the connection it went through ("handle" or "global").
|
|
*/
|
|
function useFakeDb(initial: Row[], transactions: Record<number, number> = {}, content: Content = {}) {
|
|
let rows = initial.map((r) => ({ ...r }));
|
|
let beforeTransaction: Row[] | null = null;
|
|
const log: Array<{ via: "handle" | "global"; sql: string; params: unknown[] }> = [];
|
|
const held = contentDb(transactions, content);
|
|
|
|
const select = async (sql: string, params: unknown[] = []) => {
|
|
if (sql === HIERARCHY_SQL) {
|
|
return rows.map(({ id, parent_id, is_active, is_inputable }) => ({ id, parent_id, is_active, is_inputable }));
|
|
}
|
|
if (CONTENT_SQL.test(sql)) {
|
|
// The query as written, its `$1` bound by name.
|
|
return held.prepare(sql).all(Object.fromEntries(params.map((p, i) => [`$${i + 1}`, p as number])));
|
|
}
|
|
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, a budget amount and a
|
|
// keyword, and has a child; 1000 sits at level 3.
|
|
const db = useFakeDb([...TREE, row(1000, 100)], { 2: 5 }, { budget: [[2, 40]], keywords: [[2, 1]] });
|
|
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) => CONTENT_SQL.test(l.sql))).toBe(false);
|
|
expect(db.writes().filter((w) => w === "UPDATE categories SET is_inputable = 0")).toEqual([]);
|
|
});
|
|
|
|
it("counts the depth on the active categories only: a category under a deleted parent is a root", async () => {
|
|
// 3 hangs under 2, deactivated: the tree shows 3 at the root, and so do the
|
|
// pickers. Root 1 carries 10: under 3 (level 0), 10 lands at level 2.
|
|
const db = useFakeDb([row(1, null), row(10, 1), row(2, null, { is_active: 0 }), row(3, 2)]);
|
|
await updateCategory(1, form(3));
|
|
expect(db.rows().find((r) => r.id === 1)?.parent_id).toBe(3);
|
|
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",
|
|
]);
|
|
});
|
|
|
|
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("a parent's content: transactions, budget amounts, keywords (#393, decision of 2026-10-19)", () => {
|
|
/** A sub-category of `parent` created from the form. */
|
|
const newChild = (parent: number) => ({
|
|
name: "New",
|
|
type: "expense",
|
|
color: "#000",
|
|
parent_id: parent,
|
|
is_inputable: true,
|
|
sort_order: 1,
|
|
});
|
|
|
|
// The three ways a category becomes a parent. In TREE, 11 is a leaf under
|
|
// root 1 and 20 a leaf under root 2: each puts 20, or a new category, under 11.
|
|
const ENTRY_POINTS: Array<[string, () => Promise<unknown>]> = [
|
|
["updateCategory (parent changed)", () => updateCategory(20, form(11))],
|
|
["reattachCategory", () => reattachCategory(20, 11)],
|
|
["createCategory (sub-category)", () => createCategory(newChild(11))],
|
|
];
|
|
|
|
// What 11, a leaf that takes input, holds; the code that refuses it.
|
|
const HOLDINGS: Array<[string, string, Record<number, number>, Content]> = [
|
|
["transactions", "parent_has_transactions", { 11: 2 }, {}],
|
|
["a budget amount", "parent_has_budget", {}, { budget: [[11, 25]] }],
|
|
// The grid stores what is typed: a negative amount counts too.
|
|
["a negative budget amount", "parent_has_budget", {}, { budget: [[11, -25]] }],
|
|
["a negative template amount", "parent_has_budget", {}, { templates: [[11, -40]] }],
|
|
["an active keyword", "parent_has_keywords", {}, { keywords: [[11, 1]] }],
|
|
];
|
|
|
|
// An existing parent no longer takes input: 11, with 110 under it, holding
|
|
// transactions, amounts and a keyword stored before these rules.
|
|
const EXISTING_PARENT = [...TREE.map((r) => (r.id === 11 ? { ...r, is_inputable: 0 } : r)), row(110, 11)];
|
|
|
|
describe.each(ENTRY_POINTS)("%s", (_name, act) => {
|
|
it.each(HOLDINGS)("refuses a leaf that takes input and holds %s (%s), writing nothing", async (_holding, code, transactions, content) => {
|
|
const db = useFakeDb(TREE, transactions, content);
|
|
expect((await refusal(act())).code).toBe(code);
|
|
expect(db.writes()).toEqual(["BEGIN", "ROLLBACK"]);
|
|
expect(db.rows()).toHaveLength(TREE.length);
|
|
expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(2);
|
|
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(1);
|
|
// Every check went through the transaction's handle.
|
|
expect(db.log.filter((l) => CONTENT_SQL.test(l.sql)).every((l) => l.via === "handle")).toBe(true);
|
|
});
|
|
|
|
it("accepts a parent whose amounts are all zero and whose keywords are all deleted", async () => {
|
|
const db = useFakeDb(TREE, {}, {
|
|
budget: [[11, 0], [11, -0]],
|
|
templates: [[11, 0]],
|
|
keywords: [[11, 0], [11, 0]],
|
|
});
|
|
await act();
|
|
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(0);
|
|
const writes = db.writes();
|
|
expect([writes[0], writes[writes.length - 1]]).toEqual(["BEGIN", "COMMIT"]);
|
|
});
|
|
|
|
it("accepts an existing parent, which no longer takes input, whatever it holds (decision of 2026-10-20)", async () => {
|
|
const db = useFakeDb(EXISTING_PARENT, { 11: 2 }, {
|
|
budget: [[11, 25]],
|
|
templates: [[11, 10]],
|
|
keywords: [[11, 1]],
|
|
});
|
|
await act();
|
|
// Nothing to protect: markAsParent changes nothing for it, and no content is read.
|
|
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(0);
|
|
expect(db.log.some((l) => CONTENT_SQL.test(l.sql))).toBe(false);
|
|
const writes = db.writes();
|
|
expect([writes[0], writes[writes.length - 1]]).toEqual(["BEGIN", "COMMIT"]);
|
|
});
|
|
});
|
|
|
|
it("refuses a budget amount of a template, even with no amount in the budget itself", async () => {
|
|
useFakeDb(TREE, {}, { templates: [[11, 120]] });
|
|
expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_budget");
|
|
});
|
|
|
|
it("reads the budget and the keywords of the parent only, never of the moved category", async () => {
|
|
// 20 itself holds an amount, a keyword and transactions: moving it changes nothing for them.
|
|
const db = useFakeDb(TREE, { 20: 4 }, { budget: [[20, 50]], templates: [[20, 50]], keywords: [[20, 1]] });
|
|
await updateCategory(20, form(11));
|
|
expect(db.rows().find((r) => r.id === 20)?.parent_id).toBe(11);
|
|
});
|
|
|
|
it("checks transactions, then budget amounts, then keywords: the first found is the refusal", async () => {
|
|
useFakeDb(TREE, { 11: 1 }, { budget: [[11, 10]], keywords: [[11, 1]] });
|
|
expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_transactions");
|
|
useFakeDb(TREE, {}, { budget: [[11, 10]], keywords: [[11, 1]] });
|
|
expect((await refusal(updateCategory(20, form(11)))).code).toBe("parent_has_budget");
|
|
});
|
|
|
|
it("checks the content after the hierarchy rules: a parent that loops is refused as a loop", async () => {
|
|
const db = useFakeDb(TREE, { 10: 3 }, { keywords: [[10, 1]] });
|
|
expect((await refusal(updateCategory(1, form(10)))).code).toBe("cycle");
|
|
expect(db.log.some((l) => CONTENT_SQL.test(l.sql))).toBe(false);
|
|
});
|
|
|
|
it("creates a sub-category in one transaction through its handle: the parent's content, the row, the parent's flag", async () => {
|
|
const db = useFakeDb(TREE);
|
|
const id = await createCategory(newChild(11));
|
|
const created = db.rows().find((r) => r.id === id);
|
|
expect(created).toMatchObject({ name: "New", parent_id: 11, is_inputable: 1 });
|
|
expect(db.rows().find((r) => r.id === 11)?.is_inputable).toBe(0);
|
|
expect(db.writes()).toEqual(["BEGIN", "INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)", "UPDATE categories SET is_inputable = 0", "COMMIT"]);
|
|
// The depth is read before the transaction; everything after BEGIN goes through the handle.
|
|
const begin = db.log.findIndex((l) => l.sql === "BEGIN");
|
|
expect(db.log.slice(0, begin).every((l) => l.via === "global" && l.sql.startsWith("SELECT parent_id FROM categories"))).toBe(true);
|
|
expect(db.log.slice(begin).every((l) => l.via === "handle")).toBe(true);
|
|
expect(db.log.slice(begin).filter((l) => CONTENT_SQL.test(l.sql))).toHaveLength(3);
|
|
});
|
|
|
|
it("creates a root category without any content check", async () => {
|
|
const db = useFakeDb(TREE, { 1: 3 }, { keywords: [[1, 1]] });
|
|
await createCategory({ ...newChild(11), parent_id: null });
|
|
expect(db.log.some((l) => CONTENT_SQL.test(l.sql))).toBe(false);
|
|
expect(db.writes()).toEqual(["BEGIN", "INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)", "COMMIT"]);
|
|
});
|
|
|
|
it("checks a category that still takes input even when it has children: it would stop taking input", async () => {
|
|
// 1 has children (10, 11) yet takes input (the fake's default), and holds a keyword.
|
|
useFakeDb(TREE, {}, { keywords: [[1, 1]] });
|
|
expect((await refusal(createCategory(newChild(1)))).code).toBe("parent_has_keywords");
|
|
});
|
|
});
|
|
|
|
describe("CategoryHierarchyError", () => {
|
|
it("has a message for each of its codes, in French and in English", () => {
|
|
const errorsOf = (locale: unknown) =>
|
|
(locale as { categories: { hierarchy: { errors: Record<string, string> } } }).categories.hierarchy.errors;
|
|
for (const code of CATEGORY_HIERARCHY_ERROR_CODES) {
|
|
expect(errorsOf(fr)[code], `fr ${code}`).toMatch(/\S/);
|
|
expect(errorsOf(en)[code], `en ${code}`).toMatch(/\S/);
|
|
}
|
|
expect(Object.keys(errorsOf(fr)).sort()).toEqual([...CATEGORY_HIERARCHY_ERROR_CODES].sort());
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|