Application web locale (FastAPI + SQLite + React SPA) pour la gestion des immobilisations fiscales d'une PME québécoise (T2 / CO-17). ─── Modèle de données ─────────────────────────────────────────────────────── • Deux niveaux : PARENT (immobilisation fiscale) + PIÈCE (inventaire physique) • Trois vues par actif : Propriétaire, CPA, Auditeur • Porteur d'étiquette d'assemblage (unique par immobilisation) • Piste d'audit journalisée sur chaque modification ─── Mécanique fiscale ─────────────────────────────────────────────────────── • Pool DPA par catégorie (Cat. 8, 10, 12, 46, 50) — amortissement dégressif • Règle de la demi-année paramétrable (coeff. 0..1 par exercice) • Perte finale et récupération d'amortissement calculées automatiquement • Annexe 8 / Schedule 8 multi-catégories (JSON + CSV téléchargeable) • Rapport fiscal HTML imprimable — lignes Schedule 1 (T2 : 104/107/410/414 ; CO-17 : 130/135/150/155) • Export immobilisations par catégorie (JSON + CSV) ─── Fonctionnalités ───────────────────────────────────────────────────────── • CRUD complet : parents et pièces, rattachement/détachement (libre/stock/rebut) • Écriture comptable générée au détachement • Registre de pool DPA multi-exercices avec override manuel • Sauvegarde/restauration (SQLite native backup, horodatée) • Factures jointes aux pièces (PDF/JPG/PNG, max 20 Mo, UUID + regex) • UI mobile-first : bottom nav, FAB, bottom sheet, safe-area iPhone • Guide utilisateur complet in-app (aide.html, 11 sections) • Données de démonstration riches (6 immos, 5 catégories, perte finale + récupération incluses) ─── Qualité ───────────────────────────────────────────────────────────────── • 50 tests pytest — 100 % verts (calculs DPA, export, backup, factures, seed) • Lancement en 1 commande : ./lancer.sh (Mac/Linux) ou lancer.bat (Windows) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
636 lines
26 KiB
Python
636 lines
26 KiB
Python
"""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
|