[DTP-Worker 20260803_090713] Auto exec · session 20260803_090713
CI / Contraintes NON-NÉGOCIABLES (CLAUDE.md) (push) Has been cancelled
CI / Validation JSON (schémas Faisabilité) (push) Has been cancelled
CI / Qualité documentaire (liens + 4Big) (push) Has been cancelled
CI / Reproductibilité des artefacts out/ (build == commité) (push) Has been cancelled
CI / Fraîcheur matrice de régression (run == commité) (push) Has been cancelled
CI / Intégrité du câblage CI (gate agrège tout · gates statiques verrouillés) (push) Has been cancelled
CI / Intégrité des chiffres du README (valeur == artefact cité · (push) Has been cancelled
CI / Publiciste · parser + schéma + generator (unittest) (push) Has been cancelled
CI / RBAC · 50 rôles + schéma (unittest) (push) Has been cancelled
CI / Faisabilité · générateur 4 volets + round-trip (unittest) (push) Has been cancelled
CI / RBAC · fixtures ERPNext (Role + Custom DocPerm) (push) Has been cancelled
CI / RBAC · plan User Permission (row-level) (push) Has been cancelled
CI / RBAC · Role Profile (bundles par portail) (push) Has been cancelled
CI / RBAC · run-book d'application unifié (agrégat 3 volets) (push) Has been cancelled
CI / Faisabilité · dossier bancable trilingue FR/EN/ES (push) Has been cancelled
CI / CRM · workflow vente ERPNext (lead → CONFOTUR) (push) Has been cancelled
CI / CRM · DocType porteur OTO Dossier Vente (push) Has been cancelled
CI / CRM · barème commissions vendeurs (push) Has been cancelled
CI / CRM · Financement Bancaire (gate hypothécaire RD) (push) Has been cancelled
CI / Fiscal · e-CF DGII (Compupar) (push) Has been cancelled
CI / Frontend · Workspaces 5 portails rôle (push) Has been cancelled
CI / Legal · DocType CONFOTUR Application (push) Has been cancelled
CI / QA · Audit 5D conformité (push) Has been cancelled
CI / SEO · mots-clés trilingues + schema.org + hreflang (push) Has been cancelled
CI / Chat OTOIA · montage par portail (Custom Block) (push) Has been cancelled
CI / QA · Audit 4Big (95+/100 sur 100% deliverables) (push) Has been cancelled
CI / Démo · Scénarios (run-sheet P07 banquier / P05 client) (push) Has been cancelled
CI / QA · Matrice de régression exhaustive (Sprint 8) (push) Has been cancelled
CI / DevOps · Run-book de déploiement VPS unifié (Sprint 8) (push) Has been cancelled
CI / QA · Matrice d'acceptation / traçabilité MVP (Sprint 8) (push) Has been cancelled
CI / Mobile · config app Expo/EAS (navigation par rôle) (push) Has been cancelled
CI / E2E baseline Playwright (manuel) (push) Has been cancelled
CI / Gate qualité (agrégat) (push) Has been cancelled

This commit is contained in:
Claude Code DTP Worker
2026-08-03 09:22:13 +00:00
parent 1e8cce22a0
commit 9b502e67fe
35 changed files with 2066 additions and 33 deletions
@@ -0,0 +1,312 @@
"""Tests du module Financement Bancaire — cœur métier (gate) + générateur.
Deux familles :
1. Cœur métier `finlib.gate` — calcul d'apport 20/30 + gate check 4 conditions.
Fonctions pures → scénarios exhaustifs (chaque condition qui bloque, cas
nominal qui passe, frontière apport).
2. Générateur — validate/build, déterminisme (build == build), schéma +
12 invariants, résolution RBAC réelle.
"""
import copy
import json
import os
import subprocess
import sys
import tempfile
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_MODULE = os.path.normpath(os.path.join(_HERE, ".."))
_DELIVERABLES = os.path.normpath(os.path.join(_MODULE, "..", ".."))
sys.path.insert(0, _MODULE)
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
from finlib import gate # noqa: E402
from finlib.builder import build_bundle # noqa: E402
from finlib.rbac import RoleResolver # noqa: E402
_SPEC_PATH = os.path.join(_MODULE, "financement_spec.json")
_GEN = os.path.join(_MODULE, "financement_bancaire_gen.py")
def _load_spec():
with open(_SPEC_PATH, encoding="utf-8") as fh:
return json.load(fh)
class ApportTest(unittest.TestCase):
def setUp(self):
self.cfg = gate.GateConfig.from_spec(_load_spec())
def test_taux_resident_20_etranger_30(self):
self.assertEqual(self.cfg.taux_apport_pct["resident_rd"], 20.0)
self.assertEqual(self.cfg.taux_apport_pct["etranger"], 30.0)
def test_apport_requis_resident(self):
self.assertAlmostEqual(gate.apport_requis_usd(100000, "resident_rd", self.cfg), 20000.0)
def test_apport_requis_etranger(self):
self.assertAlmostEqual(gate.apport_requis_usd(100000, "etranger", self.cfg), 30000.0)
def test_apport_requis_type_inconnu_leve(self):
with self.assertRaises(KeyError):
gate.apport_requis_usd(100000, "martien", self.cfg)
def test_apport_verse_somme_paiements(self):
dossier = {"paiements": [{"montant_verse_usd": 5000}, {"montant_verse_usd": 2500.5}]}
self.assertAlmostEqual(gate.apport_verse_usd(dossier), 7500.5)
def test_apport_verse_aucun_paiement(self):
self.assertEqual(gate.apport_verse_usd({}), 0.0)
class RequiredDocumentsTest(unittest.TestCase):
def setUp(self):
self.cfg = gate.GateConfig.from_spec(_load_spec())
def test_etranger_exige_plus_que_resident(self):
res = set(gate.required_document_ids("resident_rd", self.cfg))
etr = set(gate.required_document_ids("etranger", self.cfg))
self.assertTrue(res < etr) # sous-ensemble strict
def test_resident_ninclut_pas_docs_etrangers(self):
res = gate.required_document_ids("resident_rd", self.cfg)
self.assertNotIn("lettre_origine_fonds", res)
self.assertNotIn("passeport_2cotes", res)
def test_etranger_inclut_docs_etrangers(self):
etr = gate.required_document_ids("etranger", self.cfg)
self.assertIn("lettre_origine_fonds", etr)
self.assertIn("refs_bancaires_intl", etr)
def test_document_optionnel_exclu(self):
# titre_definitif is_required=false → jamais dans la liste des exigés.
self.assertNotIn("titre_definitif", gate.required_document_ids("etranger", self.cfg))
def _dossier_complet(cfg, spec):
"""Un dossier qui satisfait les 4 conditions (résident RD, 100 000 USD)."""
required = gate.required_document_ids("resident_rd", cfg)
return {
"id": "T-OK",
"prix_usd": 100000,
"residence_type": "resident_rd",
"banque_choisie": "banreservas",
"paiements": [{"montant_verse_usd": 20000}], # 20 % pile
"documents": [{"id": d, "statut": "Validé WAG"} for d in required],
"autorisations": [{"id": a["id"], "signature_date": "2026-08-01"} for a in spec["autorisations"]],
"wag_validated_by": "ventes-conseiller:amelie",
}
class GateCheckTest(unittest.TestCase):
def setUp(self):
self.spec = _load_spec()
self.cfg = gate.GateConfig.from_spec(self.spec)
def test_dossier_complet_soumissible(self):
ok, reasons = gate.can_submit_dossier(_dossier_complet(self.cfg, self.spec), self.cfg)
self.assertTrue(ok)
self.assertEqual(reasons, [])
def test_apport_pile_au_seuil_passe(self):
# Exactement 20 % → apport complet (frontière incluse).
d = _dossier_complet(self.cfg, self.spec)
status = gate.gate_status(d, self.cfg)
self.assertTrue(status["conditions"]["apport_initial_complet"]["ok"])
self.assertEqual(status["conditions"]["apport_initial_complet"]["percent"], 100.0)
def test_apport_manquant_bloque(self):
d = _dossier_complet(self.cfg, self.spec)
d["paiements"] = [{"montant_verse_usd": 19999}]
ok, reasons = gate.can_submit_dossier(d, self.cfg)
self.assertFalse(ok)
self.assertTrue(any("Apport initial incomplet" in r for r in reasons))
c = gate.gate_status(d, self.cfg)["conditions"]["apport_initial_complet"]
self.assertAlmostEqual(c["remaining"], 1.0)
def test_document_non_valide_bloque(self):
d = _dossier_complet(self.cfg, self.spec)
d["documents"][0]["statut"] = "Déposé" # déposé mais pas validé WAG
ok, reasons = gate.can_submit_dossier(d, self.cfg)
self.assertFalse(ok)
self.assertTrue(any("Documents exigés incomplets" in r for r in reasons))
def test_document_envoye_banque_est_recevable(self):
d = _dossier_complet(self.cfg, self.spec)
d["documents"][0]["statut"] = "Envoyé banque"
ok, _ = gate.can_submit_dossier(d, self.cfg)
self.assertTrue(ok)
def test_document_manquant_compte_comme_non_depose(self):
d = _dossier_complet(self.cfg, self.spec)
d["documents"] = d["documents"][:-1] # retire un requis
c = gate.gate_status(d, self.cfg)["conditions"]["documents_exiges"]
self.assertFalse(c["ok"])
self.assertEqual(c["validated"], c["total"] - 1)
def test_autorisation_non_signee_bloque(self):
d = _dossier_complet(self.cfg, self.spec)
d["autorisations"][0]["signature_date"] = None
ok, reasons = gate.can_submit_dossier(d, self.cfg)
self.assertFalse(ok)
self.assertTrue(any("Autorisations non signées" in r for r in reasons))
def test_validation_wag_manquante_bloque(self):
d = _dossier_complet(self.cfg, self.spec)
d["wag_validated_by"] = None
ok, reasons = gate.can_submit_dossier(d, self.cfg)
self.assertFalse(ok)
self.assertTrue(any("Validation WAG manquante" in r for r in reasons))
def test_quatre_conditions_toutes_ko(self):
# Dossier vide → 4 raisons, 0 condition complétée.
d = {"id": "T-VIDE", "prix_usd": 100000, "residence_type": "etranger"}
status = gate.gate_status(d, self.cfg)
self.assertFalse(status["can_submit"])
self.assertEqual(status["conditions_completed"], 0)
ok, reasons = gate.can_submit_dossier(d, self.cfg)
self.assertFalse(ok)
self.assertEqual(len(reasons), 4)
def test_gate_status_forme_endpoint(self):
# Forme conforme au contrat de l'endpoint gate-status.
d = _dossier_complet(self.cfg, self.spec)
status = gate.gate_status(d, self.cfg)
self.assertIn("can_submit", status)
self.assertEqual(set(status["conditions"]), set(gate.CONDITION_KEYS))
for k in gate.CONDITION_KEYS:
self.assertIn("percent", status["conditions"][k])
def test_reasons_ordre_canonique(self):
d = {"id": "T", "prix_usd": 100000, "residence_type": "etranger"}
_, reasons = gate.can_submit_dossier(d, self.cfg)
self.assertIn("Apport", reasons[0])
self.assertIn("Documents", reasons[1])
self.assertIn("Autorisations", reasons[2])
self.assertIn("Validation WAG", reasons[3])
class BuilderTest(unittest.TestCase):
def setUp(self):
self.spec = _load_spec()
self.resolver = RoleResolver.from_path()
self.bundle = build_bundle(self.spec, self.resolver)
def test_bundle_cles(self):
for k in ("manifest", "banques", "sections", "documents", "autorisations", "gate_spec", "gate_status_example"):
self.assertIn(k, self.bundle)
def test_role_validation_resolu(self):
rv = self.bundle["gate_spec"]["role_validation"]
self.assertEqual(rv["role_id"], "ventes-conseiller")
self.assertEqual(rv["erpnext_role_name"], "OTO Ventes Conseiller")
def test_role_inconnu_leve(self):
bad = copy.deepcopy(self.spec)
bad["roles"]["validation_dossier"] = "role-inexistant"
with self.assertRaises(KeyError):
build_bundle(bad, self.resolver)
def test_sections_triees_par_ordre(self):
ordres = [s["ordre"] for s in self.bundle["sections"]]
self.assertEqual(ordres, sorted(ordres))
self.assertEqual(self.bundle["sections"][0]["id"], "apport_initial")
def test_exemple_est_bloque(self):
# Le dossier d'exemple DOIT démontrer un blocage (valeur pédagogique).
self.assertFalse(self.bundle["gate_status_example"]["can_submit"])
self.assertTrue(self.bundle["gate_status_example"]["reasons"])
def test_deterministe(self):
again = build_bundle(_load_spec(), RoleResolver.from_path())
self.assertEqual(
json.dumps(self.bundle, sort_keys=True, ensure_ascii=False),
json.dumps(again, sort_keys=True, ensure_ascii=False),
)
class GeneratorCliTest(unittest.TestCase):
def _run(self, *argv):
return subprocess.run(
[sys.executable, _GEN, *argv],
capture_output=True, text=True, cwd=_MODULE,
)
def test_validate_ok(self):
r = self._run("validate")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("12 invariants", r.stdout)
@staticmethod
def _read(path):
with open(path, encoding="utf-8") as fh:
return fh.read()
def test_build_reproductible(self):
with tempfile.TemporaryDirectory() as d:
r1 = self._run("build", "-o", d)
self.assertEqual(r1.returncode, 0, r1.stderr)
first = {f: self._read(os.path.join(d, f)) for f in os.listdir(d)}
r2 = self._run("build", "-o", d)
self.assertEqual(r2.returncode, 0, r2.stderr)
second = {f: self._read(os.path.join(d, f)) for f in os.listdir(d)}
self.assertEqual(first, second)
def test_build_egale_commite(self):
# L'artefact commité out/ doit être identique à un build frais (miroir
# local du gate ci/check_artifacts.sh).
with tempfile.TemporaryDirectory() as d:
self.assertEqual(self._run("build", "-o", d).returncode, 0)
for fname in ("banques.json", "sections.json", "documents.json",
"autorisations.json", "gate_spec.json",
"gate_status_example.json", "MANIFEST.json"):
fresh = self._read(os.path.join(d, fname))
committed_path = os.path.join(_MODULE, "out", fname)
if os.path.exists(committed_path):
self.assertEqual(fresh, self._read(committed_path),
f"{fname} a dérivé de sa source")
class InvariantGuardTest(unittest.TestCase):
"""Chaque invariant du générateur DOIT mordre une mutation (anti-régression)."""
def setUp(self):
sys.path.insert(0, _MODULE)
import financement_bancaire_gen as gen # noqa
self.gen = gen
self.spec = _load_spec()
self.resolver = RoleResolver.from_path()
def _errors(self, spec):
bundle = build_bundle(spec, self.resolver)
return self.gen._validate_bundle(spec, bundle)
def test_baseline_clean(self):
self.assertEqual(self._errors(self.spec), [])
def test_taux_ordre_mordu(self):
bad = copy.deepcopy(self.spec)
bad["apport"]["residence_types"][0]["taux_apport_pct"] = 40 # résident > étranger
self.assertTrue(any("Taux d'apport" in e for e in self._errors(bad)))
def test_section_gating_deplacee_mordue(self):
bad = copy.deepcopy(self.spec)
bad["sections"][0]["gating"] = False # plus aucune section gating
self.assertTrue(any("gating" in e for e in self._errors(bad)))
def test_id_duplique_mordu(self):
bad = copy.deepcopy(self.spec)
bad["banques"].append(dict(bad["banques"][0]))
self.assertTrue(any("dupliqué" in e for e in self._errors(bad)))
def test_ltv_incoherent_mordu(self):
bad = copy.deepcopy(self.spec)
bad["banques"][1]["ltv_min_pct"] = 99 # min > max (85)
self.assertTrue(any("LTV" in e for e in self._errors(bad)))
if __name__ == "__main__":
unittest.main()