Simpl-Resultat/src/services/balance.service.test.ts
le king fu 8cb7e53698
All checks were successful
PR Check / rust (pull_request) Successful in 24m30s
PR Check / frontend (pull_request) Successful in 2m38s
fix(db): serialize DB access to fix "database is locked"; live-update log console
Database lock (the reported 0.10.0 regression):
- tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool
  (Pool::connect => max_connections = 10) and exposes no JS transaction
  primitive. BEGIN and COMMIT issued as separate db.execute calls could land on
  different pooled connections, stranding an open write transaction on an idle
  connection — a permanent write lock until app restart. Triggered when the
  Bilan page's concurrent reads interleaved with an in-flight snapshot save.
- Fix: funnel every db operation through one FIFO lock in db.ts; withTransaction
  holds it across the whole BEGIN..COMMIT so a transaction's statements never
  span connections (and the saves become genuinely atomic). A reentrancy guard
  lets nested getDb() calls run directly instead of deadlocking. Applied to all
  5 transaction sites (saveSnapshotAtomic, upsertSnapshotLines,
  proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) via
  in-place helper extraction. New db.test.ts covers serialization,
  cross-transaction non-interleaving, reentrancy, and lock release on error.

Log console (Settings -> Systems -> Journaux):
- getLogs returned the same mutated array reference, so useSyncExternalStore
  (identity comparison) never re-rendered on a new log -> the console never
  updated live. getLogs now returns an immutable snapshot rebuilt on each
  mutation (stable between mutations, new identity after each).
- Add logInfo/logWarn/logError app-logging API and instrument the snapshot save
  (info on success, error on failure) so DB issues surface in the console.

tsc + 635 vitest + vite build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:50:39 -04:00

2924 lines
102 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("./db", () => {
const getDb = vi.fn();
return {
getDb,
// Tests don't exercise the real lock; withTransaction just runs the
// callback with whatever db the test wired getDb to resolve, so every
// existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the
// service issues them on the mock db inside the callback).
withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
};
});
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,
validateDetailedSnapshot,
isDetailedLine,
normalizeSecuritySymbol,
roundToCent,
findOrCreateSecurity,
listSecurities,
getSecurity,
updateSecurity,
listHoldingsBySnapshotLine,
getHoldingsForLatestSnapshot,
computeUnrealizedGain,
getAccountLatentGainByLine,
rollupLatentGain,
type AccountUnrealizedGain,
PRICED_VALUE_TOLERANCE,
BalanceServiceError,
getSnapshotTotalsByDate,
getSnapshotTotalsByCategoryAndDate,
getSnapshotTotalsByVehicleAndDate,
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",
kind: "simple",
detailed_since: null,
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,
kind: "simple",
detailed_since: 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",
kind: "simple",
detailed_since: null,
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");
});
it("getAccountsLatestSnapshot threads vehicle_type (#204)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getAccountsLatestSnapshot();
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("a.vehicle_type AS vehicle_type");
});
});
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,
kind: "simple",
detailed_since: 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]);
// #212: the rewrite is now wrapped in BEGIN/COMMIT for holdings atomicity.
mockExecute
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1
.mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2
.mockResolvedValueOnce({ rowsAffected: 1 }) // update updated_at
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
await upsertSnapshotLines(5, [
{ account_id: 1, value: 1234.56 },
{ account_id: 2, value: 0 },
]);
// 1st call = BEGIN, 2nd = DELETE
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
expect(mockExecute.mock.calls[1][0]).toContain(
"DELETE FROM balance_snapshot_lines"
);
// Inserts use literal NULL for quantity/unit_price (simple kind invariant)
const insertSql = mockExecute.mock.calls[2][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[2][1]).toEqual([5, 1, 1234.56]);
// Second insert params (zero is allowed)
expect(mockExecute.mock.calls[3][1]).toEqual([5, 2, 0]);
// UPDATE updated_at on parent snapshot, then COMMIT last.
expect(mockExecute.mock.calls[4][0]).toContain("UPDATE balance_snapshots");
expect(mockExecute.mock.calls[4][0]).toContain("updated_at");
expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe(
"COMMIT"
);
});
it("clears all lines when called with an empty array", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
.mockResolvedValueOnce({ rowsAffected: 3 }) // delete only
.mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
await upsertSnapshotLines(5, []);
// BEGIN + DELETE + UPDATE updated_at + COMMIT — no INSERTs
expect(mockExecute).toHaveBeenCalledTimes(4);
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
expect(mockExecute.mock.calls[3][0]).toBe("COMMIT");
});
});
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: 0 }) // BEGIN
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert
.mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
await upsertSnapshotLines(5, [
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 25.5,
value: 255,
},
]);
const insertSql = mockExecute.mock.calls[2][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[2][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: 0 }) // BEGIN
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert simple
.mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert priced
.mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
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[2][0] as string).toMatch(
/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/
);
expect(mockExecute.mock.calls[2][1]).toEqual([5, 1, 1000]);
// Priced insert uses placeholders
expect(mockExecute.mock.calls[3][0] as string).toMatch(
/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/
);
expect(mockExecute.mock.calls[3][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("getSnapshotTotalsByVehicleAndDate (#204)", () => {
it("returns [] on empty DB", async () => {
mockSelect.mockResolvedValueOnce([]);
expect(await getSnapshotTotalsByVehicleAndDate()).toEqual([]);
});
it("COALESCEs NULL envelopes into a single 'none' bucket", async () => {
mockSelect.mockResolvedValueOnce([]);
await getSnapshotTotalsByVehicleAndDate();
const sql = mockSelect.mock.calls[0][0] as string;
// HIGH caveat: the GROUP BY must coalesce NULL → 'none' so null-envelope
// accounts never produce a SQL NULL key.
expect(sql).toContain("COALESCE(a.vehicle_type, 'none')");
expect(sql).toContain("GROUP BY s.snapshot_date, COALESCE(a.vehicle_type, 'none')");
expect(sql).toContain("INNER JOIN balance_accounts");
});
it("buckets a mix of envelopes plus the 'none' bucket per snapshot_date", async () => {
mockSelect.mockResolvedValueOnce([
{ snapshot_date: "2026-01-31", vehicle_key: "none", total: 500 },
{ snapshot_date: "2026-01-31", vehicle_key: "tfsa", total: 1500 },
{ snapshot_date: "2026-01-31", vehicle_key: "rrsp", total: 3000 },
{ snapshot_date: "2026-02-28", vehicle_key: "none", total: 700 },
{ snapshot_date: "2026-02-28", vehicle_key: "tfsa", total: 1700 },
]);
const out = await getSnapshotTotalsByVehicleAndDate();
expect(out).toEqual([
{
snapshot_date: "2026-01-31",
byVehicle: { none: 500, tfsa: 1500, rrsp: 3000 },
},
{
snapshot_date: "2026-02-28",
byVehicle: { none: 700, tfsa: 1700 },
},
]);
});
it("handles a snapshot composed solely of null-envelope accounts", async () => {
mockSelect.mockResolvedValueOnce([
{ snapshot_date: "2026-03-31", vehicle_key: "none", total: 4200 },
]);
const out = await getSnapshotTotalsByVehicleAndDate();
expect(out).toEqual([
{ snapshot_date: "2026-03-31", byVehicle: { none: 4200 } },
]);
});
it("applies date range params when supplied", async () => {
mockSelect.mockResolvedValueOnce([]);
await getSnapshotTotalsByVehicleAndDate({
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");
// #216: account kind + latest line id surfaced for the drill-down.
expect(sql).toContain("a.kind AS kind");
expect(sql).toContain("AS latest_snapshot_line_id");
});
});
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();
});
});
// =============================================================================
// Détail par titre (Issue #212) — securities CRUD, detailed save, holdings,
// unrealized gain, detailed→simple guard.
// =============================================================================
describe("normalizeSecuritySymbol", () => {
it("uppercases and trims", () => {
expect(normalizeSecuritySymbol(" aapl ")).toBe("AAPL");
expect(normalizeSecuritySymbol("btc")).toBe("BTC");
expect(normalizeSecuritySymbol("VeQt.TO")).toBe("VEQT.TO");
});
});
describe("roundToCent", () => {
it("rounds to two decimals (half-up at the cent)", () => {
expect(roundToCent(13.2038)).toBe(13.2);
expect(roundToCent(0.005)).toBe(0.01);
expect(roundToCent(100)).toBe(100);
expect(roundToCent(2.675)).toBeCloseTo(2.68, 5);
});
});
describe("findOrCreateSecurity", () => {
it("rejects an empty/whitespace symbol", async () => {
await expect(
findOrCreateSecurity({ symbol: " ", asset_type: "stock" })
).rejects.toMatchObject({ code: "security_symbol_required" });
expect(mockExecute).not.toHaveBeenCalled();
});
it("rejects an invalid asset_type", async () => {
await expect(
// @ts-expect-error runtime guard
findOrCreateSecurity({ symbol: "AAPL", asset_type: "bond" })
).rejects.toMatchObject({ code: "security_asset_type_invalid" });
expect(mockExecute).not.toHaveBeenCalled();
});
it("UPSERTs on the NORMALIZED symbol and returns the row (casing-dedup)", async () => {
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); // upsert
mockSelect.mockResolvedValueOnce([
{
id: 3,
symbol: "AAPL",
name: null,
currency: "CAD",
asset_type: "stock",
created_at: "",
updated_at: "",
},
]);
const sec = await findOrCreateSecurity({
symbol: " aapl ",
asset_type: "stock",
});
expect(sec.id).toBe(3);
// The INSERT and the lookup both use the normalized symbol.
expect(mockExecute.mock.calls[0][1]?.[0]).toBe("AAPL");
expect(mockExecute.mock.calls[0][0]).toContain("ON CONFLICT(symbol) DO UPDATE");
expect(mockSelect.mock.calls[0][1]).toEqual(["AAPL"]);
});
it("a second call with different casing resolves the SAME row (dedup)", async () => {
// First create.
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
mockSelect.mockResolvedValueOnce([
{ id: 9, symbol: "BTC", name: null, currency: "CAD", asset_type: "crypto", created_at: "", updated_at: "" },
]);
const a = await findOrCreateSecurity({ symbol: "btc", asset_type: "crypto" });
// Second call, different casing — the normalized conflict target returns id 9.
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
mockSelect.mockResolvedValueOnce([
{ id: 9, symbol: "BTC", name: null, currency: "CAD", asset_type: "crypto", created_at: "", updated_at: "" },
]);
const b = await findOrCreateSecurity({ symbol: " BtC ", asset_type: "crypto" });
expect(a.id).toBe(b.id);
expect(b.id).toBe(9);
});
it("threads an executor when given (in-txn create)", async () => {
const execSelect = vi.fn().mockResolvedValueOnce([
{ id: 1, symbol: "VEQT.TO", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
const execExecute = vi.fn().mockResolvedValueOnce({ rowsAffected: 1 });
const exec = { select: execSelect, execute: execExecute };
await findOrCreateSecurity({ symbol: "veqt.to", asset_type: "stock" }, exec);
// The provided executor was used, NOT the top-level db handle.
expect(execExecute).toHaveBeenCalledTimes(1);
expect(mockExecute).not.toHaveBeenCalled();
expect(mockSelect).not.toHaveBeenCalled();
});
});
describe("listSecurities / getSecurity / updateSecurity", () => {
it("listSecurities orders by symbol", async () => {
mockSelect.mockResolvedValueOnce([]);
await listSecurities();
expect(mockSelect.mock.calls[0][0]).toContain("FROM balance_securities");
expect(mockSelect.mock.calls[0][0]).toContain("ORDER BY symbol ASC");
});
it("getSecurity returns null when absent", async () => {
mockSelect.mockResolvedValueOnce([]);
expect(await getSecurity(404)).toBeNull();
});
it("updateSecurity rejects on missing id", async () => {
mockSelect.mockResolvedValueOnce([]); // getSecurity → none
await expect(updateSecurity(7, { name: "x" })).rejects.toMatchObject({
code: "security_not_found",
});
expect(mockExecute).not.toHaveBeenCalled();
});
it("updateSecurity re-normalizes symbol and validates asset_type", async () => {
mockSelect.mockResolvedValueOnce([
{ id: 7, symbol: "AAPL", name: "Apple", currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
await updateSecurity(7, { symbol: " msft ", asset_type: "stock" });
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[0]).toBe("MSFT"); // normalized
});
it("updateSecurity rejects an invalid asset_type", async () => {
mockSelect.mockResolvedValueOnce([
{ id: 7, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
await expect(
// @ts-expect-error runtime guard
updateSecurity(7, { asset_type: "etf" })
).rejects.toMatchObject({ code: "security_asset_type_invalid" });
expect(mockExecute).not.toHaveBeenCalled();
});
});
describe("isDetailedLine", () => {
it("is true iff a holdings array is present (even empty)", () => {
expect(isDetailedLine({ account_id: 1, value: 0, holdings: [] })).toBe(true);
expect(
isDetailedLine({
account_id: 1,
value: 10,
holdings: [
{ symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 10, value: 10 },
],
})
).toBe(true);
expect(isDetailedLine({ account_id: 1, value: 10 })).toBe(false);
});
});
describe("validateDetailedSnapshot", () => {
const h = (value: number, over: Partial<{ symbol: string; quantity: number; unit_price: number }> = {}) => ({
symbol: over.symbol ?? "AAPL",
asset_type: "stock" as const,
quantity: over.quantity ?? 1,
unit_price: over.unit_price ?? value,
value,
});
it("accepts a detailed line whose value = rounded-cent SUM of holdings", () => {
expect(() =>
validateDetailedSnapshot({
account_id: 7,
value: 300,
holdings: [h(100, { symbol: "AAPL" }), h(200, { symbol: "MSFT" })],
})
).not.toThrow();
});
it("tolerates a detailed account with NO holdings (pre-pivot aggregated)", () => {
expect(() =>
validateDetailedSnapshot({ account_id: 7, value: 5000, holdings: [] })
).not.toThrow();
});
it("rejects a detailed line that carries a scalar quantity/unit_price", () => {
expect(() =>
validateDetailedSnapshot({
account_id: 7,
value: 100,
quantity: 1,
holdings: [h(100)],
})
).toThrow(BalanceServiceError);
let scalarErr: unknown;
try {
validateDetailedSnapshot({
account_id: 7,
value: 100,
unit_price: 100,
holdings: [h(100)],
});
} catch (e) {
scalarErr = e;
}
expect((scalarErr as BalanceServiceError).code).toBe(
"snapshot_detailed_must_be_aggregate"
);
});
it("rejects when line value ≠ rounded-cent SUM", () => {
let caught: unknown;
try {
validateDetailedSnapshot({
account_id: 7,
value: 305, // should be 300
holdings: [h(100), h(200, { symbol: "MSFT" })],
});
} catch (e) {
caught = e;
}
expect((caught as BalanceServiceError).code).toBe(
"snapshot_detailed_value_mismatch"
);
});
it("rejects a holding with an empty symbol or bad asset_type or non-finite numbers", () => {
expect(() =>
validateDetailedSnapshot({
account_id: 7,
value: 10,
holdings: [{ symbol: " ", asset_type: "stock", quantity: 1, unit_price: 10, value: 10 }],
})
).toThrowError(/snapshot_holding_invalid|required/);
expect(() =>
validateDetailedSnapshot({
account_id: 7,
value: 10,
// @ts-expect-error runtime guard
holdings: [{ symbol: "AAPL", asset_type: "bond", quantity: 1, unit_price: 10, value: 10 }],
})
).toThrow(BalanceServiceError);
expect(() =>
validateDetailedSnapshot({
account_id: 7,
value: 10,
holdings: [{ symbol: "AAPL", asset_type: "stock", quantity: NaN, unit_price: 10, value: 10 }],
})
).toThrow(BalanceServiceError);
});
it("accepts N≥20 holdings — per-holding rounding never accumulates past a cent", () => {
// 21 positions each at 0.005 → roundToCent ⇒ 0.01 each ⇒ exact SUM 0.21.
// A naive Σ(raw)=0.105 with an absolute ε=0.01 would mis-handle this;
// rounding each first makes the comparison exact on whole cents.
const holdings = Array.from({ length: 21 }, (_, i) => ({
symbol: `S${i}`,
asset_type: "stock" as const,
quantity: 1,
unit_price: 0.005,
value: 0.005,
}));
expect(() =>
validateDetailedSnapshot({ account_id: 7, value: 0.21, holdings })
).not.toThrow();
// Off-by-a-cent must still be rejected exactly.
let offByCent: unknown;
try {
validateDetailedSnapshot({ account_id: 7, value: 0.22, holdings });
} catch (e) {
offByCent = e;
}
expect((offByCent as BalanceServiceError).code).toBe(
"snapshot_detailed_value_mismatch"
);
});
});
describe("saveSnapshotAtomic — detailed account (holdings)", () => {
it("writes the aggregated line + securities + holdings inside ONE BEGIN/COMMIT", async () => {
mockSelect
.mockResolvedValueOnce([]) // dup-check: free date
// findOrCreateSecurity AAPL lookup, then MSFT lookup
.mockResolvedValueOnce([
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
])
.mockResolvedValueOnce([
{ id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
mockExecute
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
.mockResolvedValueOnce({ lastInsertId: 500, rowsAffected: 1 }) // INSERT aggregated line
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE holdings for line 500
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPSERT security AAPL
.mockResolvedValueOnce({ rowsAffected: 1 }) // INSERT holding AAPL
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPSERT security MSFT
.mockResolvedValueOnce({ rowsAffected: 1 }) // INSERT holding MSFT
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
const res = await saveSnapshotAtomic({
existingSnapshotId: null,
snapshot_date: "2026-05-30",
lines: [
{
account_id: 7,
value: 300,
holdings: [
{ symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100, book_cost: 80 },
{ symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200, book_cost: 150 },
],
},
],
});
expect(res.snapshotId).toBe(42);
const calls = mockExecute.mock.calls.map((c: unknown[]) => String(c[0]));
// The aggregated line is inserted with NULL qty/price and the SUM value.
const aggInsertIdx = calls.findIndex((s: string) => s.includes("INSERT INTO balance_snapshot_lines"));
expect(mockExecute.mock.calls[aggInsertIdx][1]).toEqual([42, 7, 300]);
// Holdings are deleted then inserted referencing the captured line id (500).
expect(calls).toContain("DELETE FROM balance_snapshot_holdings WHERE snapshot_line_id = $1");
const holdingInsert = mockExecute.mock.calls.find((c: unknown[]) =>
String(c[0]).includes("INSERT INTO balance_snapshot_holdings")
);
expect(holdingInsert?.[1]?.[0]).toBe(500); // snapshot_line_id
// Begins once, commits last, never rolls back.
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe("COMMIT");
expect(calls).not.toContain("ROLLBACK");
});
it("ROLLS BACK the whole save when a holding INSERT fails (no partial line/holdings)", async () => {
mockSelect
.mockResolvedValueOnce([]) // dup-check
.mockResolvedValueOnce([
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]); // security AAPL lookup
mockExecute
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
.mockResolvedValueOnce({ lastInsertId: 500, rowsAffected: 1 }) // INSERT aggregated line
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE holdings
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPSERT security AAPL
.mockRejectedValueOnce(new Error("holding FK violation")) // INSERT holding FAILS
.mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK
await expect(
saveSnapshotAtomic({
existingSnapshotId: null,
snapshot_date: "2026-05-30",
lines: [
{
account_id: 7,
value: 100,
holdings: [
{ symbol: "AAPL", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 },
],
},
],
})
).rejects.toThrow("holding FK violation");
// ROLLBACK was the last call, no COMMIT happened.
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("recomputes the aggregated value server-side from the rounded-cent SUM", async () => {
// Caller passes value=0.21 with 21×0.005 holdings; the line must store 0.21.
mockSelect.mockResolvedValueOnce([]); // dup-check
for (let i = 0; i < 21; i++) {
mockSelect.mockResolvedValueOnce([
{ id: 100 + i, symbol: `S${i}`, name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
}
// BEGIN, INSERT snapshot, DELETE lines, INSERT line, DELETE holdings,
// then 21×(upsert security + insert holding), UPDATE, COMMIT.
mockExecute
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
.mockResolvedValueOnce({ lastInsertId: 500, rowsAffected: 1 }) // INSERT line
.mockResolvedValueOnce({ rowsAffected: 0 }); // DELETE holdings
for (let i = 0; i < 21; i++) {
mockExecute
.mockResolvedValueOnce({ rowsAffected: 1 }) // upsert security
.mockResolvedValueOnce({ rowsAffected: 1 }); // insert holding
}
mockExecute
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
const holdings = Array.from({ length: 21 }, (_, i) => ({
symbol: `S${i}`,
asset_type: "stock" as const,
quantity: 1,
unit_price: 0.005,
value: 0.005,
}));
await saveSnapshotAtomic({
existingSnapshotId: null,
snapshot_date: "2026-05-30",
lines: [{ account_id: 7, value: 0.21, holdings }],
});
const lineInsert = mockExecute.mock.calls.find((c: unknown[]) =>
String(c[0]).includes("INSERT INTO balance_snapshot_lines")
);
// 21 × roundToCent(0.005)=0.01 ⇒ 0.21 stored.
expect(lineInsert?.[1]).toEqual([42, 7, 0.21]);
});
});
describe("updateBalanceAccount — detailed→simple guard (#212)", () => {
const detailedAccount = {
id: 7,
balance_category_id: 1,
name: "Courtage",
symbol: null,
currency: "CAD",
notes: null,
is_active: 1,
archived_at: null,
vehicle_type: null,
kind: "detailed",
detailed_since: "2026-05-01",
created_at: "",
updated_at: "",
};
it("rejects detailed→simple when holdings exist (typed error, no UPDATE)", async () => {
mockSelect
.mockResolvedValueOnce([detailedAccount]) // getBalanceAccount
.mockResolvedValueOnce([{ n: 3 }]); // holdings count > 0
await expect(
updateBalanceAccount(7, { kind: "simple" })
).rejects.toMatchObject({ code: "account_kind_detailed_has_holdings" });
// Guard fires before the UPDATE.
expect(
mockExecute.mock.calls.some((c: unknown[]) =>
String(c[0]).includes("UPDATE balance_accounts")
)
).toBe(false);
});
it("ALLOWS detailed→simple when no holdings exist yet", async () => {
mockSelect
.mockResolvedValueOnce([detailedAccount]) // getBalanceAccount
.mockResolvedValueOnce([{ n: 0 }]); // no holdings
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); // UPDATE
await updateBalanceAccount(7, { kind: "simple" });
const sql = mockExecute.mock.calls[0][0] as string;
expect(sql).toContain("kind = $7");
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[6]).toBe("simple"); // kind
});
it("does NOT run the holdings check on simple→detailed (only the dangerous direction is gated)", async () => {
const simpleAccount = { ...detailedAccount, kind: "simple", detailed_since: null };
mockSelect.mockResolvedValueOnce([simpleAccount]); // getBalanceAccount only
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); // UPDATE
await updateBalanceAccount(7, { kind: "detailed", detailed_since: "2026-06-01" });
// Exactly one SELECT (the account read) — no holdings count query.
expect(mockSelect).toHaveBeenCalledTimes(1);
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[6]).toBe("detailed");
expect(params[7]).toBe("2026-06-01"); // detailed_since normalized
});
it("rejects an invalid kind value", async () => {
mockSelect.mockResolvedValueOnce([{ ...detailedAccount, kind: "simple" }]);
await expect(
// @ts-expect-error runtime guard
updateBalanceAccount(7, { kind: "weird" })
).rejects.toMatchObject({ code: "account_kind_invalid" });
});
});
describe("listHoldingsBySnapshotLine / getHoldingsForLatestSnapshot", () => {
it("listHoldingsBySnapshotLine joins the security and filters by line", async () => {
mockSelect.mockResolvedValueOnce([]);
await listHoldingsBySnapshotLine(500);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("FROM balance_snapshot_holdings h");
expect(sql).toContain("JOIN balance_securities s");
expect(sql).toContain("WHERE h.snapshot_line_id = $1");
expect(mockSelect.mock.calls[0][1]).toEqual([500]);
});
it("getHoldingsForLatestSnapshot EXCLUDES quantity-0 positions", async () => {
mockSelect.mockResolvedValueOnce([]);
await getHoldingsForLatestSnapshot(7);
const sql = mockSelect.mock.calls[0][0] as string;
// Latest-snapshot subquery + the qty<>0 filter for the prefill.
expect(sql).toContain("h.quantity <> 0");
expect(sql).toContain("ORDER BY s2.snapshot_date DESC");
expect(mockSelect.mock.calls[0][1]).toEqual([7]);
});
});
describe("computeUnrealizedGain", () => {
it("computes per-holding gain and % from book_cost", () => {
const r = computeUnrealizedGain([
{ security_id: 1, value: 120, book_cost: 100, symbol: "AAPL" },
{ security_id: 2, value: 80, book_cost: 100, symbol: "MSFT" },
]);
expect(r.total_value).toBe(200);
expect(r.total_book_cost).toBe(200);
expect(r.total_gain).toBe(0);
expect(r.total_gain_pct).toBe(0);
expect(r.holdings[0].gain).toBe(20);
expect(r.holdings[0].gain_pct).toBeCloseTo(0.2, 5);
expect(r.holdings[1].gain).toBe(-20);
expect(r.holdings[1].gain_pct).toBeCloseTo(-0.2, 5);
expect(r.has_unknown_book_cost).toBe(false);
});
it("book_cost = 0 ⇒ gain = value but gain_pct = null (N/A, no divide-by-zero)", () => {
const r = computeUnrealizedGain([
{ security_id: 1, value: 50, book_cost: 0 },
]);
expect(r.holdings[0].gain).toBe(50);
expect(r.holdings[0].gain_pct).toBeNull();
// book_cost 0 contributes to the (zero) aggregate denominator ⇒ total % null.
expect(r.total_book_cost).toBe(0);
expect(r.total_gain_pct).toBeNull();
expect(r.has_unknown_book_cost).toBe(false);
});
it("NULL book_cost ⇒ gain & % null, EXCLUDED from aggregate, flagged", () => {
const r = computeUnrealizedGain([
{ security_id: 1, value: 120, book_cost: 100 },
{ security_id: 2, value: 300, book_cost: null },
]);
// The NULL-book_cost holding's value still counts toward total_value…
expect(r.total_value).toBe(420);
// …but NOT toward book_cost / gain aggregates.
expect(r.total_book_cost).toBe(100);
expect(r.total_gain).toBe(20);
expect(r.total_gain_pct).toBeCloseTo(0.2, 5);
expect(r.holdings[1].gain).toBeNull();
expect(r.holdings[1].gain_pct).toBeNull();
expect(r.has_unknown_book_cost).toBe(true);
});
it("empty holdings ⇒ zeros and null %", () => {
const r = computeUnrealizedGain([]);
expect(r.total_value).toBe(0);
expect(r.total_book_cost).toBe(0);
expect(r.total_gain).toBe(0);
expect(r.total_gain_pct).toBeNull();
expect(r.has_unknown_book_cost).toBe(false);
expect(r.holdings).toEqual([]);
});
});
describe("getAccountLatentGainByLine", () => {
it("reads the line's holdings then folds them through computeUnrealizedGain", async () => {
mockSelect.mockResolvedValueOnce([
{
security_id: 1,
value: 120,
book_cost: 100,
security_symbol: "AAPL",
},
{
security_id: 2,
value: 300,
book_cost: null,
security_symbol: "MSFT",
},
]);
const r = await getAccountLatentGainByLine(42);
// It reads holdings of the supplied line id.
expect(mockSelect.mock.calls[0][1]).toEqual([42]);
// Aggregate mirrors computeUnrealizedGain (NULL book_cost excluded, flagged).
expect(r.total_value).toBe(420);
expect(r.total_gain).toBe(20);
expect(r.has_unknown_book_cost).toBe(true);
// Symbol from the security join is carried onto the per-holding rows.
expect(r.holdings[0].symbol).toBe("AAPL");
});
});
describe("rollupLatentGain", () => {
// A small AccountUnrealizedGain factory keeps the cases readable.
const g = (
over: Partial<AccountUnrealizedGain>
): AccountUnrealizedGain => ({
total_value: 0,
total_book_cost: 0,
total_gain: 0,
total_gain_pct: null,
has_unknown_book_cost: false,
holdings: [],
...over,
});
it("groups by asset class and by envelope, and sums a grand total", () => {
const r = rollupLatentGain([
{
category_key: "stock",
vehicle_type: "tfsa",
gain: g({ total_value: 120, total_book_cost: 100, total_gain: 20 }),
},
{
category_key: "stock",
vehicle_type: "rrsp",
gain: g({ total_value: 80, total_book_cost: 100, total_gain: -20 }),
},
{
category_key: "crypto",
vehicle_type: "tfsa",
gain: g({ total_value: 50, total_book_cost: 40, total_gain: 10 }),
},
]);
// Asset-class buckets: stock merges the two accounts, crypto stands alone.
const stock = r.byClass.find((b) => b.group_key === "stock")!;
expect(stock.total_value).toBe(200);
expect(stock.total_gain).toBe(0);
expect(stock.total_gain_pct).toBe(0); // 0 / 200
const crypto = r.byClass.find((b) => b.group_key === "crypto")!;
expect(crypto.total_gain_pct).toBeCloseTo(0.25, 5); // 10 / 40
// Envelope buckets: tfsa merges stock+crypto, rrsp stands alone.
const tfsa = r.byVehicle.find((b) => b.group_key === "tfsa")!;
expect(tfsa.total_value).toBe(170);
expect(tfsa.total_gain).toBe(30);
expect(tfsa.total_gain_pct).toBeCloseTo(30 / 140, 5);
// Grand total across every account.
expect(r.grandTotal.total_value).toBe(250);
expect(r.grandTotal.total_gain).toBe(10);
expect(r.grandTotal.total_gain_pct).toBeCloseTo(10 / 240, 5);
expect(r.grandTotal.has_unknown_book_cost).toBe(false);
});
it("buckets a NULL vehicle_type under the 'none' envelope key", () => {
const r = rollupLatentGain([
{
category_key: "other",
vehicle_type: null,
gain: g({ total_value: 10, total_book_cost: 10, total_gain: 0 }),
},
]);
expect(r.byVehicle.map((b) => b.group_key)).toEqual(["none"]);
});
it("a bucket with no known book_cost yields a null % and flags unknown", () => {
const r = rollupLatentGain([
{
category_key: "stock",
vehicle_type: "tfsa",
// All book_cost unknown ⇒ total_book_cost 0, gain 0, flagged.
gain: g({ total_value: 500, has_unknown_book_cost: true }),
},
]);
expect(r.byClass[0].total_gain_pct).toBeNull();
expect(r.byClass[0].has_unknown_book_cost).toBe(true);
expect(r.grandTotal.total_gain_pct).toBeNull();
expect(r.grandTotal.has_unknown_book_cost).toBe(true);
});
it("empty input ⇒ empty buckets and a zeroed grand total", () => {
const r = rollupLatentGain([]);
expect(r.byClass).toEqual([]);
expect(r.byVehicle).toEqual([]);
expect(r.grandTotal.total_value).toBe(0);
expect(r.grandTotal.total_gain_pct).toBeNull();
});
});