diff --git a/src/test/java/dev/lions/unionflow/server/entity/ReferentielComptableTest.java b/src/test/java/dev/lions/unionflow/server/entity/ReferentielComptableTest.java new file mode 100644 index 0000000..8305961 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/ReferentielComptableTest.java @@ -0,0 +1,62 @@ +package dev.lions.unionflow.server.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ReferentielComptableTest { + + @Test + @DisplayName("defaultFor null retourne SYSCOHADA") + void defaultForNullReturnsSyscohada() { + assertThat(ReferentielComptable.defaultFor(null)).isEqualTo(ReferentielComptable.SYSCOHADA); + } + + @Test + @DisplayName("defaultFor MUTUELLE_SANTE retourne SYCEBNL") + void defaultForMutuelleSanteReturnsSycebnl() { + assertThat(ReferentielComptable.defaultFor("MUTUELLE_SANTE")) + .isEqualTo(ReferentielComptable.SYCEBNL); + } + + @Test + @DisplayName("defaultFor ASSOCIATION (case-insensitive) retourne SYCEBNL") + void defaultForAssociationCaseInsensitiveReturnsSycebnl() { + assertThat(ReferentielComptable.defaultFor("association")).isEqualTo(ReferentielComptable.SYCEBNL); + assertThat(ReferentielComptable.defaultFor("Association")).isEqualTo(ReferentielComptable.SYCEBNL); + assertThat(ReferentielComptable.defaultFor("ASSOCIATION")).isEqualTo(ReferentielComptable.SYCEBNL); + } + + @Test + @DisplayName("defaultFor LIONS_CLUB / ONG / FONDATION / SYNDICAT / ORDRE_PROFESSIONNEL retourne SYCEBNL") + void defaultForBNLEntitiesReturnsSycebnl() { + for (String t : new String[] { + "LIONS_CLUB", "ONG", "FONDATION", "SYNDICAT", "ORDRE_PROFESSIONNEL", "PROJET_DEVELOPPEMENT" + }) { + assertThat(ReferentielComptable.defaultFor(t)) + .as("type=%s", t) + .isEqualTo(ReferentielComptable.SYCEBNL); + } + } + + @Test + @DisplayName("defaultFor SFD_TIER_1 retourne PCSFD_UMOA") + void defaultForSfdTier1ReturnsPcsfd() { + assertThat(ReferentielComptable.defaultFor("SFD_TIER_1")) + .isEqualTo(ReferentielComptable.PCSFD_UMOA); + assertThat(ReferentielComptable.defaultFor("SFD_CATEGORIE_III")) + .isEqualTo(ReferentielComptable.PCSFD_UMOA); + } + + @Test + @DisplayName("defaultFor type non listé retourne SYSCOHADA") + void defaultForUnknownReturnsSyscohada() { + assertThat(ReferentielComptable.defaultFor("MUTUELLE_EPARGNE_CREDIT")) + .isEqualTo(ReferentielComptable.SYSCOHADA); + assertThat(ReferentielComptable.defaultFor("COOPERATIVE_COMMERCIALE")) + .isEqualTo(ReferentielComptable.SYSCOHADA); + assertThat(ReferentielComptable.defaultFor("INCONNU_XYZ")) + .isEqualTo(ReferentielComptable.SYSCOHADA); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/pispi/dto/PispiRtpRequestTest.java b/src/test/java/dev/lions/unionflow/server/payment/pispi/dto/PispiRtpRequestTest.java new file mode 100644 index 0000000..84f2981 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/pispi/dto/PispiRtpRequestTest.java @@ -0,0 +1,90 @@ +package dev.lions.unionflow.server.payment.pispi.dto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class PispiRtpRequestTest { + + private PispiRtpRequest validRequest() { + return new PispiRtpRequest( + "RTP-001", + "SFD-CIV-001", + "ACC-1234", + "Mutuelle X", + "+22507123456@unionflow", + new BigDecimal("5000"), + "XOF", + "COTISATION", + "Cotisation oct 2026", + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(7)); + } + + @Test + @DisplayName("Request valide → validate() ne throw pas") + void validRequestPasses() { + validRequest().validate(); + } + + @Test + @DisplayName("RTP id manquant → IllegalArgumentException") + void rtpIdMissing() { + PispiRtpRequest r = new PispiRtpRequest( + "", "SFD", "ACC", "Mutuelle", "+225@unionflow", + new BigDecimal("5000"), "XOF", "P", "D", null, null); + assertThatThrownBy(r::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("RTP id"); + } + + @Test + @DisplayName("Montant <= 0 → IllegalArgumentException") + void montantNonPositif() { + PispiRtpRequest r = new PispiRtpRequest( + "RTP-1", "SFD", "ACC", "Mutuelle", "+225@unionflow", + BigDecimal.ZERO, "XOF", "P", "D", null, null); + assertThatThrownBy(r::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("positif"); + + PispiRtpRequest neg = new PispiRtpRequest( + "RTP-1", "SFD", "ACC", "Mutuelle", "+225@unionflow", + new BigDecimal("-100"), "XOF", "P", "D", null, null); + assertThatThrownBy(neg::validate).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("Alias débiteur manquant → IllegalArgumentException") + void aliasDebiteurMissing() { + PispiRtpRequest r = new PispiRtpRequest( + "RTP-1", "SFD", "ACC", "Mutuelle", "", + new BigDecimal("5000"), "XOF", "P", "D", null, null); + assertThatThrownBy(r::validate).hasMessageContaining("Alias débiteur"); + } + + @Test + @DisplayName("Devise non-XOF → IllegalArgumentException") + void deviseNonXof() { + PispiRtpRequest r = new PispiRtpRequest( + "RTP-1", "SFD", "ACC", "Mutuelle", "+225@unionflow", + new BigDecimal("5000"), "EUR", "P", "D", null, null); + assertThatThrownBy(r::validate).hasMessageContaining("XOF"); + } + + @Test + @DisplayName("Date expiration avant date exécution → IllegalArgumentException") + void expiryAvantExecution() { + LocalDateTime exec = LocalDateTime.now().plusDays(7); + LocalDateTime expiry = LocalDateTime.now().plusDays(2); + PispiRtpRequest r = new PispiRtpRequest( + "RTP-1", "SFD", "ACC", "Mutuelle", "+225@unionflow", + new BigDecimal("5000"), "XOF", "P", "D", exec, expiry); + assertThatThrownBy(r::validate).hasMessageContaining("expiration"); + } +} + diff --git a/src/test/java/dev/lions/unionflow/server/payment/pispi/dto/PispiRtpResponseTest.java b/src/test/java/dev/lions/unionflow/server/payment/pispi/dto/PispiRtpResponseTest.java new file mode 100644 index 0000000..087799a --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/pispi/dto/PispiRtpResponseTest.java @@ -0,0 +1,55 @@ +package dev.lions.unionflow.server.payment.pispi.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class PispiRtpResponseTest { + + @Test + @DisplayName("isAccepted true uniquement si status=ACCEPTED") + void isAccepted() { + var accepted = new PispiRtpResponse("R1", "ACCEPTED", null, null, null, "TX001"); + assertThat(accepted.isAccepted()).isTrue(); + assertThat(accepted.isRefused()).isFalse(); + assertThat(accepted.isPending()).isFalse(); + assertThat(accepted.isExpired()).isFalse(); + } + + @Test + @DisplayName("isRefused true uniquement si status=REFUSED") + void isRefused() { + var refused = new PispiRtpResponse("R1", "REFUSED", "FOCR", "Compte clôturé", null, null); + assertThat(refused.isRefused()).isTrue(); + assertThat(refused.isAccepted()).isFalse(); + assertThat(refused.isPending()).isFalse(); + assertThat(refused.isExpired()).isFalse(); + } + + @Test + @DisplayName("isPending true uniquement si status=PENDING") + void isPending() { + var pending = new PispiRtpResponse("R1", "PENDING", null, null, null, null); + assertThat(pending.isPending()).isTrue(); + assertThat(pending.isAccepted()).isFalse(); + } + + @Test + @DisplayName("isExpired true uniquement si status=EXPIRED") + void isExpired() { + var expired = new PispiRtpResponse("R1", "EXPIRED", null, null, null, null); + assertThat(expired.isExpired()).isTrue(); + assertThat(expired.isAccepted()).isFalse(); + } + + @Test + @DisplayName("Tous les helpers false si status null") + void statusNullAllFalse() { + var nullStatus = new PispiRtpResponse("R1", null, null, null, null, null); + assertThat(nullStatus.isAccepted()).isFalse(); + assertThat(nullStatus.isRefused()).isFalse(); + assertThat(nullStatus.isPending()).isFalse(); + assertThat(nullStatus.isExpired()).isFalse(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/security/SoDPermissionCheckerTest.java b/src/test/java/dev/lions/unionflow/server/security/SoDPermissionCheckerTest.java new file mode 100644 index 0000000..98335c7 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/security/SoDPermissionCheckerTest.java @@ -0,0 +1,130 @@ +package dev.lions.unionflow.server.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SoDPermissionCheckerTest { + + private SoDPermissionChecker checker; + private final UUID userA = UUID.randomUUID(); + private final UUID userB = UUID.randomUUID(); + private final UUID entityId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + checker = new SoDPermissionChecker(); + // AuditTrailService est injecté en prod ; on injecte un mock pour les tests. + checker.auditTrail = mock(AuditTrailService.class); + } + + @Test + @DisplayName("checkValidationDistinct PASS si créateur ≠ validateur") + void validationDistinctPass() { + var result = checker.checkValidationDistinct(userA, userB, "Cotisation", entityId); + assertThat(result.passed()).isTrue(); + assertThat(result.violationReason()).isNull(); + } + + @Test + @DisplayName("checkValidationDistinct VIOLATION si même utilisateur") + void validationDistinctViolation() { + var result = checker.checkValidationDistinct(userA, userA, "Cotisation", entityId); + assertThat(result.passed()).isFalse(); + assertThat(result.violationReason()).contains("SoD VIOLATION").contains("Cotisation"); + } + + @Test + @DisplayName("checkValidationDistinct PASS si UUID null (contexte manquant)") + void validationDistinctNullPass() { + assertThat(checker.checkValidationDistinct(null, userA, "X", entityId).passed()).isTrue(); + assertThat(checker.checkValidationDistinct(userA, null, "X", entityId).passed()).isTrue(); + } + + @Test + @DisplayName("checkRoleCombination : Trésorier + Président → VIOLATION") + void cumulTresorierPresidentInterdit() { + var result = checker.checkRoleCombination(userA, Set.of("TRESORIER", "PRESIDENT")); + assertThat(result.passed()).isFalse(); + assertThat(result.violationReason()).contains("TRESORIER + PRESIDENT"); + } + + @Test + @DisplayName("checkRoleCombination : Trésorier + Contrôleur Interne → VIOLATION (auto-contrôle)") + void cumulTresorierControleurInterne() { + var result = checker.checkRoleCombination(userA, Set.of("TRESORIER", "CONTROLEUR_INTERNE")); + assertThat(result.passed()).isFalse(); + assertThat(result.violationReason()).contains("auto-contrôle"); + } + + @Test + @DisplayName("checkRoleCombination : Président + Contrôleur Interne → VIOLATION (juge et partie)") + void cumulPresidentControleur() { + var result = checker.checkRoleCombination(userA, Set.of("PRESIDENT", "CONTROLEUR_INTERNE")); + assertThat(result.passed()).isFalse(); + assertThat(result.violationReason()).contains("juge et partie"); + } + + @Test + @DisplayName("checkRoleCombination : Commissaire aux comptes + autre → VIOLATION (indépendance OHADA)") + void cumulCommissaireInterdit() { + var result = checker.checkRoleCombination(userA, Set.of("COMMISSAIRE_COMPTES", "MEMBRE_ACTIF")); + assertThat(result.passed()).isFalse(); + assertThat(result.violationReason()).contains("COMMISSAIRE_COMPTES"); + assertThat(result.violationReason()).contains("indépendant"); + } + + @Test + @DisplayName("checkRoleCombination : un seul rôle → PASS") + void unSeulRolePass() { + assertThat(checker.checkRoleCombination(userA, Set.of("MEMBRE_ACTIF")).passed()).isTrue(); + assertThat(checker.checkRoleCombination(userA, Set.of("TRESORIER")).passed()).isTrue(); + } + + @Test + @DisplayName("checkRoleCombination : combinaison autorisée → PASS") + void combinaisonAutoriseePass() { + assertThat(checker.checkRoleCombination(userA, Set.of("MEMBRE_ACTIF", "ANIMATEUR_ZONE")).passed()) + .isTrue(); + assertThat(checker.checkRoleCombination(userA, Set.of("SECRETAIRE", "SECRETAIRE_ADJOINT")).passed()) + .isTrue(); + } + + @Test + @DisplayName("checkRoleCombination : null/empty → PASS") + void roleCombinaisonNull() { + assertThat(checker.checkRoleCombination(userA, null).passed()).isTrue(); + assertThat(checker.checkRoleCombination(userA, Set.of()).passed()).isTrue(); + } + + @Test + @DisplayName("checkComplianceOfficerEligibility : Trésorier interdit (Instruction BCEAO 001-03-2025)") + void complianceOfficerNotTresorier() { + var result = checker.checkComplianceOfficerEligibility( + userA, Set.of("TRESORIER", "MEMBRE_ACTIF")); + assertThat(result.passed()).isFalse(); + assertThat(result.violationReason()).contains("TRESORIER"); + } + + @Test + @DisplayName("checkComplianceOfficerEligibility : Commissaire aux comptes interdit (indépendance)") + void complianceOfficerNotCommissaire() { + var result = checker.checkComplianceOfficerEligibility( + userA, Set.of("COMMISSAIRE_COMPTES")); + assertThat(result.passed()).isFalse(); + assertThat(result.violationReason()).contains("COMMISSAIRE_COMPTES"); + } + + @Test + @DisplayName("checkComplianceOfficerEligibility : ADMIN_ORGANISATION + MEMBRE → PASS") + void complianceOfficerAdminMembrePass() { + var result = checker.checkComplianceOfficerEligibility( + userA, Set.of("ADMIN_ORGANISATION", "MEMBRE_ACTIF")); + assertThat(result.passed()).isTrue(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/AmlSeuilsTest.java b/src/test/java/dev/lions/unionflow/server/service/AmlSeuilsTest.java new file mode 100644 index 0000000..8e75569 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/AmlSeuilsTest.java @@ -0,0 +1,84 @@ +package dev.lions.unionflow.server.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class AmlSeuilsTest { + + @Test + @DisplayName("Seuils alignés Instruction BCEAO 002-03-2025") + void seuilsConformesInstructionBceao() { + assertThat(AmlSeuils.SEUIL_INTRA_UEMOA_FCFA).isEqualByComparingTo(new BigDecimal("10000000")); + assertThat(AmlSeuils.SEUIL_ENTREE_SORTIE_UEMOA_FCFA).isEqualByComparingTo(new BigDecimal("5000000")); + assertThat(AmlSeuils.SEUIL_OPERATION_ESPECE_FCFA).isEqualByComparingTo(new BigDecimal("1000000")); + } + + @Test + @DisplayName("Pays UEMOA = 8 États ISO 3166-1 alpha-3") + void paysUemoaContientLes8Etats() { + assertThat(AmlSeuils.PAYS_UEMOA) + .containsExactlyInAnyOrder("BEN", "BFA", "CIV", "GNB", "MLI", "NER", "SEN", "TGO"); + } + + @Test + @DisplayName("Transaction CIV → SEN (intra-UEMOA) → seuil 10M") + void transactionIntraUemoaSeuil10M() { + assertThat(AmlSeuils.seuilApplicable("CIV", "SEN")) + .isEqualByComparingTo(AmlSeuils.SEUIL_INTRA_UEMOA_FCFA); + } + + @Test + @DisplayName("Transaction CIV → FRA (sortie UEMOA) → seuil 5M") + void transactionSortieUemoaSeuil5M() { + assertThat(AmlSeuils.seuilApplicable("CIV", "FRA")) + .isEqualByComparingTo(AmlSeuils.SEUIL_ENTREE_SORTIE_UEMOA_FCFA); + } + + @Test + @DisplayName("Transaction USA → CIV (entrée UEMOA) → seuil 5M") + void transactionEntreeUemoaSeuil5M() { + assertThat(AmlSeuils.seuilApplicable("USA", "CIV")) + .isEqualByComparingTo(AmlSeuils.SEUIL_ENTREE_SORTIE_UEMOA_FCFA); + } + + @Test + @DisplayName("Pays null → seuil le plus restrictif (5M)") + void paysNullRetourneSeuilRestrictif() { + assertThat(AmlSeuils.seuilApplicable(null, "CIV")) + .isEqualByComparingTo(AmlSeuils.SEUIL_ENTREE_SORTIE_UEMOA_FCFA); + assertThat(AmlSeuils.seuilApplicable("CIV", null)) + .isEqualByComparingTo(AmlSeuils.SEUIL_ENTREE_SORTIE_UEMOA_FCFA); + } + + @Test + @DisplayName("depasseSeuil intra-UEMOA : 9M sous seuil, 11M au-dessus") + void depasseSeuilIntraUemoa() { + assertThat(AmlSeuils.depasseSeuil(new BigDecimal("9000000"), "CIV", "SEN")).isFalse(); + assertThat(AmlSeuils.depasseSeuil(new BigDecimal("10000000"), "CIV", "SEN")).isFalse(); // seuil exact = pas dépassé (>) + assertThat(AmlSeuils.depasseSeuil(new BigDecimal("11000000"), "CIV", "SEN")).isTrue(); + } + + @Test + @DisplayName("depasseSeuil sortie UEMOA : 4M sous seuil, 6M au-dessus") + void depasseSeuilSortieUemoa() { + assertThat(AmlSeuils.depasseSeuil(new BigDecimal("4000000"), "CIV", "FRA")).isFalse(); + assertThat(AmlSeuils.depasseSeuil(new BigDecimal("6000000"), "CIV", "FRA")).isTrue(); + } + + @Test + @DisplayName("depasseSeuilEspece : 999K sous seuil, 1M001 au-dessus") + void depasseSeuilEspece() { + assertThat(AmlSeuils.depasseSeuilEspece(new BigDecimal("999999"))).isFalse(); + assertThat(AmlSeuils.depasseSeuilEspece(new BigDecimal("1000001"))).isTrue(); + } + + @Test + @DisplayName("Montant null jamais dépasse") + void montantNullJamaisDepasse() { + assertThat(AmlSeuils.depasseSeuil(null, "CIV", "SEN")).isFalse(); + assertThat(AmlSeuils.depasseSeuilEspece(null)).isFalse(); + } +}