#!/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)