Bilan axe véhicule (Étape 1) data foundation: separate the fiscal envelope (now balance_accounts.vehicle_type) from the asset class (the category). - Migration v12 (additive): add vehicle_type (fiscal enum, nullable) to balance_accounts + custom_label to balance_categories; backfill the envelope onto ex-tfsa/rrsp accounts (cash stays NULL); defensive recovery of any seed i18n_key overwritten by free text (bug I). - Migration v13 (reclass, conditional/idempotent): re-link ex-tfsa/rrsp accounts to the `other` asset class and deactivate the two envelope seeds. - consolidated_schema.sql: 2 new columns, 5 asset-class seeds (no tfsa/rrsp), CELI/REER starters re-pointed to `other` + vehicle_type (avoids NULL FK). - Types: BalanceVehicleType, custom_label / vehicle_type / category_custom_label. - Service: normalizeVehicleType + vehicle_type_invalid; CRUD writes the new columns; SELECT/JOINs read them back; STARTER_ACCOUNTS + proposeStarterAccounts (is_active=1 + vehicle_type) + getStarterCollisions adjusted. - Tests: Rust chain v9→v13 (snapshot_lines identical, transfers intact, archived ex-tfsa covered, seeds deactivated, v13 EXISTS guard, idempotence, CHECK reject) + consolidated complete (5 categories, 4 starters, 0 NULL FK); TS service + StarterAccountsModal specs. No diff to migrations <= v11. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2140 lines
72 KiB
TypeScript
2140 lines
72 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||
|
||
vi.mock("./db", () => ({
|
||
getDb: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("@tauri-apps/api/core", () => ({
|
||
invoke: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("./profileService", () => ({
|
||
loadProfiles: vi.fn(),
|
||
}));
|
||
|
||
import { getDb } from "./db";
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
import { loadProfiles } from "./profileService";
|
||
import {
|
||
listBalanceCategories,
|
||
createBalanceCategory,
|
||
updateBalanceCategory,
|
||
deleteBalanceCategory,
|
||
listBalanceAccounts,
|
||
getBalanceAccount,
|
||
createBalanceAccount,
|
||
updateBalanceAccount,
|
||
archiveBalanceAccount,
|
||
unarchiveBalanceAccount,
|
||
listSnapshots,
|
||
getSnapshotByDate,
|
||
createSnapshot,
|
||
updateSnapshot,
|
||
deleteSnapshot,
|
||
listLinesBySnapshot,
|
||
upsertSnapshotLines,
|
||
saveSnapshotAtomic,
|
||
getPreviousSnapshot,
|
||
validateLineKindInvariants,
|
||
PRICED_VALUE_TOLERANCE,
|
||
BalanceServiceError,
|
||
getSnapshotTotalsByDate,
|
||
getSnapshotTotalsByCategoryAndDate,
|
||
getAccountsLatestSnapshot,
|
||
getAccountsPeriodAnchor,
|
||
computeAccountReturn,
|
||
linkTransfer,
|
||
unlinkTransfer,
|
||
listAccountTransfers,
|
||
listLinkedTransactionIds,
|
||
listAllLinkedTransfersForTooltip,
|
||
isLinkedTransactionFkError,
|
||
suggestTransferDirection,
|
||
} 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");
|
||
// is_seed hardcoded to 0; asset_type = $5, custom_label = $6 (#202).
|
||
expect(sql).toContain("0, $5, $6)");
|
||
// simple kind → asset_type coerced to NULL; custom_label NULL when omitted.
|
||
expect(params).toEqual([
|
||
"ferr",
|
||
"balance.category.ferr",
|
||
"simple",
|
||
35,
|
||
null,
|
||
null,
|
||
]);
|
||
});
|
||
|
||
it("rejects priced category without asset_type (#169)", async () => {
|
||
await expect(
|
||
createBalanceCategory({
|
||
key: "mining_etf",
|
||
i18n_key: "x.mining",
|
||
kind: "priced",
|
||
})
|
||
).rejects.toMatchObject({
|
||
name: "BalanceServiceError",
|
||
code: "asset_type_required",
|
||
});
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects an invalid asset_type value (#169)", async () => {
|
||
await expect(
|
||
createBalanceCategory({
|
||
key: "mining_etf",
|
||
i18n_key: "x.mining",
|
||
kind: "priced",
|
||
// @ts-expect-error testing runtime guard
|
||
asset_type: "gold",
|
||
})
|
||
).rejects.toMatchObject({
|
||
name: "BalanceServiceError",
|
||
code: "asset_type_invalid",
|
||
});
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("inserts a priced category with asset_type='stock' (#169)", async () => {
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 99, rowsAffected: 1 });
|
||
const id = await createBalanceCategory({
|
||
key: "tsx",
|
||
i18n_key: "x.tsx",
|
||
kind: "priced",
|
||
asset_type: "stock",
|
||
});
|
||
expect(id).toBe(99);
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[2]).toBe("priced");
|
||
expect(params[4]).toBe("stock");
|
||
});
|
||
|
||
it("inserts a priced category with asset_type='crypto' (#169)", async () => {
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 });
|
||
await createBalanceCategory({
|
||
key: "alts",
|
||
i18n_key: "x.alts",
|
||
kind: "priced",
|
||
asset_type: "crypto",
|
||
});
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[4]).toBe("crypto");
|
||
});
|
||
|
||
it("forces asset_type to NULL on simple kind even if provided (#169)", async () => {
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 });
|
||
await createBalanceCategory({
|
||
key: "savings",
|
||
i18n_key: "x.savings",
|
||
kind: "simple",
|
||
// Service coerces simple kind → asset_type=null regardless of caller.
|
||
asset_type: "stock",
|
||
});
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[4]).toBeNull();
|
||
});
|
||
});
|
||
|
||
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",
|
||
});
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// custom_label (Bilan axe véhicule, Étape 1 — issue #202)
|
||
// -----------------------------------------------------------------------------
|
||
|
||
describe("balance categories — custom_label (#202)", () => {
|
||
it("listBalanceCategories selects custom_label and filters is_active when asked", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await listBalanceCategories({ includeInactive: false });
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("custom_label");
|
||
expect(sql).toContain("WHERE is_active = 1");
|
||
});
|
||
|
||
it("listBalanceCategories includes inactive categories by default (#202 behavior-neutral)", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await listBalanceCategories();
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("custom_label");
|
||
expect(sql).not.toContain("WHERE is_active = 1");
|
||
});
|
||
|
||
it("createBalanceCategory trims custom_label and stores it as the 6th param", async () => {
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 5, rowsAffected: 1 });
|
||
await createBalanceCategory({
|
||
key: "savings",
|
||
i18n_key: "balance.category.savings",
|
||
kind: "simple",
|
||
custom_label: " Mon épargne ",
|
||
});
|
||
const sql = mockExecute.mock.calls[0][0] as string;
|
||
expect(sql).toContain("custom_label");
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[5]).toBe("Mon épargne");
|
||
});
|
||
|
||
it("createBalanceCategory normalizes a blank custom_label to null", async () => {
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 6, rowsAffected: 1 });
|
||
await createBalanceCategory({
|
||
key: "x",
|
||
i18n_key: "balance.category.x",
|
||
kind: "simple",
|
||
custom_label: " ",
|
||
});
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[5]).toBeNull();
|
||
});
|
||
|
||
it("updateBalanceCategory sets custom_label without touching i18n_key (fixes bug I)", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 1,
|
||
key: "cash",
|
||
i18n_key: "balance.category.cash",
|
||
kind: "simple",
|
||
sort_order: 10,
|
||
is_active: 1,
|
||
is_seed: 1,
|
||
asset_type: null,
|
||
custom_label: null,
|
||
},
|
||
]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await updateBalanceCategory(1, { custom_label: "Comptes courants" });
|
||
const sql = mockExecute.mock.calls[0][0] as string;
|
||
expect(sql).toContain("custom_label = $5");
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
// i18n_key (param 0) preserved verbatim; custom_label (param 4) is the rename.
|
||
expect(params[0]).toBe("balance.category.cash");
|
||
expect(params[4]).toBe("Comptes courants");
|
||
});
|
||
|
||
it("updateBalanceCategory clears custom_label on explicit null", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 1,
|
||
key: "cash",
|
||
i18n_key: "balance.category.cash",
|
||
kind: "simple",
|
||
sort_order: 10,
|
||
is_active: 1,
|
||
is_seed: 1,
|
||
asset_type: null,
|
||
custom_label: "Old label",
|
||
},
|
||
]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await updateBalanceCategory(1, { custom_label: null });
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[4]).toBeNull();
|
||
});
|
||
|
||
it("updateBalanceCategory preserves existing custom_label when omitted", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 1,
|
||
key: "cash",
|
||
i18n_key: "balance.category.cash",
|
||
kind: "simple",
|
||
sort_order: 10,
|
||
is_active: 1,
|
||
is_seed: 1,
|
||
asset_type: null,
|
||
custom_label: "Kept label",
|
||
},
|
||
]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await updateBalanceCategory(1, { sort_order: 99 });
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[4]).toBe("Kept label");
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 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");
|
||
});
|
||
|
||
it("threads category_asset_type from the join (#169)", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await listBalanceAccounts();
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("c.asset_type AS category_asset_type");
|
||
});
|
||
});
|
||
|
||
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[];
|
||
// 6th param is vehicle_type (NULL when not provided) — #202.
|
||
expect(params).toEqual([1, "Encaisse Wealthsimple", null, "CAD", null, null]);
|
||
});
|
||
|
||
it("allows a priced-category account WITHOUT a symbol (Issue #199)", async () => {
|
||
// Symbol is optional even for priced categories — manual valuation
|
||
// (quantity × unit price) never needs it; only the price-fetch button does.
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 3,
|
||
key: "stock",
|
||
i18n_key: "balance.category.stock",
|
||
kind: "priced",
|
||
sort_order: 50,
|
||
is_active: 1,
|
||
is_seed: 1,
|
||
asset_type: "stock",
|
||
},
|
||
]);
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 9, rowsAffected: 1 });
|
||
const id = await createBalanceAccount({
|
||
balance_category_id: 3,
|
||
name: "Portefeuille Wealthsimple",
|
||
// no symbol provided
|
||
});
|
||
expect(id).toBe(9);
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
// symbol param (3rd) is null — insert succeeds, no validation thrown.
|
||
expect(params[2]).toBeNull();
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// vehicle_type (Bilan axe véhicule, Étape 1 — issue #202)
|
||
// -----------------------------------------------------------------------------
|
||
|
||
describe("balance accounts — vehicle_type (#202)", () => {
|
||
const cashCategoryRow = {
|
||
id: 1,
|
||
key: "cash",
|
||
i18n_key: "balance.category.cash",
|
||
kind: "simple",
|
||
sort_order: 10,
|
||
is_active: 1,
|
||
is_seed: 1,
|
||
asset_type: null,
|
||
custom_label: null,
|
||
};
|
||
|
||
it("createBalanceAccount stores a valid vehicle_type as the 6th param", async () => {
|
||
mockSelect.mockResolvedValueOnce([cashCategoryRow]);
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 8, rowsAffected: 1 });
|
||
await createBalanceAccount({
|
||
balance_category_id: 1,
|
||
name: "Mon CELI",
|
||
vehicle_type: "tfsa",
|
||
});
|
||
const sql = mockExecute.mock.calls[0][0] as string;
|
||
expect(sql).toContain("vehicle_type");
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[5]).toBe("tfsa");
|
||
});
|
||
|
||
it("createBalanceAccount accepts every enum value", async () => {
|
||
for (const v of ["unregistered", "tfsa", "rrsp", "rrif", "fhsa", "resp"] as const) {
|
||
mockSelect.mockReset();
|
||
mockExecute.mockReset();
|
||
mockSelect.mockResolvedValueOnce([cashCategoryRow]);
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 });
|
||
await createBalanceAccount({
|
||
balance_category_id: 1,
|
||
name: `acct-${v}`,
|
||
vehicle_type: v,
|
||
});
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[5]).toBe(v);
|
||
}
|
||
});
|
||
|
||
it("createBalanceAccount rejects an out-of-enum vehicle_type", async () => {
|
||
mockSelect.mockResolvedValueOnce([cashCategoryRow]);
|
||
await expect(
|
||
createBalanceAccount({
|
||
balance_category_id: 1,
|
||
name: "Bad",
|
||
// @ts-expect-error testing runtime guard — not an automobile type
|
||
vehicle_type: "car",
|
||
})
|
||
).rejects.toMatchObject({ code: "vehicle_type_invalid" });
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("createBalanceAccount stores NULL when vehicle_type is omitted", async () => {
|
||
mockSelect.mockResolvedValueOnce([cashCategoryRow]);
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 8, rowsAffected: 1 });
|
||
await createBalanceAccount({ balance_category_id: 1, name: "Encaisse" });
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[5]).toBeNull();
|
||
});
|
||
|
||
it("getBalanceAccount selects vehicle_type", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 7,
|
||
balance_category_id: 1,
|
||
name: "Encaisse",
|
||
symbol: null,
|
||
currency: "CAD",
|
||
notes: null,
|
||
is_active: 1,
|
||
archived_at: null,
|
||
vehicle_type: "tfsa",
|
||
created_at: "",
|
||
updated_at: "",
|
||
},
|
||
]);
|
||
const acct = await getBalanceAccount(7);
|
||
expect(acct?.vehicle_type).toBe("tfsa");
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("vehicle_type");
|
||
});
|
||
|
||
it("listBalanceAccounts threads vehicle_type and category_custom_label from the join", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await listBalanceAccounts();
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("a.vehicle_type");
|
||
expect(sql).toContain("c.custom_label AS category_custom_label");
|
||
});
|
||
|
||
it("updateBalanceAccount preserves the existing vehicle_type when omitted", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 7,
|
||
balance_category_id: 1,
|
||
name: "Mon CELI",
|
||
symbol: null,
|
||
currency: "CAD",
|
||
notes: null,
|
||
is_active: 1,
|
||
archived_at: null,
|
||
vehicle_type: "tfsa",
|
||
created_at: "",
|
||
updated_at: "",
|
||
},
|
||
]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await updateBalanceAccount(7, { name: "CELI renommé" });
|
||
const sql = mockExecute.mock.calls[0][0] as string;
|
||
expect(sql).toContain("vehicle_type = $6");
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[5]).toBe("tfsa"); // preserved
|
||
});
|
||
|
||
it("updateBalanceAccount sets a new vehicle_type when provided", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 7,
|
||
balance_category_id: 1,
|
||
name: "Compte",
|
||
symbol: null,
|
||
currency: "CAD",
|
||
notes: null,
|
||
is_active: 1,
|
||
archived_at: null,
|
||
vehicle_type: null,
|
||
created_at: "",
|
||
updated_at: "",
|
||
},
|
||
]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await updateBalanceAccount(7, { vehicle_type: "rrsp" });
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[5]).toBe("rrsp");
|
||
});
|
||
|
||
it("updateBalanceAccount clears vehicle_type on explicit null", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 7,
|
||
balance_category_id: 1,
|
||
name: "Compte",
|
||
symbol: null,
|
||
currency: "CAD",
|
||
notes: null,
|
||
is_active: 1,
|
||
archived_at: null,
|
||
vehicle_type: "tfsa",
|
||
created_at: "",
|
||
updated_at: "",
|
||
},
|
||
]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await updateBalanceAccount(7, { vehicle_type: null });
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[5]).toBeNull();
|
||
});
|
||
|
||
it("updateBalanceAccount rejects an out-of-enum vehicle_type", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 7,
|
||
balance_category_id: 1,
|
||
name: "Compte",
|
||
symbol: null,
|
||
currency: "CAD",
|
||
notes: null,
|
||
is_active: 1,
|
||
archived_at: null,
|
||
vehicle_type: null,
|
||
created_at: "",
|
||
updated_at: "",
|
||
},
|
||
]);
|
||
await expect(
|
||
// @ts-expect-error testing runtime guard
|
||
updateBalanceAccount(7, { vehicle_type: "truck" })
|
||
).rejects.toMatchObject({ code: "vehicle_type_invalid" });
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("getAccountsLatestSnapshot threads category_custom_label", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await getAccountsLatestSnapshot();
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("c.custom_label AS category_custom_label");
|
||
});
|
||
});
|
||
|
||
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",
|
||
});
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Priced-kind validation (Issue #140 / Bilan #2)
|
||
// -----------------------------------------------------------------------------
|
||
|
||
describe("validateLineKindInvariants — simple kind", () => {
|
||
it("accepts a clean simple line", () => {
|
||
expect(() =>
|
||
validateLineKindInvariants({ account_id: 1, value: 1234.56 })
|
||
).not.toThrow();
|
||
});
|
||
|
||
it("accepts simple kind with explicit account_kind = simple", () => {
|
||
expect(() =>
|
||
validateLineKindInvariants({
|
||
account_id: 1,
|
||
value: 0,
|
||
account_kind: "simple",
|
||
})
|
||
).not.toThrow();
|
||
});
|
||
|
||
it("rejects a simple line carrying a quantity", () => {
|
||
expect(() =>
|
||
validateLineKindInvariants({
|
||
account_id: 1,
|
||
value: 100,
|
||
account_kind: "simple",
|
||
quantity: 10,
|
||
})
|
||
).toThrowError(BalanceServiceError);
|
||
});
|
||
|
||
it("rejects a simple line carrying a unit_price", () => {
|
||
expect(() =>
|
||
validateLineKindInvariants({
|
||
account_id: 1,
|
||
value: 100,
|
||
account_kind: "simple",
|
||
unit_price: 10,
|
||
})
|
||
).toThrowError(BalanceServiceError);
|
||
});
|
||
|
||
it("rejects a non-finite value", () => {
|
||
expect(() =>
|
||
validateLineKindInvariants({ account_id: 1, value: NaN })
|
||
).toThrowError(BalanceServiceError);
|
||
expect(() =>
|
||
validateLineKindInvariants({ account_id: 1, value: Infinity })
|
||
).toThrowError(BalanceServiceError);
|
||
});
|
||
});
|
||
|
||
describe("validateLineKindInvariants — priced kind", () => {
|
||
const baseInput = {
|
||
account_id: 7,
|
||
account_kind: "priced" as const,
|
||
};
|
||
|
||
it("accepts a clean priced line where value === qty * price", () => {
|
||
expect(() =>
|
||
validateLineKindInvariants({
|
||
...baseInput,
|
||
quantity: 10,
|
||
unit_price: 25.5,
|
||
value: 255,
|
||
})
|
||
).not.toThrow();
|
||
});
|
||
|
||
it("rejects a priced line missing the quantity", () => {
|
||
expect(() =>
|
||
validateLineKindInvariants({
|
||
...baseInput,
|
||
quantity: null,
|
||
unit_price: 25.5,
|
||
value: 255,
|
||
})
|
||
).toMatchObject; // sanity, real assertion below
|
||
expect(() =>
|
||
validateLineKindInvariants({
|
||
...baseInput,
|
||
quantity: null,
|
||
unit_price: 25.5,
|
||
value: 255,
|
||
})
|
||
).toThrowError(BalanceServiceError);
|
||
try {
|
||
validateLineKindInvariants({
|
||
...baseInput,
|
||
quantity: null,
|
||
unit_price: 25.5,
|
||
value: 255,
|
||
});
|
||
} catch (e) {
|
||
expect((e as BalanceServiceError).code).toBe(
|
||
"snapshot_priced_quantity_required"
|
||
);
|
||
}
|
||
});
|
||
|
||
it("rejects a priced line missing the unit_price", () => {
|
||
try {
|
||
validateLineKindInvariants({
|
||
...baseInput,
|
||
quantity: 10,
|
||
unit_price: null,
|
||
value: 255,
|
||
});
|
||
} catch (e) {
|
||
expect((e as BalanceServiceError).code).toBe(
|
||
"snapshot_priced_unit_price_required"
|
||
);
|
||
return;
|
||
}
|
||
throw new Error("expected throw");
|
||
});
|
||
|
||
it("rejects a priced line where value disagrees with qty × price", () => {
|
||
try {
|
||
validateLineKindInvariants({
|
||
...baseInput,
|
||
quantity: 10,
|
||
unit_price: 25.5,
|
||
// off by way more than tolerance — 255.0 expected, 999 saved
|
||
value: 999,
|
||
});
|
||
} catch (e) {
|
||
expect((e as BalanceServiceError).code).toBe(
|
||
"snapshot_priced_value_mismatch"
|
||
);
|
||
return;
|
||
}
|
||
throw new Error("expected throw");
|
||
});
|
||
|
||
it("accepts a priced line within the tolerance ε", () => {
|
||
// 12.34 × 1.07 = 13.2038 in math, but JS gives 13.2038000000000002.
|
||
// The drift is well within ε = 0.01.
|
||
const qty = 12.34;
|
||
const price = 1.07;
|
||
expect(() =>
|
||
validateLineKindInvariants({
|
||
...baseInput,
|
||
quantity: qty,
|
||
unit_price: price,
|
||
value: 13.2038,
|
||
})
|
||
).not.toThrow();
|
||
});
|
||
|
||
it("rejects a priced line just outside the tolerance ε", () => {
|
||
// expected = 100, threshold ε = 0.01 → 100.011 fails, 100.005 passes.
|
||
expect(() =>
|
||
validateLineKindInvariants({
|
||
...baseInput,
|
||
quantity: 10,
|
||
unit_price: 10,
|
||
value: 100 + PRICED_VALUE_TOLERANCE * 1.5,
|
||
})
|
||
).toThrowError(BalanceServiceError);
|
||
expect(() =>
|
||
validateLineKindInvariants({
|
||
...baseInput,
|
||
quantity: 10,
|
||
unit_price: 10,
|
||
value: 100 + PRICED_VALUE_TOLERANCE * 0.5,
|
||
})
|
||
).not.toThrow();
|
||
});
|
||
|
||
it("rejects priced when quantity is non-finite", () => {
|
||
expect(() =>
|
||
validateLineKindInvariants({
|
||
...baseInput,
|
||
quantity: NaN,
|
||
unit_price: 10,
|
||
value: 100,
|
||
})
|
||
).toThrowError(BalanceServiceError);
|
||
});
|
||
});
|
||
|
||
describe("upsertSnapshotLines — priced kind", () => {
|
||
it("rejects a priced line where qty × price drifts beyond ε", async () => {
|
||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||
await expect(
|
||
upsertSnapshotLines(5, [
|
||
{
|
||
account_id: 7,
|
||
account_kind: "priced",
|
||
quantity: 10,
|
||
unit_price: 25,
|
||
value: 999, // wrong on purpose
|
||
},
|
||
])
|
||
).rejects.toMatchObject({ code: "snapshot_priced_value_mismatch" });
|
||
// No DB mutation when validation fails up-front.
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("inserts a priced line with quantity + unit_price + value", async () => {
|
||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||
mockExecute
|
||
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
|
||
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert
|
||
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at
|
||
await upsertSnapshotLines(5, [
|
||
{
|
||
account_id: 7,
|
||
account_kind: "priced",
|
||
quantity: 10,
|
||
unit_price: 25.5,
|
||
value: 255,
|
||
},
|
||
]);
|
||
const insertSql = mockExecute.mock.calls[1][0] as string;
|
||
expect(insertSql).toContain("INSERT INTO balance_snapshot_lines");
|
||
// Priced inserts use parameter placeholders for qty/price (not literal NULLs)
|
||
expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/);
|
||
expect(mockExecute.mock.calls[1][1]).toEqual([5, 7, 10, 25.5, 255]);
|
||
});
|
||
|
||
it("supports a mix of simple + priced lines in the same batch", async () => {
|
||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||
mockExecute
|
||
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
|
||
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert simple
|
||
.mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert priced
|
||
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at
|
||
await upsertSnapshotLines(5, [
|
||
{ account_id: 1, value: 1000 },
|
||
{
|
||
account_id: 7,
|
||
account_kind: "priced",
|
||
quantity: 10,
|
||
unit_price: 50,
|
||
value: 500,
|
||
},
|
||
]);
|
||
// Simple insert uses literal NULLs for qty/price
|
||
expect(mockExecute.mock.calls[1][0] as string).toMatch(
|
||
/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/
|
||
);
|
||
expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1000]);
|
||
// Priced insert uses placeholders
|
||
expect(mockExecute.mock.calls[2][0] as string).toMatch(
|
||
/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/
|
||
);
|
||
expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 50, 500]);
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// saveSnapshotAtomic (#176) — atomic BEGIN/COMMIT/ROLLBACK orchestration
|
||
// -----------------------------------------------------------------------------
|
||
|
||
describe("saveSnapshotAtomic — new mode", () => {
|
||
it("issues BEGIN before any write and COMMIT once everything succeeds", async () => {
|
||
// Order: SELECT dup-check → INSERT snapshot → DELETE lines → INSERT line → UPDATE → COMMIT
|
||
mockSelect.mockResolvedValueOnce([]); // no duplicate
|
||
mockExecute
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||
.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
|
||
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // INSERT line
|
||
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
|
||
|
||
const res = await saveSnapshotAtomic({
|
||
existingSnapshotId: null,
|
||
snapshot_date: "2026-04-30",
|
||
lines: [{ account_id: 1, value: 1000 }],
|
||
});
|
||
|
||
expect(res.snapshotId).toBe(42);
|
||
// First execute is BEGIN
|
||
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||
// INSERT snapshot is second
|
||
expect(mockExecute.mock.calls[1][0]).toContain(
|
||
"INSERT INTO balance_snapshots"
|
||
);
|
||
// DELETE lines, INSERT line, UPDATE updated_at all happen between BEGIN and COMMIT
|
||
expect(mockExecute.mock.calls[2][0]).toContain(
|
||
"DELETE FROM balance_snapshot_lines"
|
||
);
|
||
expect(mockExecute.mock.calls[3][0]).toContain(
|
||
"INSERT INTO balance_snapshot_lines"
|
||
);
|
||
expect(mockExecute.mock.calls[4][0]).toContain("UPDATE balance_snapshots");
|
||
// Last execute is COMMIT
|
||
expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe(
|
||
"COMMIT"
|
||
);
|
||
// No ROLLBACK on success
|
||
expect(
|
||
mockExecute.mock.calls.some((c: unknown[]) => c[0] === "ROLLBACK")
|
||
).toBe(false);
|
||
});
|
||
|
||
it("rejects when a snapshot already exists at this date (snapshot_date_taken) and ROLLBACKs", async () => {
|
||
mockSelect.mockResolvedValueOnce([{ id: 7 }]); // duplicate found
|
||
mockExecute
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK
|
||
|
||
await expect(
|
||
saveSnapshotAtomic({
|
||
existingSnapshotId: null,
|
||
snapshot_date: "2026-04-30",
|
||
lines: [{ account_id: 1, value: 1000 }],
|
||
})
|
||
).rejects.toMatchObject({ code: "snapshot_date_taken" });
|
||
|
||
// BEGIN ran, then ROLLBACK because the duplicate threw mid-transaction.
|
||
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||
expect(mockExecute.mock.calls[1][0]).toBe("ROLLBACK");
|
||
// No INSERT INTO balance_snapshots happened.
|
||
expect(
|
||
mockExecute.mock.calls.some((c: unknown[]) =>
|
||
String(c[0]).includes("INSERT INTO balance_snapshots")
|
||
)
|
||
).toBe(false);
|
||
});
|
||
|
||
it("ROLLBACKs and re-throws when a line INSERT fails (no orphan snapshot persists)", async () => {
|
||
mockSelect.mockResolvedValueOnce([]); // no duplicate
|
||
mockExecute
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||
.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
|
||
.mockRejectedValueOnce(new Error("simulated FK violation")) // INSERT line fails
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK
|
||
|
||
await expect(
|
||
saveSnapshotAtomic({
|
||
existingSnapshotId: null,
|
||
snapshot_date: "2026-04-30",
|
||
lines: [{ account_id: 999, value: 1000 }],
|
||
})
|
||
).rejects.toThrow("simulated FK violation");
|
||
|
||
// BEGIN happened, ROLLBACK was the last call — no COMMIT.
|
||
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||
expect(
|
||
mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]
|
||
).toBe("ROLLBACK");
|
||
expect(
|
||
mockExecute.mock.calls.some((c: unknown[]) => c[0] === "COMMIT")
|
||
).toBe(false);
|
||
});
|
||
|
||
it("rejects validation failures BEFORE BEGIN — no transaction is opened", async () => {
|
||
await expect(
|
||
saveSnapshotAtomic({
|
||
existingSnapshotId: null,
|
||
snapshot_date: "2026-04-30",
|
||
// Priced line missing quantity should fail validation before any DB write.
|
||
lines: [
|
||
{ account_id: 1, value: 100, account_kind: "priced", unit_price: 10 },
|
||
],
|
||
})
|
||
).rejects.toMatchObject({ code: "snapshot_priced_quantity_required" });
|
||
// Pre-DB validation: no BEGIN, no SELECT, no execute at all.
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
expect(mockSelect).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("saveSnapshotAtomic — edit mode", () => {
|
||
it("skips INSERT INTO balance_snapshots when existingSnapshotId is provided", async () => {
|
||
mockExecute
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
|
||
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // INSERT line
|
||
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
|
||
|
||
const res = await saveSnapshotAtomic({
|
||
existingSnapshotId: 5,
|
||
snapshot_date: "2026-04-30",
|
||
lines: [{ account_id: 1, value: 1000 }],
|
||
});
|
||
|
||
expect(res.snapshotId).toBe(5);
|
||
// No SELECT (no duplicate check in edit mode), no INSERT INTO balance_snapshots.
|
||
expect(mockSelect).not.toHaveBeenCalled();
|
||
expect(
|
||
mockExecute.mock.calls.some((c: unknown[]) =>
|
||
String(c[0]).includes("INSERT INTO balance_snapshots")
|
||
)
|
||
).toBe(false);
|
||
// BEGIN / DELETE / INSERT line / UPDATE / COMMIT
|
||
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||
expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe(
|
||
"COMMIT"
|
||
);
|
||
});
|
||
|
||
it("moves the snapshot date in-txn and preserves the existing lines (Issue #200)", async () => {
|
||
// In edit mode with moveToDate set: BEGIN → collision SELECT (free) →
|
||
// UPDATE date → DELETE lines → INSERT line → UPDATE updated_at → COMMIT.
|
||
mockSelect.mockResolvedValueOnce([]); // collision check → target date free
|
||
mockExecute
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE snapshot_date
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
|
||
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // INSERT line (preserved)
|
||
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
|
||
|
||
const res = await saveSnapshotAtomic({
|
||
existingSnapshotId: 5,
|
||
snapshot_date: "2026-04-15",
|
||
moveToDate: "2026-05-20",
|
||
lines: [{ account_id: 1, value: 1234 }],
|
||
});
|
||
|
||
expect(res.snapshotId).toBe(5);
|
||
// Collision SELECT excludes the moved snapshot's own id.
|
||
const clashParams = mockSelect.mock.calls[0][1] as unknown[];
|
||
expect(clashParams).toEqual(["2026-05-20", 5]);
|
||
// First execute is BEGIN, then the date UPDATE happens before the lines.
|
||
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||
expect(mockExecute.mock.calls[1][0]).toContain("SET snapshot_date = $1");
|
||
expect(mockExecute.mock.calls[1][1]).toEqual(["2026-05-20", 5]);
|
||
// Lines are still rewritten (preserved): DELETE then INSERT.
|
||
expect(mockExecute.mock.calls[2][0]).toContain(
|
||
"DELETE FROM balance_snapshot_lines"
|
||
);
|
||
expect(mockExecute.mock.calls[3][0]).toContain(
|
||
"INSERT INTO balance_snapshot_lines"
|
||
);
|
||
// Commits, never rolls back.
|
||
expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe(
|
||
"COMMIT"
|
||
);
|
||
expect(
|
||
mockExecute.mock.calls.some((c: unknown[]) => c[0] === "ROLLBACK")
|
||
).toBe(false);
|
||
});
|
||
|
||
it("rolls back and throws snapshot_date_exists when moveToDate collides with another snapshot (Issue #200)", async () => {
|
||
mockSelect.mockResolvedValueOnce([{ id: 42 }]); // collision: another snapshot at the target
|
||
mockExecute
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||
.mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK
|
||
|
||
await expect(
|
||
saveSnapshotAtomic({
|
||
existingSnapshotId: 5,
|
||
snapshot_date: "2026-04-15",
|
||
moveToDate: "2026-05-20",
|
||
lines: [{ account_id: 1, value: 1234 }],
|
||
})
|
||
).rejects.toMatchObject({ code: "snapshot_date_exists" });
|
||
|
||
// BEGIN ran, then ROLLBACK — no date UPDATE, no line writes committed.
|
||
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||
expect(mockExecute.mock.calls[1][0]).toBe("ROLLBACK");
|
||
expect(
|
||
mockExecute.mock.calls.some((c: unknown[]) =>
|
||
String(c[0]).includes("SET snapshot_date")
|
||
)
|
||
).toBe(false);
|
||
expect(
|
||
mockExecute.mock.calls.some((c: unknown[]) => c[0] === "COMMIT")
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Time-series aggregators (Issue #141 / Bilan #3)
|
||
// -----------------------------------------------------------------------------
|
||
|
||
describe("getSnapshotTotalsByDate", () => {
|
||
it("returns an empty array on an empty DB", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
expect(await getSnapshotTotalsByDate()).toEqual([]);
|
||
});
|
||
|
||
it("aggregates SUM(value) and orders ASC by snapshot_date", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{ snapshot_date: "2026-01-31", total: 1000 },
|
||
{ snapshot_date: "2026-02-28", total: 1100 },
|
||
{ snapshot_date: "2026-03-31", total: 1250 },
|
||
]);
|
||
const out = await getSnapshotTotalsByDate();
|
||
expect(out).toEqual([
|
||
{ snapshot_date: "2026-01-31", total: 1000 },
|
||
{ snapshot_date: "2026-02-28", total: 1100 },
|
||
{ snapshot_date: "2026-03-31", total: 1250 },
|
||
]);
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("FROM balance_snapshots");
|
||
expect(sql).toContain("LEFT JOIN balance_snapshot_lines");
|
||
expect(sql).toContain("GROUP BY s.snapshot_date");
|
||
expect(sql).toContain("ORDER BY s.snapshot_date ASC");
|
||
// Empty range → no WHERE clause + no params
|
||
expect(sql).not.toContain("WHERE");
|
||
expect(mockSelect.mock.calls[0][1]).toEqual([]);
|
||
});
|
||
|
||
it("applies an inclusive [from, to] date range filter", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await getSnapshotTotalsByDate({ from: "2026-01-01", to: "2026-03-31" });
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("WHERE");
|
||
expect(sql).toContain("s.snapshot_date >=");
|
||
expect(sql).toContain("s.snapshot_date <=");
|
||
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-03-31"]);
|
||
});
|
||
|
||
it("supports an open-ended `from` only", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await getSnapshotTotalsByDate({ from: "2026-01-01" });
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("s.snapshot_date >=");
|
||
expect(sql).not.toContain("s.snapshot_date <=");
|
||
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
|
||
});
|
||
});
|
||
|
||
describe("getSnapshotTotalsByCategoryAndDate", () => {
|
||
it("returns [] on empty DB", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
expect(await getSnapshotTotalsByCategoryAndDate()).toEqual([]);
|
||
});
|
||
|
||
it("buckets multiple category rows under the same snapshot_date", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{ snapshot_date: "2026-01-31", category_key: "cash", total: 500 },
|
||
{ snapshot_date: "2026-01-31", category_key: "tfsa", total: 1500 },
|
||
{ snapshot_date: "2026-02-28", category_key: "cash", total: 700 },
|
||
{ snapshot_date: "2026-02-28", category_key: "tfsa", total: 1700 },
|
||
]);
|
||
const out = await getSnapshotTotalsByCategoryAndDate();
|
||
expect(out).toEqual([
|
||
{
|
||
snapshot_date: "2026-01-31",
|
||
byCategory: { cash: 500, tfsa: 1500 },
|
||
},
|
||
{
|
||
snapshot_date: "2026-02-28",
|
||
byCategory: { cash: 700, tfsa: 1700 },
|
||
},
|
||
]);
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("INNER JOIN balance_snapshot_lines");
|
||
expect(sql).toContain("INNER JOIN balance_accounts");
|
||
expect(sql).toContain("INNER JOIN balance_categories");
|
||
expect(sql).toContain("GROUP BY s.snapshot_date, c.key");
|
||
});
|
||
|
||
it("applies date range params when supplied", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await getSnapshotTotalsByCategoryAndDate({
|
||
from: "2026-01-01",
|
||
to: "2026-12-31",
|
||
});
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("WHERE");
|
||
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]);
|
||
});
|
||
});
|
||
|
||
describe("getAccountsLatestSnapshot", () => {
|
||
it("returns [] when there are no active accounts", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
expect(await getAccountsLatestSnapshot()).toEqual([]);
|
||
});
|
||
|
||
it("returns one row per active account joined with category metadata", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
account_id: 1,
|
||
account_name: "BMO chequing",
|
||
symbol: null,
|
||
balance_category_id: 10,
|
||
category_key: "cash",
|
||
category_i18n_key: "balance.category.cash",
|
||
category_kind: "simple",
|
||
latest_snapshot_date: "2026-03-31",
|
||
latest_value: 1234.56,
|
||
},
|
||
{
|
||
account_id: 2,
|
||
account_name: "Wealthsimple TFSA",
|
||
symbol: null,
|
||
balance_category_id: 11,
|
||
category_key: "tfsa",
|
||
category_i18n_key: "balance.category.tfsa",
|
||
category_kind: "simple",
|
||
latest_snapshot_date: null,
|
||
latest_value: null,
|
||
},
|
||
]);
|
||
const out = await getAccountsLatestSnapshot();
|
||
expect(out).toHaveLength(2);
|
||
expect(out[0].latest_value).toBe(1234.56);
|
||
expect(out[1].latest_value).toBeNull();
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
// Filter: only active, non-archived accounts.
|
||
expect(sql).toContain("a.is_active = 1");
|
||
expect(sql).toContain("a.archived_at IS NULL");
|
||
// LEFT JOIN-equivalent: scalar subquery so accounts with no lines still surface.
|
||
expect(sql).toContain("ORDER BY s.snapshot_date DESC");
|
||
expect(sql).toContain("LIMIT 1");
|
||
});
|
||
});
|
||
|
||
describe("getAccountsPeriodAnchor", () => {
|
||
it("queries with a from-only filter", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{ account_id: 1, anchor_snapshot_date: "2026-01-31", anchor_value: 1000 },
|
||
]);
|
||
const rows = await getAccountsPeriodAnchor({ from: "2026-01-01" });
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0].anchor_value).toBe(1000);
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
// Window function: ROW_NUMBER partitioned by account_id, earliest first.
|
||
expect(sql).toContain("ROW_NUMBER()");
|
||
expect(sql).toContain("PARTITION BY l.account_id");
|
||
expect(sql).toContain("ORDER BY s.snapshot_date ASC");
|
||
expect(sql).toContain("WHERE rn = 1");
|
||
// Old aggregate-in-WHERE pattern must be gone (regression guard, #175).
|
||
expect(sql).not.toContain("MIN(s.snapshot_date)");
|
||
expect(sql).not.toContain("GROUP BY l.account_id");
|
||
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
|
||
});
|
||
|
||
it("queries with both from and to", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await getAccountsPeriodAnchor({ from: "2026-01-01", to: "2026-12-31" });
|
||
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]);
|
||
});
|
||
|
||
it("works with an empty range (open-ended)", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await getAccountsPeriodAnchor({});
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
// No WHERE clause when neither bound is set.
|
||
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
|
||
});
|
||
|
||
it("returns earliest snapshot per account within range", async () => {
|
||
// Multiple accounts, each with multiple snapshots in the window.
|
||
// The DB returns one row per account (the rn = 1 row), so the mocked
|
||
// result mirrors that contract.
|
||
mockSelect.mockResolvedValueOnce([
|
||
{ account_id: 1, anchor_snapshot_date: "2026-02-29", anchor_value: 1500 },
|
||
{ account_id: 2, anchor_snapshot_date: "2026-03-31", anchor_value: 2700 },
|
||
]);
|
||
const rows = await getAccountsPeriodAnchor({
|
||
from: "2026-02-01",
|
||
to: "2026-06-30",
|
||
});
|
||
expect(rows).toEqual([
|
||
{ account_id: 1, anchor_snapshot_date: "2026-02-29", anchor_value: 1500 },
|
||
{ account_id: 2, anchor_snapshot_date: "2026-03-31", anchor_value: 2700 },
|
||
]);
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("ROW_NUMBER()");
|
||
expect(sql).toContain("PARTITION BY l.account_id");
|
||
expect(sql).toContain("ORDER BY s.snapshot_date ASC");
|
||
expect(sql).toContain("WHERE rn = 1");
|
||
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-02-01", "2026-06-30"]);
|
||
});
|
||
|
||
it("returns [] for an empty window (no snapshots in range)", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
const rows = await getAccountsPeriodAnchor({
|
||
from: "2099-01-01",
|
||
to: "2099-12-31",
|
||
});
|
||
expect(rows).toEqual([]);
|
||
});
|
||
|
||
// Regression: /balance load (issue #175) used to throw "misuse of aggregate
|
||
// function MIN()" because MIN was used inside the WHERE of a scalar
|
||
// subquery. With ROW_NUMBER() the query is plain SQLite — assert the
|
||
// service forwards rows from db.select without throwing.
|
||
it("regression #175: loads without SQLite aggregate misuse error", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{ account_id: 1, anchor_snapshot_date: "2026-01-15", anchor_value: 500 },
|
||
]);
|
||
await expect(
|
||
getAccountsPeriodAnchor({ from: "2026-01-01", to: "2026-12-31" })
|
||
).resolves.toEqual([
|
||
{ account_id: 1, anchor_snapshot_date: "2026-01-15", anchor_value: 500 },
|
||
]);
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
// The exact pattern that triggered the SQLite error must not reappear.
|
||
expect(sql).not.toMatch(/=\s*MIN\(s\.snapshot_date\)/);
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Returns + transfers (Issue #142)
|
||
// -----------------------------------------------------------------------------
|
||
|
||
describe("computeAccountReturn", () => {
|
||
beforeEach(() => {
|
||
vi.mocked(loadProfiles).mockReset();
|
||
vi.mocked(invoke).mockReset();
|
||
});
|
||
|
||
it("invokes the Tauri command with the active profile's db_filename", async () => {
|
||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||
active_profile_id: "p1",
|
||
profiles: [
|
||
{
|
||
id: "p1",
|
||
name: "Max",
|
||
color: "#fff",
|
||
pin_hash: null,
|
||
db_filename: "max.db",
|
||
created_at: "0",
|
||
},
|
||
],
|
||
});
|
||
const fakeReturn = {
|
||
value_start: 1000,
|
||
value_end: 1100,
|
||
net_contributions: 0,
|
||
return_pct: 0.1,
|
||
annualized_pct: 0.42,
|
||
is_partial: false,
|
||
has_no_transfers_warning: true,
|
||
};
|
||
vi.mocked(invoke).mockResolvedValueOnce(fakeReturn);
|
||
|
||
const out = await computeAccountReturn(7, "2026-01-01", "2026-04-01");
|
||
|
||
expect(out).toEqual(fakeReturn);
|
||
expect(invoke).toHaveBeenCalledWith("compute_account_return", {
|
||
dbFilename: "max.db",
|
||
accountId: 7,
|
||
periodStart: "2026-01-01",
|
||
periodEnd: "2026-04-01",
|
||
});
|
||
});
|
||
|
||
it("rejects malformed period dates before invoking the command", async () => {
|
||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||
active_profile_id: "p1",
|
||
profiles: [
|
||
{
|
||
id: "p1",
|
||
name: "Max",
|
||
color: "#fff",
|
||
pin_hash: null,
|
||
db_filename: "max.db",
|
||
created_at: "0",
|
||
},
|
||
],
|
||
});
|
||
await expect(
|
||
computeAccountReturn(1, "not-a-date", "2026-04-01")
|
||
).rejects.toBeInstanceOf(BalanceServiceError);
|
||
expect(invoke).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("throws transfer_active_profile_unknown when no active profile resolves", async () => {
|
||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||
active_profile_id: "missing",
|
||
profiles: [],
|
||
});
|
||
await expect(
|
||
computeAccountReturn(1, "2026-01-01", "2026-04-01")
|
||
).rejects.toMatchObject({ code: "transfer_active_profile_unknown" });
|
||
expect(invoke).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("suggestTransferDirection", () => {
|
||
it("maps negative bank amounts to 'in' (money left bank → arrived in account)", () => {
|
||
expect(suggestTransferDirection(-100)).toBe("in");
|
||
});
|
||
it("maps positive bank amounts to 'out' (money came back from account)", () => {
|
||
expect(suggestTransferDirection(50)).toBe("out");
|
||
});
|
||
it("treats zero as 'out' as a deterministic fallback", () => {
|
||
expect(suggestTransferDirection(0)).toBe("out");
|
||
});
|
||
});
|
||
|
||
describe("linkTransfer", () => {
|
||
it("rejects an invalid direction without touching the DB", async () => {
|
||
await expect(
|
||
// @ts-expect-error testing runtime guard
|
||
linkTransfer(1, 2, "sideways")
|
||
).rejects.toBeInstanceOf(BalanceServiceError);
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("guards against duplicate links with a typed error", async () => {
|
||
mockSelect.mockResolvedValueOnce([{ id: 5 }]);
|
||
await expect(linkTransfer(1, 2, "in")).rejects.toMatchObject({
|
||
code: "transfer_already_linked",
|
||
});
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("inserts and returns the new transfer id", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 99, rowsAffected: 1 });
|
||
const id = await linkTransfer(1, 2, "out", " manual ");
|
||
expect(id).toBe(99);
|
||
const sql = mockExecute.mock.calls[0][0] as string;
|
||
expect(sql).toContain("INSERT INTO balance_account_transfers");
|
||
expect(mockExecute.mock.calls[0][1]).toEqual([1, 2, "out", "manual"]);
|
||
});
|
||
|
||
it("normalizes empty notes to null", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 });
|
||
await linkTransfer(1, 2, "in", " ");
|
||
expect(mockExecute.mock.calls[0][1][3]).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("unlinkTransfer", () => {
|
||
it("throws transfer_not_linked when no row was deleted", async () => {
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 0 });
|
||
await expect(unlinkTransfer(1, 2)).rejects.toMatchObject({
|
||
code: "transfer_not_linked",
|
||
});
|
||
});
|
||
|
||
it("succeeds when one row is deleted", async () => {
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 1 });
|
||
await expect(unlinkTransfer(1, 2)).resolves.toBeUndefined();
|
||
expect(mockExecute.mock.calls[0][1]).toEqual([1, 2]);
|
||
});
|
||
});
|
||
|
||
describe("listAccountTransfers", () => {
|
||
it("filters by account_id only when no date range is supplied", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await listAccountTransfers(7);
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("FROM balance_account_transfers bat");
|
||
expect(sql).toContain("JOIN transactions t");
|
||
expect(sql).toContain("JOIN balance_accounts a");
|
||
expect(sql).toContain("WHERE bat.account_id = $1");
|
||
expect(sql).not.toContain("t.date >=");
|
||
expect(mockSelect.mock.calls[0][1]).toEqual([7]);
|
||
});
|
||
|
||
it("appends inclusive date bounds when supplied", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await listAccountTransfers(7, { from: "2026-01-01", to: "2026-04-01" });
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("t.date >=");
|
||
expect(sql).toContain("t.date <=");
|
||
expect(mockSelect.mock.calls[0][1]).toEqual([7, "2026-01-01", "2026-04-01"]);
|
||
});
|
||
});
|
||
|
||
describe("listLinkedTransactionIds", () => {
|
||
it("returns a Set of transaction ids", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{ transaction_id: 5 },
|
||
{ transaction_id: 12 },
|
||
]);
|
||
const ids = await listLinkedTransactionIds();
|
||
expect(ids).toBeInstanceOf(Set);
|
||
expect(ids.has(5)).toBe(true);
|
||
expect(ids.has(12)).toBe(true);
|
||
expect(ids.size).toBe(2);
|
||
});
|
||
});
|
||
|
||
describe("listAllLinkedTransfersForTooltip", () => {
|
||
it("groups multiple links per transaction id", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{ transaction_id: 1, account_id: 10, account_name: "TFSA", direction: "in" },
|
||
{ transaction_id: 1, account_id: 20, account_name: "RRSP", direction: "out" },
|
||
{ transaction_id: 2, account_id: 10, account_name: "TFSA", direction: "in" },
|
||
]);
|
||
const map = await listAllLinkedTransfersForTooltip();
|
||
expect(map.get(1)).toHaveLength(2);
|
||
expect(map.get(2)).toHaveLength(1);
|
||
expect(map.get(1)?.[0].account_name).toBe("TFSA");
|
||
});
|
||
});
|
||
|
||
describe("isLinkedTransactionFkError", () => {
|
||
it("matches the canonical SQLite FK error text", () => {
|
||
expect(
|
||
isLinkedTransactionFkError(new Error("FOREIGN KEY constraint failed"))
|
||
).toBe(true);
|
||
});
|
||
|
||
it("matches the wrapped tauri-plugin-sql variant", () => {
|
||
expect(
|
||
isLinkedTransactionFkError(
|
||
new Error("code: 787, message: FOREIGN KEY constraint failed")
|
||
)
|
||
).toBe(true);
|
||
});
|
||
|
||
it("does not match unrelated errors", () => {
|
||
expect(isLinkedTransactionFkError(new Error("something else"))).toBe(false);
|
||
expect(isLinkedTransactionFkError(undefined)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// prices namespace (Issue #156 / Bilan #5)
|
||
// -----------------------------------------------------------------------------
|
||
|
||
import { prices } from "./balance.service";
|
||
|
||
const FAKE_PRICE_RESPONSE = {
|
||
symbol: "AAPL",
|
||
date: "2026-04-25",
|
||
price: 173.45,
|
||
currency: "USD",
|
||
source: "yahoo",
|
||
cached: false,
|
||
actual_date: null,
|
||
fetched_at: "2026-04-25T14:32:11Z",
|
||
};
|
||
|
||
describe("balance.service.prices", () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(invoke).mockReset();
|
||
prices.__resetForTests();
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
// 1. Happy path 200
|
||
it("fetchPrice happy path returns ok:true with price fields", async () => {
|
||
vi.mocked(invoke).mockResolvedValueOnce(FAKE_PRICE_RESPONSE);
|
||
|
||
const result = await prices.fetchPrice("AAPL", "2026-04-25");
|
||
|
||
expect(result.ok).toBe(true);
|
||
if (result.ok) {
|
||
expect(result.symbol).toBe("AAPL");
|
||
expect(result.date).toBe("2026-04-25");
|
||
expect(result.price).toBe(173.45);
|
||
expect(result.currency).toBe("USD");
|
||
expect(result.source).toBe("yahoo");
|
||
expect(result.cached).toBe(false);
|
||
}
|
||
expect(invoke).toHaveBeenCalledTimes(1);
|
||
expect(invoke).toHaveBeenCalledWith("fetch_price", {
|
||
symbol: "AAPL",
|
||
date: "2026-04-25",
|
||
});
|
||
});
|
||
|
||
// 2. 401 (auth) — no retry
|
||
it("fetchPrice auth error returns ok:false code:auth with 1 invoke call", async () => {
|
||
vi.mocked(invoke).mockRejectedValueOnce('{"code":"auth"}');
|
||
|
||
const promise = prices.fetchPrice("AAPL", "2026-04-25");
|
||
await vi.runAllTimersAsync();
|
||
const result = await promise;
|
||
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.error.code).toBe("auth");
|
||
expect(result.error.i18nKey).toBe(
|
||
"balance.priceFetching.errors.authFailed"
|
||
);
|
||
}
|
||
expect(invoke).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
// 3. 403 premium_required — no retry
|
||
it("fetchPrice premium_required returns immediately without retry", async () => {
|
||
vi.mocked(invoke).mockRejectedValueOnce('{"code":"premium_required"}');
|
||
|
||
const promise = prices.fetchPrice("AAPL", "2026-04-25");
|
||
await vi.runAllTimersAsync();
|
||
const result = await promise;
|
||
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.error.code).toBe("premium_required");
|
||
expect(result.error.i18nKey).toBe(
|
||
"balance.priceFetching.errors.premiumRequired"
|
||
);
|
||
}
|
||
expect(invoke).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
// 4. 404 symbol_not_found — no retry
|
||
it("fetchPrice symbol_not_found returns immediately without retry", async () => {
|
||
vi.mocked(invoke).mockRejectedValueOnce('{"code":"symbol_not_found"}');
|
||
|
||
const promise = prices.fetchPrice("AAPL", "2026-04-25");
|
||
await vi.runAllTimersAsync();
|
||
const result = await promise;
|
||
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.error.code).toBe("symbol_not_found");
|
||
expect(result.error.i18nKey).toBe(
|
||
"balance.priceFetching.errors.symbolNotFound"
|
||
);
|
||
}
|
||
expect(invoke).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
// 5. 429 rate_limit — no retry, carries retry_after_s
|
||
it("fetchPrice rate_limit 429 returns ok:false with retry_after_s, no retry", async () => {
|
||
vi.mocked(invoke).mockRejectedValueOnce(
|
||
'{"code":"rate_limit","retry_after_s":30}'
|
||
);
|
||
|
||
const promise = prices.fetchPrice("AAPL", "2026-04-25");
|
||
await vi.runAllTimersAsync();
|
||
const result = await promise;
|
||
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.error.code).toBe("rate_limit");
|
||
if (result.error.code === "rate_limit") {
|
||
expect(result.error.retry_after_s).toBe(30);
|
||
expect(result.error.i18nKey).toBe(
|
||
"balance.priceFetching.errors.rateLimit"
|
||
);
|
||
}
|
||
}
|
||
expect(invoke).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
// 6. 5xx provider_unavailable — 3 retries with 2/4/8s backoff (4 total calls)
|
||
it("fetchPrice provider_unavailable retries 3 times with 2/4/8s backoff", async () => {
|
||
vi.mocked(invoke).mockRejectedValue('{"code":"provider_unavailable"}');
|
||
|
||
const promise = prices.fetchPrice("AAPL", "2026-04-25");
|
||
|
||
// Advance through all retry delays: 2s + 4s + 8s = 14s total
|
||
await vi.advanceTimersByTimeAsync(2000); // retry 1 fires after 2s
|
||
await vi.advanceTimersByTimeAsync(4000); // retry 2 fires after 4s
|
||
await vi.advanceTimersByTimeAsync(8000); // retry 3 fires after 8s
|
||
await vi.runAllTimersAsync();
|
||
|
||
const result = await promise;
|
||
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.error.code).toBe("provider_unavailable");
|
||
expect(result.error.i18nKey).toBe(
|
||
"balance.priceFetching.errors.serverUnavailable"
|
||
);
|
||
}
|
||
// 1 initial + 3 retries = 4 total invoke calls
|
||
expect(invoke).toHaveBeenCalledTimes(4);
|
||
});
|
||
|
||
// 7. In-flight deduplication
|
||
it("fetchPrice dedup: two parallel calls with same key → only one invoke", async () => {
|
||
vi.mocked(invoke).mockResolvedValueOnce(FAKE_PRICE_RESPONSE);
|
||
|
||
const p1 = prices.fetchPrice("AAPL", "2026-04-25");
|
||
const p2 = prices.fetchPrice("AAPL", "2026-04-25");
|
||
|
||
await vi.runAllTimersAsync();
|
||
|
||
const [r1, r2] = await Promise.all([p1, p2]);
|
||
|
||
expect(invoke).toHaveBeenCalledTimes(1);
|
||
expect(r1.ok).toBe(true);
|
||
expect(r2.ok).toBe(true);
|
||
if (r1.ok && r2.ok) {
|
||
expect(r1.price).toBe(r2.price);
|
||
}
|
||
});
|
||
|
||
// 8. Rate-limit pacing: calls are serialized through _enforceRateLimit,
|
||
// so 3 concurrent calls result in 3 sequential invoke calls, each separated
|
||
// by at least MIN_INTERVAL_MS (2s). We verify that the setTimeout inside
|
||
// _enforceRateLimit is actually called with the correct delay.
|
||
it("fetchPrice rate-limit pacing: each call waits at least 2s after the previous", async () => {
|
||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||
|
||
vi.mocked(invoke).mockResolvedValue(FAKE_PRICE_RESPONSE);
|
||
|
||
// Start 3 calls for different symbols (no dedup). Only the first fires
|
||
// immediately; the others queue up behind the rate-limit.
|
||
const p1 = prices.fetchPrice("AAPL", "2026-01-01");
|
||
const p2 = prices.fetchPrice("MSFT", "2026-01-01");
|
||
const p3 = prices.fetchPrice("TSLA", "2026-01-01");
|
||
|
||
// Advance enough time for all 3 to complete (3 × 2s = 6s).
|
||
await vi.advanceTimersByTimeAsync(6000);
|
||
await vi.runAllTimersAsync();
|
||
|
||
await Promise.all([p1, p2, p3]);
|
||
|
||
// All 3 invoke calls must have been made.
|
||
expect(invoke).toHaveBeenCalledTimes(3);
|
||
|
||
// At least 2 setTimeout calls for the rate-limit waits (p2 and p3 must wait).
|
||
// The actual delay argument should be ~2000ms (or close to it, as the
|
||
// timer fires slightly early in fake-timer environments).
|
||
const rateLimitTimers = setTimeoutSpy.mock.calls.filter(
|
||
([, delay]) => typeof delay === "number" && delay > 0 && delay <= 2000
|
||
);
|
||
expect(rateLimitTimers.length).toBeGreaterThanOrEqual(2);
|
||
|
||
setTimeoutSpy.mockRestore();
|
||
});
|
||
|
||
// 9. Session cap: 101st call returns session_cap_reached without calling invoke
|
||
it("fetchPrice session cap: 101st call returns session_cap_reached", async () => {
|
||
// Set up invoke to always resolve successfully
|
||
vi.mocked(invoke).mockResolvedValue(FAKE_PRICE_RESPONSE);
|
||
|
||
// Fire 100 successful calls to fill the session cap.
|
||
// We bypass the rate-limit by advancing time enough between each.
|
||
for (let i = 0; i < 100; i++) {
|
||
const p = prices.fetchPrice(`SYM${i}`, "2026-01-01");
|
||
await vi.advanceTimersByTimeAsync(2000);
|
||
await p;
|
||
}
|
||
|
||
// The 101st call should immediately return session_cap_reached.
|
||
vi.mocked(invoke).mockClear(); // reset call counter
|
||
const result = await prices.fetchPrice("EXTRA", "2026-01-01");
|
||
|
||
expect(result.ok).toBe(false);
|
||
if (!result.ok) {
|
||
expect(result.error.code).toBe("session_cap_reached");
|
||
expect(result.error.i18nKey).toBe(
|
||
"balance.priceFetching.errors.sessionCapReached"
|
||
);
|
||
}
|
||
// invoke must NOT have been called for the 101st request
|
||
expect(invoke).not.toHaveBeenCalled();
|
||
});
|
||
});
|