Files
oto-enterprise-os-dtp/05_deliverables_mvp/legal/confotur/tests/test_confotur.py
T
Claude Code DTP Worker 35a20247f8 [DTP-Worker] Sprint 5 · Générateur DocType CONFOTUR Application (ONAPI/Legal · roadmap L55)
DocType custom porteur des dossiers d'incitation touristique CONFOTUR (Ley
158-01, RD), référencé par RBAC (3 rôles) et le workflow vente mais jamais
généré. Permissions = permissions_cibles RBAC mot pour mot ; is_submittable
déduit de l'action submit ; estado/dossier_vente dérivés du workflow ; aucun
taux/loi/montant fabriqué (#6). 14 invariants, 44 tests, hand-off dans out/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 08:09:41 +00:00

337 lines
13 KiB
Python

#!/usr/bin/env python3
"""Tests du generateur du DocType `CONFOTUR Application` (Sprint 5 · ONAPI/Legal).
Stdlib pur (`unittest`) -> aucune installation pip requise sur le runner Gitea.
La bibliotheque `jsonschema` sert d'*oracle* quand elle est presente, pour se
premunir d'un ecart entre le validateur maison et draft-07.
Axe central : la CROSS-COHERENCE DocType <-> RBAC <-> workflow. Les permissions
du DocType DOIVENT etre, mot pour mot, les `permissions_cibles` du contrat RBAC ;
le caractere soumissible en decoule ; `estado` et `dossier_vente` derivent du
workflow ; aucun chiffre CONFOTUR n'est fabrique (#6).
"""
from __future__ import annotations
import copy
import json
import os
import subprocess
import sys
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_MODULE = os.path.normpath(os.path.join(_HERE, ".."))
_LEGAL = os.path.normpath(os.path.join(_MODULE, ".."))
_DELIVERABLES = os.path.normpath(os.path.join(_LEGAL, ".."))
_CRM = os.path.join(_DELIVERABLES, "crm")
sys.path.insert(0, _MODULE)
sys.path.insert(0, _CRM)
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
from cflib import builder, frappe, rbac_scan # noqa: E402
from workflow_vente.wflib.rbac import RoleResolver # noqa: E402
from lib import validator as maison # type: ignore # noqa: E402
import confotur_application_gen as gen # noqa: E402
try:
import jsonschema # type: ignore
_HAS_JSONSCHEMA = True
except Exception: # pragma: no cover
_HAS_JSONSCHEMA = False
def _load(path: str) -> dict:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
class BaseFixture(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.spec = _load(gen._SPEC_PATH)
cls.wf_spec = _load(gen._WF_SPEC_PATH)
cls.schema = _load(gen._SCHEMA_PATH)
cls.contract = rbac_scan.load_contract()
cls.resolver = RoleResolver.from_path()
cls.bundle = builder.build_bundle(cls.spec, cls.wf_spec, cls.contract, cls.resolver)
cls.dt = cls.bundle["doctype"]
cls.manifest = cls.bundle["manifest"]
cls.fields = {f["fieldname"]: f for f in cls.dt["fields"]}
class TestBundleValid(BaseFixture):
def test_validate_green(self) -> None:
errors = gen._validate(self.bundle, self.spec, self.wf_spec, self.contract, self.resolver)
self.assertEqual(errors, [], f"invariants: {errors}")
def test_schema_maison(self) -> None:
self.assertEqual(maison.validate(self.bundle, self.schema), [])
@unittest.skipUnless(_HAS_JSONSCHEMA, "jsonschema absent")
def test_schema_oracle(self) -> None:
jsonschema.validate(self.bundle, self.schema) # type: ignore
def test_deterministe(self) -> None:
again = builder.build_bundle(self.spec, self.wf_spec, self.contract, self.resolver)
self.assertEqual(
json.dumps(self.bundle, sort_keys=True, ensure_ascii=False),
json.dumps(again, sort_keys=True, ensure_ascii=False),
)
def test_top_level_keys(self) -> None:
self.assertEqual(set(self.bundle), {"manifest", "doctype"})
class TestDocTypeIdentity(BaseFixture):
def test_name_is_target(self) -> None:
self.assertEqual(self.dt["name"], "CONFOTUR Application")
self.assertEqual(self.manifest["doctype_name"], "CONFOTUR Application")
def test_custom(self) -> None:
self.assertEqual(self.dt["custom"], 1)
self.assertTrue(self.manifest["custom"])
def test_native_envelope(self) -> None:
self.assertEqual(self.dt["doctype"], "DocType")
self.assertEqual(self.dt["engine"], "InnoDB")
self.assertEqual(self.dt["autoname"], "naming_series:")
def test_naming_series_readonly(self) -> None:
ns = self.fields["naming_series"]
self.assertEqual(ns["options"], self.spec["doctype"]["naming_series"])
self.assertEqual(ns["read_only"], 1)
def test_field_order_matches_fields(self) -> None:
self.assertEqual(self.dt["field_order"], [f["fieldname"] for f in self.dt["fields"]])
def test_unique_fieldnames(self) -> None:
names = [f["fieldname"] for f in self.dt["fields"]]
self.assertEqual(len(names), len(set(names)))
def test_title_and_search_fields_real(self) -> None:
self.assertIn(self.dt["title_field"], self.fields)
for sf in self.dt["search_fields"].split(","):
self.assertIn(sf, self.fields)
class TestPermissionsFromRBAC(BaseFixture):
"""Coeur du livrable : les DocPerm SONT les permissions_cibles RBAC."""
def setUp(self) -> None:
self.targeting = rbac_scan.roles_targeting("CONFOTUR Application", self.contract)
self.by_name = {self.resolver.erpnext_name(r["role_id"]): r for r in self.targeting}
def test_three_roles(self) -> None:
self.assertEqual(len(self.targeting), 3)
self.assertEqual(
{r["role_id"] for r in self.targeting},
{"ventes-confotur", "legal-onapi", "legal-directeur"},
)
def test_exhaustive_and_no_extra(self) -> None:
perm_roles = {p["role"] for p in self.dt["permissions"]}
self.assertEqual(perm_roles, set(self.by_name))
def test_actions_word_for_word(self) -> None:
for row in self.dt["permissions"]:
granted = {p for p in frappe.VALID_PERMS if row.get(p) == 1}
self.assertEqual(granted, set(self.by_name[row["role"]]["actions"]))
def test_permlevel_zero(self) -> None:
for row in self.dt["permissions"]:
self.assertEqual(row["permlevel"], 0)
def test_role_names_prefixed(self) -> None:
for row in self.dt["permissions"]:
self.assertTrue(row["role"].startswith("OTO "))
def test_report_permission_supported(self) -> None:
# legal-directeur porte `report` (specifique a ce module vs dossier_vente).
directeur = next(p for p in self.dt["permissions"] if p["role"] == "OTO Legal Directeur")
self.assertEqual(directeur.get("report"), 1)
class TestSubmittable(BaseFixture):
def test_submittable_because_directeur_submits(self) -> None:
self.assertEqual(self.dt["is_submittable"], 1)
self.assertTrue(self.manifest["is_submittable"])
def test_confotur_states_are_submitted(self) -> None:
ds = {s["update_value"]: s["doc_status"] for s in self.wf_spec["states"]}
for uv in builder.confotur_state_values(self.wf_spec):
self.assertEqual(ds[uv], "1")
class TestDerivedOptions(BaseFixture):
def test_dossier_vente_link_from_workflow(self) -> None:
self.assertEqual(self.fields["dossier_vente"]["options"], self.wf_spec["document_type"])
def test_projet_and_client_links(self) -> None:
self.assertEqual(self.fields["projet"]["options"], "Project")
self.assertEqual(self.fields["cliente"]["options"], "Customer")
def test_entite_options_from_spec(self) -> None:
self.assertEqual(
self.fields["entite_porteuse"]["options"].split("\n"), self.spec["entites"]
)
self.assertEqual(self.fields["entite_porteuse"]["reqd"], 1)
def test_estado_options_from_workflow(self) -> None:
opts = self.fields["estado"]["options"].split("\n")
self.assertEqual(opts[0], "") # facultatif a la creation
self.assertEqual([o for o in opts if o], builder.confotur_state_values(self.wf_spec))
def test_linked_doctypes_manifest(self) -> None:
linked = sorted({f.get("options") for f in self.dt["fields"]
if f["fieldtype"] == "Link" and f.get("options")})
self.assertEqual(self.manifest["doctypes_lies_a_confirmer"], linked)
class TestDepotEvents(BaseFixture):
def test_two_events_deposer_approuver(self) -> None:
de = self.manifest["depot_events"]
self.assertEqual(len(de), 2)
self.assertEqual({e["update_value"] for e in de},
{"confotur_depose", "confotur_approuve"})
def test_events_roles_have_doctype_access(self) -> None:
role_ids = {r["role_id"] for r in
rbac_scan.roles_targeting("CONFOTUR Application", self.contract)}
for e in self.manifest["depot_events"]:
self.assertIn(e["role_id"], role_ids)
def test_events_resolved_correctly(self) -> None:
for e in self.manifest["depot_events"]:
self.assertEqual(e["erpnext_role_name"], self.resolver.erpnext_name(e["role_id"]))
self.assertEqual(e["portail"], self.resolver.portail(e["role_id"]))
class TestAntiInvention(BaseFixture):
def test_no_money_fieldtype(self) -> None:
money = {"Currency", "Float", "Int", "Percent"}
for f in self.dt["fields"]:
self.assertNotIn(f["fieldtype"], money)
def test_no_default_except_naming_series(self) -> None:
for f in self.dt["fields"]:
if f["fieldname"] != "naming_series":
self.assertNotIn("default", f)
def test_note_present(self) -> None:
self.assertIn("data_room", self.manifest["note_anti_invention"])
self.assertRegex(self.manifest["note_anti_invention"], r"P05/P07")
class TestManifestCounts(BaseFixture):
def test_counts(self) -> None:
c = self.manifest["counts"]
fields = self.dt["fields"]
data = [f for f in fields if f["fieldtype"] not in frappe.LAYOUT_FIELDTYPES]
sections = [f for f in fields if f["fieldtype"] == "Section Break"]
self.assertEqual(c["fields"], len(fields))
self.assertEqual(c["data_fields"], len(data))
self.assertEqual(c["sections"], len(sections))
self.assertEqual(c["permissions"], len(self.dt["permissions"]))
self.assertEqual(c["roles"], 3)
self.assertEqual(c["depot_events"], 2)
class TestNegative(BaseFixture):
"""Le validateur DOIT rejeter toute derive introduite volontairement."""
def _val(self, bundle) -> list[str]:
return gen._validate(bundle, self.spec, self.wf_spec, self.contract, self.resolver)
def test_reject_tampered_permission(self) -> None:
b = copy.deepcopy(self.bundle)
# On ajoute `delete` a un role -> ne correspond plus au contrat RBAC.
b["doctype"]["permissions"][0]["delete"] = 1
self.assertTrue(self._val(b))
def test_reject_extra_role(self) -> None:
b = copy.deepcopy(self.bundle)
b["doctype"]["permissions"].append({"role": "OTO Faux Role", "permlevel": 0, "read": 1})
self.assertTrue(self._val(b))
def test_reject_wrong_submittable(self) -> None:
b = copy.deepcopy(self.bundle)
b["doctype"]["is_submittable"] = 0
self.assertTrue(self._val(b))
def test_reject_hardcoded_dossier_link(self) -> None:
b = copy.deepcopy(self.bundle)
for f in b["doctype"]["fields"]:
if f["fieldname"] == "dossier_vente":
f["options"] = "Some Other DocType"
self.assertTrue(self._val(b))
def test_reject_money_field(self) -> None:
b = copy.deepcopy(self.bundle)
b["doctype"]["fields"].append({"fieldname": "montant", "fieldtype": "Currency"})
b["doctype"]["field_order"].append("montant")
self.assertTrue(self._val(b))
def test_reject_smuggled_default(self) -> None:
b = copy.deepcopy(self.bundle)
for f in b["doctype"]["fields"]:
if f["fieldname"] == "estado":
f["default"] = "confotur_approuve"
self.assertTrue(self._val(b))
def test_reject_estado_extra_option(self) -> None:
b = copy.deepcopy(self.bundle)
for f in b["doctype"]["fields"]:
if f["fieldname"] == "estado":
f["options"] = f["options"] + "\netat_invente"
self.assertTrue(self._val(b))
def test_reject_bad_counts(self) -> None:
b = copy.deepcopy(self.bundle)
b["manifest"]["counts"]["roles"] = 99
self.assertTrue(self._val(b))
class TestBuilderGuards(BaseFixture):
def test_missing_target_role_raises(self) -> None:
contract = copy.deepcopy(self.contract)
for role in contract["roles"]:
role["permissions_cibles"] = [
pc for pc in role.get("permissions_cibles", [])
if pc.get("doctype") != "CONFOTUR Application"
]
with self.assertRaises(ValueError):
builder.build_bundle(self.spec, self.wf_spec, contract, self.resolver)
def test_duplicate_doctype_in_role_raises(self) -> None:
contract = copy.deepcopy(self.contract)
for role in contract["roles"]:
if role["id"] == "legal-onapi":
role["permissions_cibles"].append(
{"doctype": "CONFOTUR Application", "actions": ["read"]}
)
with self.assertRaises(ValueError):
rbac_scan.roles_targeting("CONFOTUR Application", contract)
def test_unknown_options_source_raises(self) -> None:
spec = copy.deepcopy(self.spec)
spec["field_groups"][0]["fields"][0]["options_source"] = "inexistant"
with self.assertRaises(ValueError):
builder.build_bundle(spec, self.wf_spec, self.contract, self.resolver)
class TestCLI(unittest.TestCase):
def test_build_and_validate_exit_zero(self) -> None:
for cmd in ("validate", "build"):
r = subprocess.run(
[sys.executable, os.path.join(_MODULE, "confotur_application_gen.py"), cmd],
capture_output=True, text=True,
)
self.assertEqual(r.returncode, 0, r.stderr)
if __name__ == "__main__":
unittest.main(verbosity=2)