Issue #142 / Bilan #4 — TS bridge for the Modified Dietz command + plain CRUD for transfer linking. Types (`src/shared/types/index.ts`): - `BalanceTransferDirection` ('in' | 'out') - `BalanceAccountTransfer` (raw row) + `BalanceAccountTransferWithTransaction` (joined view) - `AccountReturn` (mirrors the Rust struct, ready to receive the invoke payload as-is) Service (`src/services/balance.service.ts`): - `computeAccountReturn(accountId, periodStart, periodEnd)`: resolves the active profile's `db_filename` from `loadProfiles()` and calls the `compute_account_return` Tauri command. - `linkTransfer(accountId, transactionId, direction, notes?)`: INSERT with duplicate guard (typed `transfer_already_linked` error instead of raw SQL UNIQUE failure). - `unlinkTransfer(accountId, transactionId)`: DELETE with `transfer_not_linked` guard for stale-UI calls. - `listAccountTransfers(accountId, dateRange?)`: joined SELECT for modal/list rendering. - `listLinkedTransactionIds()`: returns a `Set<number>` for the transaction icon (one query, in-memory `.has()` lookups thereafter). - `listAllLinkedTransfersForTooltip()`: returns `Map<transactionId, links[]>` for tooltip rendering. - `suggestTransferDirection(amount)`: pure helper for the modal — maps negative bank amounts to 'in', positive to 'out'. - `isLinkedTransactionFkError(error)`: detects the canonical SQLite "FK constraint failed" text so `transactionService.deleteTransaction` can surface a clear i18n message. - 5 new error codes added to `BalanceErrorCode`. Tests (`balance.service.test.ts`): 22 new vitest cases bringing the file to 85 passed. Mocks `@tauri-apps/api/core` `invoke` and `./profileService` `loadProfiles`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1206 lines
39 KiB
TypeScript
1206 lines
39 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } 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,
|
||
createBalanceAccount,
|
||
updateBalanceAccount,
|
||
archiveBalanceAccount,
|
||
unarchiveBalanceAccount,
|
||
listSnapshots,
|
||
getSnapshotByDate,
|
||
createSnapshot,
|
||
updateSnapshot,
|
||
deleteSnapshot,
|
||
listLinesBySnapshot,
|
||
upsertSnapshotLines,
|
||
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");
|
||
expect(sql).toMatch(/0\)$/); // is_seed hardcoded to 0
|
||
expect(params).toEqual(["ferr", "balance.category.ferr", "simple", 35]);
|
||
});
|
||
});
|
||
|
||
describe("deleteBalanceCategory", () => {
|
||
it("refuses to delete a seeded category", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 1,
|
||
key: "cash",
|
||
i18n_key: "balance.category.cash",
|
||
kind: "simple",
|
||
sort_order: 10,
|
||
is_active: 1,
|
||
is_seed: 1,
|
||
},
|
||
]);
|
||
await expect(deleteBalanceCategory(1)).rejects.toMatchObject({
|
||
code: "category_seed_protected",
|
||
});
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("refuses to delete a category with linked accounts", async () => {
|
||
// 1st select = getBalanceCategory; 2nd select = COUNT(*) accounts linked
|
||
mockSelect
|
||
.mockResolvedValueOnce([
|
||
{
|
||
id: 8,
|
||
key: "ferr",
|
||
i18n_key: "balance.category.ferr",
|
||
kind: "simple",
|
||
sort_order: 35,
|
||
is_active: 1,
|
||
is_seed: 0,
|
||
},
|
||
])
|
||
.mockResolvedValueOnce([{ count: 2 }]);
|
||
await expect(deleteBalanceCategory(8)).rejects.toMatchObject({
|
||
code: "category_has_accounts",
|
||
});
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("deletes a user-created category with no linked accounts", async () => {
|
||
mockSelect
|
||
.mockResolvedValueOnce([
|
||
{
|
||
id: 8,
|
||
key: "ferr",
|
||
i18n_key: "balance.category.ferr",
|
||
kind: "simple",
|
||
sort_order: 35,
|
||
is_active: 1,
|
||
is_seed: 0,
|
||
},
|
||
])
|
||
.mockResolvedValueOnce([{ count: 0 }]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await deleteBalanceCategory(8);
|
||
expect(mockExecute).toHaveBeenCalledWith(
|
||
"DELETE FROM balance_categories WHERE id = $1",
|
||
[8]
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("updateBalanceCategory", () => {
|
||
it("renames a seeded category (allowed)", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 1,
|
||
key: "cash",
|
||
i18n_key: "balance.category.cash",
|
||
kind: "simple",
|
||
sort_order: 10,
|
||
is_active: 1,
|
||
is_seed: 1,
|
||
},
|
||
]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await updateBalanceCategory(1, { i18n_key: "balance.category.cash_renamed" });
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[0]).toBe("balance.category.cash_renamed");
|
||
});
|
||
|
||
it("rejects update on missing category", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await expect(updateBalanceCategory(999, { sort_order: 5 })).rejects.toMatchObject({
|
||
code: "category_not_found",
|
||
});
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Accounts
|
||
// -----------------------------------------------------------------------------
|
||
|
||
describe("listBalanceAccounts", () => {
|
||
it("excludes archived accounts by default", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await listBalanceAccounts();
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("a.is_active = 1");
|
||
expect(sql).toContain("a.archived_at IS NULL");
|
||
});
|
||
|
||
it("includes archived accounts when requested", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await listBalanceAccounts({ includeArchived: true });
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).not.toContain("archived_at IS NULL");
|
||
});
|
||
});
|
||
|
||
describe("createBalanceAccount", () => {
|
||
it("rejects empty name", async () => {
|
||
await expect(
|
||
createBalanceAccount({ balance_category_id: 1, name: " " })
|
||
).rejects.toMatchObject({ code: "name_required" });
|
||
});
|
||
|
||
it("rejects non-CAD currency at the MVP", async () => {
|
||
await expect(
|
||
createBalanceAccount({
|
||
balance_category_id: 1,
|
||
name: "USD account",
|
||
currency: "USD",
|
||
})
|
||
).rejects.toMatchObject({ code: "currency_unsupported" });
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects when the category does not exist", async () => {
|
||
mockSelect.mockResolvedValueOnce([]); // getBalanceCategory returns null
|
||
await expect(
|
||
createBalanceAccount({ balance_category_id: 999, name: "Mystery" })
|
||
).rejects.toMatchObject({ code: "category_not_found" });
|
||
});
|
||
|
||
it("inserts with default CAD currency", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 1,
|
||
key: "cash",
|
||
i18n_key: "balance.category.cash",
|
||
kind: "simple",
|
||
sort_order: 10,
|
||
is_active: 1,
|
||
is_seed: 1,
|
||
},
|
||
]);
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 7, rowsAffected: 1 });
|
||
const id = await createBalanceAccount({
|
||
balance_category_id: 1,
|
||
name: "Encaisse Wealthsimple",
|
||
});
|
||
expect(id).toBe(7);
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params).toEqual([1, "Encaisse Wealthsimple", null, "CAD", null]);
|
||
});
|
||
});
|
||
|
||
describe("updateBalanceAccount", () => {
|
||
it("rejects when account does not exist", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await expect(updateBalanceAccount(42, { name: "x" })).rejects.toMatchObject({
|
||
code: "account_not_found",
|
||
});
|
||
});
|
||
|
||
it("normalizes empty symbol to null", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 7,
|
||
balance_category_id: 1,
|
||
name: "Encaisse",
|
||
symbol: "OLD",
|
||
currency: "CAD",
|
||
notes: null,
|
||
is_active: 1,
|
||
archived_at: null,
|
||
created_at: "",
|
||
updated_at: "",
|
||
},
|
||
]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await updateBalanceAccount(7, { symbol: " " });
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[2]).toBeNull(); // symbol
|
||
});
|
||
});
|
||
|
||
describe("archiveBalanceAccount / unarchiveBalanceAccount", () => {
|
||
it("archives an existing account", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 7,
|
||
balance_category_id: 1,
|
||
name: "Encaisse",
|
||
symbol: null,
|
||
currency: "CAD",
|
||
notes: null,
|
||
is_active: 1,
|
||
archived_at: null,
|
||
created_at: "",
|
||
updated_at: "",
|
||
},
|
||
]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await archiveBalanceAccount(7);
|
||
const sql = mockExecute.mock.calls[0][0] as string;
|
||
expect(sql).toContain("archived_at = CURRENT_TIMESTAMP");
|
||
expect(sql).toContain("is_active = 0");
|
||
});
|
||
|
||
it("unarchives an existing account", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{
|
||
id: 7,
|
||
balance_category_id: 1,
|
||
name: "Encaisse",
|
||
symbol: null,
|
||
currency: "CAD",
|
||
notes: null,
|
||
is_active: 0,
|
||
archived_at: "2026-04-25 10:00:00",
|
||
created_at: "",
|
||
updated_at: "",
|
||
},
|
||
]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await unarchiveBalanceAccount(7);
|
||
const sql = mockExecute.mock.calls[0][0] as string;
|
||
expect(sql).toContain("archived_at = NULL");
|
||
expect(sql).toContain("is_active = 1");
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Snapshots + lines (Issue #146 / Bilan #1b — simple kind only)
|
||
// -----------------------------------------------------------------------------
|
||
|
||
const FAKE_SNAPSHOT = {
|
||
id: 5,
|
||
snapshot_date: "2026-04-15",
|
||
notes: null,
|
||
created_at: "",
|
||
updated_at: "",
|
||
};
|
||
|
||
describe("listSnapshots", () => {
|
||
it("orders by snapshot_date DESC", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await listSnapshots();
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("FROM balance_snapshots");
|
||
expect(sql).toContain("ORDER BY snapshot_date DESC");
|
||
});
|
||
});
|
||
|
||
describe("getSnapshotByDate", () => {
|
||
it("rejects empty / invalid dates with snapshot_date_required", async () => {
|
||
await expect(getSnapshotByDate("")).rejects.toMatchObject({
|
||
code: "snapshot_date_required",
|
||
});
|
||
await expect(getSnapshotByDate("2026/04/15")).rejects.toMatchObject({
|
||
code: "snapshot_date_required",
|
||
});
|
||
expect(mockSelect).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns the snapshot row when found", async () => {
|
||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||
const got = await getSnapshotByDate("2026-04-15");
|
||
expect(got).toEqual(FAKE_SNAPSHOT);
|
||
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-04-15"]);
|
||
});
|
||
|
||
it("returns null when no row matches", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
expect(await getSnapshotByDate("2026-04-15")).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("createSnapshot", () => {
|
||
it("rejects an invalid date", async () => {
|
||
await expect(
|
||
createSnapshot({ snapshot_date: " " })
|
||
).rejects.toMatchObject({ code: "snapshot_date_required" });
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects a duplicate snapshot date with snapshot_date_taken", async () => {
|
||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); // existing
|
||
await expect(
|
||
createSnapshot({ snapshot_date: "2026-04-15" })
|
||
).rejects.toMatchObject({ code: "snapshot_date_taken" });
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("inserts a new snapshot and returns its id", async () => {
|
||
mockSelect.mockResolvedValueOnce([]); // no existing
|
||
mockExecute.mockResolvedValueOnce({ lastInsertId: 12, rowsAffected: 1 });
|
||
const id = await createSnapshot({
|
||
snapshot_date: "2026-04-25",
|
||
notes: " monthly check ",
|
||
});
|
||
expect(id).toBe(12);
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params).toEqual(["2026-04-25", "monthly check"]);
|
||
});
|
||
});
|
||
|
||
describe("updateSnapshot", () => {
|
||
it("rejects when snapshot does not exist", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await expect(
|
||
updateSnapshot(999, { notes: "x" })
|
||
).rejects.toMatchObject({ code: "snapshot_not_found" });
|
||
});
|
||
|
||
it("normalizes empty notes to null", async () => {
|
||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await updateSnapshot(5, { notes: " " });
|
||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||
expect(params[0]).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("deleteSnapshot", () => {
|
||
it("rejects when snapshot does not exist", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await expect(deleteSnapshot(999)).rejects.toMatchObject({
|
||
code: "snapshot_not_found",
|
||
});
|
||
});
|
||
|
||
it("deletes when found (lines cascade via FK)", async () => {
|
||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
|
||
await deleteSnapshot(5);
|
||
expect(mockExecute).toHaveBeenCalledWith(
|
||
"DELETE FROM balance_snapshots WHERE id = $1",
|
||
[5]
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("listLinesBySnapshot", () => {
|
||
it("orders by id and filters by snapshot_id", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await listLinesBySnapshot(5);
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("FROM balance_snapshot_lines");
|
||
expect(sql).toContain("WHERE snapshot_id = $1");
|
||
expect(sql).toContain("ORDER BY id");
|
||
expect(mockSelect.mock.calls[0][1]).toEqual([5]);
|
||
});
|
||
});
|
||
|
||
describe("upsertSnapshotLines (simple kind)", () => {
|
||
it("rejects when the parent snapshot is missing", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
await expect(
|
||
upsertSnapshotLines(99, [{ account_id: 1, value: 1000 }])
|
||
).rejects.toMatchObject({ code: "snapshot_not_found" });
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects non-finite values with snapshot_value_invalid", async () => {
|
||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||
await expect(
|
||
upsertSnapshotLines(5, [
|
||
{ account_id: 1, value: 1000 },
|
||
// @ts-expect-error testing runtime guard
|
||
{ account_id: 2, value: "not a number" },
|
||
])
|
||
).rejects.toMatchObject({ code: "snapshot_value_invalid" });
|
||
// Validation happens up-front, before any mutation
|
||
expect(mockExecute).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects NaN and Infinity", async () => {
|
||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||
await expect(
|
||
upsertSnapshotLines(5, [{ account_id: 1, value: NaN }])
|
||
).rejects.toMatchObject({ code: "snapshot_value_invalid" });
|
||
|
||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||
await expect(
|
||
upsertSnapshotLines(5, [{ account_id: 1, value: Infinity }])
|
||
).rejects.toMatchObject({ code: "snapshot_value_invalid" });
|
||
});
|
||
|
||
it("clears existing lines, inserts each line with NULL quantity/unit_price, and bumps updated_at", async () => {
|
||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||
mockExecute
|
||
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
|
||
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1
|
||
.mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2
|
||
.mockResolvedValueOnce({ rowsAffected: 1 }); // update updated_at
|
||
await upsertSnapshotLines(5, [
|
||
{ account_id: 1, value: 1234.56 },
|
||
{ account_id: 2, value: 0 },
|
||
]);
|
||
// 1st call = DELETE
|
||
expect(mockExecute.mock.calls[0][0]).toContain(
|
||
"DELETE FROM balance_snapshot_lines"
|
||
);
|
||
// Inserts use literal NULL for quantity/unit_price (simple kind invariant)
|
||
const insertSql = mockExecute.mock.calls[1][0] as string;
|
||
expect(insertSql).toContain("INSERT INTO balance_snapshot_lines");
|
||
expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/);
|
||
expect(insertSql).toContain("'manual'");
|
||
// First insert params
|
||
expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1234.56]);
|
||
// Second insert params (zero is allowed)
|
||
expect(mockExecute.mock.calls[2][1]).toEqual([5, 2, 0]);
|
||
// Final call = UPDATE updated_at on parent snapshot
|
||
expect(mockExecute.mock.calls[3][0]).toContain(
|
||
"UPDATE balance_snapshots"
|
||
);
|
||
expect(mockExecute.mock.calls[3][0]).toContain("updated_at");
|
||
});
|
||
|
||
it("clears all lines when called with an empty array", async () => {
|
||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||
mockExecute
|
||
.mockResolvedValueOnce({ rowsAffected: 3 }) // delete only
|
||
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at
|
||
await upsertSnapshotLines(5, []);
|
||
// Only DELETE + UPDATE updated_at — no INSERTs
|
||
expect(mockExecute).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|
||
|
||
describe("getPreviousSnapshot", () => {
|
||
it("returns the most recent snapshot strictly before referenceDate", async () => {
|
||
mockSelect.mockResolvedValueOnce([
|
||
{ ...FAKE_SNAPSHOT, snapshot_date: "2026-03-15" },
|
||
]);
|
||
const got = await getPreviousSnapshot("2026-04-15");
|
||
expect(got?.snapshot_date).toBe("2026-03-15");
|
||
const sql = mockSelect.mock.calls[0][0] as string;
|
||
expect(sql).toContain("snapshot_date < $1");
|
||
expect(sql).toContain("ORDER BY snapshot_date DESC");
|
||
expect(sql).toContain("LIMIT 1");
|
||
});
|
||
|
||
it("returns null when no earlier snapshot exists", async () => {
|
||
mockSelect.mockResolvedValueOnce([]);
|
||
expect(await getPreviousSnapshot("2026-04-15")).toBeNull();
|
||
});
|
||
|
||
it("rejects an invalid reference date", async () => {
|
||
await expect(getPreviousSnapshot("nope")).rejects.toMatchObject({
|
||
code: "snapshot_date_required",
|
||
});
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 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]);
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 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;
|
||
expect(sql).toContain("MIN(s.snapshot_date)");
|
||
expect(sql).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/);
|
||
});
|
||
});
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 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);
|
||
});
|
||
});
|