Simpl-Resultat/src/services/balance.service.test.ts
le king fu 5861346eb3
All checks were successful
PR Check / rust (pull_request) Successful in 22m31s
PR Check / frontend (pull_request) Successful in 2m26s
feat(balance): data layer — vehicle_type + custom_label migrations, starters, service (#202)
Bilan axe véhicule (Étape 1) data foundation: separate the fiscal envelope
(now balance_accounts.vehicle_type) from the asset class (the category).

- Migration v12 (additive): add vehicle_type (fiscal enum, nullable) to
  balance_accounts + custom_label to balance_categories; backfill the envelope
  onto ex-tfsa/rrsp accounts (cash stays NULL); defensive recovery of any seed
  i18n_key overwritten by free text (bug I).
- Migration v13 (reclass, conditional/idempotent): re-link ex-tfsa/rrsp accounts
  to the `other` asset class and deactivate the two envelope seeds.
- consolidated_schema.sql: 2 new columns, 5 asset-class seeds (no tfsa/rrsp),
  CELI/REER starters re-pointed to `other` + vehicle_type (avoids NULL FK).
- Types: BalanceVehicleType, custom_label / vehicle_type / category_custom_label.
- Service: normalizeVehicleType + vehicle_type_invalid; CRUD writes the new
  columns; SELECT/JOINs read them back; STARTER_ACCOUNTS + proposeStarterAccounts
  (is_active=1 + vehicle_type) + getStarterCollisions adjusted.
- Tests: Rust chain v9→v13 (snapshot_lines identical, transfers intact, archived
  ex-tfsa covered, seeds deactivated, v13 EXISTS guard, idempotence, CHECK reject)
  + consolidated complete (5 categories, 4 starters, 0 NULL FK); TS service +
  StarterAccountsModal specs. No diff to migrations <= v11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:37:56 -04:00

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