test(balance): cross-cutting integration tests (#144) #152

Merged
maximus merged 4 commits from issue-144-bilan-6 into main 2026-04-26 13:25:38 +00:00
Showing only changes of commit 9adfb85d84 - Show all commits

View file

@ -0,0 +1,574 @@
/**
* Integration tests for the Bilan (balance sheet) feature Issue #144.
*
* Cross-cutting tests that exercise the *whole* TS surface in one go:
*
* account priced category priced snapshot linked transfer return
*
* Like `category-migration.test.ts` we cannot spin up real `tauri-plugin-sql`
* (the bridge only lives inside the Tauri WebView). Instead we drive every
* service against an in-memory FakeDb that:
* - records every executed SQL,
* - returns hand-tuned `select` results to mimic the real schema,
* - simulates `lastInsertId` / `rowsAffected` for INSERT/DELETE.
*
* The Tauri `invoke` is mocked `computeAccountReturn` lives on the Rust
* side (`compute_account_return`), so we assert the request payload and
* have the mock return a stable `AccountReturn` shape. The Rust math itself
* is covered by `return_calculator.rs`'s `#[cfg(test)] mod tests`.
*
* Scope (from spec-plan-bilan.md, Issue #144):
* 1. End-to-end happy path
* 2. Currency-lock (CHECK `currency = 'CAD'`) at the service level
* 3. Migration v9 on a seeded DB covered in Rust (lib.rs `mod tests`)
* 4. TransactionsPage non-regression for the inlined transfer icon
* 5. Coverage best-effort (deferred see decisions-log.md)
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({
getDb: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("../services/profileService", () => ({
loadProfiles: vi.fn(),
}));
import { getDb } from "../services/db";
import { invoke } from "@tauri-apps/api/core";
import { loadProfiles } from "../services/profileService";
import {
createBalanceCategory,
createBalanceAccount,
listBalanceAccounts,
createSnapshot,
upsertSnapshotLines,
listLinesBySnapshot,
linkTransfer,
unlinkTransfer,
listAccountTransfers,
computeAccountReturn,
BalanceServiceError,
PRICED_VALUE_TOLERANCE,
} from "../services/balance.service";
// ---------------------------------------------------------------------------
// FakeDb harness: scripted select results, recorded execute calls.
// ---------------------------------------------------------------------------
interface FakeDb {
calls: Array<{ sql: string; params?: unknown[] }>;
selectQueue: Array<unknown[]>;
executeQueue: Array<{ lastInsertId?: number; rowsAffected?: number }>;
select: ReturnType<typeof vi.fn>;
execute: ReturnType<typeof vi.fn>;
}
function makeFakeDb(): FakeDb {
const db: FakeDb = {
calls: [],
selectQueue: [],
executeQueue: [],
select: vi.fn(),
execute: vi.fn(),
};
db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
db.calls.push({ sql, params });
if (db.selectQueue.length === 0) {
throw new Error(`Unscripted SELECT (no queued result): ${sql}`);
}
return db.selectQueue.shift();
});
db.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
db.calls.push({ sql, params });
if (db.executeQueue.length === 0) {
// Default: 1 affected row, monotonically increasing lastInsertId
return { rowsAffected: 1, lastInsertId: db.calls.length };
}
return db.executeQueue.shift();
});
return db;
}
let fake: FakeDb;
beforeEach(() => {
fake = makeFakeDb();
vi.mocked(getDb).mockResolvedValue(
{ select: fake.select, execute: fake.execute } as never
);
vi.mocked(invoke).mockReset();
vi.mocked(loadProfiles).mockReset();
});
// Helper: queue a sequence of SELECT results in FIFO order.
function queueSelects(...rows: unknown[][]) {
for (const r of rows) fake.selectQueue.push(r);
}
// Helper: queue a sequence of EXECUTE results in FIFO order.
function queueExecutes(
...results: Array<{ lastInsertId?: number; rowsAffected?: number }>
) {
for (const r of results) fake.executeQueue.push(r);
}
// ---------------------------------------------------------------------------
// 1. End-to-end happy path
// ---------------------------------------------------------------------------
//
// Walks the full Bilan flow as if the user just installed the app:
// 1. Create a custom priced category ("etf-prov")
// 2. Create an account on that category with a stock symbol
// 3. Reload the joined accounts list and confirm the account is there
// 4. Create a snapshot dated today
// 5. Save a priced line for the new account (qty * price = value)
// 6. Read the lines back and confirm what was persisted
// 7. Link a transaction to the account as a +CAD deposit
// 8. Compute the account's return → mock returns a stable shape, we
// assert the wiring uses the active profile's db_filename and forwards
// every parameter as ISO YYYY-MM-DD.
//
// Each step is asserted at the service-call level (params + queued SQL),
// then we run cross-step sanity checks.
describe("integration — Bilan end-to-end happy path", () => {
it("walks account → priced category → snapshot → transfer → return cleanly", async () => {
// ---- 1. Create a custom priced category ----
queueExecutes({ lastInsertId: 100 });
const categoryId = await createBalanceCategory({
key: "etf-prov",
i18n_key: "balance.category.etf_prov",
kind: "priced",
sort_order: 80,
});
expect(categoryId).toBe(100);
// ---- 2. Create the account on that category ----
// Service first SELECTs the category to validate it exists, then
// INSERTs the account.
queueSelects([
{
id: 100,
key: "etf-prov",
i18n_key: "balance.category.etf_prov",
kind: "priced",
sort_order: 80,
is_active: 1,
is_seed: 0,
},
]);
queueExecutes({ lastInsertId: 7 });
const accountId = await createBalanceAccount({
balance_category_id: categoryId,
name: "VFV (Wealthsimple)",
symbol: "VFV.TO",
});
expect(accountId).toBe(7);
// ---- 3. listBalanceAccounts: account joined with category ----
queueSelects([
{
id: 7,
balance_category_id: 100,
name: "VFV (Wealthsimple)",
symbol: "VFV.TO",
currency: "CAD",
notes: null,
is_active: 1,
archived_at: null,
created_at: "",
updated_at: "",
category_key: "etf-prov",
category_i18n_key: "balance.category.etf_prov",
category_kind: "priced",
},
]);
const accounts = await listBalanceAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0].category_kind).toBe("priced");
expect(accounts[0].symbol).toBe("VFV.TO");
// ---- 4. Create a snapshot dated 2026-04-25 ----
// createSnapshot first SELECTs by date (must be empty) then INSERTs.
queueSelects([]); // no existing snapshot
queueExecutes({ lastInsertId: 50 });
const snapshotId = await createSnapshot({ snapshot_date: "2026-04-25" });
expect(snapshotId).toBe(50);
// ---- 5. Save a priced line: 10 shares × $200 = $2000 ----
// upsertSnapshotLines: SELECT snapshot, then DELETE existing lines, then
// one INSERT per line, then UPDATE updated_at.
queueSelects([
{
id: 50,
snapshot_date: "2026-04-25",
notes: null,
created_at: "",
updated_at: "",
},
]);
queueExecutes(
{ rowsAffected: 0 }, // delete (no prior lines)
{ lastInsertId: 200 }, // insert priced line
{ rowsAffected: 1 } // bump updated_at
);
await upsertSnapshotLines(50, [
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 200,
value: 2000,
},
]);
// The 2nd execute call should be the INSERT with the priced placeholders.
const insertCall = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_snapshot_lines")
);
expect(insertCall).toBeDefined();
expect(insertCall!.params).toEqual([50, 7, 10, 200, 2000]);
// ---- 6. Read the lines back ----
queueSelects([
{
id: 200,
snapshot_id: 50,
account_id: 7,
quantity: 10,
unit_price: 200,
value: 2000,
price_source: "manual",
price_fetched_at: null,
created_at: "",
updated_at: "",
},
]);
const lines = await listLinesBySnapshot(50);
expect(lines).toHaveLength(1);
expect(lines[0].value).toBe(2000);
expect(lines[0].quantity).toBe(10);
expect(lines[0].unit_price).toBe(200);
// ---- 7. Link a transaction (id=42) as a +CAD deposit (in) ----
// linkTransfer: SELECT existing duplicate (none), then INSERT.
queueSelects([]); // no existing duplicate
queueExecutes({ lastInsertId: 9 });
const transferId = await linkTransfer(7, 42, "in", "monthly contribution");
expect(transferId).toBe(9);
const linkCall = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_account_transfers")
);
expect(linkCall).toBeDefined();
expect(linkCall!.params).toEqual([7, 42, "in", "monthly contribution"]);
// ---- 8. Compute the account return ----
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "max",
profiles: [
{
id: "max",
name: "Max",
color: "#3b82f6",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
const fakeReturn = {
value_start: 1500,
value_end: 2000,
net_contributions: 400,
return_pct: 0.0667, // (2000 - 1500 - 400) / (1500 + W*400) ≈ 6.67%
annualized_pct: 0.28,
is_partial: false,
has_no_transfers_warning: false,
};
vi.mocked(invoke).mockResolvedValueOnce(fakeReturn);
const ret = await computeAccountReturn(7, "2026-01-01", "2026-04-25");
expect(ret).toEqual(fakeReturn);
// Wiring check: profile resolution + ISO date forwarding.
expect(invoke).toHaveBeenCalledWith("compute_account_return", {
dbFilename: "max.db",
accountId: 7,
periodStart: "2026-01-01",
periodEnd: "2026-04-25",
});
// ---- Cross-step sanity: every coherent value matches expectations.
// The end snapshot value (2000) matches what we saved.
expect(ret.value_end).toBe(2000);
// The reported return is a finite, non-zero number on a non-trivial period.
expect(ret.return_pct).not.toBeNull();
expect(Number.isFinite(ret.return_pct!)).toBe(true);
// Net contributions match the 1 linked transfer (+400 in).
expect(ret.net_contributions).toBeGreaterThan(0);
});
it("supports unlink as the inverse of link", async () => {
queueExecutes({ rowsAffected: 1 });
await expect(unlinkTransfer(7, 42)).resolves.toBeUndefined();
const unlinkCall = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("DELETE FROM balance_account_transfers")
);
expect(unlinkCall!.params).toEqual([7, 42]);
});
it("listAccountTransfers reads back what link wrote (joined view)", async () => {
queueSelects([
{
id: 9,
account_id: 7,
transaction_id: 42,
direction: "in",
notes: "monthly contribution",
created_at: "2026-04-25 10:00:00",
transaction_date: "2026-04-15",
transaction_description: "Wealthsimple contrib",
transaction_amount: -400,
account_name: "VFV (Wealthsimple)",
},
]);
const links = await listAccountTransfers(7);
expect(links).toHaveLength(1);
expect(links[0].direction).toBe("in");
expect(links[0].account_name).toBe("VFV (Wealthsimple)");
});
});
// ---------------------------------------------------------------------------
// 2. Currency lock — CAD only at the MVP
// ---------------------------------------------------------------------------
//
// The MVP locks accounts to CAD: the SQL CHECK is `currency = 'CAD'` and the
// service rejects any other value with a typed `currency_unsupported` before
// the SQL even fires. Asserts:
// - USD is rejected with the typed code,
// - the rejection happens BEFORE any SELECT/EXECUTE on the DB,
// - the default (no `currency` field) flows through and lands as 'CAD',
// - the SQL CHECK side is covered in Rust (lib.rs `migration_v9_*` tests).
describe("integration — currency lock (CAD only)", () => {
it("rejects USD at the service level with a typed error", async () => {
await expect(
createBalanceAccount({
balance_category_id: 1,
name: "USD account",
currency: "USD",
})
).rejects.toBeInstanceOf(BalanceServiceError);
try {
await createBalanceAccount({
balance_category_id: 1,
name: "USD account",
currency: "USD",
});
} catch (e) {
expect((e as BalanceServiceError).code).toBe("currency_unsupported");
}
// CRITICAL: the rejection must happen up-front — no DB hit.
expect(fake.calls.length).toBe(0);
});
it("accepts the default and persists 'CAD' explicitly", async () => {
queueSelects([
{
id: 1,
key: "cash",
i18n_key: "balance.category.cash",
kind: "simple",
sort_order: 10,
is_active: 1,
is_seed: 1,
},
]);
queueExecutes({ lastInsertId: 5 });
await createBalanceAccount({
balance_category_id: 1,
name: "Encaisse",
});
const insertCall = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_accounts")
);
expect(insertCall).toBeDefined();
// [category_id, name, symbol, currency, notes]
expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null]);
});
it("rejects EUR / GBP / JPY too — not a CAD-only typo allowlist", async () => {
for (const ccy of ["EUR", "GBP", "JPY", "AUD"]) {
await expect(
createBalanceAccount({
balance_category_id: 1,
name: `Mystery ${ccy}`,
currency: ccy,
})
).rejects.toMatchObject({ code: "currency_unsupported" });
}
expect(fake.calls.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// 3. Priced-kind invariant — coherence of the qty × price = value chain
// ---------------------------------------------------------------------------
//
// Tied to the priced-kind path, but at the integration layer: a snapshot
// saved with a drifting (qty * price ≠ value) line must be rejected before
// any DB mutation, so the SQL CHECK never has the chance to fire and we
// don't accidentally clear pre-existing lines.
describe("integration — priced invariant rejects out-of-tolerance saves", () => {
it("does not run DELETE when one line is bad", async () => {
queueSelects([
{
id: 50,
snapshot_date: "2026-04-25",
notes: null,
created_at: "",
updated_at: "",
},
]);
await expect(
upsertSnapshotLines(50, [
{ account_id: 1, value: 1000 },
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 25,
// expected ≈ 250, way beyond ε
value: 999,
},
])
).rejects.toMatchObject({ code: "snapshot_priced_value_mismatch" });
// Critical safety: the DELETE must not have fired — otherwise the user
// would lose all existing lines on a partial save.
const deletes = fake.calls.filter(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("DELETE FROM balance_snapshot_lines")
);
expect(deletes).toHaveLength(0);
});
it("accepts a drift just within the tolerance", async () => {
queueSelects([
{
id: 50,
snapshot_date: "2026-04-25",
notes: null,
created_at: "",
updated_at: "",
},
]);
queueExecutes(
{ rowsAffected: 0 },
{ lastInsertId: 1 },
{ rowsAffected: 1 }
);
// 12.34 * 1.07 = 13.2038... — drift well within ε = 0.01
const drift = PRICED_VALUE_TOLERANCE * 0.5;
await expect(
upsertSnapshotLines(50, [
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 10,
value: 100 + drift,
},
])
).resolves.toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 4. Returns: malformed period dates rejected before the Tauri invoke
// ---------------------------------------------------------------------------
describe("integration — computeAccountReturn validates dates client-side", () => {
it("rejects non-ISO dates without invoking the Rust command", async () => {
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "max",
profiles: [
{
id: "max",
name: "Max",
color: "#000",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
await expect(
computeAccountReturn(7, "01/01/2026", "2026-04-25")
).rejects.toBeInstanceOf(BalanceServiceError);
// The Tauri side must NOT have been hit — fail-fast on bad dates.
expect(invoke).not.toHaveBeenCalled();
});
it("rejects when the active profile cannot be resolved", async () => {
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "ghost",
profiles: [],
});
await expect(
computeAccountReturn(7, "2026-01-01", "2026-04-25")
).rejects.toMatchObject({ code: "transfer_active_profile_unknown" });
expect(invoke).not.toHaveBeenCalled();
});
it("forwards a partial-period AccountReturn shape unchanged", async () => {
// When `is_partial = true` (no V_start), the Rust side returns a payload
// with explicit nulls. The TS shim must not coerce them away.
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "max",
profiles: [
{
id: "max",
name: "Max",
color: "#000",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
const partial = {
value_start: null,
value_end: 1500,
net_contributions: 200,
return_pct: null,
annualized_pct: null,
is_partial: true,
has_no_transfers_warning: false,
};
vi.mocked(invoke).mockResolvedValueOnce(partial);
const out = await computeAccountReturn(7, "2026-01-01", "2026-04-25");
expect(out).toEqual(partial);
expect(out.is_partial).toBe(true);
expect(out.value_start).toBeNull();
});
});