[DTP-Worker] Sprint 2 · Générateur Role Profile par portail (RBAC 50 rôles → 6 bundles assignables ERPNext v15)
- roleprofile_gen/ : profilelib (frappe Role Profile + Has Role natif v15, builder déterministe), roleprofile.schema.json, CLI build/validate refusant d'écrire si invariant cassé, 11 tests stdlib, README, .gitignore (out/). - 6 profils couvrant les 50 rôles de façon bijective (5 portails métier + console technique plateforme). Aucun DocType custom (que du natif). - Anti-invention #6 : rôles 100 % issus du contrat, nom de profil = convention déterministe dérivée de la clé portail. - CI : job rbac-roleprofile-tests ajouté au gate (.gitea/workflows/ci.yml). - Doc : SPEC §7 ét.5 + encart livré. Daily report session 8. - Régression : 83 tests verts (72 + 11). Gate CI local vert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""Tests du générateur de Role Profile (stdlib `unittest`, zéro pip).
|
||||
|
||||
Réutilise le validateur maison Publiciste (workflow #5 · pas de duplication) et,
|
||||
s'il est installé, l'oracle `jsonschema` en plus. Invariants couverts :
|
||||
1. Le bundle valide contre roleprofile.schema.json (maison + oracle).
|
||||
2. Couverture BIJECTIVE : chaque rôle du contrat dans exactement un profil.
|
||||
3. Un profil par portail distinct du contrat ; nom = convention déterministe.
|
||||
4. Cohérence portail : tous les rôles d'un profil partagent le même portail.
|
||||
5. Manifeste fidèle : portails, flag métier (vs plateforme), comptes par profil.
|
||||
6. Anti-invention : aucun rôle hors contrat, aucun DocType custom (que du natif v15).
|
||||
7. Déterminisme : deux builds successifs bit-à-bit identiques + tris stables.
|
||||
8. Garde-fous : portail vide et profil vide → ValueError.
|
||||
9. Le CLI validate fonctionne de bout en bout.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_GEN = os.path.normpath(os.path.join(_HERE, "..")) # roleprofile_gen/
|
||||
_RBAC = os.path.normpath(os.path.join(_GEN, "..")) # rbac/
|
||||
_DELIVERABLES = os.path.normpath(os.path.join(_RBAC, "..")) # 05_deliverables_mvp/
|
||||
|
||||
sys.path.insert(0, _GEN)
|
||||
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
|
||||
|
||||
from profilelib import builder, frappe # noqa: E402
|
||||
from lib import validator as maison # noqa: E402
|
||||
|
||||
_CONTRACT = os.path.join(_RBAC, "rbac_50_roles.json")
|
||||
_SCHEMA = os.path.join(_GEN, "roleprofile.schema.json")
|
||||
|
||||
|
||||
def _load(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _load_cli():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"roleprofile_gen", os.path.join(_GEN, "roleprofile_gen.py")
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class RoleProfileGenTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.contract = _load(_CONTRACT)
|
||||
cls.schema = _load(_SCHEMA)
|
||||
cls.bundle = builder.build_bundle(cls.contract)
|
||||
cls.profiles = cls.bundle["role_profile"]
|
||||
cls.portail_of_role = {
|
||||
r["erpnext_role_name"]: r["portail"] for r in cls.contract["roles"]
|
||||
}
|
||||
|
||||
# 1 · conformité au schéma (validateur maison)
|
||||
def test_bundle_valide_schema_maison(self):
|
||||
errors = maison.validate(self.bundle, self.schema)
|
||||
self.assertEqual(errors, [], f"Erreurs de schéma (maison) : {errors}")
|
||||
|
||||
# 1bis · oracle jsonschema si disponible
|
||||
def test_bundle_valide_oracle_jsonschema(self):
|
||||
try:
|
||||
import jsonschema # noqa: PLC0415
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema non installé — oracle ignoré")
|
||||
jsonschema.validate(self.bundle, self.schema)
|
||||
|
||||
# 2 · couverture bijective des 50 rôles
|
||||
def test_couverture_bijective_50_roles(self):
|
||||
assigned = [row["role"] for p in self.profiles for row in p["roles"]]
|
||||
self.assertEqual(len(assigned), 50)
|
||||
self.assertEqual(len(assigned), len(set(assigned)), "Rôle dans plusieurs profils")
|
||||
self.assertEqual(set(assigned), set(self.portail_of_role), "Couverture ≠ contrat")
|
||||
|
||||
# 3 · un profil par portail distinct, nom = convention déterministe
|
||||
def test_un_profil_par_portail(self):
|
||||
portails = {r["portail"] for r in self.contract["roles"]}
|
||||
noms = {p["name"] for p in self.profiles}
|
||||
self.assertEqual(len(self.profiles), len(portails))
|
||||
self.assertEqual(
|
||||
noms, {frappe.role_profile_name(p) for p in portails}
|
||||
)
|
||||
for p in self.profiles:
|
||||
self.assertEqual(p["name"], p["role_profile"])
|
||||
self.assertTrue(p["name"].startswith("OTO Portail "))
|
||||
|
||||
# 4 · cohérence portail : un profil ne mélange pas les portails
|
||||
def test_coherence_portail(self):
|
||||
for p in self.profiles:
|
||||
portails = {self.portail_of_role[row["role"]] for row in p["roles"]}
|
||||
self.assertEqual(len(portails), 1, f"{p['name']} mélange {portails}")
|
||||
portail = portails.pop()
|
||||
self.assertEqual(p["name"], frappe.role_profile_name(portail))
|
||||
|
||||
# 5 · manifeste fidèle (portails, flag métier, comptes)
|
||||
def test_manifeste_fidele(self):
|
||||
m = self.bundle["manifest"]
|
||||
portails = {r["portail"] for r in self.contract["roles"]}
|
||||
metier = set(self.contract["portails_business"])
|
||||
self.assertEqual({x["portail"] for x in m["profiles"]}, portails)
|
||||
nb_reel = {}
|
||||
for p in self.profiles:
|
||||
portail = self.portail_of_role[p["roles"][0]["role"]]
|
||||
nb_reel[portail] = len(p["roles"])
|
||||
for x in m["profiles"]:
|
||||
self.assertEqual(x["metier"], x["portail"] in metier)
|
||||
self.assertEqual(x["nb_roles"], nb_reel[x["portail"]])
|
||||
self.assertEqual(x["role_profile"], frappe.role_profile_name(x["portail"]))
|
||||
self.assertEqual(m["counts"]["roles_couverts"], 50)
|
||||
self.assertEqual(m["counts"]["role_profiles"], len(self.profiles))
|
||||
self.assertEqual(m["counts"]["portails_metier"], len(metier & portails))
|
||||
|
||||
# 6 · anti-invention : que du natif v15, aucun rôle hors contrat
|
||||
def test_anti_invention(self):
|
||||
for p in self.profiles:
|
||||
self.assertEqual(p["doctype"], "Role Profile")
|
||||
for row in p["roles"]:
|
||||
self.assertEqual(row["doctype"], "Has Role")
|
||||
self.assertIn(row["role"], self.portail_of_role, "Rôle hors contrat")
|
||||
|
||||
# 7 · déterminisme + tris stables
|
||||
def test_deterministe(self):
|
||||
a = json.dumps(builder.build_bundle(_load(_CONTRACT)))
|
||||
b = json.dumps(builder.build_bundle(_load(_CONTRACT)))
|
||||
self.assertEqual(a, b, "Génération non déterministe")
|
||||
portails_seq = [x["portail"] for x in self.bundle["manifest"]["profiles"]]
|
||||
self.assertEqual(portails_seq, sorted(portails_seq), "profils non triés")
|
||||
for p in self.profiles:
|
||||
roles_seq = [row["role"] for row in p["roles"]]
|
||||
self.assertEqual(roles_seq, sorted(roles_seq), f"{p['name']}: rôles non triés")
|
||||
|
||||
# 8 · garde-fous
|
||||
def test_portail_vide_rejete(self):
|
||||
with self.assertRaises(ValueError):
|
||||
frappe.role_profile_name("")
|
||||
|
||||
def test_profil_vide_rejete(self):
|
||||
with self.assertRaises(ValueError):
|
||||
frappe.role_profile_fixture("ventes", [])
|
||||
|
||||
# 9 · CLI validate de bout en bout
|
||||
def test_cli_validate_ok(self):
|
||||
cli = _load_cli()
|
||||
self.assertEqual(cli.main(["validate"]), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user