Simpl-Resultat/src/services/balance.service.test.ts
le king fu afc338b564 feat(balance): extend balance.service with snapshots + lines (simple kind)
Adds the snapshots + lines section of the balance service for Issue #146
(Bilan #1b). Simple-kind only — quantity / unit_price are forced to NULL
both at the SQL CHECK level (already in v9) and at the service level
(`upsertSnapshotLines` validates ahead of time). Priced-kind upsert lands
in #140.

New service exports:
- listSnapshots / getSnapshotByDate / getSnapshotById / getPreviousSnapshot
- createSnapshot (throws snapshot_date_taken when UNIQUE per date violated
  so the UI can redirect to edit mode)
- updateSnapshot / deleteSnapshot (cascade lines via FK)
- listLinesBySnapshot / upsertSnapshotLines (rewrite-all strategy)

New BalanceErrorCode entries: snapshot_date_required, snapshot_date_taken,
snapshot_not_found, snapshot_value_invalid, snapshot_priced_unsupported.

New shared types: BalanceSnapshot, BalanceSnapshotLine.

22 new vitest cases cover: invalid-date guards, unique-per-date violation,
simple-kind null invariant on inserts, NaN/Infinity rejection,
clear+rewrite line semantics, getPreviousSnapshot strict-before ordering.

Refs #146

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:49:19 -04:00

548 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("./db", () => ({
getDb: vi.fn(),
}));
import { getDb } from "./db";
import {
listBalanceCategories,
createBalanceCategory,
updateBalanceCategory,
deleteBalanceCategory,
listBalanceAccounts,
createBalanceAccount,
updateBalanceAccount,
archiveBalanceAccount,
unarchiveBalanceAccount,
listSnapshots,
getSnapshotByDate,
createSnapshot,
updateSnapshot,
deleteSnapshot,
listLinesBySnapshot,
upsertSnapshotLines,
getPreviousSnapshot,
BalanceServiceError,
} from "./balance.service";
const mockSelect = vi.fn();
const mockExecute = vi.fn();
const mockDb = { select: mockSelect, execute: mockExecute };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
mockExecute.mockReset();
});
// -----------------------------------------------------------------------------
// Categories
// -----------------------------------------------------------------------------
describe("listBalanceCategories", () => {
it("orders by sort_order then key", async () => {
mockSelect.mockResolvedValueOnce([]);
await listBalanceCategories();
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("FROM balance_categories");
expect(sql).toContain("ORDER BY sort_order, key");
});
});
describe("createBalanceCategory", () => {
it("rejects an empty key", async () => {
await expect(
createBalanceCategory({ key: " ", i18n_key: "x", kind: "simple" })
).rejects.toBeInstanceOf(BalanceServiceError);
expect(mockExecute).not.toHaveBeenCalled();
});
it("rejects an invalid kind", async () => {
await expect(
createBalanceCategory({
key: "custom",
i18n_key: "balance.category.custom",
// @ts-expect-error testing runtime guard
kind: "weird",
})
).rejects.toBeInstanceOf(BalanceServiceError);
expect(mockExecute).not.toHaveBeenCalled();
});
it("inserts with is_seed = 0 and returns lastInsertId", async () => {
mockExecute.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 });
const id = await createBalanceCategory({
key: "ferr",
i18n_key: "balance.category.ferr",
kind: "simple",
sort_order: 35,
});
expect(id).toBe(42);
const sql = mockExecute.mock.calls[0][0] as string;
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(sql).toContain("INSERT INTO balance_categories");
expect(sql).toContain("is_seed");
expect(sql).toMatch(/0\)$/); // is_seed hardcoded to 0
expect(params).toEqual(["ferr", "balance.category.ferr", "simple", 35]);
});
});
describe("deleteBalanceCategory", () => {
it("refuses to delete a seeded category", async () => {
mockSelect.mockResolvedValueOnce([
{
id: 1,
key: "cash",
i18n_key: "balance.category.cash",
kind: "simple",
sort_order: 10,
is_active: 1,
is_seed: 1,
},
]);
await expect(deleteBalanceCategory(1)).rejects.toMatchObject({
code: "category_seed_protected",
});
expect(mockExecute).not.toHaveBeenCalled();
});
it("refuses to delete a category with linked accounts", async () => {
// 1st select = getBalanceCategory; 2nd select = COUNT(*) accounts linked
mockSelect
.mockResolvedValueOnce([
{
id: 8,
key: "ferr",
i18n_key: "balance.category.ferr",
kind: "simple",
sort_order: 35,
is_active: 1,
is_seed: 0,
},
])
.mockResolvedValueOnce([{ count: 2 }]);
await expect(deleteBalanceCategory(8)).rejects.toMatchObject({
code: "category_has_accounts",
});
expect(mockExecute).not.toHaveBeenCalled();
});
it("deletes a user-created category with no linked accounts", async () => {
mockSelect
.mockResolvedValueOnce([
{
id: 8,
key: "ferr",
i18n_key: "balance.category.ferr",
kind: "simple",
sort_order: 35,
is_active: 1,
is_seed: 0,
},
])
.mockResolvedValueOnce([{ count: 0 }]);
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
await deleteBalanceCategory(8);
expect(mockExecute).toHaveBeenCalledWith(
"DELETE FROM balance_categories WHERE id = $1",
[8]
);
});
});
describe("updateBalanceCategory", () => {
it("renames a seeded category (allowed)", async () => {
mockSelect.mockResolvedValueOnce([
{
id: 1,
key: "cash",
i18n_key: "balance.category.cash",
kind: "simple",
sort_order: 10,
is_active: 1,
is_seed: 1,
},
]);
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
await updateBalanceCategory(1, { i18n_key: "balance.category.cash_renamed" });
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[0]).toBe("balance.category.cash_renamed");
});
it("rejects update on missing category", async () => {
mockSelect.mockResolvedValueOnce([]);
await expect(updateBalanceCategory(999, { sort_order: 5 })).rejects.toMatchObject({
code: "category_not_found",
});
});
});
// -----------------------------------------------------------------------------
// Accounts
// -----------------------------------------------------------------------------
describe("listBalanceAccounts", () => {
it("excludes archived accounts by default", async () => {
mockSelect.mockResolvedValueOnce([]);
await listBalanceAccounts();
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("a.is_active = 1");
expect(sql).toContain("a.archived_at IS NULL");
});
it("includes archived accounts when requested", async () => {
mockSelect.mockResolvedValueOnce([]);
await listBalanceAccounts({ includeArchived: true });
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).not.toContain("archived_at IS NULL");
});
});
describe("createBalanceAccount", () => {
it("rejects empty name", async () => {
await expect(
createBalanceAccount({ balance_category_id: 1, name: " " })
).rejects.toMatchObject({ code: "name_required" });
});
it("rejects non-CAD currency at the MVP", async () => {
await expect(
createBalanceAccount({
balance_category_id: 1,
name: "USD account",
currency: "USD",
})
).rejects.toMatchObject({ code: "currency_unsupported" });
expect(mockExecute).not.toHaveBeenCalled();
});
it("rejects when the category does not exist", async () => {
mockSelect.mockResolvedValueOnce([]); // getBalanceCategory returns null
await expect(
createBalanceAccount({ balance_category_id: 999, name: "Mystery" })
).rejects.toMatchObject({ code: "category_not_found" });
});
it("inserts with default CAD currency", async () => {
mockSelect.mockResolvedValueOnce([
{
id: 1,
key: "cash",
i18n_key: "balance.category.cash",
kind: "simple",
sort_order: 10,
is_active: 1,
is_seed: 1,
},
]);
mockExecute.mockResolvedValueOnce({ lastInsertId: 7, rowsAffected: 1 });
const id = await createBalanceAccount({
balance_category_id: 1,
name: "Encaisse Wealthsimple",
});
expect(id).toBe(7);
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params).toEqual([1, "Encaisse Wealthsimple", null, "CAD", null]);
});
});
describe("updateBalanceAccount", () => {
it("rejects when account does not exist", async () => {
mockSelect.mockResolvedValueOnce([]);
await expect(updateBalanceAccount(42, { name: "x" })).rejects.toMatchObject({
code: "account_not_found",
});
});
it("normalizes empty symbol to null", async () => {
mockSelect.mockResolvedValueOnce([
{
id: 7,
balance_category_id: 1,
name: "Encaisse",
symbol: "OLD",
currency: "CAD",
notes: null,
is_active: 1,
archived_at: null,
created_at: "",
updated_at: "",
},
]);
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
await updateBalanceAccount(7, { symbol: " " });
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[2]).toBeNull(); // symbol
});
});
describe("archiveBalanceAccount / unarchiveBalanceAccount", () => {
it("archives an existing account", async () => {
mockSelect.mockResolvedValueOnce([
{
id: 7,
balance_category_id: 1,
name: "Encaisse",
symbol: null,
currency: "CAD",
notes: null,
is_active: 1,
archived_at: null,
created_at: "",
updated_at: "",
},
]);
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
await archiveBalanceAccount(7);
const sql = mockExecute.mock.calls[0][0] as string;
expect(sql).toContain("archived_at = CURRENT_TIMESTAMP");
expect(sql).toContain("is_active = 0");
});
it("unarchives an existing account", async () => {
mockSelect.mockResolvedValueOnce([
{
id: 7,
balance_category_id: 1,
name: "Encaisse",
symbol: null,
currency: "CAD",
notes: null,
is_active: 0,
archived_at: "2026-04-25 10:00:00",
created_at: "",
updated_at: "",
},
]);
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
await unarchiveBalanceAccount(7);
const sql = mockExecute.mock.calls[0][0] as string;
expect(sql).toContain("archived_at = NULL");
expect(sql).toContain("is_active = 1");
});
});
// -----------------------------------------------------------------------------
// Snapshots + lines (Issue #146 / Bilan #1b — simple kind only)
// -----------------------------------------------------------------------------
const FAKE_SNAPSHOT = {
id: 5,
snapshot_date: "2026-04-15",
notes: null,
created_at: "",
updated_at: "",
};
describe("listSnapshots", () => {
it("orders by snapshot_date DESC", async () => {
mockSelect.mockResolvedValueOnce([]);
await listSnapshots();
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("FROM balance_snapshots");
expect(sql).toContain("ORDER BY snapshot_date DESC");
});
});
describe("getSnapshotByDate", () => {
it("rejects empty / invalid dates with snapshot_date_required", async () => {
await expect(getSnapshotByDate("")).rejects.toMatchObject({
code: "snapshot_date_required",
});
await expect(getSnapshotByDate("2026/04/15")).rejects.toMatchObject({
code: "snapshot_date_required",
});
expect(mockSelect).not.toHaveBeenCalled();
});
it("returns the snapshot row when found", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
const got = await getSnapshotByDate("2026-04-15");
expect(got).toEqual(FAKE_SNAPSHOT);
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-04-15"]);
});
it("returns null when no row matches", async () => {
mockSelect.mockResolvedValueOnce([]);
expect(await getSnapshotByDate("2026-04-15")).toBeNull();
});
});
describe("createSnapshot", () => {
it("rejects an invalid date", async () => {
await expect(
createSnapshot({ snapshot_date: " " })
).rejects.toMatchObject({ code: "snapshot_date_required" });
expect(mockExecute).not.toHaveBeenCalled();
});
it("rejects a duplicate snapshot date with snapshot_date_taken", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); // existing
await expect(
createSnapshot({ snapshot_date: "2026-04-15" })
).rejects.toMatchObject({ code: "snapshot_date_taken" });
expect(mockExecute).not.toHaveBeenCalled();
});
it("inserts a new snapshot and returns its id", async () => {
mockSelect.mockResolvedValueOnce([]); // no existing
mockExecute.mockResolvedValueOnce({ lastInsertId: 12, rowsAffected: 1 });
const id = await createSnapshot({
snapshot_date: "2026-04-25",
notes: " monthly check ",
});
expect(id).toBe(12);
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params).toEqual(["2026-04-25", "monthly check"]);
});
});
describe("updateSnapshot", () => {
it("rejects when snapshot does not exist", async () => {
mockSelect.mockResolvedValueOnce([]);
await expect(
updateSnapshot(999, { notes: "x" })
).rejects.toMatchObject({ code: "snapshot_not_found" });
});
it("normalizes empty notes to null", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
await updateSnapshot(5, { notes: " " });
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[0]).toBeNull();
});
});
describe("deleteSnapshot", () => {
it("rejects when snapshot does not exist", async () => {
mockSelect.mockResolvedValueOnce([]);
await expect(deleteSnapshot(999)).rejects.toMatchObject({
code: "snapshot_not_found",
});
});
it("deletes when found (lines cascade via FK)", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
await deleteSnapshot(5);
expect(mockExecute).toHaveBeenCalledWith(
"DELETE FROM balance_snapshots WHERE id = $1",
[5]
);
});
});
describe("listLinesBySnapshot", () => {
it("orders by id and filters by snapshot_id", async () => {
mockSelect.mockResolvedValueOnce([]);
await listLinesBySnapshot(5);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("FROM balance_snapshot_lines");
expect(sql).toContain("WHERE snapshot_id = $1");
expect(sql).toContain("ORDER BY id");
expect(mockSelect.mock.calls[0][1]).toEqual([5]);
});
});
describe("upsertSnapshotLines (simple kind)", () => {
it("rejects when the parent snapshot is missing", async () => {
mockSelect.mockResolvedValueOnce([]);
await expect(
upsertSnapshotLines(99, [{ account_id: 1, value: 1000 }])
).rejects.toMatchObject({ code: "snapshot_not_found" });
expect(mockExecute).not.toHaveBeenCalled();
});
it("rejects non-finite values with snapshot_value_invalid", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
await expect(
upsertSnapshotLines(5, [
{ account_id: 1, value: 1000 },
// @ts-expect-error testing runtime guard
{ account_id: 2, value: "not a number" },
])
).rejects.toMatchObject({ code: "snapshot_value_invalid" });
// Validation happens up-front, before any mutation
expect(mockExecute).not.toHaveBeenCalled();
});
it("rejects NaN and Infinity", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
await expect(
upsertSnapshotLines(5, [{ account_id: 1, value: NaN }])
).rejects.toMatchObject({ code: "snapshot_value_invalid" });
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
await expect(
upsertSnapshotLines(5, [{ account_id: 1, value: Infinity }])
).rejects.toMatchObject({ code: "snapshot_value_invalid" });
});
it("clears existing lines, inserts each line with NULL quantity/unit_price, and bumps updated_at", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1
.mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2
.mockResolvedValueOnce({ rowsAffected: 1 }); // update updated_at
await upsertSnapshotLines(5, [
{ account_id: 1, value: 1234.56 },
{ account_id: 2, value: 0 },
]);
// 1st call = DELETE
expect(mockExecute.mock.calls[0][0]).toContain(
"DELETE FROM balance_snapshot_lines"
);
// Inserts use literal NULL for quantity/unit_price (simple kind invariant)
const insertSql = mockExecute.mock.calls[1][0] as string;
expect(insertSql).toContain("INSERT INTO balance_snapshot_lines");
expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/);
expect(insertSql).toContain("'manual'");
// First insert params
expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1234.56]);
// Second insert params (zero is allowed)
expect(mockExecute.mock.calls[2][1]).toEqual([5, 2, 0]);
// Final call = UPDATE updated_at on parent snapshot
expect(mockExecute.mock.calls[3][0]).toContain(
"UPDATE balance_snapshots"
);
expect(mockExecute.mock.calls[3][0]).toContain("updated_at");
});
it("clears all lines when called with an empty array", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute
.mockResolvedValueOnce({ rowsAffected: 3 }) // delete only
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at
await upsertSnapshotLines(5, []);
// Only DELETE + UPDATE updated_at — no INSERTs
expect(mockExecute).toHaveBeenCalledTimes(2);
});
});
describe("getPreviousSnapshot", () => {
it("returns the most recent snapshot strictly before referenceDate", async () => {
mockSelect.mockResolvedValueOnce([
{ ...FAKE_SNAPSHOT, snapshot_date: "2026-03-15" },
]);
const got = await getPreviousSnapshot("2026-04-15");
expect(got?.snapshot_date).toBe("2026-03-15");
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("snapshot_date < $1");
expect(sql).toContain("ORDER BY snapshot_date DESC");
expect(sql).toContain("LIMIT 1");
});
it("returns null when no earlier snapshot exists", async () => {
mockSelect.mockResolvedValueOnce([]);
expect(await getPreviousSnapshot("2026-04-15")).toBeNull();
});
it("rejects an invalid reference date", async () => {
await expect(getPreviousSnapshot("nope")).rejects.toMatchObject({
code: "snapshot_date_required",
});
});
});