Simpl-Resultat/src/__integration__/balance-flow.test.ts
le king fu 3963f552ae
All checks were successful
PR Check / rust (push) Successful in 23m42s
PR Check / frontend (push) Successful in 2m26s
PR Check / rust (pull_request) Successful in 22m55s
PR Check / frontend (pull_request) Successful in 2m24s
feat(balance): add asset_type column to balance_categories
Priced balance categories now carry an explicit `asset_type`
('stock' | 'crypto') so PriceFetchControl can route to the right
provider without symbol heuristics. ETH = Ethan Allen NYSE AND
Ethereum crypto are no longer ambiguous.

Migration v10 adds a nullable column and backfills the two seeded
priced categories (key='stock','crypto'). Legacy custom priced rows
stay NULL until the user edits the category — SnapshotLineRow hides
the price-fetch button when asset_type is NULL on a priced row, so
manual entry remains available.

Service-side validation rejects priced creation without asset_type
('asset_type_required') and rejects values outside ('stock','crypto')
('asset_type_invalid'). Simple kind coerces asset_type to NULL.

The CategoryVariant of AccountForm shows the selector only when
kind=priced, requires it on submit, and resets it on kind switch.
i18n keys added under balance.category.assetType.* (FR + EN).

Tests:
- 4 new Rust migration tests in lib.rs (column add, seed backfill,
  legacy row stays NULL, CHECK rejects 'gold')
- 6 new vitest cases on createBalanceCategory + listBalanceAccounts
  asserts c.asset_type AS category_asset_type in the join
- balance-flow integration test updated to pass asset_type='stock'

No new test for SnapshotLineRow render guard — project lacks
@testing-library/react + jsdom; the guard is one boolean expression
covered by manual QA per autopilot decisions in PR #167.

Fixes #169
2026-04-28 19:54:04 -04:00

575 lines
18 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.

/**
* 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,
asset_type: "stock",
});
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();
});
});