Merge pull request 'test(balance): cross-cutting integration tests (#144)' (#152) from issue-144-bilan-6 into main
This commit is contained in:
commit
51a6cec8f1
5 changed files with 1020 additions and 0 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
- **Bilan — suite de tests d'intégration cross-cutting** (infrastructure de tests) : clôt la feature *Bilan* avec une couche de tests d'intégration qui exerce toute la surface TypeScript en un seul flux de bout en bout (compte → catégorie cotée → snapshot coté → transfert lié → rendement) et des assertions dédiées sur le verrou de devise (CAD seulement au MVP, refusé à la fois côté service et côté CHECK SQL), la sécurité de tolérance pour le type coté (un mauvais enregistrement ne doit PAS supprimer les lignes existantes), le câblage de `computeAccountReturn` (résolution du profil actif, transmission des dates ISO, conservation telle quelle d'une réponse de période partielle). Trois nouveaux tests Rust d'intégration appliquent la migration v9 par-dessus un schéma v1 seedé contenant déjà des transactions pour vérifier (1) aucune perte ni mutation de données, (2) le round-trip lier / délier sur de vraies `transaction_id`, (3) la chaîne FK RESTRICT (suppression d'une transaction liée bloquée, autorisée après détachement), (4) la cohabitation indépendante des espaces d'identifiants `categories.id` (v1) et `balance_categories.id` (v9). Un test de non-régression au niveau source sur `TransactionTable.tsx` verrouille le contrat de l'icône de transfert inlinée : prop optionnelle, court-circuit en chaînage optionnel, clés i18n, aria-label, layout partagé de la cellule description — pour que la page reste rendue à l'identique en l'absence de transferts liés. (#144)
|
||||||
- **Bilan — rendements Modified Dietz et liaison de transferts** (route `/balance`) : le rendement par compte arrive enfin. Nouveau module Rust `commands/return_calculator.rs` qui implémente la formule Modified Dietz `R = (V_fin − V_début − ΣCF_i) / (V_début + ΣW_i × CF_i)` avec pondération des apports à la précision du jour `W_i = (T − t_i) / T`, et annualisation `(1 + R)^(365/T) − 1`. Les cas limites — snapshot d'extrémité manquant, aucun flux taggé sur la période, compte créé en cours de période, vidé puis rechargé, période de durée nulle — sont surfacés via les flags explicites `is_partial` / `has_no_transfers_warning` pour que l'UI affiche un tiret + tooltip clair plutôt qu'un nombre incompréhensible. Nouvelle commande Tauri `compute_account_return(account_id, period_start, period_end)` qui exécute trois lectures SQL courtes contre la BD du profil actif (dernier snapshot ≤ début de période, dernier snapshot ≤ fin de période, transferts joints aux transactions filtrés sur la période) puis alimente le calculateur. Sept tests Rust co-localisés en TDD couvrent chaque cas avant l'implémentation. Le tableau des comptes sur `/balance` affiche désormais quatre colonnes supplémentaires côte à côte : 3M / 1A / Depuis création (Modified Dietz) plus une colonne *Non ajusté* qui calcule simplement `(V_fin − V_début) / V_début` pour qu'on voie d'un coup d'œil quelle part du rendement vient de la pondération des apports. Le menu d'actions de chaque ligne reçoit l'item *Lier transferts* qui ouvre une modal de sélection multiple avec filtres période / catégorie / recherche texte ; la modal propose automatiquement le sens (`in` pour les montants bancaires négatifs, `out` pour les positifs) et l'utilisateur peut inverser ligne par ligne avant de soumettre. Les transactions liées à un ou plusieurs comptes de bilan affichent maintenant une petite icône `Link2` à côté de la description dans la page *Transactions*, avec un tooltip listant les noms et sens des comptes. Les chemins de suppression en lot (par fichier importé et tout effacer) pré-vérifient l'existence d'un lien dans `balance_account_transfers` et surfacent l'erreur typée `TransactionLinkedToBalanceError` (« Cette transaction est liée au compte de bilan X — déliez-la avant de supprimer ») au lieu de laisser fuiter l'erreur SQLite brute. Le graphique d'évolution sur `/balance` superpose désormais des lignes verticales de référence à chaque date de transfert lié (vert pour `in`, rouge pour `out`). Nouvelles clés i18n sous `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
|
- **Bilan — rendements Modified Dietz et liaison de transferts** (route `/balance`) : le rendement par compte arrive enfin. Nouveau module Rust `commands/return_calculator.rs` qui implémente la formule Modified Dietz `R = (V_fin − V_début − ΣCF_i) / (V_début + ΣW_i × CF_i)` avec pondération des apports à la précision du jour `W_i = (T − t_i) / T`, et annualisation `(1 + R)^(365/T) − 1`. Les cas limites — snapshot d'extrémité manquant, aucun flux taggé sur la période, compte créé en cours de période, vidé puis rechargé, période de durée nulle — sont surfacés via les flags explicites `is_partial` / `has_no_transfers_warning` pour que l'UI affiche un tiret + tooltip clair plutôt qu'un nombre incompréhensible. Nouvelle commande Tauri `compute_account_return(account_id, period_start, period_end)` qui exécute trois lectures SQL courtes contre la BD du profil actif (dernier snapshot ≤ début de période, dernier snapshot ≤ fin de période, transferts joints aux transactions filtrés sur la période) puis alimente le calculateur. Sept tests Rust co-localisés en TDD couvrent chaque cas avant l'implémentation. Le tableau des comptes sur `/balance` affiche désormais quatre colonnes supplémentaires côte à côte : 3M / 1A / Depuis création (Modified Dietz) plus une colonne *Non ajusté* qui calcule simplement `(V_fin − V_début) / V_début` pour qu'on voie d'un coup d'œil quelle part du rendement vient de la pondération des apports. Le menu d'actions de chaque ligne reçoit l'item *Lier transferts* qui ouvre une modal de sélection multiple avec filtres période / catégorie / recherche texte ; la modal propose automatiquement le sens (`in` pour les montants bancaires négatifs, `out` pour les positifs) et l'utilisateur peut inverser ligne par ligne avant de soumettre. Les transactions liées à un ou plusieurs comptes de bilan affichent maintenant une petite icône `Link2` à côté de la description dans la page *Transactions*, avec un tooltip listant les noms et sens des comptes. Les chemins de suppression en lot (par fichier importé et tout effacer) pré-vérifient l'existence d'un lien dans `balance_account_transfers` et surfacent l'erreur typée `TransactionLinkedToBalanceError` (« Cette transaction est liée au compte de bilan X — déliez-la avant de supprimer ») au lieu de laisser fuiter l'erreur SQLite brute. Le graphique d'évolution sur `/balance` superpose désormais des lignes verticales de référence à chaque date de transfert lié (vert pour `in`, rouge pour `out`). Nouvelles clés i18n sous `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
|
||||||
- **Bilan — page `/balance` avec graphique d'évolution et entrée sidebar** (route `/balance`) : quatrième tranche de la feature *Bilan*, qui la rend enfin accessible depuis la navigation. La nouvelle page compose (1) une carte d'aperçu avec la valeur nette agrégée du dernier snapshot, le Δ% par rapport au snapshot chronologiquement précédent (affiché « — » quand il n'existe qu'un seul snapshot), un avertissement de fraîcheur quand le dernier snapshot date de plus de 60 jours, et un CTA *Nouveau snapshot* qui pointe vers `/balance/snapshot` ; (2) un sélecteur de période (3 mois / 6 mois / 1 an / 3 ans / Tout) qui recharge toutes les séries en parallèle ; (3) un graphique d'évolution avec deux modes — *Ligne* (une seule série `SUM(value) GROUP BY snapshot_date`) et *Empilé par catégorie* (une `<Area stackId>` Recharts par `balance_categories.key`) ; (4) un tableau des comptes listant chaque compte actif avec sa dernière valeur snapshot, le Δ% par compte sur la période active (valeur la plus récente vs valeur du premier snapshot dans la fenêtre — null si pas d'ancrage, affiché « — »), et un menu d'actions (Détail désactivé en attendant la #142, Archiver). Les colonnes de rendement (3M / 1A / depuis création / non ajusté) sont réservées pour une version ultérieure avec un commentaire `TODO`. La sidebar expose désormais l'entrée *Bilan* (icône `Wallet`) entre *Rapports* et *Paramètres*. Le service gagne trois helpers de série temporelle : `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` ainsi qu'un calcul d'ancrage par compte `getAccountsPeriodAnchor(range)` — tous couverts par des tests unitaires. Nouveau hook `useBalanceOverview` (`useReducer` scoped) qui pilote l'état de la page. Nouvelles clés i18n sous `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
|
- **Bilan — page `/balance` avec graphique d'évolution et entrée sidebar** (route `/balance`) : quatrième tranche de la feature *Bilan*, qui la rend enfin accessible depuis la navigation. La nouvelle page compose (1) une carte d'aperçu avec la valeur nette agrégée du dernier snapshot, le Δ% par rapport au snapshot chronologiquement précédent (affiché « — » quand il n'existe qu'un seul snapshot), un avertissement de fraîcheur quand le dernier snapshot date de plus de 60 jours, et un CTA *Nouveau snapshot* qui pointe vers `/balance/snapshot` ; (2) un sélecteur de période (3 mois / 6 mois / 1 an / 3 ans / Tout) qui recharge toutes les séries en parallèle ; (3) un graphique d'évolution avec deux modes — *Ligne* (une seule série `SUM(value) GROUP BY snapshot_date`) et *Empilé par catégorie* (une `<Area stackId>` Recharts par `balance_categories.key`) ; (4) un tableau des comptes listant chaque compte actif avec sa dernière valeur snapshot, le Δ% par compte sur la période active (valeur la plus récente vs valeur du premier snapshot dans la fenêtre — null si pas d'ancrage, affiché « — »), et un menu d'actions (Détail désactivé en attendant la #142, Archiver). Les colonnes de rendement (3M / 1A / depuis création / non ajusté) sont réservées pour une version ultérieure avec un commentaire `TODO`. La sidebar expose désormais l'entrée *Bilan* (icône `Wallet`) entre *Rapports* et *Paramètres*. Le service gagne trois helpers de série temporelle : `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` ainsi qu'un calcul d'ancrage par compte `getAccountsPeriodAnchor(range)` — tous couverts par des tests unitaires. Nouveau hook `useBalanceOverview` (`useReducer` scoped) qui pilote l'état de la page. Nouvelles clés i18n sous `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
|
||||||
- **Bilan — type coté (quantité × prix unitaire)** (routes `/balance/accounts` et `/balance/snapshot`) : troisième tranche de la feature *Bilan*. Les catégories exposent désormais un sélecteur de *type* à la création : `simple` (saisie d'un montant direct) ou `coté` (`quantité × prix_unitaire`). Les comptes liés à une catégorie cotée exigent un symbole. L'éditeur de snapshot bascule selon le type de la catégorie du compte : les comptes simples conservent leur unique champ de valeur ; les comptes cotés affichent trois champs — `quantité`, `prix unitaire` (les deux obligatoires) et un champ `valeur` en lecture seule calculé en temps réel à partir de `quantité × prix unitaire` (arrondi à 2 décimales). Une étiquette d'attribution `[Manuel]` apparaît sur chaque ligne cotée ; la future étiquette `[via Maximus le AAAA-MM-JJ]` arrivera avec la récupération automatique des prix. Le bouton *Pré-remplir depuis le précédent* copie maintenant les quantités pour les comptes cotés mais laisse les prix unitaires vides (un prix frais doit être saisi à chaque fois). Le service valide les lignes cotées avant la CHECK SQL : invariants de type (les lignes cotées doivent porter à la fois quantité et prix unitaire ; les lignes simples ne doivent porter ni l'un ni l'autre) et invariant de valeur `|valeur − quantité × prix unitaire| ≤ 0,01` (un centime de tolérance pour absorber les arrondis flottants). La suppression d'une catégorie est désormais mieux guardée : une catégorie liée à un ou plusieurs comptes affiche un bandeau d'erreur listant le nombre et jusqu'à trois noms de comptes pour que l'utilisateur sache exactement lesquels archiver d'abord ; les catégories standard restent protégées côté service avec leur bouton désactivé dans l'interface. Nouvelles clés i18n `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
|
- **Bilan — type coté (quantité × prix unitaire)** (routes `/balance/accounts` et `/balance/snapshot`) : troisième tranche de la feature *Bilan*. Les catégories exposent désormais un sélecteur de *type* à la création : `simple` (saisie d'un montant direct) ou `coté` (`quantité × prix_unitaire`). Les comptes liés à une catégorie cotée exigent un symbole. L'éditeur de snapshot bascule selon le type de la catégorie du compte : les comptes simples conservent leur unique champ de valeur ; les comptes cotés affichent trois champs — `quantité`, `prix unitaire` (les deux obligatoires) et un champ `valeur` en lecture seule calculé en temps réel à partir de `quantité × prix unitaire` (arrondi à 2 décimales). Une étiquette d'attribution `[Manuel]` apparaît sur chaque ligne cotée ; la future étiquette `[via Maximus le AAAA-MM-JJ]` arrivera avec la récupération automatique des prix. Le bouton *Pré-remplir depuis le précédent* copie maintenant les quantités pour les comptes cotés mais laisse les prix unitaires vides (un prix frais doit être saisi à chaque fois). Le service valide les lignes cotées avant la CHECK SQL : invariants de type (les lignes cotées doivent porter à la fois quantité et prix unitaire ; les lignes simples ne doivent porter ni l'un ni l'autre) et invariant de valeur `|valeur − quantité × prix unitaire| ≤ 0,01` (un centime de tolérance pour absorber les arrondis flottants). La suppression d'une catégorie est désormais mieux guardée : une catégorie liée à un ou plusieurs comptes affiche un bandeau d'erreur listant le nombre et jusqu'à trois noms de comptes pour que l'utilisateur sache exactement lesquels archiver d'abord ; les catégories standard restent protégées côté service avec leur bouton désactivé dans l'interface. Nouvelles clés i18n `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Balance sheet — cross-cutting integration test suite** (test infrastructure): closes out the *Bilan* feature with a layer of integration tests that exercise the whole TypeScript surface in a single happy-path flow (account → priced category → priced snapshot → linked transfer → return) plus dedicated assertions for currency lock (CAD-only at the MVP, rejected at both the service layer and SQL CHECK), priced-kind tolerance safety (a bad save must NOT clear pre-existing lines), `computeAccountReturn` wiring (active-profile resolution, ISO date forwarding, partial-period payload pass-through). Three new Rust integration tests apply migration v9 on top of a seeded v1 schema with pre-existing transactions to verify (1) no row loss / data mutation, (2) link / unlink transfer round-trip on real transaction ids, (3) the FK RESTRICT chain (linked transaction deletion blocked, unblocked after unlink), (4) the v1 `categories.id` and v9 `balance_categories.id` namespaces coexist independently. A non-regression source-level test on `TransactionTable.tsx` locks down the inlined transfer icon contract: optional prop, optional-chaining short-circuit, i18n keys, aria-label, shared description-cell layout — so the page renders identically when no transfers are linked. (#144)
|
||||||
- **Balance sheet — Modified Dietz returns and transfer linking** (route `/balance`): per-account performance now ships. New Rust module `commands/return_calculator.rs` implements the Modified Dietz formula `R = (V_end − V_start − ΣCF_i) / (V_start + ΣW_i × CF_i)` with day-precision contribution weights `W_i = (T − t_i) / T`, plus `(1 + R)^(365/T) − 1` annualization. Edge cases — missing endpoint snapshot, no flows tagged in the period, account created mid-period, depleted-then-refilled, zero-length period — are surfaced with explicit `is_partial` / `has_no_transfers_warning` flags so the UI shows a clean dash + tooltip instead of a confusing number. The new Tauri command `compute_account_return(account_id, period_start, period_end)` runs three short SQL reads against the active profile DB (latest snapshot ≤ period start, latest snapshot ≤ period end, transfers JOINed with transactions filtered to the period) and feeds the calculator. Seven co-located TDD tests cover every case before the implementation. The accounts table on `/balance` now shows four extra columns side-by-side: 3M / 1Y / Since-inception (Modified Dietz) plus an *Unadjusted* column showing the simple `(V_end − V_start) / V_start` so the user can see at a glance how much of the return came from contribution timing. Each row's actions menu gains a *Link transfers* item that opens a multi-select modal with date range / category / free-text filters; the modal auto-proposes the direction (`in` for negative bank amounts, `out` for positive) and the user can flip it per row before submitting. Transactions linked to one or more balance accounts now show a small `Link2` icon next to the description in the *Transactions* page, with a tooltip listing the account name(s) and direction(s). Bulk transaction-deletion paths (per-imported-file and clear-all) now pre-check for any link in `balance_account_transfers` and surface a typed `TransactionLinkedToBalanceError` ("This transaction is linked to balance account X — unlink it before deleting") instead of leaking the raw SQLite FK error. The evolution chart on `/balance` now overlays vertical reference lines at every linked-transfer date (green for `in`, red for `out`). New i18n keys under `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
|
- **Balance sheet — Modified Dietz returns and transfer linking** (route `/balance`): per-account performance now ships. New Rust module `commands/return_calculator.rs` implements the Modified Dietz formula `R = (V_end − V_start − ΣCF_i) / (V_start + ΣW_i × CF_i)` with day-precision contribution weights `W_i = (T − t_i) / T`, plus `(1 + R)^(365/T) − 1` annualization. Edge cases — missing endpoint snapshot, no flows tagged in the period, account created mid-period, depleted-then-refilled, zero-length period — are surfaced with explicit `is_partial` / `has_no_transfers_warning` flags so the UI shows a clean dash + tooltip instead of a confusing number. The new Tauri command `compute_account_return(account_id, period_start, period_end)` runs three short SQL reads against the active profile DB (latest snapshot ≤ period start, latest snapshot ≤ period end, transfers JOINed with transactions filtered to the period) and feeds the calculator. Seven co-located TDD tests cover every case before the implementation. The accounts table on `/balance` now shows four extra columns side-by-side: 3M / 1Y / Since-inception (Modified Dietz) plus an *Unadjusted* column showing the simple `(V_end − V_start) / V_start` so the user can see at a glance how much of the return came from contribution timing. Each row's actions menu gains a *Link transfers* item that opens a multi-select modal with date range / category / free-text filters; the modal auto-proposes the direction (`in` for negative bank amounts, `out` for positive) and the user can flip it per row before submitting. Transactions linked to one or more balance accounts now show a small `Link2` icon next to the description in the *Transactions* page, with a tooltip listing the account name(s) and direction(s). Bulk transaction-deletion paths (per-imported-file and clear-all) now pre-check for any link in `balance_account_transfers` and surface a typed `TransactionLinkedToBalanceError` ("This transaction is linked to balance account X — unlink it before deleting") instead of leaking the raw SQLite FK error. The evolution chart on `/balance` now overlays vertical reference lines at every linked-transfer date (green for `in`, red for `out`). New i18n keys under `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
|
||||||
- **Balance sheet — `/balance` overview page, evolution chart and sidebar entry** (route `/balance`): fourth slice of the *Bilan* feature finally surfaces it in the navigation. The new page composes (1) an overview card with the latest aggregate net worth, the Δ% versus the previous chronological snapshot (rendered as "—" when only one snapshot exists), a 60-day staleness warning when the latest snapshot is older than that threshold, and a *New snapshot* CTA pointing at `/balance/snapshot`; (2) a period selector (3 months / 6 months / 1 year / 3 years / All) that re-fetches every series in parallel; (3) an evolution chart with two modes — *Line* (single series of `SUM(value) GROUP BY snapshot_date`) and *Stacked by category* (one Recharts `<Area stackId>` per `balance_categories.key`); (4) an accounts table listing every active account with its latest snapshot value, the per-account Δ% over the active period (latest value vs the value at the earliest snapshot inside the window — null when no anchor exists, rendered as "—"), and an actions menu (Details placeholder, Archive). Return-metric columns (3M / 1Y / since-creation / unadjusted) are reserved for a later release with a `TODO` marker. The sidebar now exposes the *Balance sheet* entry (`Wallet` icon) between *Reports* and *Settings*. The service grows three time-series helpers: `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` and a per-account anchor query `getAccountsPeriodAnchor(range)` — all guarded by unit tests. New `useBalanceOverview` hook (scoped `useReducer`) drives the page state. New i18n keys under `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
|
- **Balance sheet — `/balance` overview page, evolution chart and sidebar entry** (route `/balance`): fourth slice of the *Bilan* feature finally surfaces it in the navigation. The new page composes (1) an overview card with the latest aggregate net worth, the Δ% versus the previous chronological snapshot (rendered as "—" when only one snapshot exists), a 60-day staleness warning when the latest snapshot is older than that threshold, and a *New snapshot* CTA pointing at `/balance/snapshot`; (2) a period selector (3 months / 6 months / 1 year / 3 years / All) that re-fetches every series in parallel; (3) an evolution chart with two modes — *Line* (single series of `SUM(value) GROUP BY snapshot_date`) and *Stacked by category* (one Recharts `<Area stackId>` per `balance_categories.key`); (4) an accounts table listing every active account with its latest snapshot value, the per-account Δ% over the active period (latest value vs the value at the earliest snapshot inside the window — null when no anchor exists, rendered as "—"), and an actions menu (Details placeholder, Archive). Return-metric columns (3M / 1Y / since-creation / unadjusted) are reserved for a later release with a `TODO` marker. The sidebar now exposes the *Balance sheet* entry (`Wallet` icon) between *Reports* and *Settings*. The service grows three time-series helpers: `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` and a per-account anchor query `getAccountsPeriodAnchor(range)` — all guarded by unit tests. New `useBalanceOverview` hook (scoped `useReducer`) drives the page state. New i18n keys under `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
|
||||||
- **Balance sheet — priced kind (quantity × unit price)** (routes `/balance/accounts` and `/balance/snapshot`): third slice of the *Bilan* feature. Categories now expose a *kind* selector at creation: `simple` (direct value entry) or `priced` (`quantity × unit_price`). Accounts linked to a priced category require a symbol. The snapshot editor dispatches on the account's category kind: simple accounts keep their single value field, priced accounts get three inputs — `quantity`, `unit_price` (both required) and a read-only `value` field computed live from `quantity × unit_price` (rounded to 2 decimals). A `[Manual]` / `[Manuel]` attribution tag is shown on each priced row; the future `[via Maximus on YYYY-MM-DD]` tag will land with automatic price-fetching. The *Prefill from previous* button now copies quantities for priced accounts but leaves unit prices blank (a fresh price must be entered each time). The service validates priced lines ahead of the SQL CHECK: kind invariants (priced lines must carry both quantity and unit_price; simple lines must carry neither) and a value-match invariant `|value − quantity × unit_price| ≤ 0.01` (one cent tolerance to absorb floating-point drift). Category deletion now blocks earlier and surfaces a richer error: a category linked to one or more accounts shows a dismissable banner listing the count and up to three account names so the user knows exactly which accounts to archive first; seeded categories remain protected at the service layer with their button disabled in the UI. New i18n keys `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
|
- **Balance sheet — priced kind (quantity × unit price)** (routes `/balance/accounts` and `/balance/snapshot`): third slice of the *Bilan* feature. Categories now expose a *kind* selector at creation: `simple` (direct value entry) or `priced` (`quantity × unit_price`). Accounts linked to a priced category require a symbol. The snapshot editor dispatches on the account's category kind: simple accounts keep their single value field, priced accounts get three inputs — `quantity`, `unit_price` (both required) and a read-only `value` field computed live from `quantity × unit_price` (rounded to 2 decimals). A `[Manual]` / `[Manuel]` attribution tag is shown on each priced row; the future `[via Maximus on YYYY-MM-DD]` tag will land with automatic price-fetching. The *Prefill from previous* button now copies quantities for priced accounts but leaves unit prices blank (a fresh price must be entered each time). The service validates priced lines ahead of the SQL CHECK: kind invariants (priced lines must carry both quantity and unit_price; simple lines must carry neither) and a value-match invariant `|value − quantity × unit_price| ≤ 0.01` (one cent tolerance to absorb floating-point drift). Category deletion now blocks earlier and surfaces a richer error: a category linked to one or more accounts shows a dismissable banner listing the count and up to three account names so the user knows exactly which accounts to archive first; seeded categories remain protected at the service layer with their button disabled in the UI. New i18n keys `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
|
||||||
|
|
|
||||||
|
|
@ -692,5 +692,353 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 7, "seed must remain idempotent on replay");
|
assert_eq!(count, 7, "seed must remain idempotent on replay");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Issue #144 (Bilan #6) — integration tests on a seeded DB
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The previous tests apply BALANCE_SCHEMA on an empty DB. These tests
|
||||||
|
// simulate the realistic upgrade path: a profile DB with imported
|
||||||
|
// transactions already there gets the v9 migration applied on top, and
|
||||||
|
// we verify:
|
||||||
|
// - existing transactions are not affected by the migration (no row
|
||||||
|
// loss, no schema collision),
|
||||||
|
// - link / unlink transfer round-trips on real (non-stub) transaction
|
||||||
|
// ids,
|
||||||
|
// - the FK RESTRICT correctly chains: try to delete a linked
|
||||||
|
// transaction → blocked, unlink → delete succeeds.
|
||||||
|
|
||||||
|
/// Seed a DB with the *full app schema* (transactions + categories +
|
||||||
|
/// keywords + suppliers + adjustments + ...) then apply BALANCE_SCHEMA on
|
||||||
|
/// top — mirroring what migration v9 does on an existing user profile.
|
||||||
|
/// Returns the connection ready for assertions.
|
||||||
|
fn seeded_db_with_balance_schema() -> Connection {
|
||||||
|
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||||
|
.expect("enable FKs");
|
||||||
|
// Apply the full app schema (v1) — we only need the transactions
|
||||||
|
// table for the v9 FK, but applying the whole schema verifies that
|
||||||
|
// nothing in v9 collides with the existing tables.
|
||||||
|
conn.execute_batch(crate::database::SCHEMA)
|
||||||
|
.expect("apply v1 SCHEMA");
|
||||||
|
// Pre-seed a few transactions to mimic an existing profile (the user
|
||||||
|
// already had data when we shipped v9).
|
||||||
|
conn.execute_batch(
|
||||||
|
"INSERT INTO transactions (date, description, amount) VALUES
|
||||||
|
('2026-01-15', 'Salary deposit', 3500.0),
|
||||||
|
('2026-02-01', 'Wealthsimple contribution', -400.0),
|
||||||
|
('2026-03-15', 'Grocery store', -125.50),
|
||||||
|
('2026-04-01', 'Wealthsimple contribution', -400.0);",
|
||||||
|
)
|
||||||
|
.expect("seed transactions");
|
||||||
|
// Now apply v9 on top — same way the runtime would.
|
||||||
|
conn.execute_batch(crate::database::BALANCE_SCHEMA)
|
||||||
|
.expect("apply v9 BALANCE_SCHEMA on seeded DB");
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_preserves_existing_transactions_on_seeded_db() {
|
||||||
|
let conn = seeded_db_with_balance_schema();
|
||||||
|
// Existing transactions must be untouched by the migration.
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM transactions", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 4, "existing transactions must survive the migration");
|
||||||
|
|
||||||
|
// Spot-check one row's content (no silent data mutation).
|
||||||
|
let amount: f64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT amount FROM transactions WHERE description = 'Salary deposit'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
(amount - 3500.0).abs() < f64::EPSILON,
|
||||||
|
"salary amount must be preserved verbatim"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The seeded categories from BALANCE_SCHEMA must coexist with the
|
||||||
|
// pre-existing categories table from v1 (different name, no clash).
|
||||||
|
let bal_cat_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bal_cat_count, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn integration_link_unlink_transfer_roundtrip_on_seeded_db() {
|
||||||
|
let conn = seeded_db_with_balance_schema();
|
||||||
|
|
||||||
|
// Create a balance account on the seeded 'cash' category.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||||
|
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Wealthsimple cash')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let account_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Pick the Feb contribution (-$400) — a typical "in" transfer for the
|
||||||
|
// Wealthsimple account from the bank perspective.
|
||||||
|
let tx_id: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM transactions WHERE date = '2026-02-01'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 1. Link
|
||||||
|
let inserted = conn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction, notes)
|
||||||
|
VALUES (?1, ?2, 'in', 'monthly contribution')",
|
||||||
|
rusqlite::params![account_id, tx_id],
|
||||||
|
)
|
||||||
|
.expect("link succeeds with real transaction id");
|
||||||
|
assert_eq!(inserted, 1);
|
||||||
|
|
||||||
|
// 2. Verify the row is queryable through the joined view used by
|
||||||
|
// `listAccountTransfers` in TS.
|
||||||
|
let (joined_amount, direction): (f64, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT t.amount, bat.direction
|
||||||
|
FROM balance_account_transfers bat
|
||||||
|
JOIN transactions t ON t.id = bat.transaction_id
|
||||||
|
WHERE bat.account_id = ?1",
|
||||||
|
rusqlite::params![account_id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.expect("joined view must read");
|
||||||
|
assert!((joined_amount - (-400.0)).abs() < f64::EPSILON);
|
||||||
|
assert_eq!(direction, "in");
|
||||||
|
|
||||||
|
// 3. Try to delete the linked transaction — must be blocked (RESTRICT).
|
||||||
|
let blocked = conn.execute(
|
||||||
|
"DELETE FROM transactions WHERE id = ?1",
|
||||||
|
rusqlite::params![tx_id],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
blocked.is_err(),
|
||||||
|
"linked transaction deletion must be blocked by FK RESTRICT"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Unlink
|
||||||
|
let unlinked = conn
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM balance_account_transfers
|
||||||
|
WHERE account_id = ?1 AND transaction_id = ?2",
|
||||||
|
rusqlite::params![account_id, tx_id],
|
||||||
|
)
|
||||||
|
.expect("unlink succeeds");
|
||||||
|
assert_eq!(unlinked, 1);
|
||||||
|
|
||||||
|
// 5. After unlink, deleting the transaction must succeed.
|
||||||
|
let allowed = conn
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM transactions WHERE id = ?1",
|
||||||
|
rusqlite::params![tx_id],
|
||||||
|
)
|
||||||
|
.expect("after unlink, transaction can be deleted");
|
||||||
|
assert_eq!(allowed, 1);
|
||||||
|
|
||||||
|
// 6. Sanity: no orphan transfer rows survived.
|
||||||
|
let remaining_links: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM balance_account_transfers WHERE transaction_id = ?1",
|
||||||
|
rusqlite::params![tx_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(remaining_links, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn integration_modified_dietz_inputs_read_back_correctly_on_seeded_db() {
|
||||||
|
// Reads back the snapshot endpoints + cash flows the way
|
||||||
|
// `compute_account_return` does, on a DB that has both v1 transactions
|
||||||
|
// and v9 balance tables. Asserts the SQL queries used by
|
||||||
|
// `balance_commands.rs::read_value_at_or_before` and `read_cash_flows`
|
||||||
|
// return the expected shapes.
|
||||||
|
let conn = seeded_db_with_balance_schema();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||||
|
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Wealthsimple cash')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let account_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Two snapshot endpoints (V_start, V_end) and one mid-period contribution.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshots (snapshot_date) VALUES
|
||||||
|
('2026-01-01'),
|
||||||
|
('2026-04-01')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let s_start: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM balance_snapshots WHERE snapshot_date='2026-01-01'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let s_end: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM balance_snapshots WHERE snapshot_date='2026-04-01'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
||||||
|
VALUES (?1, ?2, 1000.0)",
|
||||||
|
rusqlite::params![s_start, account_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
||||||
|
VALUES (?1, ?2, 1500.0)",
|
||||||
|
rusqlite::params![s_end, account_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Link the Feb 1 contribution as an `in` transfer.
|
||||||
|
let tx_id: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM transactions WHERE date='2026-02-01'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
|
||||||
|
VALUES (?1, ?2, 'in')",
|
||||||
|
rusqlite::params![account_id, tx_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Mirror `read_value_at_or_before` for V_start — exact SQL used in
|
||||||
|
// `balance_commands.rs`.
|
||||||
|
let v_start: Option<f64> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT l.value
|
||||||
|
FROM balance_snapshot_lines l
|
||||||
|
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||||
|
WHERE l.account_id = ?1
|
||||||
|
AND s.snapshot_date <= ?2
|
||||||
|
ORDER BY s.snapshot_date DESC
|
||||||
|
LIMIT 1",
|
||||||
|
rusqlite::params![account_id, "2026-01-01"],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
assert_eq!(v_start, Some(1000.0));
|
||||||
|
|
||||||
|
// V_end at 2026-04-01 — picks up the second snapshot.
|
||||||
|
let v_end: Option<f64> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT l.value
|
||||||
|
FROM balance_snapshot_lines l
|
||||||
|
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||||
|
WHERE l.account_id = ?1
|
||||||
|
AND s.snapshot_date <= ?2
|
||||||
|
ORDER BY s.snapshot_date DESC
|
||||||
|
LIMIT 1",
|
||||||
|
rusqlite::params![account_id, "2026-04-01"],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
assert_eq!(v_end, Some(1500.0));
|
||||||
|
|
||||||
|
// Cash flows in [2026-01-01, 2026-04-01] — exactly one (-400 abs amount → +400 in).
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT t.date, ABS(t.amount), bat.direction
|
||||||
|
FROM balance_account_transfers bat
|
||||||
|
JOIN transactions t ON t.id = bat.transaction_id
|
||||||
|
WHERE bat.account_id = ?1
|
||||||
|
AND t.date BETWEEN ?2 AND ?3
|
||||||
|
ORDER BY t.date",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let flows: Vec<(String, f64, String)> = stmt
|
||||||
|
.query_map(
|
||||||
|
rusqlite::params![account_id, "2026-01-01", "2026-04-01"],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.map(|r| r.unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(flows.len(), 1);
|
||||||
|
assert_eq!(flows[0].0, "2026-02-01");
|
||||||
|
assert!((flows[0].1 - 400.0).abs() < f64::EPSILON);
|
||||||
|
assert_eq!(flows[0].2, "in");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn integration_v9_preserves_v1_categories_and_keywords() {
|
||||||
|
// Defensive: v9 introduces `balance_categories` while v1 already has
|
||||||
|
// `categories`. Make sure neither is mistaken for the other and that
|
||||||
|
// the v1 seeds (when present) survive the migration cleanly.
|
||||||
|
let conn = seeded_db_with_balance_schema();
|
||||||
|
|
||||||
|
// Insert a v1 category + keyword (mimicking v1 seed data already present).
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO categories (id, name, type, color, sort_order)
|
||||||
|
VALUES (50, 'Épicerie', 'expense', '#10b981', 50)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO keywords (keyword, category_id, priority, is_active)
|
||||||
|
VALUES ('IGA', 50, 100, 1)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Now insert a v9 category with the SAME numeric id (should be allowed
|
||||||
|
// — different table, different namespace).
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_categories (id, key, i18n_key, kind, sort_order)
|
||||||
|
VALUES (50, 'mortgage', 'balance.category.mortgage', 'simple', 100)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect(
|
||||||
|
"balance_categories.id namespace must be independent from categories.id",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The v1 row is untouched.
|
||||||
|
let v1_name: String = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT name FROM categories WHERE id = 50",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v1_name, "Épicerie");
|
||||||
|
|
||||||
|
// The v9 row is queryable on its own table.
|
||||||
|
let v9_key: String = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT key FROM balance_categories WHERE id = 50",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v9_key, "mortgage");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
574
src/__integration__/balance-flow.test.ts
Normal file
574
src/__integration__/balance-flow.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
96
src/__integration__/transactions-transfer-icon.test.ts
Normal file
96
src/__integration__/transactions-transfer-icon.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
/**
|
||||||
|
* Non-regression check for the inlined transfer icon in TransactionTable
|
||||||
|
* (Issue #142 → #144 follow-up).
|
||||||
|
*
|
||||||
|
* The spec promises that — without any linked transfers — the transactions
|
||||||
|
* table renders exactly as it did before #142 inlined the `<Link2>` icon.
|
||||||
|
* The icon is gated by a single conditional in the JSX:
|
||||||
|
*
|
||||||
|
* {linkedTransfersByTxId?.has(row.id) && (...)}
|
||||||
|
*
|
||||||
|
* If `linkedTransfersByTxId` is undefined OR the map has no entry for `row.id`,
|
||||||
|
* the icon block is short-circuited and the row layout is unchanged.
|
||||||
|
*
|
||||||
|
* Why this approach: this project does not bundle `@testing-library/react`
|
||||||
|
* (see `package.json`), and adding it just for one non-regression check is
|
||||||
|
* out of scope here. Existing component tests (`CategoryCombobox.test.ts`,
|
||||||
|
* `ViewModeToggle.test.ts`, `TrendsChartTypeToggle.test.ts`) likewise extract
|
||||||
|
* pure helpers and assert on them rather than mounting JSX. So we go one
|
||||||
|
* level lower: assert the source-level shape of `TransactionTable.tsx`.
|
||||||
|
*
|
||||||
|
* The assertions are structural on the source file:
|
||||||
|
* 1. The conditional block exists and is gated by `linkedTransfersByTxId?.has`.
|
||||||
|
* 2. The block consumes `Link2` from `lucide-react`.
|
||||||
|
* 3. The prop is OPTIONAL on the component's interface — passing nothing
|
||||||
|
* must remain a valid call (zero-impact path).
|
||||||
|
* 4. The tooltip text comes from the i18n key family `transactions.transferIcon.*`
|
||||||
|
* (so a future rename catches our attention here).
|
||||||
|
* 5. The icon uses `aria-label` for accessibility (Issue #142 acceptance criterion).
|
||||||
|
* 6. The condition uses optional-chaining (so passing `undefined` short-circuits
|
||||||
|
* cleanly without throwing).
|
||||||
|
*
|
||||||
|
* If the icon is ever pulled out into its own component, the tests should be
|
||||||
|
* rewritten to import and exercise that component directly instead. Until
|
||||||
|
* then, this is a tight static contract that catches accidental regressions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
const TABLE_SRC = readFileSync(
|
||||||
|
resolve(
|
||||||
|
import.meta.dirname,
|
||||||
|
"..",
|
||||||
|
"components",
|
||||||
|
"transactions",
|
||||||
|
"TransactionTable.tsx"
|
||||||
|
),
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("non-regression: TransactionTable transfer icon (#142)", () => {
|
||||||
|
it("guards the icon block behind `linkedTransfersByTxId?.has(row.id)`", () => {
|
||||||
|
expect(TABLE_SRC).toMatch(/linkedTransfersByTxId\?\.has\(row\.id\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses optional chaining so the icon is opt-in (undefined short-circuits)", () => {
|
||||||
|
// Optional chaining is the safe-render guarantee: if the parent never
|
||||||
|
// passes the prop, `?.has` returns undefined → the && short-circuits to
|
||||||
|
// false, the JSX block is skipped, and the row layout is unchanged.
|
||||||
|
expect(TABLE_SRC).toMatch(/linkedTransfersByTxId\?\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports `Link2` from lucide-react for the icon glyph", () => {
|
||||||
|
expect(TABLE_SRC).toMatch(/from\s+["']lucide-react["']/);
|
||||||
|
expect(TABLE_SRC).toMatch(/\bLink2\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares `linkedTransfersByTxId` as an OPTIONAL prop", () => {
|
||||||
|
// The "?" after the name on the interface is the contract that omitting
|
||||||
|
// the prop is allowed. Without it the entire transactions page would
|
||||||
|
// need to thread the lookup through, breaking pre-#142 callers.
|
||||||
|
expect(TABLE_SRC).toMatch(/linkedTransfersByTxId\?:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses `transactions.transferIcon.*` i18n keys for the tooltip and aria-label", () => {
|
||||||
|
// Both the tooltip body and the aria label go through i18n — neither
|
||||||
|
// is a hardcoded English/French string.
|
||||||
|
expect(TABLE_SRC).toMatch(/transactions\.transferIcon\.tooltip/);
|
||||||
|
expect(TABLE_SRC).toMatch(/transactions\.transferIcon\.ariaLabel/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches an `aria-label` for screen readers (a11y)", () => {
|
||||||
|
expect(TABLE_SRC).toMatch(/aria-label=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the description column structure shared with non-linked rows", () => {
|
||||||
|
// The icon lives inside the description cell, in a flex container
|
||||||
|
// alongside the original `<span class="truncate" title=...>` that
|
||||||
|
// existed pre-#142. If someone moved the description span into a
|
||||||
|
// wrapper that the icon required, this assertion would fail.
|
||||||
|
expect(TABLE_SRC).toMatch(
|
||||||
|
/<span\s+className="truncate"\s+title=\{row\.description\}/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue