import unittest import sys from pathlib import Path from types import SimpleNamespace sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.core.decision_types import type_decision_depuis_proposition from app.core.financial_position import PositionTresorerie from app.core.sanitization import safe_query_params from app.core.security import create_token, decode_token from app.models.poste import Poste from app.models.affectation import Affectation from app.models.candidature import Candidature from app.models.collecte import Collecte from app.models.depense import Depense from app.models.evenement import Evenement from app.models.jeton import Jeton from app.models.instance_admin import InstanceAdmin from app.models.litterature import Litterature from app.models.presence import Presence from app.models.proposition import Proposition from app.models.pv_reunion import PvReunion from app.models.reunion import Reunion from app.models.vente_litterature import VenteLitterature from app.models.vente_jeton import VenteJeton class DecisionTypeTests(unittest.TestCase): def test_resolution_prefix(self): prop = SimpleNamespace(numero_resolution="RES-2026-001", candidature_id=None) self.assertEqual(type_decision_depuis_proposition(prop), "resolution") def test_nomination_from_candidature(self): prop = SimpleNamespace(numero_resolution=None, candidature_id="candidate-id") self.assertEqual(type_decision_depuis_proposition(prop), "nomination") def test_contribution_prefix(self): prop = SimpleNamespace(numero_resolution="DON-2026-001", candidature_id=None) self.assertEqual(type_decision_depuis_proposition(prop), "contribution") def test_plain_proposition_without_decision_marker(self): prop = SimpleNamespace(numero_resolution=None, candidature_id=None) self.assertEqual(type_decision_depuis_proposition(prop), "proposition") class PositionTresorerieTests(unittest.TestCase): def test_available_bank_excludes_reserves_and_commitments(self): position = PositionTresorerie( solde_banque=100.0, encaisse=25.0, total_reserves=40.0, engagements_non_debites=15.0, ) self.assertEqual(position.disponible_bancaire, 45.0) self.assertEqual(position.disponible_total, 70.0) class AuditTests(unittest.TestCase): def test_sensitive_query_params_are_masked(self): request = SimpleNamespace(query_params={"pin": "1234", "membre_id": "abc"}) self.assertEqual(safe_query_params(request.query_params), {"pin": "***", "membre_id": "abc"}) class SecurityTokenTests(unittest.TestCase): def test_sysadmin_instance_token_has_no_group_context(self): token = create_token("admin-id", None, expire_jours=1, subject_type="instance_admin") payload = decode_token(token) self.assertEqual(payload["sub"], "admin-id") self.assertEqual(payload["typ"], "instance_admin") self.assertNotIn("grp", payload) class MultiTenantTests(unittest.TestCase): def test_postes_are_group_scoped(self): self.assertIn("groupe_id", Poste.__table__.columns) self.assertFalse(Poste.__table__.columns["groupe_id"].nullable) def test_instance_admin_is_not_group_scoped(self): self.assertNotIn("groupe_id", InstanceAdmin.__table__.columns) self.assertTrue(InstanceAdmin.__table__.columns["identifiant"].unique) def test_core_business_tables_are_group_scoped(self): for model in [ Affectation, Candidature, Collecte, Depense, Evenement, Jeton, Litterature, Proposition, Reunion, VenteLitterature, VenteJeton, ]: with self.subTest(model=model.__name__): self.assertIn("groupe_id", model.__table__.columns) self.assertFalse(model.__table__.columns["groupe_id"].nullable) def test_child_tables_without_group_id_are_documented_exceptions(self): exceptions = { "presences": "scoped_by_reunion", "pv_reunions": "scoped_by_reunion", } for model in [Presence, PvReunion]: with self.subTest(model=model.__name__): self.assertNotIn("groupe_id", model.__table__.columns) self.assertIn(model.__tablename__, exceptions) def test_role_fallback_queries_are_group_bounded(self): root = Path(__file__).resolve().parents[1] for rel in [ "app/routers/jetons.py", "app/routers/ventes_litterature.py", "app/routers/litterature.py", "app/routers/rapports_rsg.py", "app/routers/commandes_jetons.py", ]: source = (root / rel).read_text(encoding="utf-8") with self.subTest(router=rel): self.assertIn("Affectation.groupe_id == membre.groupe_id", source) self.assertIn("Poste.groupe_id == membre.groupe_id", source) def test_presence_batch_validates_members_in_current_group(self): source = (Path(__file__).resolve().parents[1] / "app/routers/presences.py").read_text(encoding="utf-8") self.assertIn("mbr.groupe_id != membre.groupe_id", source) self.assertIn("reunion.groupe_id != invitation.groupe_id", source) if __name__ == "__main__": unittest.main()