35a20247f8
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>
337 lines
13 KiB
Python
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)
|