"""Tests de régression — API du gestionnaire d'immobilisations. Couvre : CRUD actifs, rattachement/détachement, porteur d'étiquette, mécanique DPA (demi-année, perte finale, récupération), export CSV, rapport fiscal, sauvegardes, factures jointes, seed-demo. Exécuter : .venv/bin/pytest tests/ -v """ import io import pytest from fastapi.testclient import TestClient from app.main import app client = TestClient(app) @pytest.fixture(autouse=True) def reset_db(): """Base et registre vides avant chaque test.""" client.post("/api/reset") yield client.post("/api/reset") # ─── Helpers ──────────────────────────────────────────────────────────────── def _newest(assets: list) -> dict: """Retourne l'actif avec le plus grand id (le dernier créé).""" return max(assets, key=lambda a: a["id"]) def _make_parent(name="Parent", cca=50) -> dict: r = client.post("/api/assets", json={"type": "parent", "name": name, "cca_class_id": cca}) return _newest(r.json()["assets"]) def _make_piece(name="Pièce", cost=500.0, acq=None, cca=50) -> dict: body = {"type": "piece", "name": name, "cost_cad": cost, "cca_class_id": cca} if acq: body["acquisition_date"] = acq r = client.post("/api/assets", json=body) return _newest(r.json()["assets"]) # ═══════════════════════════════════════════════════════════ # CRUD — actifs # ═══════════════════════════════════════════════════════════ def test_create_parent(): r = client.post("/api/assets", json={"type": "parent", "name": "Srv Test", "cca_class_id": 50}) assert r.status_code == 200 d = _newest(r.json()["assets"]) assert d["type"] == "parent" assert d["name"] == "Srv Test" assert d["view_cpa"]["cca_class"]["id"] == 50 def test_create_piece(): r = client.post("/api/assets", json={"type": "piece", "name": "RAM Test", "cost_cad": 420.0}) assert r.status_code == 200 d = _newest(r.json()["assets"]) assert d["type"] == "piece" assert float(d["view_cpa"]["capitalizable_base_cad"]) == pytest.approx(420.0) def test_list_assets(): client.post("/api/assets", json={"type": "parent", "name": "P1"}) client.post("/api/assets", json={"type": "piece", "name": "PC1", "cost_cad": 100.0}) r = client.get("/api/assets") assert r.status_code == 200 assert len(r.json()) == 2 def test_get_asset(): aid = _make_piece()["id"] r = client.get(f"/api/assets/{aid}") assert r.status_code == 200 assert r.json()["id"] == aid def test_get_asset_not_found(): r = client.get("/api/assets/99999") assert r.status_code == 404 def test_patch_name(): aid = _make_piece(name="Ancien")["id"] r = client.patch(f"/api/assets/{aid}", json={"changes": {"name": "Nouveau"}}) assert r.status_code == 200 updated = next(a for a in r.json()["assets"] if a["id"] == aid) assert updated["name"] == "Nouveau" def test_patch_cost(): aid = _make_piece(cost=100.0)["id"] r = client.patch(f"/api/assets/{aid}", json={"changes": {"view_cpa.capitalizable_base_cad": 999.0}}) assert r.status_code == 200 updated = next(a for a in r.json()["assets"] if a["id"] == aid) assert float(updated["view_cpa"]["capitalizable_base_cad"]) == pytest.approx(999.0) def test_delete_piece(): aid = _make_piece()["id"] r = client.delete(f"/api/assets/{aid}") assert r.status_code == 200 assert not any(a["id"] == aid for a in r.json()["assets"]) def test_delete_parent_frees_pieces(): """Supprimer un parent libère ses pièces (elles deviennent libres).""" p = _make_parent() pc = _make_piece(cost=100.0) client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) r = client.delete(f"/api/assets/{p['id']}") assert r.status_code == 200 assets = r.json()["assets"] child = next(a for a in assets if a["id"] == pc["id"]) assert child["parent_id"] is None # ═══════════════════════════════════════════════════════════ # Rattachement / détachement # ═══════════════════════════════════════════════════════════ def test_attach_updates_parent_base(): """La base amortissable du parent = somme exacte des coûts de ses pièces.""" p = _make_parent() pc1 = _make_piece(cost=1000.0) pc2 = _make_piece(cost=500.0) client.post(f"/api/assets/{pc1['id']}/attach", json={"parent_id": p["id"]}) client.post(f"/api/assets/{pc2['id']}/attach", json={"parent_id": p["id"]}) r = client.get(f"/api/assets/{p['id']}") assert float(r.json()["view_cpa"]["capitalizable_base_cad"]) == pytest.approx(1500.0) def test_detach_free(): p = _make_parent() pc = _make_piece() client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) r = client.post(f"/api/assets/{pc['id']}/detach", json={"disposition": "free"}) assert r.status_code == 200 detached = next(a for a in r.json()["assets"] if a["id"] == pc["id"]) assert detached["parent_id"] is None def test_detach_scrap(): p = _make_parent() pc = _make_piece() client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) r = client.post(f"/api/assets/{pc['id']}/detach", json={"disposition": "scrap"}) assert r.status_code == 200 scrapped = next(a for a in r.json()["assets"] if a["id"] == pc["id"]) assert scrapped["view_admin"]["operational_status"] == "Rebut" assert scrapped["parent_id"] is None def test_detach_reduces_parent_base(): p = _make_parent() pc1 = _make_piece(cost=1000.0) pc2 = _make_piece(cost=400.0) client.post(f"/api/assets/{pc1['id']}/attach", json={"parent_id": p["id"]}) client.post(f"/api/assets/{pc2['id']}/attach", json={"parent_id": p["id"]}) client.post(f"/api/assets/{pc2['id']}/detach", json={"disposition": "free"}) r = client.get(f"/api/assets/{p['id']}") assert float(r.json()["view_cpa"]["capitalizable_base_cad"]) == pytest.approx(1000.0) def test_set_tag_bearer(): p = _make_parent() pc = _newest(client.post("/api/assets", json={ "type": "piece", "name": "Porteur", "cost_cad": 500.0, "inventory_tag": "TAG-001" }).json()["assets"]) client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) r = client.post(f"/api/assets/{pc['id']}/set-tag-bearer") assert r.status_code == 200 bearer = next(a for a in r.json()["assets"] if a["id"] == pc["id"]) assert bearer["view_admin"]["is_tag_bearer"] is True def test_tag_bearer_unique_per_parent(): """Désigner un nouveau porteur retire le statut à l'ancien.""" p = _make_parent() pc1 = _newest(client.post("/api/assets", json={ "type": "piece", "name": "A", "cost_cad": 100.0, "inventory_tag": "T1" }).json()["assets"]) pc2 = _newest(client.post("/api/assets", json={ "type": "piece", "name": "B", "cost_cad": 200.0, "inventory_tag": "T2" }).json()["assets"]) client.post(f"/api/assets/{pc1['id']}/attach", json={"parent_id": p["id"]}) client.post(f"/api/assets/{pc2['id']}/attach", json={"parent_id": p["id"]}) client.post(f"/api/assets/{pc1['id']}/set-tag-bearer") r = client.post(f"/api/assets/{pc2['id']}/set-tag-bearer") assets = r.json()["assets"] assert next(a for a in assets if a["id"] == pc1["id"])["view_admin"]["is_tag_bearer"] is False assert next(a for a in assets if a["id"] == pc2["id"])["view_admin"]["is_tag_bearer"] is True # ═══════════════════════════════════════════════════════════ # Catégories DPA # ═══════════════════════════════════════════════════════════ def test_cca_categories(): r = client.get("/api/cca-categories") assert r.status_code == 200 ids = [c["id"] for c in r.json()] for expected in (50, 8, 10, 12, 46): assert expected in ids def test_cca_categories_have_rate(): cats = client.get("/api/cca-categories").json() for c in cats: assert 0 < c["dpa_rate"] <= 1.0 # ═══════════════════════════════════════════════════════════ # Registre pool DPA — mécanique fiscale # ═══════════════════════════════════════════════════════════ def test_pool_demi_annee_standard(): """Première année, ajouts = 10 000 $, coeff 0,5 → DPA = 2 750 $ (Cat. 50).""" r = client.post("/api/cca-registry/year", json={ "year": 2024, "cca_class_id": 50, "additions_override": 10_000.0, "dispositions": 0, "adjustment_coeff": 0.5, "opening_ucc_override": 0, }) assert r.status_code == 200 y = next(yr for yr in r.json()["years"] if yr["year"] == 2024) assert float(y["additions"]) == pytest.approx(10_000.0) assert float(y["adjustment"]) == pytest.approx(5_000.0) assert float(y["base_for_cca"]) == pytest.approx(5_000.0) assert float(y["cca_claimed"]) == pytest.approx(2_750.0) # 5 000 × 0.55 assert float(y["closing_ucc"]) == pytest.approx(7_250.0) def test_pool_annee_pleine(): """Deuxième année sans acquisitions → DPA pleine sur le solde résiduel.""" client.post("/api/cca-registry/year", json={ "year": 2023, "cca_class_id": 50, "additions_override": 10_000.0, "adjustment_coeff": 0.5, "opening_ucc_override": 0, }) r = client.post("/api/cca-registry/year", json={ "year": 2024, "cca_class_id": 50, "additions_override": 0, "adjustment_coeff": 1.0, }) y = next(yr for yr in r.json()["years"] if yr["year"] == 2024) # Ouverture 2024 = 7 250 $ (clôture 2023) → DPA = 7 250 × 0.55 = 3 987.50 assert float(y["opening_ucc"]) == pytest.approx(7_250.0) assert float(y["cca_claimed"]) == pytest.approx(3_987.50) def test_pool_recapture(): """Produit de cession > FNACC résiduelle → récupération d'amortissement.""" client.post("/api/cca-registry/year", json={ "year": 2022, "cca_class_id": 10, "additions_override": 0.0, "dispositions": 0, "adjustment_coeff": 0.5, "opening_ucc_override": 3_000.0, }) client.post("/api/cca-registry/year", json={ "year": 2023, "cca_class_id": 10, "additions_override": 0, "dispositions": 0, "adjustment_coeff": 1.0, }) # Vente à 2 500 $, FNACC < 2 500 → récupération r = client.post("/api/cca-registry/year", json={ "year": 2024, "cca_class_id": 10, "additions_override": 0, "dispositions": 2_500.0, "adjustment_coeff": 1.0, "pool_physically_empty": True, }) y = next(yr for yr in r.json()["years"] if yr["year"] == 2024) assert float(y["recapture"]) > 0, "Récupération attendue" assert float(y["terminal_loss"]) == 0 assert float(y["closing_ucc"]) == 0 def test_pool_perte_finale(): """Catégorie physiquement vide avec FNACC positive → perte finale.""" client.post("/api/cca-registry/year", json={ "year": 2024, "cca_class_id": 12, "additions_override": 3_000.0, "dispositions": 0, "adjustment_coeff": 0.5, "opening_ucc_override": 0, }) r = client.post("/api/cca-registry/year", json={ "year": 2025, "cca_class_id": 12, "additions_override": 0, "dispositions": 0, "adjustment_coeff": 1.0, "pool_physically_empty": True, }) y = next(yr for yr in r.json()["years"] if yr["year"] == 2025) assert float(y["terminal_loss"]) > 0, "Perte finale attendue" assert float(y["cca_claimed"]) == 0 assert float(y["closing_ucc"]) == 0 def test_pool_progression_continue(): """La FNACC de clôture d'une année est l'ouverture de la suivante.""" for yr, coeff in [(2022, 0.5), (2023, 1.0), (2024, 1.0)]: client.post("/api/cca-registry/year", json={ "year": yr, "cca_class_id": 50, "additions_override": 0, "dispositions": 0, "adjustment_coeff": coeff, **({"opening_ucc_override": 20_000.0} if yr == 2022 else {}), }) r = client.get("/api/cca-registry?cca_class_id=50") years = {y["year"]: y for y in r.json()["years"]} assert float(years[2022]["closing_ucc"]) == pytest.approx(float(years[2023]["opening_ucc"])) assert float(years[2023]["closing_ucc"]) == pytest.approx(float(years[2024]["opening_ucc"])) def test_delete_registry_year(): client.post("/api/cca-registry/year", json={"year": 2024, "cca_class_id": 50}) r = client.delete("/api/cca-registry/year/2024?cca_class_id=50") assert r.status_code == 200 assert not any(y["year"] == 2024 for y in r.json()["years"]) # ═══════════════════════════════════════════════════════════ # Annexe 8 / Schedule 8 # ═══════════════════════════════════════════════════════════ def test_annexe8_empty(): r = client.get("/api/annexe8?year=2024") assert r.status_code == 200 assert r.json()["classes"] == [] def test_annexe8_calcul_dpa_cat8(): """Annexe 8 utilise le bon taux de la catégorie (Cat. 8 = 20 %, pas 55 %).""" p = _make_parent(cca=8) pc = _make_piece(cost=10_000.0, acq="2024-01-01", cca=8) client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) client.post("/api/cca-registry/year", json={ "year": 2024, "cca_class_id": 8, "dispositions": 0, "adjustment_coeff": 0.5, }) r = client.get("/api/annexe8?year=2024") cls8 = next(c for c in r.json()["classes"] if c["cca_class_id"] == 8) y = cls8["year_data"] # adj = 0.5 × 10 000 = 5 000 ; base = 5 000 ; DPA = 5 000 × 0.20 = 1 000 assert float(y["cca_claimed"]) == pytest.approx(1_000.0) assert float(y["closing_ucc"]) == pytest.approx(9_000.0) def test_annexe8_cat50_calcul(): p = _make_parent(cca=50) pc = _make_piece(cost=20_000.0, acq="2023-03-01", cca=50) client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) client.post("/api/cca-registry/year", json={ "year": 2023, "cca_class_id": 50, "dispositions": 0, "adjustment_coeff": 0.5, }) r = client.get("/api/annexe8?year=2023") cls50 = next(c for c in r.json()["classes"] if c["cca_class_id"] == 50) y = cls50["year_data"] assert float(y["additions"]) == pytest.approx(20_000.0) assert float(y["cca_claimed"]) == pytest.approx(5_500.0) # 10 000 × 0.55 # ═══════════════════════════════════════════════════════════ # Export fiscal # ═══════════════════════════════════════════════════════════ def test_fiscal_export_base_correcte(): p = _make_parent(cca=50) for cost in (5_000.0, 1_500.0, 300.0): pc = _make_piece(cost=cost) client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) r = client.get("/api/fiscal-export") assert r.status_code == 200 cls = next(c for c in r.json()["classes"] if c["cca_class_id"] == 50) assert float(cls["total_base_cad"]) == pytest.approx(6_800.0) def test_fiscal_export_plusieurs_categories(): for cca in (50, 8, 46): p = _make_parent(cca=cca) pc = _make_piece(cost=1_000.0, cca=cca) client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) r = client.get("/api/fiscal-export") ids = [c["cca_class_id"] for c in r.json()["classes"]] assert 50 in ids and 8 in ids and 46 in ids def test_fiscal_export_csv(): r = client.get("/api/fiscal-export.csv") assert r.status_code == 200 assert "text/csv" in r.headers["content-type"] def test_annexe8_csv(): r = client.get("/api/annexe8.csv?year=2024") assert r.status_code == 200 assert "text/csv" in r.headers["content-type"] def test_cca_registry_csv(): r = client.get("/api/cca-registry.csv?cca_class_id=50") assert r.status_code == 200 assert "text/csv" in r.headers["content-type"] # ═══════════════════════════════════════════════════════════ # Rapport fiscal (Schedule 1 / Annexe 1) # ═══════════════════════════════════════════════════════════ def test_rapport_fiscal_structure(): r = client.get("/api/rapport-fiscal?year=2024") assert r.status_code == 200 d = r.json() assert d["year"] == 2024 s1 = d["schedule1"] for key in ("book_deprec_cad", "cca_claimed_cad", "recapture_cad", "terminal_loss_cad"): assert key in s1 def test_rapport_fiscal_html(): r = client.get("/api/rapport-fiscal.html?year=2024") assert r.status_code == 200 assert "text/html" in r.headers["content-type"] assert "Chezlepro" in r.text def test_rapport_schedule1_cca_coherent(): """DPA du Schedule 1 = DPA totale de l'Annexe 8.""" p = _make_parent(cca=50) pc = _make_piece(cost=10_000.0, acq="2024-01-01", cca=50) client.post(f"/api/assets/{pc['id']}/attach", json={"parent_id": p["id"]}) client.post("/api/cca-registry/year", json={ "year": 2024, "cca_class_id": 50, "dispositions": 0, "adjustment_coeff": 0.5, }) rapport = client.get("/api/rapport-fiscal?year=2024").json() annexe8 = client.get("/api/annexe8?year=2024").json() total_dpa_a8 = sum( float(c["year_data"]["cca_claimed"]) for c in annexe8["classes"] if c["year_data"] ) assert rapport["schedule1"]["cca_claimed_cad"] == pytest.approx(total_dpa_a8) # ═══════════════════════════════════════════════════════════ # Sauvegardes et restauration # ═══════════════════════════════════════════════════════════ def test_create_and_list_backup(): _make_piece(name="Avant backup", cost=999.0) r = client.post("/api/backup") assert r.status_code == 200 filename = r.json()["filename"] assert filename.startswith("immos_") and filename.endswith(".db") r2 = client.get("/api/backups") assert any(b["filename"] == filename for b in r2.json()) def test_restore_backup(): _make_piece(name="Avant backup", cost=999.0) filename = client.post("/api/backup").json()["filename"] client.post("/api/reset") assert client.get("/api/assets").json() == [] r = client.post("/api/restore", json={"filename": filename}) assert r.status_code == 200 restored = client.get("/api/assets").json() assert any(a["name"] == "Avant backup" for a in restored) def test_restore_invalide(): r = client.post("/api/restore", json={"filename": "../../etc/passwd"}) assert r.status_code == 400 r2 = client.post("/api/restore", json={"filename": "immos_1234-99-99_99-99-99.db"}) assert r2.status_code == 404 # ═══════════════════════════════════════════════════════════ # Reset — vide actifs ET registre # ═══════════════════════════════════════════════════════════ def test_reset_vide_actifs(): _make_piece() client.post("/api/reset") assert client.get("/api/assets").json() == [] def test_reset_vide_registre(): """Après reset, le registre ne conserve aucune année.""" client.post("/api/cca-registry/year", json={ "year": 2024, "cca_class_id": 50, "additions_override": 1_000.0 }) client.post("/api/reset") r = client.get("/api/cca-registry?cca_class_id=50") assert r.json()["years"] == [] # ═══════════════════════════════════════════════════════════ # Factures jointes # ═══════════════════════════════════════════════════════════ def test_invoice_upload_et_suppression(): contenu = b"%PDF-1.4 fichier test" files = {"file": ("facture.pdf", io.BytesIO(contenu), "application/pdf")} r = client.post("/api/invoices", files=files) assert r.status_code == 200 file_id = r.json()["file_id"] assert file_id.endswith(".pdf") assert len(file_id) == 36 # 32 hex + ".pdf" r2 = client.get(f"/api/invoices/{file_id}") assert r2.status_code == 200 assert r2.content == contenu client.delete(f"/api/invoices/{file_id}") assert client.get(f"/api/invoices/{file_id}").status_code == 404 def test_invoice_upload_image(): contenu = b"\xff\xd8\xff\xe0" # en-tête JPEG minimal files = {"file": ("photo.jpg", io.BytesIO(contenu), "image/jpeg")} r = client.post("/api/invoices", files=files) assert r.status_code == 200 assert r.json()["file_id"].endswith(".jpg") def test_invoice_extension_invalide(): files = {"file": ("script.exe", io.BytesIO(b"MZ"), "application/octet-stream")} r = client.post("/api/invoices", files=files) assert r.status_code == 400 def test_invoice_path_traversal(): # file_id avec des caractères invalides (non-hex) → rejeté par la validation ou le routage r = client.get("/api/invoices/INVALID_FILE_ID_WITH_UPPER_CASE.pdf") assert r.status_code in (400, 404, 422) def test_invoice_inexistant(): r = client.get("/api/invoices/" + "a" * 32 + ".pdf") assert r.status_code == 404 # ═══════════════════════════════════════════════════════════ # Données de démonstration — seed-demo # ═══════════════════════════════════════════════════════════ def test_seed_demo_actifs(): r = client.post("/api/seed-demo") assert r.status_code == 200 assets = r.json()["assets"] parents = [a for a in assets if a["type"] == "parent"] pieces = [a for a in assets if a["type"] == "piece"] assert len(parents) >= 6, "Au moins 6 immobilisations attendues" assert len(pieces) >= 10, "Au moins 10 pièces attendues" # Plusieurs catégories DPA cat_ids = {a["view_cpa"]["cca_class"]["id"] for a in parents} assert len(cat_ids) >= 4, f"Au moins 4 catégories attendues, obtenu : {cat_ids}" # Pièces au rebut rebuts = [a for a in pieces if a["view_admin"]["operational_status"] == "Rebut"] assert len(rebuts) >= 2, "Au moins 2 pièces au rebut (Veeam + Broadcom)" # Porteurs d'étiquette bearers = [a for a in pieces if a["view_admin"].get("is_tag_bearer")] assert len(bearers) >= 4, "Un porteur par immobilisation active" # Pièces libres (stock) libres = [a for a in pieces if a["parent_id"] is None and a["view_admin"]["operational_status"] != "Rebut"] assert len(libres) >= 2, "Des pièces de rechange en stock" def test_seed_demo_registre_cat50(): client.post("/api/seed-demo") r = client.get("/api/cca-registry?cca_class_id=50") assert r.status_code == 200 assert len(r.json()["years"]) >= 3, "3 exercices pour Cat. 50 (2023-2025)" def test_seed_demo_perte_finale_cat12(): """Cat. 12 — perte finale en 2025 : licence Veeam non renouvelée.""" client.post("/api/seed-demo") r = client.get("/api/annexe8?year=2025") cls12 = next((c for c in r.json()["classes"] if c["cca_class_id"] == 12), None) assert cls12 is not None, "Cat. 12 doit apparaître dans l'Annexe 8" y = cls12["year_data"] assert y is not None assert float(y["terminal_loss"]) > 0, "Perte finale attendue en 2025 (Cat. 12)" def test_seed_demo_recuperation_cat10(): """Cat. 10 — récupération en 2024 : IBM vendu pour plus que la FNACC.""" client.post("/api/seed-demo") r = client.get("/api/annexe8?year=2024") cls10 = next((c for c in r.json()["classes"] if c["cca_class_id"] == 10), None) assert cls10 is not None, "Cat. 10 doit apparaître dans l'Annexe 8" y = cls10["year_data"] assert y is not None assert float(y["recapture"]) > 0, "Récupération attendue en 2024 (Cat. 10)" def test_seed_demo_bases_coherentes(): """La base de chaque parent = somme des coûts de ses pièces rattachées.""" client.post("/api/seed-demo") assets = client.get("/api/assets").json() pieces_by_parent: dict[int, list[float]] = {} for a in assets: if a["type"] == "piece" and a["parent_id"] is not None: pieces_by_parent.setdefault(a["parent_id"], []).append( float(a["view_cpa"]["capitalizable_base_cad"]) ) for a in assets: if a["type"] != "parent" or a["id"] not in pieces_by_parent: continue expected = sum(pieces_by_parent[a["id"]]) actual = float(a["view_cpa"]["capitalizable_base_cad"]) assert actual == pytest.approx(expected, rel=1e-4), ( f"Base incohérente pour « {a['name']} » : {actual} ≠ {expected}" ) def test_seed_double_idempotent(): """Deux appels seed-demo successifs ne provoquent pas d'erreur.""" assert client.post("/api/seed-demo").status_code == 200 assert client.post("/api/seed-demo").status_code == 200