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
575 lines
18 KiB
TypeScript
575 lines
18 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|