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>
548 lines
18 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|