[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:
Claude Code DTP Worker
2026-07-30 04:04:04 +00:00
parent b1b011ab81
commit 70e022ccd6
11 changed files with 879 additions and 2 deletions
@@ -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)