Files
lifetrack/apps/api/app/tests/modules/test_finance_api.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS +
Vite + Tailwind + ECharts, déploiement Docker Compose. Modules
auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework
de connecteurs à deux voies (importeurs de fichiers + ingestion JSON)
pour brancher de nouvelles sources sans toucher au noyau.

Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié
contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16
(28 tables, SPA servie par nginx, wizard de premier démarrage).

Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les
documents de conception et de recherche dans docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:48:57 +02:00

788 lines
26 KiB
Python

"""HTTP contract of the finance module (datamodel-finance.md §9).
Covers the endpoints an agent/UI can call: seeding, CRUD, import (preview +
run + rollback), rules, budgets, transfers and the chart-ready stats.
"""
from io import BytesIO
from pathlib import Path
from typing import Any
from fastapi.testclient import TestClient
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "finance"
API = "/api/finance"
def upload(name: str) -> tuple[str, BytesIO, str]:
return (name, BytesIO((FIXTURES / name).read_bytes()), "text/csv")
def create_account(
client: TestClient, headers: dict[str, str], name: str = "BoursoBank"
) -> dict[str, Any]:
response = client.post(
f"{API}/accounts",
json={"name": name, "kind": "checking", "initial_balance": 100},
headers=headers,
)
assert response.status_code == 201, response.text
return response.json()
def profile_id(client: TestClient, headers: dict[str, str], name: str) -> str:
profiles = client.get(f"{API}/source-profiles", headers=headers).json()
return next(p["id"] for p in profiles if p["name"] == name)
def import_boursobank(
client: TestClient, headers: dict[str, str], account_id: str
) -> dict[str, Any]:
response = client.post(
f"{API}/imports",
files={"file": upload("boursobank.csv")},
data={
"account_id": account_id,
"source_profile_id": profile_id(
client, headers, "BoursoBank / Boursorama (CSV)"
),
},
headers=headers,
)
assert response.status_code == 201, response.text
return response.json()
# ---------------------------------------------------------------------------
# Auth + seeding
# ---------------------------------------------------------------------------
def test_endpoints_require_authentication(client: TestClient) -> None:
response = client.get(f"{API}/accounts")
assert response.status_code == 401
assert response.json()["error"]["code"] == "unauthorized"
def test_categories_are_seeded_on_first_access(
client: TestClient, auth_headers: dict[str, str]
) -> None:
response = client.get(f"{API}/categories", headers=auth_headers)
assert response.status_code == 200
tree = response.json()
names = {node["name"] for node in tree}
assert {"Alimentation", "Logement", "Transports", "Salaire"} <= names
alimentation = next(n for n in tree if n["name"] == "Alimentation")
assert {c["name"] for c in alimentation["children"]} >= {"Courses", "Livraison"}
transfer = next(n for n in tree if n["kind"] == "transfer")
assert transfer["name"] == "Virements internes"
assert transfer["is_system"] is True
def test_builtin_source_profiles_are_seeded_and_read_only(
client: TestClient, auth_headers: dict[str, str]
) -> None:
profiles = client.get(f"{API}/source-profiles", headers=auth_headers).json()
builtin = [p for p in profiles if p["is_builtin"]]
assert len(builtin) >= 12
assert {"csv", "ofx", "paypal_csv"} == {p["kind"] for p in builtin}
target = next(p for p in builtin if p["name"] == "Fortuneo (CSV)")
forbidden = client.patch(
f"{API}/source-profiles/{target['id']}",
json={"name": "Bidouille"},
headers=auth_headers,
)
assert forbidden.status_code == 403
clone = client.post(
f"{API}/source-profiles/{target['id']}/clone", headers=auth_headers
)
assert clone.status_code == 201
assert clone.json()["is_builtin"] is False
assert clone.json()["name"] == "Fortuneo (CSV) (copie)"
patched = client.patch(
f"{API}/source-profiles/{clone.json()['id']}",
json={"name": "Fortuneo perso"},
headers=auth_headers,
)
assert patched.status_code == 200
def test_deleting_a_category_cleans_transactions_budgets_and_rules(
client: TestClient, auth_headers: dict[str, str]
) -> None:
account = create_account(client, auth_headers)
import_boursobank(client, auth_headers, account["id"])
tree = client.get(f"{API}/categories", headers=auth_headers).json()
alimentation = next(n for n in tree if n["name"] == "Alimentation")
courses = next(c for c in alimentation["children"] if c["name"] == "Courses")
rule = client.post(
f"{API}/rules",
json={
"name": "Courses Carrefour",
"matchers": {"label_contains": ["CARREFOUR"]},
"actions": {"set_category_id": courses["id"]},
},
headers=auth_headers,
).json()
client.post(
f"{API}/rules/apply", json={"scope": "uncategorized"}, headers=auth_headers
)
client.post(
f"{API}/budgets",
json={
"category_id": alimentation["id"],
"monthly_amount": 400,
"start_month": "2026-01-01",
},
headers=auth_headers,
)
system = next(n for n in tree if n["is_system"])
refused = client.delete(f"{API}/categories/{system['id']}", headers=auth_headers)
assert refused.status_code == 422
deleted = client.delete(
f"{API}/categories/{alimentation['id']}", headers=auth_headers
)
assert deleted.status_code == 204
tree_after = client.get(f"{API}/categories", headers=auth_headers).json()
names = {node["name"] for node in tree_after}
assert "Alimentation" not in names
tx = client.get(
f"{API}/transactions", params={"q": "carrefour"}, headers=auth_headers
).json()["items"][0]
assert tx["category_id"] is None
assert tx["category_source"] is None
assert client.get(f"{API}/budgets", headers=auth_headers).json() == []
rules = client.get(f"{API}/rules", headers=auth_headers).json()
orphan = next(r for r in rules if r["id"] == rule["id"])
assert orphan["actions"].get("set_category_id") is None
assert orphan["enabled"] is False
def test_category_depth_is_limited_to_two_levels(
client: TestClient, auth_headers: dict[str, str]
) -> None:
tree = client.get(f"{API}/categories", headers=auth_headers).json()
child = next(n for n in tree if n["name"] == "Alimentation")["children"][0]
response = client.post(
f"{API}/categories",
json={"name": "Trop profond", "parent_id": child["id"]},
headers=auth_headers,
)
assert response.status_code == 422
assert "deux niveaux" in response.json()["error"]["message"]
# ---------------------------------------------------------------------------
# Accounts
# ---------------------------------------------------------------------------
def test_accounts_crud(client: TestClient, auth_headers: dict[str, str]) -> None:
account = create_account(client, auth_headers)
assert account["balance"] == 100.0
assert account["transaction_count"] == 0
duplicate = client.post(
f"{API}/accounts", json={"name": "BoursoBank"}, headers=auth_headers
)
assert duplicate.status_code == 409
patched = client.patch(
f"{API}/accounts/{account['id']}",
json={"institution": "BoursoBank", "is_archived": True},
headers=auth_headers,
)
assert patched.status_code == 200
assert patched.json()["is_archived"] is True
assert client.get(f"{API}/accounts", headers=auth_headers).json() == []
listed = client.get(
f"{API}/accounts", params={"include_archived": True}, headers=auth_headers
).json()
assert len(listed) == 1
assert (
client.delete(
f"{API}/accounts/{account['id']}", headers=auth_headers
).status_code
== 204
)
def test_account_with_transactions_cannot_be_deleted(
client: TestClient, auth_headers: dict[str, str]
) -> None:
account = create_account(client, auth_headers)
import_boursobank(client, auth_headers, account["id"])
response = client.delete(f"{API}/accounts/{account['id']}", headers=auth_headers)
assert response.status_code == 409
assert "Archivez" in response.json()["error"]["message"]
# ---------------------------------------------------------------------------
# Imports
# ---------------------------------------------------------------------------
def test_import_preview_writes_nothing(
client: TestClient, auth_headers: dict[str, str]
) -> None:
account = create_account(client, auth_headers)
response = client.post(
f"{API}/imports/preview",
files={"file": upload("boursobank.csv")},
data={
"account_id": account["id"],
"source_profile_id": profile_id(
client, auth_headers, "BoursoBank / Boursorama (CSV)"
),
},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["rows_total"] == 8
assert body["rows_error"] == 1
assert body["would_skip_duplicates"] == 0
assert body["date_min"] == "2026-06-01"
assert len(body["rows_preview"]) == 7
assert body["rows_preview"][0]["amount"] == -42.9
listed = client.get(f"{API}/transactions", headers=auth_headers).json()
assert listed["total"] == 0
def test_import_then_rollback(client: TestClient, auth_headers: dict[str, str]) -> None:
account = create_account(client, auth_headers)
run = import_boursobank(client, auth_headers, account["id"])
assert run["status"] == "completed"
assert run["stats"]["rows_imported"] == 7
runs = client.get(f"{API}/imports", headers=auth_headers).json()
assert runs["total"] == 1
detail = client.get(f"{API}/imports/{run['id']}", headers=auth_headers)
assert detail.status_code == 200
# A duplicate re-upload is reported, not inserted twice.
again = import_boursobank(client, auth_headers, account["id"])
assert again["stats"]["rows_imported"] == 0
assert again["stats"]["duplicate_file_of"] == run["import_run_id"]
deleted = client.delete(f"{API}/imports/{run['id']}", headers=auth_headers)
assert deleted.status_code == 204
remaining = client.get(f"{API}/transactions", headers=auth_headers).json()
assert remaining["total"] == 0
def test_rollback_refuses_manually_edited_rows_without_force(
client: TestClient, auth_headers: dict[str, str]
) -> None:
account = create_account(client, auth_headers)
run = import_boursobank(client, auth_headers, account["id"])
tx = client.get(f"{API}/transactions", headers=auth_headers).json()["items"][0]
categories = client.get(f"{API}/categories", headers=auth_headers).json()
courses = next(
c for node in categories for c in node["children"] if c["name"] == "Courses"
)
client.patch(
f"{API}/transactions/{tx['id']}",
json={"category_id": courses["id"]},
headers=auth_headers,
)
blocked = client.delete(f"{API}/imports/{run['id']}", headers=auth_headers)
assert blocked.status_code == 409
forced = client.delete(
f"{API}/imports/{run['id']}", params={"force": True}, headers=auth_headers
)
assert forced.status_code == 204
def test_generic_imports_endpoint_serves_the_finance_importers(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""The registered importers (C3) also work through POST /api/imports."""
sources = client.get("/api/imports/sources", headers=auth_headers).json()
finance_ids = {s["id"] for s in sources if s["domain"] == "finance"}
assert {"bank_generic_csv", "bank_ofx", "paypal_csv"} <= finance_ids
first = client.post(
"/api/imports",
files={"file": upload("sample.ofx")},
data={"source": "bank_ofx"},
headers=auth_headers,
)
assert first.status_code == 201, first.text
assert first.json()["rows_inserted"] == 4
assert first.json()["status"] == "completed"
# A default account was created for that format…
accounts = client.get(f"{API}/accounts", headers=auth_headers).json()
assert [a["name"] for a in accounts] == ["Compte importé (OFX)"]
assert accounts[0]["transaction_count"] == 4
# …and re-importing the same file stays idempotent.
again = client.post(
"/api/imports",
files={"file": upload("sample.ofx")},
data={"source": "bank_ofx"},
headers=auth_headers,
)
assert again.json()["rows_inserted"] == 0
assert again.json()["rows_duplicates"] == 4
# Central rollback removes the finance rows (FK ON DELETE CASCADE).
assert (
client.delete(
f"/api/imports/{first.json()['id']}", headers=auth_headers
).status_code
== 204
)
assert client.get(f"{API}/transactions", headers=auth_headers).json()["total"] == 0
# ---------------------------------------------------------------------------
# Transactions
# ---------------------------------------------------------------------------
def test_transactions_filters_and_manual_flag(
client: TestClient, auth_headers: dict[str, str]
) -> None:
account = create_account(client, auth_headers)
import_boursobank(client, auth_headers, account["id"])
page = client.get(
f"{API}/transactions",
params={"page_size": 3, "sort": "booked_date"},
headers=auth_headers,
).json()
assert page["total"] == 7
assert len(page["items"]) == 3
assert page["items"][0]["booked_date"] == "2026-06-01"
assert page["items"][0]["account_name"] == "BoursoBank"
credits = client.get(
f"{API}/transactions", params={"direction": "credit"}, headers=auth_headers
).json()
assert credits["total"] == 1
assert credits["items"][0]["amount"] == 2450.0
searched = client.get(
f"{API}/transactions", params={"q": "netflix"}, headers=auth_headers
).json()
assert searched["total"] == 1
window = client.get(
f"{API}/transactions",
params={"date_from": "2026-06-05", "date_to": "2026-06-10"},
headers=auth_headers,
).json()
assert window["total"] == 3
uncategorized = client.get(
f"{API}/transactions", params={"category_id": "none"}, headers=auth_headers
).json()
assert uncategorized["total"] == 7
bad_sort = client.get(
f"{API}/transactions", params={"sort": "hacker"}, headers=auth_headers
)
assert bad_sort.status_code == 422
def test_patch_and_bulk_categorize_set_user_source(
client: TestClient, auth_headers: dict[str, str]
) -> None:
account = create_account(client, auth_headers)
import_boursobank(client, auth_headers, account["id"])
items = client.get(f"{API}/transactions", headers=auth_headers).json()["items"]
categories = client.get(f"{API}/categories", headers=auth_headers).json()
courses = next(
c for node in categories for c in node["children"] if c["name"] == "Courses"
)
patched = client.patch(
f"{API}/transactions/{items[0]['id']}",
json={"category_id": courses["id"], "notes": "Vérifié"},
headers=auth_headers,
).json()
assert patched["category_source"] == "user"
assert patched["category_name"] == "Courses"
assert patched["notes"] == "Vérifié"
# Date/amount of an imported row stay read-only.
refused = client.patch(
f"{API}/transactions/{items[0]['id']}",
json={"amount": -1},
headers=auth_headers,
)
assert refused.status_code == 422
bulk = client.post(
f"{API}/transactions/bulk-categorize",
json={
"transaction_ids": [item["id"] for item in items[:3]],
"category_id": courses["id"],
},
headers=auth_headers,
).json()
assert bulk["updated"] == 3
# Deleting an imported row is refused (roll the import back instead).
assert (
client.delete(
f"{API}/transactions/{items[0]['id']}", headers=auth_headers
).status_code
== 422
)
def test_manual_transaction_lifecycle(
client: TestClient, auth_headers: dict[str, str]
) -> None:
account = create_account(client, auth_headers)
created = client.post(
f"{API}/transactions",
json={
"account_id": account["id"],
"booked_date": "2026-08-02",
"amount": -12.5,
"label_clean": "Café du coin",
},
headers=auth_headers,
)
assert created.status_code == 201
body = created.json()
assert body["amount"] == -12.5
assert body["import_run_id"] is None
twin = client.post(
f"{API}/transactions",
json={
"account_id": account["id"],
"booked_date": "2026-08-02",
"amount": -12.5,
"label_clean": "Café du coin",
},
headers=auth_headers,
)
assert twin.status_code == 201 # occurrence 1, not a conflict
assert (
client.delete(
f"{API}/transactions/{body['id']}", headers=auth_headers
).status_code
== 204
)
# ---------------------------------------------------------------------------
# Rules
# ---------------------------------------------------------------------------
def test_rules_crud_preview_and_apply(
client: TestClient, auth_headers: dict[str, str]
) -> None:
account = create_account(client, auth_headers)
import_boursobank(client, auth_headers, account["id"])
categories = client.get(f"{API}/categories", headers=auth_headers).json()
courses = next(
c for node in categories for c in node["children"] if c["name"] == "Courses"
)
invalid = client.post(
f"{API}/rules",
json={
"name": "Regex cassée",
"matchers": {"label_regex": "["},
"actions": {"set_category_id": courses["id"]},
},
headers=auth_headers,
)
assert invalid.status_code == 422
empty = client.post(
f"{API}/rules",
json={"name": "Vide", "matchers": {}, "actions": {}},
headers=auth_headers,
)
assert empty.status_code == 422
preview = client.post(
f"{API}/rules/preview",
json={"matchers": {"label_contains": ["CARREFOUR"]}},
headers=auth_headers,
).json()
assert preview["total_matched"] == 1
created = client.post(
f"{API}/rules",
json={
"name": "Courses Carrefour",
"priority": 10,
"matchers": {"label_contains": ["CARREFOUR"], "direction": "debit"},
"actions": {
"set_category_id": courses["id"],
"set_counterparty": "Carrefour",
},
},
headers=auth_headers,
)
assert created.status_code == 201
rule = created.json()
second = client.post(
f"{API}/rules",
json={
"name": "Loyer",
"matchers": {"label_contains": ["LOYER"]},
"actions": {"set_label_clean": "Loyer"},
},
headers=auth_headers,
).json()
reordered = client.post(
f"{API}/rules/reorder",
json={"ordered_ids": [second["id"], rule["id"]]},
headers=auth_headers,
).json()
assert [r["priority"] for r in reordered] == [0, 10]
dry = client.post(
f"{API}/rules/apply",
json={"scope": "uncategorized", "dry_run": True},
headers=auth_headers,
).json()
assert dry["matched"] == 2
assert dry["dry_run"] is True
applied = client.post(
f"{API}/rules/apply", json={"scope": "uncategorized"}, headers=auth_headers
).json()
assert applied["updated"] == 2
assert {entry["name"] for entry in applied["by_rule"]} == {
"Courses Carrefour",
"Loyer",
}
tx = client.get(
f"{API}/transactions", params={"q": "carrefour"}, headers=auth_headers
).json()["items"][0]
assert tx["category_name"] == "Courses"
assert tx["category_source"] == "rule"
assert tx["counterparty"] == "Carrefour"
forced = client.post(
f"{API}/rules/apply", json={"scope": "all"}, headers=auth_headers
)
assert forced.status_code == 422 # force=true required
assert (
client.delete(f"{API}/rules/{rule['id']}", headers=auth_headers).status_code
== 204
)
# ---------------------------------------------------------------------------
# Budgets
# ---------------------------------------------------------------------------
def test_budgets_overlap_and_effective_from(
client: TestClient, auth_headers: dict[str, str]
) -> None:
categories = client.get(f"{API}/categories", headers=auth_headers).json()
alimentation = next(c for c in categories if c["name"] == "Alimentation")
salaire = next(c for c in categories if c["name"] == "Salaire")
created = client.post(
f"{API}/budgets",
json={
"category_id": alimentation["id"],
"monthly_amount": 450,
"start_month": "2026-01-01",
},
headers=auth_headers,
)
assert created.status_code == 201
overlap = client.post(
f"{API}/budgets",
json={
"category_id": alimentation["id"],
"monthly_amount": 500,
"start_month": "2026-06-01",
},
headers=auth_headers,
)
assert overlap.status_code == 409
income_budget = client.post(
f"{API}/budgets",
json={
"category_id": salaire["id"],
"monthly_amount": 100,
"start_month": "2026-01-01",
},
headers=auth_headers,
)
assert income_budget.status_code == 422
split = client.patch(
f"{API}/budgets/{created.json()['id']}",
json={"monthly_amount": 520, "effective_from": "2026-08-01"},
headers=auth_headers,
).json()
assert len(split) == 2
assert split[0]["end_month"] == "2026-07-01"
assert split[1]["start_month"] == "2026-08-01"
assert split[1]["monthly_amount"] == 520.0
listed = client.get(
f"{API}/budgets", params={"month": "2026-08"}, headers=auth_headers
).json()
assert len(listed) == 1
assert listed[0]["monthly_amount"] == 520.0
assert listed[0]["category_name"] == "Alimentation"
assert (
client.delete(
f"{API}/budgets/{split[1]['id']}", headers=auth_headers
).status_code
== 204
)
# ---------------------------------------------------------------------------
# Transfers + stats
# ---------------------------------------------------------------------------
def test_transfer_endpoints(client: TestClient, auth_headers: dict[str, str]) -> None:
bank = create_account(client, auth_headers, "BoursoBank")
paypal = create_account(client, auth_headers, "PayPal")
import_boursobank(client, auth_headers, bank["id"])
response = client.post(
f"{API}/imports",
files={"file": upload("paypal_fr.csv")},
data={
"account_id": paypal["id"],
"source_profile_id": profile_id(
client, auth_headers, "PayPal — rapport d'activité (CSV)"
),
},
headers=auth_headers,
)
assert response.status_code == 201
assert response.json()["stats"]["transfers_detected"] == 1
transfers = client.get(
f"{API}/transactions", params={"is_transfer": True}, headers=auth_headers
).json()
assert transfers["total"] == 2
group_id = transfers["items"][0]["transfer_group_id"]
assert transfers["items"][0]["category_name"] == "Virements internes"
unlinked = client.delete(f"{API}/transfers/{group_id}", headers=auth_headers)
assert unlinked.status_code == 204
assert (
client.get(
f"{API}/transactions", params={"is_transfer": True}, headers=auth_headers
).json()["total"]
== 0
)
detected = client.post(
f"{API}/transfers/detect", json={}, headers=auth_headers
).json()
assert detected["pairs_created"] == 1
legs = client.get(
f"{API}/transactions", params={"is_transfer": True}, headers=auth_headers
).json()["items"]
client.delete(
f"{API}/transfers/{legs[0]['transfer_group_id']}", headers=auth_headers
)
linked = client.post(
f"{API}/transfers/link",
json={
"transaction_id_a": legs[0]["id"],
"transaction_id_b": legs[1]["id"],
},
headers=auth_headers,
)
assert linked.status_code == 200
assert linked.json()["transfer_group_id"]
def test_stats_endpoints_are_chart_ready(
client: TestClient, auth_headers: dict[str, str]
) -> None:
account = create_account(client, auth_headers)
import_boursobank(client, auth_headers, account["id"])
monthly = client.get(
f"{API}/stats/monthly-by-category",
params={"months": 6},
headers=auth_headers,
).json()
assert len(monthly["months"]) == 6
assert all(len(series["data"]) == 6 for series in monthly["series"])
assert monthly["series"][0]["color"].startswith("#")
cashflow = client.get(
f"{API}/stats/cashflow", params={"months": 4}, headers=auth_headers
).json()
assert set(cashflow) == {
"months",
"income",
"expenses",
"net",
"cumulative_net",
}
assert len(cashflow["cumulative_net"]) == 4
merchants = client.get(
f"{API}/stats/top-merchants", params={"months": 24}, headers=auth_headers
).json()
assert merchants["period"]["from"] <= merchants["period"]["to"]
assert merchants["items"]
recurring = client.get(f"{API}/stats/recurring", headers=auth_headers).json()
assert recurring["items"] == []
assert recurring["monthly_total_estimate"] == 0.0
budgets = client.get(
f"{API}/stats/budget-progress",
params={"month": "2026-06"},
headers=auth_headers,
).json()
assert budgets["month"] == "2026-06"
sankey = client.get(
f"{API}/stats/sankey", params={"month": "2026-06"}, headers=auth_headers
).json()
names = [node["name"] for node in sankey["nodes"]]
assert "Revenus" in names
assert len(names) == len(set(names))
assert all(link["value"] > 0 for link in sankey["links"])
dashboard = client.get(f"{API}/dashboard", headers=auth_headers).json()
assert dashboard["accounts_count"] == 1
assert dashboard["uncategorized_count"] == 7
# 100 € initial balance + the net of the 7 imported rows.
assert dashboard["total_balance"] == 1688.61
assert dashboard["month_expenses"] == 0.0 # the file covers June 2026
bad_month = client.get(
f"{API}/stats/budget-progress",
params={"month": "2026-13"},
headers=auth_headers,
)
assert bad_month.status_code == 422