[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>
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user