Simpl-Resultat/src/__integration__/balance-flow.test.ts
le king fu 6c82501d6d test(balance): integration + regression coverage for per-security detail (#217)
Cross-cutting Étape 2 coverage proving the per-security detail feature does not
regress aggregations, returns, date-move, or deletion.

Rust (lib.rs, +3 real-SQLite tests):
- regression_detailed_account_totals_equal_simple_account_totals: a detailed
  account whose holdings sum to V yields byte-identical date/category/vehicle
  SUM() totals to a simple account worth V (frozen golden numbers 1300/300/...).
  Proven where SUM() GROUP BY actually runs, unlike the TS mock harness.
- migration_v14_to_v16_on_populated_db_preserves_integrity_and_totals: realistic
  multi-type DB (simple + convertible priced w/ 2-snapshot history + non-
  convertible priced) — totals byte-identical before/after v16, values
  preserved, qty/price NULLed only on converted lines, one shared security,
  non-convertible line fully intact.
- regression_snapshot_delete_cascades_to_holdings: two-hop CASCADE
  (snapshot -> line -> holdings); security survives (RESTRICT).

TS (balance-flow.test.ts, +6 integration tests):
- detailed snapshot save end-to-end (aggregated line + securities + holdings in
  one BEGIN/COMMIT); aggregated line value = rounded-cent SUM(holdings).
- rollback on injected holding INSERT failure (no partial line/holdings).
- snapshot date-move with a detailed account: line + holdings move together;
  collision rolls both back (#200).
- golden-value invariant: detailed line stores the same value a simple account
  would, feeding getSnapshotTotalsByDate identically.
- deleteSnapshot emits exactly one parent DELETE (FK cascades the rest).

No production code changed — pure test PR. Builds on the v16 tests (#211) and
the detailed-save/securities unit tests (#212) without duplicating them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:03:58 -04:00

969 lines
34 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,
saveSnapshotAtomic,
deleteSnapshot,
getSnapshotTotalsByDate,
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, vehicle_type] (#202)
expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null, 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();
});
});
// ---------------------------------------------------------------------------
// 5. Per-security detail (Étape 2, #217) — detailed snapshot save, rollback,
// date-move with holdings, and the golden-value aggregation invariant.
// ---------------------------------------------------------------------------
//
// The unit-level holdings/securities mechanics are covered exhaustively in
// balance.service.test.ts (#212). Here we assert the END-TO-END service
// behaviour at the integration layer and — critically — the REGRESSION
// invariant that makes detailed accounts safe: a detailed account whose
// holdings sum to V writes an aggregated line worth exactly V, so every
// SUM(value) aggregator sees it identically to a simple account worth V.
//
// (The real-SQLite proof of that equality lives in lib.rs
// `regression_detailed_account_totals_equal_simple_account_totals`, where
// SUM() actually runs. Here we prove the TS half: the stored line value is
// the rounded-cent SUM, which is the only number the aggregator reads.)
describe("integration — detailed snapshot save end-to-end (#217)", () => {
it("writes the aggregated line + securities + holdings in ONE BEGIN/COMMIT", async () => {
// New snapshot, one detailed account: AAPL (2×50=100) + MSFT (1×200=200)
// ⇒ aggregated line value 300, two holdings, all atomic.
queueSelects([]); // dup-check: date free
// findOrCreateSecurity AAPL then MSFT lookups (post-upsert SELECT).
queueSelects([
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
queueSelects([
{ id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
queueExecutes(
{ rowsAffected: 0 }, // BEGIN
{ lastInsertId: 42, rowsAffected: 1 }, // INSERT snapshot
{ rowsAffected: 0 }, // DELETE lines
{ lastInsertId: 500, rowsAffected: 1 }, // INSERT aggregated line
{ rowsAffected: 0 }, // DELETE holdings for line 500
{ rowsAffected: 1 }, // UPSERT security AAPL
{ rowsAffected: 1 }, // INSERT holding AAPL
{ rowsAffected: 1 }, // UPSERT security MSFT
{ rowsAffected: 1 }, // INSERT holding MSFT
{ rowsAffected: 1 }, // UPDATE updated_at
{ rowsAffected: 0 } // COMMIT
);
const res = await saveSnapshotAtomic({
existingSnapshotId: null,
snapshot_date: "2026-05-30",
lines: [
{
account_id: 7,
value: 300,
holdings: [
{ symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100, book_cost: 80 },
{ symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200, book_cost: 150 },
],
},
],
});
expect(res.snapshotId).toBe(42);
const execCalls = fake.calls.filter(
(c) =>
typeof c.sql === "string" &&
!c.sql.includes("SELECT") // executes only
);
// First write is BEGIN, last is COMMIT, no ROLLBACK anywhere.
expect(execCalls[0].sql).toBe("BEGIN");
expect(execCalls[execCalls.length - 1].sql).toBe("COMMIT");
expect(execCalls.some((c) => c.sql === "ROLLBACK")).toBe(false);
// CRITICAL invariant: the aggregated line is stored with NULL qty/price and
// value = rounded-cent SUM(holdings) = 300 — the exact number the
// aggregators read. This is the TS-side golden-value guarantee.
const lineInsert = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_snapshot_lines")
);
expect(lineInsert!.params).toEqual([42, 7, 300]);
// Both holdings reference the captured line id (500).
const holdingInserts = fake.calls.filter(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_snapshot_holdings")
);
expect(holdingInserts).toHaveLength(2);
expect(holdingInserts[0].params?.[0]).toBe(500);
expect(holdingInserts[1].params?.[0]).toBe(500);
});
it("ROLLS BACK the whole save when a holding INSERT fails (no partial line/holdings)", async () => {
queueSelects([]); // dup-check
queueSelects([
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
// BEGIN, INSERT snapshot, DELETE lines, INSERT line, DELETE holdings,
// UPSERT security, then the holding INSERT REJECTS, then ROLLBACK.
fake.executeQueue.push({ rowsAffected: 0 }); // BEGIN
fake.executeQueue.push({ lastInsertId: 42, rowsAffected: 1 }); // INSERT snapshot
fake.executeQueue.push({ rowsAffected: 0 }); // DELETE lines
fake.executeQueue.push({ lastInsertId: 500, rowsAffected: 1 }); // INSERT line
fake.executeQueue.push({ rowsAffected: 0 }); // DELETE holdings
fake.executeQueue.push({ rowsAffected: 1 }); // UPSERT security
// Make the NEXT execute (the holding INSERT) reject, then allow ROLLBACK.
let failed = false;
fake.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
fake.calls.push({ sql, params });
if (!failed && sql.includes("INSERT INTO balance_snapshot_holdings")) {
failed = true;
throw new Error("holding FK violation");
}
if (fake.executeQueue.length === 0) {
return { rowsAffected: 1, lastInsertId: fake.calls.length };
}
return fake.executeQueue.shift()!;
});
await expect(
saveSnapshotAtomic({
existingSnapshotId: null,
snapshot_date: "2026-05-30",
lines: [
{
account_id: 7,
value: 100,
holdings: [
{ symbol: "AAPL", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 },
],
},
],
})
).rejects.toThrow("holding FK violation");
// ROLLBACK was the last write; COMMIT never happened ⇒ no partial state.
const execCalls = fake.calls.filter(
(c) => typeof c.sql === "string" && !c.sql.includes("SELECT")
);
expect(execCalls[execCalls.length - 1].sql).toBe("ROLLBACK");
expect(execCalls.some((c) => c.sql === "COMMIT")).toBe(false);
});
it("moves a detailed snapshot's date — line AND holdings move together (#200)", async () => {
// Edit-mode move: the date UPDATE + the line/holdings rewrite happen in the
// SAME transaction, so the holdings follow their line to the new date.
queueSelects([]); // collision check: target date free
queueSelects([
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
queueExecutes(
{ rowsAffected: 0 }, // BEGIN
{ rowsAffected: 1 }, // UPDATE snapshot_date
{ rowsAffected: 0 }, // DELETE lines (cascades old holdings)
{ lastInsertId: 800, rowsAffected: 1 }, // INSERT aggregated line at new date
{ rowsAffected: 0 }, // DELETE holdings for line 800
{ rowsAffected: 1 }, // UPSERT security AAPL
{ rowsAffected: 1 }, // INSERT holding AAPL
{ rowsAffected: 1 }, // UPDATE updated_at
{ rowsAffected: 0 } // COMMIT
);
const res = await saveSnapshotAtomic({
existingSnapshotId: 5,
snapshot_date: "2026-04-15",
moveToDate: "2026-05-20",
lines: [
{
account_id: 7,
value: 100,
holdings: [
{ symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 100, value: 100, book_cost: 90 },
],
},
],
});
expect(res.snapshotId).toBe(5);
// The date UPDATE precedes the line rewrite (so a collision rolls back both).
const dateUpdate = fake.calls.find(
(c) => typeof c.sql === "string" && c.sql.includes("SET snapshot_date = $1")
);
expect(dateUpdate!.params).toEqual(["2026-05-20", 5]);
// The holding is re-inserted alongside the moved line (referencing line 800).
const holdingInsert = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_snapshot_holdings")
);
expect(holdingInsert).toBeDefined();
expect(holdingInsert!.params?.[0]).toBe(800);
// Whole thing commits; the move + holdings are one unit.
const execCalls = fake.calls.filter(
(c) => typeof c.sql === "string" && !c.sql.includes("SELECT")
);
expect(execCalls[execCalls.length - 1].sql).toBe("COMMIT");
expect(execCalls.some((c) => c.sql === "ROLLBACK")).toBe(false);
});
it("rolls back the move (date + holdings) when the target date collides (#200)", async () => {
queueSelects([{ id: 99 }]); // collision: another snapshot already at target
queueExecutes(
{ rowsAffected: 0 }, // BEGIN
{ rowsAffected: 0 } // ROLLBACK
);
await expect(
saveSnapshotAtomic({
existingSnapshotId: 5,
snapshot_date: "2026-04-15",
moveToDate: "2026-05-20",
lines: [
{
account_id: 7,
value: 100,
holdings: [
{ symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 100, value: 100 },
],
},
],
})
).rejects.toMatchObject({ code: "snapshot_date_exists" });
// No date UPDATE, no holding INSERT — the collision aborted before any write.
expect(
fake.calls.some(
(c) => typeof c.sql === "string" && c.sql.includes("SET snapshot_date")
)
).toBe(false);
expect(
fake.calls.some(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_snapshot_holdings")
)
).toBe(false);
const execCalls = fake.calls.filter(
(c) => typeof c.sql === "string" && !c.sql.includes("SELECT")
);
expect(execCalls[execCalls.length - 1].sql).toBe("ROLLBACK");
});
});
describe("integration — golden-value invariant: detailed line feeds aggregators like a simple line (#217)", () => {
// The regression contract: whatever the holdings are, the aggregated line
// value the service writes equals the value a simple account would carry —
// so getSnapshotTotalsByDate (which only ever reads l.value) is unchanged.
// We prove BOTH halves drive identical aggregator output by feeding the
// aggregator the canned total in each case and asserting equality.
it("a detailed account worth ΣV stores the same line value as a simple account worth V", async () => {
// --- Detailed path: holdings 100 + 200 ⇒ line value must be 300. ---
queueSelects([]); // dup-check
queueSelects([
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
queueSelects([
{ id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
queueExecutes(
{ rowsAffected: 0 }, // BEGIN
{ lastInsertId: 42, rowsAffected: 1 }, // INSERT snapshot
{ rowsAffected: 0 }, // DELETE lines
{ lastInsertId: 500, rowsAffected: 1 }, // INSERT aggregated line
{ rowsAffected: 0 }, // DELETE holdings
{ rowsAffected: 1 }, // UPSERT AAPL
{ rowsAffected: 1 }, // INSERT holding AAPL
{ rowsAffected: 1 }, // UPSERT MSFT
{ rowsAffected: 1 }, // INSERT holding MSFT
{ rowsAffected: 1 }, // UPDATE updated_at
{ rowsAffected: 0 } // COMMIT
);
await saveSnapshotAtomic({
existingSnapshotId: null,
snapshot_date: "2026-05-30",
lines: [
{
account_id: 7,
value: 300,
holdings: [
{ symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 },
{ symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200 },
],
},
],
});
const detailedLineInsert = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_snapshot_lines")
);
const detailedStoredValue = detailedLineInsert!.params![2];
expect(detailedStoredValue).toBe(300);
// --- Simple path: a simple account worth 300 stores the same value. ---
fake = makeFakeDbLocal();
vi.mocked(getDb).mockResolvedValue(
{ select: fake.select, execute: fake.execute } as never
);
fake.selectQueue.push([
{ id: 5, snapshot_date: "2026-05-30", notes: null, created_at: "", updated_at: "" },
]); // getSnapshotById
queueExecutes(
{ rowsAffected: 0 }, // BEGIN
{ rowsAffected: 0 }, // DELETE lines
{ lastInsertId: 700, rowsAffected: 1 }, // INSERT simple line
{ rowsAffected: 1 }, // UPDATE updated_at
{ rowsAffected: 0 } // COMMIT
);
await upsertSnapshotLines(5, [{ account_id: 7, value: 300 }]);
const simpleLineInsert = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_snapshot_lines")
);
const simpleStoredValue = simpleLineInsert!.params![2];
// The frozen golden number: BOTH paths persist value = 300.
expect(detailedStoredValue).toBe(simpleStoredValue);
expect(simpleStoredValue).toBe(300);
// And the aggregator (which reads only l.value) returns the same total in
// both worlds — proven by feeding it the canned per-date SUM each path
// produces. Detailed world:
fake = makeFakeDbLocal();
vi.mocked(getDb).mockResolvedValue(
{ select: fake.select, execute: fake.execute } as never
);
fake.selectQueue.push([{ snapshot_date: "2026-05-30", total: 300 }]);
const detailedTotals = await getSnapshotTotalsByDate();
// Simple world: identical canned SUM ⇒ identical aggregator output.
fake = makeFakeDbLocal();
vi.mocked(getDb).mockResolvedValue(
{ select: fake.select, execute: fake.execute } as never
);
fake.selectQueue.push([{ snapshot_date: "2026-05-30", total: 300 }]);
const simpleTotals = await getSnapshotTotalsByDate();
expect(detailedTotals).toEqual(simpleTotals);
expect(detailedTotals).toEqual([{ snapshot_date: "2026-05-30", total: 300 }]);
});
});
describe("integration — snapshot deletion cascades to holdings at the service boundary (#217)", () => {
it("deleteSnapshot issues the DELETE that the DB cascades to lines + holdings", async () => {
// The two-hop CASCADE is a DB-FK behaviour (proven in lib.rs
// regression_snapshot_delete_cascades_to_holdings). At the service layer we
// assert the single DELETE is emitted on the right row — the FK does the
// rest. Guard against a regression that would start manually deleting
// holdings (a sign the CASCADE was dropped) or skip the parent delete.
fake.selectQueue.push([
{ id: 50, snapshot_date: "2026-05-30", notes: null, created_at: "", updated_at: "" },
]); // getSnapshotById
fake.executeQueue.push({ rowsAffected: 1 });
await deleteSnapshot(50);
const deletes = fake.calls.filter(
(c) => typeof c.sql === "string" && c.sql.startsWith("DELETE")
);
// Exactly one DELETE — on balance_snapshots. No manual holdings/line wipes
// (the FK CASCADE handles those; a manual delete would be the regression).
expect(deletes).toHaveLength(1);
expect(deletes[0].sql).toContain("DELETE FROM balance_snapshots WHERE id = $1");
expect(deletes[0].params).toEqual([50]);
});
});
// A standalone FakeDb factory for the multi-DB golden-value test, which swaps
// the active db handle mid-test. Mirrors makeFakeDb but is reusable inline.
function makeFakeDbLocal(): 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) {
return { rowsAffected: 1, lastInsertId: db.calls.length };
}
return db.executeQueue.shift();
});
return db;
}