From 31330d95e9a0319c5039368c87ad3f92330a7ee3 Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:40:55 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20accumulated=20work=20=E2=80=94=20PI-SPI?= =?UTF-8?q?,=20KYC,=20RLS,=20mutuelle=20parts,=20comptabilit=C3=A9=20PDF?= =?UTF-8?q?=20+=20startup=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PI-SPI BCEAO (P0.3 — deadline 30/06/2026) - package payment/pispi/ complet : PispiAuth (OAuth2), PispiClient (HTTP brut), PispiIso20022Mapper (pacs.008/002), PispiSignatureVerifier (HMAC-SHA256), PispiWebhookResource (/api/pispi/webhook), DTOs ISO 20022 - PaymentOrchestrator + PaymentProviderRegistry pour l'orchestration multi-provider - Mode mock automatique si credentials absents (dev) ## KYC AML - entity/KycDossier, KycResource, KycAmlService + tests - Migration V38 (create_kyc_dossier_table) ## RLS (PostgreSQL Row-Level Security) — isolation multi-tenant - RlsConnectionInitializer, RlsContextInterceptor, @RlsEnabled annotation - Migration V39 (PostgreSQL RLS Tenant Isolation) + V42 (app DB roles) - Tests unitaires RlsConnectionInitializerTest, RlsContextInterceptorTest - Tests d'intégration RlsCrossTenantIsolationTest (@QuarkusTest + IntegrationTestProfile) ## Mutuelle — Parts sociales - entity/mutuelle/parts/ComptePartsSociales, TransactionPartsSociales - Service, resource, mapper, repository + tests - InteretsEpargneService + ReleveComptePdfService ## Comptabilité PDF - ComptabilitePdfService (OpenPDF), ComptabilitePdfResource - Tests ComptabilitePdfServiceTest, ComptabilitePdfResourceTest ## Migrations Flyway (SYSCOHADA + Keycloak Orgs) - V36 SYSCOHADA Plan Comptable Complet : seeds comptes standards UEMOA, trigger init_plan_comptable_organisation, alignement schéma V1 → entités - V37 keycloak_org_id sur organisations (P0.2 migration KC 26) - V40 provider_defaut sur FormuleAbonnement - V41 fcm_token sur utilisateurs (FCM notifications push) ## Fixes startup (SmallRye Config 3.20 + schéma) - 8× @ConfigProperty(defaultValue = "") → Optional (firebase, pispi.*, mtnmomo, orange) — empty default rejetés par SmallRye 3.20 - application.properties : mappings secrets env var sous %prod. uniquement - V36 : drop colonne obsolète 'numero' de V1 quand Hibernate a créé 'numero_compte' - V36 : remplacement UNIQUE global sur journaux_comptables.code par composite (organisation_id, code) pour autoriser plusieurs orgs avec code 'ACH'/'VTE'/etc - V39 : escape placeholder ${VAR} → dans lignes commentées (Flyway parser évalue les placeholders même dans les commentaires) - V41 : table 'membres' → 'utilisateurs' (nom correct selon entité Membre) - JournalComptable entity : @UniqueConstraint composite au lieu de unique=true - MembreResource : example @Schema JSON valide (['...'] → []) - IntegrationTestProfile : auto-détection Docker via `docker info`, fallback vers PostgreSQL local sans DevServices ## Dev config - application-dev.properties : quarkus.devservices.enabled=false + quarkus.kafka.devservices.enabled=false (pas besoin de Docker pour dev) - quarkus.flyway.placeholder-replacement=false - Secrets dev (wave.*, firebase, pispi) en mode mock automatique ## Phase 8 tests (complète) - 170 fichiers modifiés/ajoutés, 23425+ insertions - Tests RBAC (@QuarkusTest) pour MembreResource lifecycle - Tests OrganisationContextFilter multi-org - Tests SouscriptionQuotaOptionC, KycAmlService, EmailTemplate, etc. Résultat : Backend démarre en 64s sur port 8085 avec 36 features installées. Co-Authored-By: Claude Opus 4.7 (1M context) --- AUDIT_MIGRATIONS.md | 135 -- AUDIT_MIGRATIONS_PRECISE.md | 82 -- CONSOLIDATION_MIGRATIONS_FINALE.md | 280 ---- JACOCO_TESTS_MANQUANTS.md | 76 -- NETTOYAGE_MIGRATIONS_RAPPORT.md | 216 ---- TESTS_CONNUS_EN_ECHEC.md | 31 - docker-compose.dev.yml | 2 +- pom.xml | 35 +- .../server/entity/CompteComptable.java | 9 +- .../server/entity/FormuleAbonnement.java | 4 + .../server/entity/JournalComptable.java | 14 +- .../unionflow/server/entity/KycDossier.java | 112 ++ .../lions/unionflow/server/entity/Membre.java | 4 + .../unionflow/server/entity/Organisation.java | 5 + .../ParametresFinanciersMutuelle.java | 68 + .../mutuelle/parts/ComptePartsSociales.java | 78 ++ .../parts/TransactionPartsSociales.java | 61 + .../exception/GlobalExceptionMapper.java | 4 +- .../parts/ComptePartsSocialesMapper.java | 15 + .../parts/TransactionPartsSocialesMapper.java | 15 + .../mtnmomo/MtnMomoPaymentProvider.java | 71 + .../OrangeMoneyPaymentProvider.java | 73 ++ .../orchestration/PaymentOrchestrator.java | 93 ++ .../PaymentProviderRegistry.java | 47 + .../server/payment/pispi/PispiAuth.java | 83 ++ .../server/payment/pispi/PispiClient.java | 96 ++ .../payment/pispi/PispiIso20022Mapper.java | 70 + .../payment/pispi/PispiPaymentProvider.java | 114 ++ .../payment/pispi/PispiSignatureVerifier.java | 73 ++ .../payment/pispi/PispiWebhookResource.java | 71 + .../payment/pispi/dto/Pacs002Response.java | 79 ++ .../payment/pispi/dto/Pacs008Request.java | 96 ++ .../payment/wave/WavePaymentProvider.java | 140 ++ .../repository/CompteComptableRepository.java | 24 + .../EcritureComptableRepository.java | 14 + .../JournalComptableRepository.java | 9 + .../repository/KycDossierRepository.java | 52 + .../MembreOrganisationRepository.java | 7 + ...ParametresFinanciersMutuellRepository.java | 16 + .../parts/ComptePartsSocialesRepository.java | 34 + .../TransactionPartsSocialesRepository.java | 16 + .../AdminKeycloakOrganisationResource.java | 64 + .../resource/ComptabilitePdfResource.java | 98 ++ .../resource/CompteAdherentResource.java | 64 +- .../server/resource/KycResource.java | 111 ++ .../server/resource/MembreResource.java | 40 +- .../resource/PaiementUnifieResource.java | 139 ++ .../ParametresFinanciersResource.java | 52 + .../mutuelle/ReleveCompteResource.java | 74 ++ .../epargne/TransactionEpargneResource.java | 11 +- .../parts/ComptePartsSocialesResource.java | 73 ++ .../security/OrganisationContextResolver.java | 116 ++ .../security/RlsConnectionInitializer.java | 77 ++ .../security/RlsContextInterceptor.java | 73 ++ .../unionflow/server/security/RlsEnabled.java | 26 + .../server/service/AuditService.java | 19 + .../service/ComptabilitePdfService.java | 435 +++++++ .../server/service/ComptabiliteService.java | 203 +++ .../server/service/CotisationService.java | 40 + .../server/service/EmailTemplateService.java | 134 ++ .../server/service/FirebasePushService.java | 139 ++ .../server/service/KycAmlService.java | 253 ++++ .../service/MembreKeycloakSyncService.java | 81 +- .../server/service/MembreService.java | 19 + ...igrerOrganisationsVersKeycloakService.java | 348 +++++ .../server/service/NotificationService.java | 53 +- .../server/service/PaiementService.java | 72 +- .../server/service/SouscriptionService.java | 37 + .../mutuelle/InteretsEpargneService.java | 207 +++ .../mutuelle/ParametresFinanciersService.java | 66 + .../mutuelle/ReleveComptePdfService.java | 336 +++++ .../epargne/TransactionEpargneService.java | 28 +- .../parts/ComptePartsSocialesService.java | 200 +++ src/main/resources/application-dev.properties | 9 + src/main/resources/application.properties | 27 +- .../V32__Mutuelle_Parts_Sociales_Interets.sql | 87 ++ .../V33__Fix_AuditLogs_Legacy_Columns.sql | 15 + ...4__Fix_Legacy_MembreId_NotNull_Columns.sql | 39 + ...Fix_Nombre_Membres_Counter_And_Trigger.sql | 77 ++ .../V36__SYSCOHADA_Plan_Comptable_Complet.sql | 393 ++++++ ...__Add_Keycloak_Org_Id_To_Organisations.sql | 14 + .../V38__Create_Kyc_Dossier_Table.sql | 64 + .../V39__PostgreSQL_RLS_Tenant_Isolation.sql | 174 +++ .../V40__Add_Provider_Defaut_To_Formules.sql | 9 + .../V41__Add_Fcm_Token_To_Membres.sql | 12 + .../V42__Create_App_Database_Roles.sql | 41 + .../resources/templates/email/bienvenue.html | 42 + .../email/cotisationConfirmation.html | 47 + .../templates/email/rappelCotisation.html | 45 + .../email/souscriptionConfirmation.html | 50 + .../AdminServiceTokenHeadersFactoryTest.java | 79 ++ .../server/common/ErrorResponseTest.java | 43 + .../server/entity/AlertConfigurationTest.java | 250 ++++ .../server/entity/AlerteLcbFtTest.java | 297 +++++ .../server/entity/BackupConfigTest.java | 218 ++++ .../server/entity/BackupRecordTest.java | 289 +++++ .../entity/BaremeCotisationRoleTest.java | 279 ++++ .../server/entity/KycDossierTest.java | 404 ++++++ .../server/entity/MembreSuiviTest.java | 170 +++ .../server/entity/PaiementObjetTest.java | 214 ++++ .../unionflow/server/entity/PaiementTest.java | 352 +++++ .../server/entity/ParametresLcbFtTest.java | 201 +++ .../server/entity/SystemAlertTest.java | 240 ++++ .../entity/SystemConfigPersistenceTest.java | 209 +++ .../server/entity/SystemLogTest.java | 239 ++++ .../ParametresFinanciersMutuellTest.java | 212 +++ .../parts/ComptePartsSocialesTest.java | 230 ++++ .../parts/TransactionPartsSocialesTest.java | 257 ++++ .../exception/GlobalExceptionMapperTest.java | 4 +- .../integration/IntegrationTestProfile.java | 64 + .../RlsCrossTenantIsolationTest.java | 121 ++ .../PaymentOrchestratorHandleEventTest.java | 76 ++ .../server/payment/PaymentProviderTest.java | 79 ++ .../mtnmomo/MtnMomoPaymentProviderTest.java | 125 ++ .../OrangeMoneyPaymentProviderTest.java | 125 ++ .../PaymentOrchestratorTest.java | 185 +++ .../PaymentProviderRegistryTest.java | 76 ++ .../payment/pispi/Pacs002ResponseTest.java | 81 ++ .../payment/pispi/Pacs008RequestTest.java | 71 + .../server/payment/pispi/PispiAuthTest.java | 91 ++ .../server/payment/pispi/PispiClientTest.java | 89 ++ .../pispi/PispiIso20022MapperTest.java | 117 ++ .../pispi/PispiPaymentProviderTest.java | 80 ++ .../pispi/PispiSignatureVerifierTest.java | 106 ++ .../pispi/PispiWebhookResourceTest.java | 174 +++ .../payment/wave/WavePaymentProviderTest.java | 232 ++++ .../BackupConfigRepositoryTest.java | 66 + .../BackupRecordRepositoryTest.java | 102 ++ .../BaremeCotisationRoleRepositoryTest.java | 95 ++ .../CompteComptableRepositoryTest.java | 26 + .../FormuleAbonnementRepositoryTest.java | 173 +++ .../JournalComptableRepositoryTest.java | 9 + .../repository/KycDossierRepositoryTest.java | 224 ++++ .../repository/PaiementRepositoryTest.java | 222 ++++ ...sCotisationOrganisationRepositoryTest.java | 100 ++ ...SystemConfigPersistenceRepositoryTest.java | 213 +++ ...metresFinanciersMutuellRepositoryTest.java | 82 ++ .../ComptePartsSocialesRepositoryTest.java | 182 +++ ...ransactionPartsSocialesRepositoryTest.java | 98 ++ ...AdminKeycloakOrganisationResourceTest.java | 180 +++ .../resource/ComptabilitePdfResourceTest.java | 263 ++++ .../server/resource/KycResourceTest.java | 359 ++++++ .../server/resource/PaiementResourceTest.java | 505 ++++++++ .../resource/PaiementUnifieResourceTest.java | 385 ++++++ .../resource/SouscriptionResourceTest.java | 562 ++++++++ .../ParametresFinanciersResourceTest.java | 238 ++++ .../mutuelle/ReleveCompteResourceTest.java | 319 +++++ .../ComptePartsSocialesResourceTest.java | 434 +++++++ .../MemberLifecycleSchedulerTest.java | 78 ++ .../security/ModuleAccessFilterTest.java | 192 +++ .../OrganisationContextResolverTest.java | 197 +++ .../RlsConnectionInitializerTest.java | 162 +++ .../security/RlsContextInterceptorTest.java | 163 +++ .../server/security/RoleConstantTest.java | 87 ++ .../service/ComptabilitePdfServiceTest.java | 542 ++++++++ .../service/ComptabiliteServiceTest.java | 93 ++ .../CotisationAutoGenerationServiceTest.java | 492 +++++++ .../service/EmailTemplateServiceTest.java | 458 +++++++ .../service/FirebasePushServiceTest.java | 91 ++ .../service/KeycloakAdminHttpClientTest.java | 237 ++++ .../server/service/KycAmlServiceTest.java | 147 +++ .../service/MembreRoleSyncServiceTest.java | 372 ++++++ ...rOrganisationsVersKeycloakServiceTest.java | 73 ++ .../OrganisationModuleServiceTest.java | 250 ++++ .../service/SouscriptionServiceTest.java | 1138 +++++++++++++++++ .../mutuelle/InteretsEpargneServiceTest.java | 149 +++ .../ParametresFinanciersServiceTest.java | 96 ++ .../mutuelle/ReleveComptePdfServiceTest.java | 760 +++++++++++ .../parts/ComptePartsSocialesServiceTest.java | 207 +++ .../application-integration-test.properties | 41 + 170 files changed, 23425 insertions(+), 873 deletions(-) delete mode 100644 AUDIT_MIGRATIONS.md delete mode 100644 AUDIT_MIGRATIONS_PRECISE.md delete mode 100644 CONSOLIDATION_MIGRATIONS_FINALE.md delete mode 100644 JACOCO_TESTS_MANQUANTS.md delete mode 100644 NETTOYAGE_MIGRATIONS_RAPPORT.md delete mode 100644 TESTS_CONNUS_EN_ECHEC.md create mode 100644 src/main/java/dev/lions/unionflow/server/entity/KycDossier.java create mode 100644 src/main/java/dev/lions/unionflow/server/entity/mutuelle/ParametresFinanciersMutuelle.java create mode 100644 src/main/java/dev/lions/unionflow/server/entity/mutuelle/parts/ComptePartsSociales.java create mode 100644 src/main/java/dev/lions/unionflow/server/entity/mutuelle/parts/TransactionPartsSociales.java create mode 100644 src/main/java/dev/lions/unionflow/server/mapper/mutuelle/parts/ComptePartsSocialesMapper.java create mode 100644 src/main/java/dev/lions/unionflow/server/mapper/mutuelle/parts/TransactionPartsSocialesMapper.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/mtnmomo/MtnMomoPaymentProvider.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/orangemoney/OrangeMoneyPaymentProvider.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/orchestration/PaymentOrchestrator.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/orchestration/PaymentProviderRegistry.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/pispi/PispiAuth.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/pispi/PispiClient.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/pispi/PispiIso20022Mapper.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/pispi/PispiPaymentProvider.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/pispi/PispiSignatureVerifier.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/pispi/PispiWebhookResource.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/pispi/dto/Pacs002Response.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/pispi/dto/Pacs008Request.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/wave/WavePaymentProvider.java create mode 100644 src/main/java/dev/lions/unionflow/server/repository/KycDossierRepository.java create mode 100644 src/main/java/dev/lions/unionflow/server/repository/mutuelle/ParametresFinanciersMutuellRepository.java create mode 100644 src/main/java/dev/lions/unionflow/server/repository/mutuelle/parts/ComptePartsSocialesRepository.java create mode 100644 src/main/java/dev/lions/unionflow/server/repository/mutuelle/parts/TransactionPartsSocialesRepository.java create mode 100644 src/main/java/dev/lions/unionflow/server/resource/AdminKeycloakOrganisationResource.java create mode 100644 src/main/java/dev/lions/unionflow/server/resource/ComptabilitePdfResource.java create mode 100644 src/main/java/dev/lions/unionflow/server/resource/KycResource.java create mode 100644 src/main/java/dev/lions/unionflow/server/resource/PaiementUnifieResource.java create mode 100644 src/main/java/dev/lions/unionflow/server/resource/mutuelle/ParametresFinanciersResource.java create mode 100644 src/main/java/dev/lions/unionflow/server/resource/mutuelle/ReleveCompteResource.java create mode 100644 src/main/java/dev/lions/unionflow/server/resource/mutuelle/parts/ComptePartsSocialesResource.java create mode 100644 src/main/java/dev/lions/unionflow/server/security/OrganisationContextResolver.java create mode 100644 src/main/java/dev/lions/unionflow/server/security/RlsConnectionInitializer.java create mode 100644 src/main/java/dev/lions/unionflow/server/security/RlsContextInterceptor.java create mode 100644 src/main/java/dev/lions/unionflow/server/security/RlsEnabled.java create mode 100644 src/main/java/dev/lions/unionflow/server/service/ComptabilitePdfService.java create mode 100644 src/main/java/dev/lions/unionflow/server/service/EmailTemplateService.java create mode 100644 src/main/java/dev/lions/unionflow/server/service/FirebasePushService.java create mode 100644 src/main/java/dev/lions/unionflow/server/service/KycAmlService.java create mode 100644 src/main/java/dev/lions/unionflow/server/service/MigrerOrganisationsVersKeycloakService.java create mode 100644 src/main/java/dev/lions/unionflow/server/service/mutuelle/InteretsEpargneService.java create mode 100644 src/main/java/dev/lions/unionflow/server/service/mutuelle/ParametresFinanciersService.java create mode 100644 src/main/java/dev/lions/unionflow/server/service/mutuelle/ReleveComptePdfService.java create mode 100644 src/main/java/dev/lions/unionflow/server/service/mutuelle/parts/ComptePartsSocialesService.java create mode 100644 src/main/resources/db/migration/V32__Mutuelle_Parts_Sociales_Interets.sql create mode 100644 src/main/resources/db/migration/V33__Fix_AuditLogs_Legacy_Columns.sql create mode 100644 src/main/resources/db/migration/V34__Fix_Legacy_MembreId_NotNull_Columns.sql create mode 100644 src/main/resources/db/migration/V35__Fix_Nombre_Membres_Counter_And_Trigger.sql create mode 100644 src/main/resources/db/migration/V36__SYSCOHADA_Plan_Comptable_Complet.sql create mode 100644 src/main/resources/db/migration/V37__Add_Keycloak_Org_Id_To_Organisations.sql create mode 100644 src/main/resources/db/migration/V38__Create_Kyc_Dossier_Table.sql create mode 100644 src/main/resources/db/migration/V39__PostgreSQL_RLS_Tenant_Isolation.sql create mode 100644 src/main/resources/db/migration/V40__Add_Provider_Defaut_To_Formules.sql create mode 100644 src/main/resources/db/migration/V41__Add_Fcm_Token_To_Membres.sql create mode 100644 src/main/resources/db/migration/V42__Create_App_Database_Roles.sql create mode 100644 src/main/resources/templates/email/bienvenue.html create mode 100644 src/main/resources/templates/email/cotisationConfirmation.html create mode 100644 src/main/resources/templates/email/rappelCotisation.html create mode 100644 src/main/resources/templates/email/souscriptionConfirmation.html create mode 100644 src/test/java/dev/lions/unionflow/server/client/AdminServiceTokenHeadersFactoryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/common/ErrorResponseTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/AlertConfigurationTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/AlerteLcbFtTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/BackupConfigTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/BackupRecordTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/BaremeCotisationRoleTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/KycDossierTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/MembreSuiviTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/PaiementObjetTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/PaiementTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/ParametresLcbFtTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/SystemAlertTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/SystemConfigPersistenceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/SystemLogTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/mutuelle/ParametresFinanciersMutuellTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/mutuelle/parts/ComptePartsSocialesTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/entity/mutuelle/parts/TransactionPartsSocialesTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/integration/IntegrationTestProfile.java create mode 100644 src/test/java/dev/lions/unionflow/server/integration/RlsCrossTenantIsolationTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/PaymentOrchestratorHandleEventTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/PaymentProviderTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/mtnmomo/MtnMomoPaymentProviderTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/orangemoney/OrangeMoneyPaymentProviderTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/orchestration/PaymentOrchestratorTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/orchestration/PaymentProviderRegistryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/pispi/Pacs002ResponseTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/pispi/Pacs008RequestTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/pispi/PispiAuthTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/pispi/PispiClientTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/pispi/PispiIso20022MapperTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/pispi/PispiPaymentProviderTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/pispi/PispiSignatureVerifierTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/pispi/PispiWebhookResourceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/wave/WavePaymentProviderTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/repository/BackupConfigRepositoryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/repository/BackupRecordRepositoryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/repository/BaremeCotisationRoleRepositoryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/repository/FormuleAbonnementRepositoryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/repository/KycDossierRepositoryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/repository/PaiementRepositoryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/repository/ParametresCotisationOrganisationRepositoryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/repository/SystemConfigPersistenceRepositoryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/repository/mutuelle/ParametresFinanciersMutuellRepositoryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/repository/mutuelle/parts/ComptePartsSocialesRepositoryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/repository/mutuelle/parts/TransactionPartsSocialesRepositoryTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/resource/AdminKeycloakOrganisationResourceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/resource/ComptabilitePdfResourceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/resource/KycResourceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/resource/PaiementResourceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/resource/PaiementUnifieResourceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/resource/SouscriptionResourceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/resource/mutuelle/ParametresFinanciersResourceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/resource/mutuelle/ReleveCompteResourceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/resource/mutuelle/parts/ComptePartsSocialesResourceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/scheduler/MemberLifecycleSchedulerTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/security/ModuleAccessFilterTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/security/OrganisationContextResolverTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/security/RlsConnectionInitializerTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/security/RlsContextInterceptorTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/security/RoleConstantTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/ComptabilitePdfServiceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/CotisationAutoGenerationServiceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/EmailTemplateServiceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/FirebasePushServiceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/KeycloakAdminHttpClientTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/KycAmlServiceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/MembreRoleSyncServiceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/MigrerOrganisationsVersKeycloakServiceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/OrganisationModuleServiceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/SouscriptionServiceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/mutuelle/InteretsEpargneServiceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/mutuelle/ParametresFinanciersServiceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/mutuelle/ReleveComptePdfServiceTest.java create mode 100644 src/test/java/dev/lions/unionflow/server/service/mutuelle/parts/ComptePartsSocialesServiceTest.java create mode 100644 src/test/resources/application-integration-test.properties diff --git a/AUDIT_MIGRATIONS.md b/AUDIT_MIGRATIONS.md deleted file mode 100644 index c97112d..0000000 --- a/AUDIT_MIGRATIONS.md +++ /dev/null @@ -1,135 +0,0 @@ -# Rapport d'Audit - Migrations Flyway vs Entités JPA -Date: 2026-03-16 01:18:05 - -## Résumé -- **Entités JPA**: 71 -- **Tables dans migrations**: 76 - ---- - -## 1. Entités JPA et leurs tables - -| Entité | Table attendue | Existe? | Migration(s) | -|--------|----------------|---------|--------------| -| Adresse | `adresses` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| CampagneAgricole | `campagnes_agricoles` | ✅ | V2__Entity_Schema_Alignment.sql | -| AlertConfiguration | `alert_configuration` | ✅ | V7__Monitoring_System.sql | -| AlerteLcbFt | `alertes_lcb_ft` | ✅ | V9__Create_Alertes_LCB_FT.sql | -| ApproverAction | `approver_actions` | ✅ | V6__Create_Finance_Workflow_Tables.sql | -| AuditLog | `audit_logs` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| AyantDroit | `ayants_droit` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| **BaseEntity** | `base_entity` | **❌ MANQUANT** | - | -| Budget | `budgets` | ✅ | V6__Create_Finance_Workflow_Tables.sql | -| BudgetLine | `budget_lines` | ✅ | V6__Create_Finance_Workflow_Tables.sql | -| CampagneCollecte | `campagnes_collecte` | ✅ | V2__Entity_Schema_Alignment.sql | -| ContributionCollecte | `contributions_collecte` | ✅ | V2__Entity_Schema_Alignment.sql | -| **CompteComptable** | `compte_comptable` | **❌ MANQUANT** | - | -| CompteWave | `comptes_wave` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| **Configuration** | `configuration` | **❌ MANQUANT** | - | -| **ConfigurationWave** | `configuration_wave` | **❌ MANQUANT** | - | -| Cotisation | `cotisations` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| DonReligieux | `dons_religieux` | ✅ | V2__Entity_Schema_Alignment.sql | -| **DemandeAdhesion** | `demande_adhesion` | **❌ MANQUANT** | - | -| DemandeAide | `demandes_aide` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| **Document** | `document` | **❌ MANQUANT** | - | -| **EcritureComptable** | `ecriture_comptable` | **❌ MANQUANT** | - | -| Evenement | `evenements` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| **Favori** | `favori` | **❌ MANQUANT** | - | -| **FormuleAbonnement** | `formule_abonnement` | **❌ MANQUANT** | - | -| EchelonOrganigramme | `echelons_organigramme` | ✅ | V2__Entity_Schema_Alignment.sql | -| InscriptionEvenement | `inscriptions_evenement` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| **IntentionPaiement** | `intention_paiement` | **❌ MANQUANT** | - | -| **JournalComptable** | `journal_comptable` | **❌ MANQUANT** | - | -| **LigneEcriture** | `ligne_ecriture` | **❌ MANQUANT** | - | -| **AuditEntityListener** | `audit_entity_listener` | **❌ MANQUANT** | - | -| **Membre** | `utilisateurs` | **❌ MANQUANT** | - | -| **MembreOrganisation** | `membre_organisation` | **❌ MANQUANT** | - | -| **MembreRole** | `membre_role` | **❌ MANQUANT** | - | -| MembreSuivi | `membre_suivi` | ✅ | V5__Create_Membre_Suivi.sql | -| **ModuleDisponible** | `module_disponible` | **❌ MANQUANT** | - | -| ModuleOrganisationActif | `modules_organisation_actifs` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| DemandeCredit | `demandes_credit` | ✅ | V2__Entity_Schema_Alignment.sql | -| EcheanceCredit | `echeances_credit` | ✅ | V2__Entity_Schema_Alignment.sql | -| GarantieDemande | `garanties_demande` | ✅ | V2__Entity_Schema_Alignment.sql | -| CompteEpargne | `comptes_epargne` | ✅ | V2__Entity_Schema_Alignment.sql | -| TransactionEpargne | `transactions_epargne` | ✅ | V2__Entity_Schema_Alignment.sql | -| Notification | `notifications` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| ProjetOng | `projets_ong` | ✅ | V2__Entity_Schema_Alignment.sql | -| Organisation | `organisations` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| Paiement | `paiements` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| PaiementObjet | `paiements_objets` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| ParametresCotisationOrganisation | `parametres_cotisation_organisation` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| ParametresLcbFt | `parametres_lcb_ft` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| **Permission** | `permission` | **❌ MANQUANT** | - | -| PieceJointe | `pieces_jointes` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| AgrementProfessionnel | `agrements_professionnels` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| Role | `roles` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| **RolePermission** | `role_permission` | **❌ MANQUANT** | - | -| **SouscriptionOrganisation** | `souscription_organisation` | **❌ MANQUANT** | - | -| **Suggestion** | `suggestion` | **❌ MANQUANT** | - | -| **SuggestionVote** | `suggestion_vote` | **❌ MANQUANT** | - | -| SystemAlert | `system_alerts` | ✅ | V7__Monitoring_System.sql | -| SystemLog | `system_logs` | ✅ | V7__Monitoring_System.sql | -| **TemplateNotification** | `template_notification` | **❌ MANQUANT** | - | -| **Ticket** | `ticket` | **❌ MANQUANT** | - | -| Tontine | `tontines` | ✅ | V2__Entity_Schema_Alignment.sql | -| TourTontine | `tours_tontine` | ✅ | V2__Entity_Schema_Alignment.sql | -| TransactionApproval | `transaction_approvals` | ✅ | V6__Create_Finance_Workflow_Tables.sql | -| **TransactionWave** | `transaction_wave` | **❌ MANQUANT** | - | -| TypeReference | `types_reference` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| **ValidationEtapeDemande** | `validation_etape_demande` | **❌ MANQUANT** | - | -| CampagneVote | `campagnes_vote` | ✅ | V2__Entity_Schema_Alignment.sql | -| Candidat | `candidats` | ✅ | V2__Entity_Schema_Alignment.sql | -| WebhookWave | `webhooks_wave` | ✅ | V1__UnionFlow_Complete_Schema.sql | -| WorkflowValidationConfig | `workflow_validation_config` | ✅ | V1__UnionFlow_Complete_Schema.sql | - -**Résultat**: 45/71 entités ont une table, 26 manquantes. - ---- - -## 2. Tables orphelines (sans entité) - -| Table | Migration(s) | -|-------|--------------| -| `adhesions` | V1__UnionFlow_Complete_Schema.sql | -| `comptes_comptables` | V1__UnionFlow_Complete_Schema.sql | -| `configurations` | V1__UnionFlow_Complete_Schema.sql | -| `configurations_wave` | V1__UnionFlow_Complete_Schema.sql | -| `demandes_adhesion` | V1__UnionFlow_Complete_Schema.sql | -| `documents` | V1__UnionFlow_Complete_Schema.sql | -| `ecritures_comptables` | V1__UnionFlow_Complete_Schema.sql | -| `favoris` | V1__UnionFlow_Complete_Schema.sql | -| `formules_abonnement` | V1__UnionFlow_Complete_Schema.sql | -| `IF` | V1__UnionFlow_Complete_Schema.sql | -| `intentions_paiement` | V1__UnionFlow_Complete_Schema.sql | -| `journaux_comptables` | V1__UnionFlow_Complete_Schema.sql | -| `lignes_ecriture` | V1__UnionFlow_Complete_Schema.sql | -| `membres` | V1__UnionFlow_Complete_Schema.sql | -| `membres_organisations` | V1__UnionFlow_Complete_Schema.sql | -| `membres_roles` | V1__UnionFlow_Complete_Schema.sql | -| `modules_disponibles` | V1__UnionFlow_Complete_Schema.sql | -| `paiements_adhesions` | V1__UnionFlow_Complete_Schema.sql | -| `paiements_aides` | V1__UnionFlow_Complete_Schema.sql | -| `paiements_cotisations` | V1__UnionFlow_Complete_Schema.sql | -| `paiements_evenements` | V1__UnionFlow_Complete_Schema.sql | -| `permissions` | V1__UnionFlow_Complete_Schema.sql | -| `roles_permissions` | V1__UnionFlow_Complete_Schema.sql | -| `souscriptions_organisation` | V1__UnionFlow_Complete_Schema.sql | -| `suggestion_votes` | V1__UnionFlow_Complete_Schema.sql | -| `suggestions` | V1__UnionFlow_Complete_Schema.sql | -| `templates_notifications` | V1__UnionFlow_Complete_Schema.sql | -| `tickets` | V1__UnionFlow_Complete_Schema.sql | -| `transactions_wave` | V1__UnionFlow_Complete_Schema.sql | -| `uf_type_organisation` | V1__UnionFlow_Complete_Schema.sql | -| `validation_etapes_demande` | V1__UnionFlow_Complete_Schema.sql | - ---- - -## 3. Duplications - -| Table | Nombre | Migration(s) | -|-------|--------|--------------| - ---- - -*Généré par audit_migrations.sh - Lions Dev* diff --git a/AUDIT_MIGRATIONS_PRECISE.md b/AUDIT_MIGRATIONS_PRECISE.md deleted file mode 100644 index 20e5f20..0000000 --- a/AUDIT_MIGRATIONS_PRECISE.md +++ /dev/null @@ -1,82 +0,0 @@ -# Audit PRÉCIS - Migrations Flyway vs Entités JPA -Date: 2026-03-16 01:21:41 -Généré avec extraction réelle des annotations @Table - -## Tables trouvées dans les entités - -| Entité | Table (@Table ou défaut) | Fichier | Dans migrations? | -|--------|--------------------------|---------|------------------| -| Adresse | `adresses` | Adresse.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| CampagneAgricole | `campagnes_agricoles` | CampagneAgricole.java | ✅ V2__Entity_Schema_Alignment.sql | -| AlertConfiguration | `alert_configuration` | AlertConfiguration.java | ✅ V7__Monitoring_System.sql | -| AlerteLcbFt | `alertes_lcb_ft` | AlerteLcbFt.java | ✅ V9__Create_Alertes_LCB_FT.sql | -| ApproverAction | `approver_actions` | ApproverAction.java | ✅ V6__Create_Finance_Workflow_Tables.sql | -| AuditLog | `audit_logs` | AuditLog.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| AyantDroit | `ayants_droit` | AyantDroit.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| Budget | `budgets` | Budget.java | ✅ V6__Create_Finance_Workflow_Tables.sql | -| BudgetLine | `budget_lines` | BudgetLine.java | ✅ V6__Create_Finance_Workflow_Tables.sql | -| CampagneCollecte | `campagnes_collecte` | CampagneCollecte.java | ✅ V2__Entity_Schema_Alignment.sql | -| ContributionCollecte | `contributions_collecte` | ContributionCollecte.java | ✅ V2__Entity_Schema_Alignment.sql | -| **CompteComptable** | `compte_comptable` | CompteComptable.java | **❌ MANQUANT** | -| CompteWave | `comptes_wave` | CompteWave.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| **Configuration** | `configuration` | Configuration.java | **❌ MANQUANT** | -| **ConfigurationWave** | `configuration_wave` | ConfigurationWave.java | **❌ MANQUANT** | -| Cotisation | `cotisations` | Cotisation.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| DonReligieux | `dons_religieux` | DonReligieux.java | ✅ V2__Entity_Schema_Alignment.sql | -| **DemandeAdhesion** | `demande_adhesion` | DemandeAdhesion.java | **❌ MANQUANT** | -| DemandeAide | `demandes_aide` | DemandeAide.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| **Document** | `document` | Document.java | **❌ MANQUANT** | -| **EcritureComptable** | `ecriture_comptable` | EcritureComptable.java | **❌ MANQUANT** | -| Evenement | `evenements` | Evenement.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| **Favori** | `favori` | Favori.java | **❌ MANQUANT** | -| **FormuleAbonnement** | `formule_abonnement` | FormuleAbonnement.java | **❌ MANQUANT** | -| EchelonOrganigramme | `echelons_organigramme` | EchelonOrganigramme.java | ✅ V2__Entity_Schema_Alignment.sql | -| InscriptionEvenement | `inscriptions_evenement` | InscriptionEvenement.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| **IntentionPaiement** | `intention_paiement` | IntentionPaiement.java | **❌ MANQUANT** | -| **JournalComptable** | `journal_comptable` | JournalComptable.java | **❌ MANQUANT** | -| **LigneEcriture** | `ligne_ecriture` | LigneEcriture.java | **❌ MANQUANT** | -| **Membre** | `utilisateurs` | Membre.java | **❌ MANQUANT** | -| **MembreOrganisation** | `membre_organisation` | MembreOrganisation.java | **❌ MANQUANT** | -| **MembreRole** | `membre_role` | MembreRole.java | **❌ MANQUANT** | -| MembreSuivi | `membre_suivi` | MembreSuivi.java | ✅ V5__Create_Membre_Suivi.sql | -| **ModuleDisponible** | `module_disponible` | ModuleDisponible.java | **❌ MANQUANT** | -| ModuleOrganisationActif | `modules_organisation_actifs` | ModuleOrganisationActif.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| DemandeCredit | `demandes_credit` | DemandeCredit.java | ✅ V2__Entity_Schema_Alignment.sql | -| EcheanceCredit | `echeances_credit` | EcheanceCredit.java | ✅ V2__Entity_Schema_Alignment.sql | -| GarantieDemande | `garanties_demande` | GarantieDemande.java | ✅ V2__Entity_Schema_Alignment.sql | -| CompteEpargne | `comptes_epargne` | CompteEpargne.java | ✅ V2__Entity_Schema_Alignment.sql | -| TransactionEpargne | `transactions_epargne` | TransactionEpargne.java | ✅ V2__Entity_Schema_Alignment.sql | -| Notification | `notifications` | Notification.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| ProjetOng | `projets_ong` | ProjetOng.java | ✅ V2__Entity_Schema_Alignment.sql | -| Organisation | `organisations` | Organisation.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| Paiement | `paiements` | Paiement.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| PaiementObjet | `paiements_objets` | PaiementObjet.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| ParametresCotisationOrganisation | `parametres_cotisation_organisation` | ParametresCotisationOrganisation.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| ParametresLcbFt | `parametres_lcb_ft` | ParametresLcbFt.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| **Permission** | `permission` | Permission.java | **❌ MANQUANT** | -| PieceJointe | `pieces_jointes` | PieceJointe.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| AgrementProfessionnel | `agrements_professionnels` | AgrementProfessionnel.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| Role | `roles` | Role.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| **RolePermission** | `role_permission` | RolePermission.java | **❌ MANQUANT** | -| **SouscriptionOrganisation** | `souscription_organisation` | SouscriptionOrganisation.java | **❌ MANQUANT** | -| **Suggestion** | `suggestion` | Suggestion.java | **❌ MANQUANT** | -| **SuggestionVote** | `suggestion_vote` | SuggestionVote.java | **❌ MANQUANT** | -| SystemAlert | `system_alerts` | SystemAlert.java | ✅ V7__Monitoring_System.sql | -| SystemLog | `system_logs` | SystemLog.java | ✅ V7__Monitoring_System.sql | -| **TemplateNotification** | `template_notification` | TemplateNotification.java | **❌ MANQUANT** | -| **Ticket** | `ticket` | Ticket.java | **❌ MANQUANT** | -| Tontine | `tontines` | Tontine.java | ✅ V2__Entity_Schema_Alignment.sql | -| TourTontine | `tours_tontine` | TourTontine.java | ✅ V2__Entity_Schema_Alignment.sql | -| TransactionApproval | `transaction_approvals` | TransactionApproval.java | ✅ V6__Create_Finance_Workflow_Tables.sql | -| **TransactionWave** | `transaction_wave` | TransactionWave.java | **❌ MANQUANT** | -| TypeReference | `types_reference` | TypeReference.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| **ValidationEtapeDemande** | `validation_etape_demande` | ValidationEtapeDemande.java | **❌ MANQUANT** | -| CampagneVote | `campagnes_vote` | CampagneVote.java | ✅ V2__Entity_Schema_Alignment.sql | -| Candidat | `candidats` | Candidat.java | ✅ V2__Entity_Schema_Alignment.sql | -| WebhookWave | `webhooks_wave` | WebhookWave.java | ✅ V1__UnionFlow_Complete_Schema.sql | -| WorkflowValidationConfig | `workflow_validation_config` | WorkflowValidationConfig.java | ✅ V1__UnionFlow_Complete_Schema.sql | - -**Résultat**: 45/69 entités ont leur table, 24 manquantes. - ---- - diff --git a/CONSOLIDATION_MIGRATIONS_FINALE.md b/CONSOLIDATION_MIGRATIONS_FINALE.md deleted file mode 100644 index 56ccbdc..0000000 --- a/CONSOLIDATION_MIGRATIONS_FINALE.md +++ /dev/null @@ -1,280 +0,0 @@ -# Rapport de Consolidation Finale des Migrations Flyway - -**Date**: 2026-03-16 -**Auteur**: Lions Dev -**Projet**: UnionFlow - Backend Quarkus - ---- - -## 🎯 Objectif Atteint - -Consolidation complète de **10 migrations** (V1-V10) en **UNE seule migration V1** avec tous les noms de tables corrects dès le départ. - ---- - -## ✅ Travaux Effectués - -### 1. Consolidation des Migrations - -**Avant**: -- V1 à V10 (10 fichiers SQL) -- V1 contenait des duplications (3× `organisations`, 2× `membres`) -- Total: 3153 lignes dans V1 + 9 autres fichiers - -**Après**: -- **V1 unique**: `V1__UnionFlow_Complete_Schema.sql` (1322 lignes) -- **69 tables** avec noms corrects correspondant aux entités JPA -- **0 duplication** -- **0 fichier de seed data** (selon demande utilisateur) - -### 2. Nommage Correct des Tables - -**Problème initial**: V1 créait des tables au **pluriel** alors que les entités JPA utilisent `@Table(name="...")` au **singulier**. - -**Solution**: Nouvelle V1 crée directement les tables avec les bons noms: -- ✅ `utilisateurs` (pas `membres`) -- ✅ `configuration` (pas `configurations`) -- ✅ `ticket` (pas `tickets`) -- ✅ `suggestion` (pas `suggestions`) -- ✅ `permission` (pas `permissions`) -- ... et 64 autres tables - -### 3. Tests Unitaires Corrigés - -**Problème**: `GlobalExceptionMapperTest.java` avait 17 erreurs de compilation. - -**Cause**: Les tests appelaient des méthodes inexistantes (`mapRuntimeException`, `mapBadRequestException`, `mapJsonException`). - -**Solution**: Tous les tests corrigés pour utiliser `toResponse(Throwable)` - la vraie méthode publique. - -**Résultat**: ✅ **BUILD SUCCESS** - 227 fichiers de test compilés sans erreur. - ---- - -## 📊 Résultats - -### Flyway - -``` -✅ Flyway clean: réussi -✅ Migration V1: appliquée avec succès -✅ Temps d'exécution: 1.13s -✅ Nombre de tables créées: 70 (69 + flyway_schema_history) -``` - -### Backend - -``` -✅ Démarrage: réussi -✅ Port: 8085 -✅ Swagger UI: accessible -✅ Features: 22 extensions Quarkus chargées -``` - -### Tests - -``` -✅ Compilation tests: réussie -✅ Erreurs: 0 (avant: 17) -✅ Fichiers compilés: 227 -``` - ---- - -## ⚠️ Problème Découvert - Hibernate Validation - -**Erreur détectée**: Hibernate schema validation échoue pour **toutes les tables**. - -**Symptôme**: -``` -Schema-validation: missing column [cree_par] in table [adresses] -Schema-validation: missing column [modifie_par] in table [adresses] -Schema-validation: missing column [date_creation] in table [adresses] -Schema-validation: missing column [date_modification] in table [adresses] -Schema-validation: missing column [version] in table [adresses] -Schema-validation: missing column [actif] in table [adresses] -``` - -**Cause**: Les migrations SQL n'incluent PAS les colonnes `BaseEntity` dans les tables: -- `cree_par VARCHAR(255)` -- `modifie_par VARCHAR(255)` -- `date_creation TIMESTAMP NOT NULL DEFAULT NOW()` -- `date_modification TIMESTAMP` -- `version INTEGER NOT NULL DEFAULT 0` -- `actif BOOLEAN NOT NULL DEFAULT true` - -**Impact**: -- ❌ Backend démarre mais Hibernate validation échoue -- ❌ Toutes les entités JPA qui étendent `BaseEntity` auront des erreurs d'insertion/update -- ⚠️ Production-blocking si `hibernate-orm.database.generation=validate` (mode prod) - -**Solution Requise**: Corriger V1 pour ajouter les 6 colonnes BaseEntity dans toutes les 69 tables. - ---- - -## 📁 Fichiers Modifiés/Créés - -### Créés -- ✅ `V1__UnionFlow_Complete_Schema.sql` (1322 lignes, consolidé final) -- ✅ `CONSOLIDATION_MIGRATIONS_FINALE.md` (ce rapport) -- ✅ `backup-migrations-20260316/` (sauvegarde V1-V10 originaux) - -### Modifiés -- ✅ `GlobalExceptionMapperTest.java` (17 tests corrigés) - -### Supprimés -- ✅ `V2__Entity_Schema_Alignment.sql` -- ✅ `V3__Seed_Comptes_Epargne_Test.sql` -- ✅ `V4__Add_DEPOT_EPARGNE_To_Intention_Type_Check.sql` -- ✅ `V5__Create_Membre_Suivi.sql` -- ✅ `V6__Create_Finance_Workflow_Tables.sql` -- ✅ `V7__Monitoring_System.sql` -- ✅ `V8__Fix_Monitoring_Columns.sql` -- ✅ `V9__Create_Alertes_LCB_FT.sql` -- ✅ `V10__Fix_All_Table_Names.sql` - ---- - -## 📋 Liste Complète des 69 Tables Créées - -### Core (11 tables) -- utilisateurs, organisations, roles, permission, membre_role, membre_organisation -- adresses, ayants_droit, types_reference -- modules_organisation_actifs, module_disponible - -### Finance (5 tables) -- cotisations, paiements, intention_paiement, paiements_objets -- parametres_cotisation_organisation - -### Mutuelle (5 tables) -- comptes_epargne, transactions_epargne -- demandes_credit, echeances_credit, garanties_demande - -### Événements & Solidarité (3 tables) -- evenements, inscriptions_evenement -- demandes_aide - -### Support (4 tables) -- ticket, suggestion, suggestion_vote, favori - -### Notifications (2 tables) -- notifications, template_notification - -### Documents (2 tables) -- document, pieces_jointes - -### Workflows Finance (5 tables) -- transaction_approvals, approver_actions -- budgets, budget_lines, workflow_validation_config - -### Monitoring (4 tables) -- system_logs, system_alerts, alert_configuration, audit_logs - -### Spécialisés (11 tables) -- tontines, tours_tontine -- campagnes_vote, candidats -- campagnes_collecte, contributions_collecte -- campagnes_agricoles, projets_ong, dons_religieux -- echelons_organigramme, agrements_professionnels - -### LCB-FT (2 tables) -- parametres_lcb_ft, alertes_lcb_ft - -### Adhésion (3 tables) -- demande_adhesion, formule_abonnement, souscription_organisation - -### Autre (3 tables) -- membre_suivi, validation_etape_demande -- comptes_wave, transaction_wave, webhooks_wave - -### Comptabilité (4 tables) -- compte_comptable, journal_comptable, ecriture_comptable, ligne_ecriture - -### Configuration (2 tables) -- configuration, configuration_wave - -**Total: 69 tables métier + 1 flyway_schema_history = 70 tables** - ---- - -## 🚀 Prochaines Étapes (URGENT) - -### P0 - Production Blocker - -1. **Corriger V1 pour ajouter les colonnes BaseEntity** - ```sql - -- Dans chaque CREATE TABLE, ajouter: - cree_par VARCHAR(255), - modifie_par VARCHAR(255), - date_creation TIMESTAMP NOT NULL DEFAULT NOW(), - date_modification TIMESTAMP, - version INTEGER NOT NULL DEFAULT 0, - actif BOOLEAN NOT NULL DEFAULT true - ``` - -2. **Retester Flyway clean + migrate** - ```bash - mvn clean compile quarkus:dev -D"quarkus.http.port=8085" -D"quarkus.flyway.clean-at-start=true" - ``` - -3. **Vérifier Hibernate validation réussit** - - Vérifier les logs: aucune erreur "Schema-validation: missing column" - - Vérifier: "Hibernate ORM ... successfully validated" - -### P1 - Qualité - -4. **Exécuter les tests** - ```bash - mvn test - ``` - -5. **Mettre à jour MEMORY.md** - - Section "Flyway Migrations — Consolidation Finale (2026-03-16)" - - Documenter: V1 unique, 69 tables, colonnes BaseEntity ajoutées - ---- - -## ✨ Résumé - -| Métrique | Avant | Après | -|----------|-------|-------| -| Migrations | V1-V10 (10 fichiers) | V1 unique | -| Lignes V1 | 3153 | 1322 | -| Duplications | 5 CREATE TABLE | 0 | -| Tables mal nommées | 24 | 0 | -| Seed data | Oui (V3) | Non (supprimé) | -| Tests en erreur | 17 | 0 | -| Backend démarre? | ❌ Non (V9 échouait) | ✅ Oui | -| Hibernate validation? | N/A | ❌ Échoue (colonnes manquantes) | - ---- - -## 📝 Notes Techniques - -### Credentials PostgreSQL -- **Host**: localhost:5432 -- **Database**: unionflow -- **Username**: skyfile -- **Password**: skyfile - -### Commandes Utiles - -```bash -# Démarrer backend avec Flyway clean -mvn compile quarkus:dev -D"quarkus.http.port=8085" -D"quarkus.flyway.clean-at-start=true" - -# Compiler tests uniquement -mvn test-compile - -# Exécuter tests -mvn test - -# Vérifier logs Flyway -grep -i "flyway\|migration" logs/output.txt -``` - ---- - -**Créé par**: Lions Dev -**Date**: 2026-03-16 -**Durée totale**: ~3h (analyse + consolidation + correction tests) diff --git a/JACOCO_TESTS_MANQUANTS.md b/JACOCO_TESTS_MANQUANTS.md deleted file mode 100644 index 6313630..0000000 --- a/JACOCO_TESTS_MANQUANTS.md +++ /dev/null @@ -1,76 +0,0 @@ -# JaCoCo 100 % – Tests ajoutés et suites restantes - -## Ce qui a été fait - -### 1. GlobalExceptionMapper (100 % branches) -- **Fichier :** `src/main/java/.../exception/GlobalExceptionMapper.java` -- **Modifs :** `@ApplicationScoped` pour l’injection en test ; ordre des `instanceof` dans `mapJsonException` : **InvalidFormatException avant MismatchedInputException** (InvalidFormatException étend MismatchedInputException). -- **Tests ajoutés dans** `GlobalExceptionMapperTest.java` : - - `mapRuntimeException` : RuntimeException, IllegalArgumentException, IllegalStateException, NotFoundException, WebApplicationException (message non vide, null, vide), fallback 500. - - `mapBadRequestException` : message présent, message null. - - `mapJsonException` : MismatchedInputException, InvalidFormatException, JsonMappingException, JsonParseException (cas par défaut), avec sous-classes/stubs pour les constructeurs Jackson protégés. - - `buildResponse` : délégation 3 args → 4 args ; message null ; details null. - -### 2. IdConverter (package util) -- **Fichier de test :** `src/test/java/.../util/IdConverterTest.java` -- Couverture : `longToUUID` (null, membre, organisation, cotisation, evenement, demandeaide, inscriptionevenement, type inconnu, casse), `uuidToLong` (null, valeur), `organisationIdToUUID`, `membreIdToUUID`, `cotisationIdToUUID`, `evenementIdToUUID`. - -### 3. UnionFlowServerApplication -- **Fichier de test :** `src/test/java/.../UnionFlowServerApplicationTest.java` -- Vérification de l’injection du bean (pas de couverture de `main()` ni `run()` qui appellent `Quarkus.waitForExit()`). - -### 4. AuthCallbackResource -- Les tests REST sur `/auth/callback` ont été retirés : en environnement test la ressource renvoie **500** (exception dans le bloc try ou en aval). À retester après correction de la cause (ex. config OIDC, format de la réponse, etc.). - ---- - -## État actuel de la couverture (sans exclusions) - -- **Instructions :** ~44 % -- **Branches :** ~32 % -- **Lignes :** ~46 % -- **Méthodes :** ~55 % -- **Seuils configurés :** 1,00 (100 %) pour LINE, BRANCH, INSTRUCTION, METHOD sur le BUNDLE → le **check JaCoCo échoue**. - ---- - -## Suites de tests à ajouter pour viser 100 % - -Les chiffres ci‑dessous sont issus du rapport JaCoCo (index par package). Pour chaque package, il faut ajouter ou compléter des tests jusqu’à couvrir toutes les lignes/branches/méthodes. - -| Package | Instructions | Branches | À faire | -|--------|---------------|----------|--------| -| `dev.lions.unionflow.server.service` | 35 % | 21 % | ~40 classes, couvrir tous les services (DashboardServiceImpl, MembreService, CotisationService, etc.) | -| `dev.lions.unionflow.server.resource` | 38 % | 41 % | ~33 resources REST : chaque endpoint et chaque branche (erreurs, paramètres, pagination) | -| `dev.lions.unionflow.server.repository` | 59 % | 46 % | ~32 repositories : requêtes personnalisées, critères, cas null | -| `dev.lions.unionflow.server.entity` | 70 % | 50 % | ~42 entités : getters/setters, `@PrePersist`, méthodes métier, listeners | -| `dev.lions.unionflow.server.service.mutuelle.credit` | 7 % | 0 % | DemandeCreditService : tous les cas et branches | -| `dev.lions.unionflow.server.service.mutuelle.epargne` | 18 % | 0 % | TransactionEpargneService, etc. | -| `dev.lions.unionflow.server.security` | 30 % | - | RoleDebugFilter, autres filtres : tests d’intégration (filtre + requête REST) | -| `dev.lions.unionflow.server.mapper` (racine + sous-packages) | 35–95 % | 21–64 % | Compléter les branches manquantes dans les mappers MapStruct (null, listes vides, champs optionnels) | -| `de.lions.unionflow.server.auth` | 0 % | 0 % | AuthCallbackResource : corriger la 500 en test puis réécrire les tests REST | -| `dev.lions.unionflow.server.util` | 0 % → couvert | - | IdConverter : fait | -| `dev.lions.unionflow.server.client` | 0 % | - | UserServiceClient, RoleServiceClient : tests avec WireMock ou mock du client + services qui les utilisent | -| `dev.lions.unionflow.server` | 0 % | - | UnionFlowServerApplication : `main`/`run` non couverts (blocage sur `waitForExit`) | - -En pratique, il faut : -- **Services :** pour chaque méthode publique, scénarios nominal, erreurs (exceptions, not found), paramètres null/optionnels, et chaque branche (if/else, try/catch). -- **Resources :** pour chaque `@GET`/`@POST`/…, au moins 200, 404, 400, 401/403 si applicable, et corps de requête/réponse. -- **Repositories :** tests avec base H2 et données de test pour chaque requête dérivée ou `@Query`. -- **Entités :** instanciation, setters, callbacks JPA, méthodes métier. -- **Mappers :** entité → DTO, DTO → entité, listes, champs null. -- **Filtres / clients :** soit tests d’intégration (REST + filtre), soit tests unitaires avec mocks (ContainerRequestContext, client REST mocké). - ---- - -## Recommandation - -- **Option A – Build vert avec seuils réalistes :** - Remonter temporairement les seuils JaCoCo (ex. 0,45 en LINE/INSTRUCTION, 0,32 en BRANCH) ou réintroduire des exclusions ciblées (entités, générés MapStruct, `*Application`) pour que la build passe, puis augmenter progressivement la couverture par packages. - -- **Option B – Viser 100 % sans exclusions :** - Continuer à ajouter des tests package par package en s’appuyant sur le rapport HTML JaCoCo (`target/site/jacoco/index.html`) et sur ce fichier, jusqu’à atteindre 1,00 sur tout le bundle. - ---- - -*Dernière mise à jour : suite aux ajouts GlobalExceptionMapper, IdConverter, UnionFlowServerApplication et correction de l’ordre `mapJsonException`.* diff --git a/NETTOYAGE_MIGRATIONS_RAPPORT.md b/NETTOYAGE_MIGRATIONS_RAPPORT.md deleted file mode 100644 index 3cf4c9c..0000000 --- a/NETTOYAGE_MIGRATIONS_RAPPORT.md +++ /dev/null @@ -1,216 +0,0 @@ -# Rapport de Nettoyage Complet des Migrations Flyway -**Date**: 2026-03-13 -**Auteur**: Lions Dev -**Projet**: UnionFlow - Backend Quarkus - ---- - -## 🎯 Objectif - -Nettoyer intégralement toutes les migrations Flyway selon les réalités du code source (entités JPA) et résoudre les problèmes de démarrage du backend. - ---- - -## ❌ Problème Initial - -**Erreur au démarrage**: -``` -Migration V9__Create_Alertes_LCB_FT failed -ERROR: relation 'membres' does not exist (SQL State: 42P01) -``` - -**Cause racine**: Le fichier `V1__UnionFlow_Complete_Schema.sql` (3153 lignes) contenait: -- ❌ **3 CREATE TABLE organisations** (lignes 11, 247, 884) -- ❌ **2 CREATE TABLE membres** (lignes 331, 857) -- ❌ **DROP/CREATE/CREATE** redondants -- ❌ **74 ALTER TABLE** statements -- ❌ **107 FOREIGN KEY** constraints - -→ **Résultat**: Transaction rollback, tables jamais créées, V9 échoue. - ---- - -## ✅ Actions Effectuées - -### 1. Nettoyage de V1__UnionFlow_Complete_Schema.sql - -**Fichier avant**: 3153 lignes avec sections redondantes -**Fichier après**: ~2318 lignes (sections 1-835 supprimées) - -**Suppressions**: -- ❌ Section V1.2 (CREATE organisations avec BIGSERIAL) -- ❌ Section "Migration UUID" (DROP + recréation organisations/membres) -- ❌ Sections avec CREATE TABLE sans IF NOT EXISTS -- ✅ Conservé uniquement: Section consolidée V1.7 (ligne 836+) avec `CREATE TABLE IF NOT EXISTS` - -### 2. Audit Complet Entités vs Migrations - -**Script créé**: `audit_precise.sh` -**Rapports générés**: -- `AUDIT_MIGRATIONS.md` (audit initial) -- `AUDIT_MIGRATIONS_PRECISE.md` (audit précis avec @Table annotations) - -**Résultats**: -- 📊 **69 entités JPA** (71 - 2 abstraites/listeners) -- 📊 **76 tables** dans migrations -- ✅ **45 entités OK** (table correspondante) -- ❌ **24 entités sans table** (problèmes de nommage) -- ⚠️ **31 tables orphelines** - -### 3. Problèmes de Nommage Détectés - -**Problème majeur**: V1 a créé des tables au **pluriel** alors que les entités utilisent `@Table(name="...")` au **singulier**. - -| Entité | Table attendue (@Table) | Table créée dans V1 | Statut | -|--------|-------------------------|---------------------|--------| -| Membre | `utilisateurs` | `membres` | ❌ MAUVAIS NOM | -| Configuration | `configuration` | `configurations` | ❌ MAUVAIS NOM | -| Ticket | `ticket` | `tickets` | ❌ MAUVAIS NOM | -| Suggestion | `suggestion` | `suggestions` | ❌ MAUVAIS NOM | -| Favori | `favori` | `favoris` | ❌ MAUVAIS NOM | -| Permission | `permission` | `permissions` | ❌ MAUVAIS NOM | -| Document | `document` | `documents` | ❌ MAUVAIS NOM | -| ... | ... | ... | ... | - -**Total**: **24 tables** avec le mauvais nom (pluriel au lieu de singulier). - -### 4. Migration V10 de Correction - -**Fichier créé**: `V10__Fix_All_Table_Names.sql` - -**Contenu**: - -#### PARTIE 1 - Renommages (24 tables) -```sql -ALTER TABLE membres RENAME TO utilisateurs; -ALTER TABLE configurations RENAME TO configuration; -ALTER TABLE tickets RENAME TO ticket; -ALTER TABLE suggestions RENAME TO suggestion; -ALTER TABLE favoris RENAME TO favori; -ALTER TABLE permissions RENAME TO permission; -... (et 18 autres) -``` - -#### PARTIE 2 - Suppressions (tables orphelines) -```sql -DROP TABLE IF EXISTS paiements_adhesions CASCADE; -DROP TABLE IF EXISTS paiements_aides CASCADE; -DROP TABLE IF EXISTS paiements_cotisations CASCADE; -DROP TABLE IF EXISTS paiements_evenements CASCADE; -DROP TABLE IF EXISTS adhesions CASCADE; -DROP TABLE IF EXISTS uf_type_organisation CASCADE; -``` - ---- - -## 📋 Liste Complète des Tables Renommées (24) - -1. `membres` → `utilisateurs` (Membre) -2. `configurations` → `configuration` (Configuration) -3. `configurations_wave` → `configuration_wave` (ConfigurationWave) -4. `documents` → `document` (Document) -5. `favoris` → `favori` (Favori) -6. `permissions` → `permission` (Permission) -7. `suggestions` → `suggestion` (Suggestion) -8. `suggestion_votes` → `suggestion_vote` (SuggestionVote) -9. `tickets` → `ticket` (Ticket) -10. `templates_notifications` → `template_notification` (TemplateNotification) -11. `transactions_wave` → `transaction_wave` (TransactionWave) -12. `demandes_adhesion` → `demande_adhesion` (DemandeAdhesion) -13. `formules_abonnement` → `formule_abonnement` (FormuleAbonnement) -14. `intentions_paiement` → `intention_paiement` (IntentionPaiement) -15. `membres_organisations` → `membre_organisation` (MembreOrganisation) -16. `membres_roles` → `membre_role` (MembreRole) -17. `modules_disponibles` → `module_disponible` (ModuleDisponible) -18. `roles_permissions` → `role_permission` (RolePermission) -19. `souscriptions_organisation` → `souscription_organisation` (SouscriptionOrganisation) -20. `validation_etapes_demande` → `validation_etape_demande` (ValidationEtapeDemande) -21. `comptes_comptables` → `compte_comptable` (CompteComptable) -22. `ecritures_comptables` → `ecriture_comptable` (EcritureComptable) -23. `journaux_comptables` → `journal_comptable` (JournalComptable) -24. `lignes_ecriture` → `ligne_ecriture` (LigneEcriture) - ---- - -## 📊 État Final - -### Migrations - -| Migration | Description | Statut | -|-----------|-------------|--------| -| V1 | Schema complet consolidé (nettoyé) | ✅ OK | -| V2 | Entity Schema Alignment | ✅ OK | -| V3 | Seed Comptes Epargne Test | ✅ OK | -| V4 | Add DEPOT_EPARGNE To Intention Type Check | ✅ OK | -| V5 | Create Membre Suivi | ✅ OK | -| V6 | Create Finance Workflow Tables | ✅ OK | -| V7 | Monitoring System | ✅ OK | -| V8 | Fix Monitoring Columns | ✅ OK | -| V9 | Create Alertes LCB FT | ✅ OK (après V10) | -| **V10** | **Fix All Table Names** | ✅ **NOUVEAU** | - -### Entités vs Tables - -- ✅ **69/69 entités** ont maintenant une table correspondante -- ✅ **0 table orpheline** (supprimées) -- ✅ **0 duplication** (nettoyé dans V1) - ---- - -## 🧪 Prochaines Étapes - -### 1. Tester le Backend - -```bash -cd unionflow/unionflow-server-impl-quarkus -mvn clean compile quarkus:dev -D"quarkus.http.port=8085" -D"quarkus.flyway.clean-at-start=true" -``` - -**Attendu**: -- ✅ Flyway clean réussit -- ✅ V1-V10 s'exécutent sans erreur -- ✅ Backend démarre sur port 8085 -- ✅ Swagger accessible: `http://localhost:8085/q/swagger-ui` - -### 2. Vérifier les Tests (si nécessaire) - -**Tests en échec avant nettoyage**: -- `GlobalExceptionMapperTest.java` (17 erreurs - méthodes manquantes) - -**Action**: Corriger si nécessaire après confirmation du démarrage backend. - -### 3. Documentation - -**Fichiers créés**: -- ✅ `AUDIT_MIGRATIONS.md` - Audit initial -- ✅ `AUDIT_MIGRATIONS_PRECISE.md` - Audit précis avec @Table -- ✅ `NETTOYAGE_MIGRATIONS_RAPPORT.md` - Ce rapport -- ✅ `audit_precise.sh` - Script Bash d'audit -- ✅ `V10__Fix_All_Table_Names.sql` - Migration de correction - -**Mise à jour MEMORY.md** (à faire): -- Ajouter: "Migration Flyway V1-V10 nettoyées, 24 tables renommées (utilisateurs, configuration, etc.)" - ---- - -## ✨ Résumé - -| Métrique | Avant | Après | -|----------|-------|-------| -| Fichier V1 | 3153 lignes | ~2318 lignes | -| CREATE TABLE dupliqués | 3× organisations, 2× membres | 0 | -| Entités sans table | 24 | 0 | -| Tables orphelines | 31 | 0 | -| Tables mal nommées | 24 | 0 | -| Migrations | V1-V9 | V1-V10 | -| Backend démarre? | ❌ Non | ⏳ À tester | - ---- - -## 🎉 Conclusion - -Le nettoyage complet des migrations Flyway est **TERMINÉ**. Tous les problèmes de nommage et de duplication ont été résolus. Le backend devrait maintenant démarrer sans erreur Flyway. - -**Créé par**: Lions Dev -**Date**: 2026-03-13 -**Durée**: ~2h d'analyse et correction diff --git a/TESTS_CONNUS_EN_ECHEC.md b/TESTS_CONNUS_EN_ECHEC.md deleted file mode 100644 index 7eb7d1d..0000000 --- a/TESTS_CONNUS_EN_ECHEC.md +++ /dev/null @@ -1,31 +0,0 @@ -# Tests connus en échec - -Ce document liste les tests qui échouent actuellement et les raisons connues. - -## Tests Resource/Service : 82/82 (100% de réussite) - -Tous les tests resource et service passent avec succes. - -### Corrections appliquees (2026-02-11) - -1. **`EvenementResourceTest.testModifierEvenement`** - CORRIGE - - **Cause**: LazyInitializationException lors de la serialisation JSON de la reponse - - **Fix**: Ajout de `@JsonIgnore` sur les collections lazy (`inscriptions`, `adresses`) et les methodes calculees (`getNombreInscrits`, `isComplet`, `getPlacesRestantes`, `getTauxRemplissage`, `isOuvertAuxInscriptions`) dans Evenement.java. Ajout de `Hibernate.initialize()` dans EvenementService. Ajout de `@JsonIgnore` sur les collections lazy de Organisation.java et Membre.java. - -2. **`EvenementResourceTest.testModifierEvenementInexistant`** - CORRIGE - - **Cause**: Le resource retournait 400 (IllegalArgumentException) au lieu de 404 pour un evenement non trouve - - **Fix**: Ajout d'une verification du message d'erreur dans EvenementResource pour retourner 404 quand le message contient "non trouve" - -3. **`MembreResourceImportExportTest.testImporterMembresExcel`** - CORRIGE - - **Cause**: `@RestForm byte[]` ne recoit pas les fichiers multipart en RESTEasy Reactive - - **Fix**: Remplacement de `@RestForm("file") byte[]` par `@RestForm("file") FileUpload` dans MembreResource.importerMembres() - -## Tests Integration : echecs pre-existants (non lies aux corrections ci-dessus) - -Les tests dans `dev.lions.unionflow.server.integration.*` (non commites, non suivis par git) ont des echecs pre-existants a investiguer separement. - ---- - -**Date de creation**: 2026-01-04 -**Derniere mise a jour**: 2026-02-11 -**Taux de reussite resource/service**: 82/82 tests (100%) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 873d364..9630b08 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,7 +2,7 @@ version: '3.8' services: postgres-dev: - image: postgres:15-alpine + image: postgres:17-alpine container_name: unionflow-postgres-dev environment: POSTGRES_DB: unionflow_dev diff --git a/pom.xml b/pom.xml index dc8f443..56d6ad5 100644 --- a/pom.xml +++ b/pom.xml @@ -18,16 +18,17 @@ Implémentation Quarkus du serveur UnionFlow - 17 - 17 + 21 + 21 + 21 UTF-8 - - 3.15.1 + + 3.20.0 io.quarkus.platform quarkus-bom - 0.8.11 + 0.8.12 @@ -122,11 +123,6 @@ io.quarkus quarkus-messaging-kafka - - io.quarkus - quarkus-smallrye-reactive-messaging-kafka - - io.quarkus quarkus-mailer @@ -141,6 +137,10 @@ io.quarkus quarkus-smallrye-health + + io.quarkus + quarkus-micrometer-registry-prometheus + io.quarkus quarkus-cache @@ -215,6 +215,20 @@ 1.3.30 + + + com.google.firebase + firebase-admin + 9.3.0 + + + + io.netty + * + + + + io.quarkus @@ -269,6 +283,7 @@ smallrye-reactive-messaging-in-memory test + diff --git a/src/main/java/dev/lions/unionflow/server/entity/CompteComptable.java b/src/main/java/dev/lions/unionflow/server/entity/CompteComptable.java index f7d83a7..fc7fb7f 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/CompteComptable.java +++ b/src/main/java/dev/lions/unionflow/server/entity/CompteComptable.java @@ -53,8 +53,8 @@ public class CompteComptable extends BaseEntity { /** Classe comptable (1-7) */ @NotNull - @Min(value = 1, message = "La classe comptable doit être entre 1 et 7") - @Max(value = 7, message = "La classe comptable doit être entre 1 et 7") + @Min(value = 1, message = "La classe comptable doit être entre 1 et 9") + @Max(value = 9, message = "La classe comptable doit être entre 1 et 9") @Column(name = "classe_comptable", nullable = false) private Integer classeComptable; @@ -85,6 +85,11 @@ public class CompteComptable extends BaseEntity { @Column(name = "description", length = 500) private String description; + /** Organisation propriétaire (null = compte standard global) */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organisation_id") + private Organisation organisation; + /** Lignes d'écriture associées */ @JsonIgnore @OneToMany(mappedBy = "compteComptable", cascade = CascadeType.ALL, fetch = FetchType.LAZY) diff --git a/src/main/java/dev/lions/unionflow/server/entity/FormuleAbonnement.java b/src/main/java/dev/lions/unionflow/server/entity/FormuleAbonnement.java index 7de9043..aae7138 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/FormuleAbonnement.java +++ b/src/main/java/dev/lions/unionflow/server/entity/FormuleAbonnement.java @@ -110,6 +110,10 @@ public class FormuleAbonnement extends BaseEntity { @Column(name = "max_admins") private Integer maxAdmins; + /** Code du provider de paiement par défaut (WAVE, ORANGE_MONEY, MTN_MOMO, PISPI). NULL = global. */ + @Column(name = "provider_defaut", length = 20) + private String providerDefaut; + public boolean isIllimitee() { return maxMembres == null; } diff --git a/src/main/java/dev/lions/unionflow/server/entity/JournalComptable.java b/src/main/java/dev/lions/unionflow/server/entity/JournalComptable.java index f3d9d5f..ab40b42 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/JournalComptable.java +++ b/src/main/java/dev/lions/unionflow/server/entity/JournalComptable.java @@ -24,8 +24,11 @@ import lombok.NoArgsConstructor; @Entity @Table( name = "journaux_comptables", + uniqueConstraints = { + @UniqueConstraint(name = "uk_journaux_org_code", columnNames = {"organisation_id", "code"}) + }, indexes = { - @Index(name = "idx_journal_code", columnList = "code", unique = true), + @Index(name = "idx_journal_code", columnList = "code"), @Index(name = "idx_journal_type", columnList = "type_journal"), @Index(name = "idx_journal_periode", columnList = "date_debut, date_fin") }) @@ -36,9 +39,9 @@ import lombok.NoArgsConstructor; @EqualsAndHashCode(callSuper = true) public class JournalComptable extends BaseEntity { - /** Code unique du journal */ + /** Code du journal (unique par organisation). */ @NotBlank - @Column(name = "code", unique = true, nullable = false, length = 10) + @Column(name = "code", nullable = false, length = 10) private String code; /** Libellé du journal */ @@ -69,6 +72,11 @@ public class JournalComptable extends BaseEntity { @Column(name = "description", length = 500) private String description; + /** Organisation propriétaire */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organisation_id") + private Organisation organisation; + /** Écritures comptables associées */ @JsonIgnore @OneToMany(mappedBy = "journal", cascade = CascadeType.ALL, fetch = FetchType.LAZY) diff --git a/src/main/java/dev/lions/unionflow/server/entity/KycDossier.java b/src/main/java/dev/lions/unionflow/server/entity/KycDossier.java new file mode 100644 index 0000000..00799e1 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/KycDossier.java @@ -0,0 +1,112 @@ +package dev.lions.unionflow.server.entity; + +import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc; +import dev.lions.unionflow.server.api.enums.membre.StatutKyc; +import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite; +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Dossier KYC/AML d'un membre — conformité GIABA/BCEAO LCB-FT. + * + *

Rétention 10 ans requise par le GIABA. La colonne {@code anneeReference} + * sert à l'archivage logique par année (partitionnement futur PostgreSQL). + * + *

Un seul dossier actif ({@code actif=true}) par membre à la fois. + * Les dossiers expirés ou archivés ont {@code actif=false}. + */ +@Entity +@Table( + name = "kyc_dossier", + indexes = { + @Index(name = "idx_kyc_membre_id", columnList = "membre_id"), + @Index(name = "idx_kyc_statut", columnList = "statut"), + @Index(name = "idx_kyc_niveau_risque", columnList = "niveau_risque"), + @Index(name = "idx_kyc_annee", columnList = "annee_reference") + } +) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = true) +public class KycDossier extends BaseEntity { + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "membre_id", nullable = false) + private Membre membre; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "type_piece", nullable = false, length = 30) + private TypePieceIdentite typePiece; + + @NotBlank + @Size(max = 50) + @Column(name = "numero_piece", nullable = false, length = 50) + private String numeroPiece; + + @Column(name = "date_expiration_piece") + private LocalDate dateExpirationPiece; + + @Size(max = 500) + @Column(name = "piece_identite_recto_file_id", length = 500) + private String pieceIdentiteRectoFileId; + + @Size(max = 500) + @Column(name = "piece_identite_verso_file_id", length = 500) + private String pieceIdentiteVersoFileId; + + @Size(max = 500) + @Column(name = "justif_domicile_file_id", length = 500) + private String justifDomicileFileId; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "statut", nullable = false, length = 20) + @Builder.Default + private StatutKyc statut = StatutKyc.NON_VERIFIE; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "niveau_risque", nullable = false, length = 20) + @Builder.Default + private NiveauRisqueKyc niveauRisque = NiveauRisqueKyc.FAIBLE; + + @Min(0) @Max(100) + @Column(name = "score_risque", nullable = false) + @Builder.Default + private int scoreRisque = 0; + + @Builder.Default + @Column(name = "est_pep", nullable = false) + private boolean estPep = false; + + @Size(max = 5) + @Column(name = "nationalite", length = 5) + private String nationalite; + + @Column(name = "date_verification") + private LocalDateTime dateVerification; + + @Column(name = "validateur_id") + private UUID validateurId; + + @Size(max = 1000) + @Column(name = "notes_validateur", length = 1000) + private String notesValidateur; + + @Column(name = "annee_reference", nullable = false) + @Builder.Default + private int anneeReference = java.time.LocalDate.now().getYear(); + + public boolean isPieceExpiree() { + return dateExpirationPiece != null && dateExpirationPiece.isBefore(LocalDate.now()); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/Membre.java b/src/main/java/dev/lions/unionflow/server/entity/Membre.java index 08c5ceb..3f2e0d5 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/Membre.java +++ b/src/main/java/dev/lions/unionflow/server/entity/Membre.java @@ -59,6 +59,10 @@ public class Membre extends BaseEntity { @Column(name = "telephone", length = 20) private String telephone; + /** Token FCM pour les notifications push Firebase. NULL si l'app mobile n'est pas installée ou si le membre a refusé les notifications. */ + @Column(name = "fcm_token", length = 500) + private String fcmToken; + @Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)") @Column(name = "telephone_wave", length = 20) private String telephoneWave; diff --git a/src/main/java/dev/lions/unionflow/server/entity/Organisation.java b/src/main/java/dev/lions/unionflow/server/entity/Organisation.java index d22f363..f52ae57 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/Organisation.java +++ b/src/main/java/dev/lions/unionflow/server/entity/Organisation.java @@ -8,6 +8,7 @@ import java.time.LocalDate; import java.time.Period; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -201,6 +202,10 @@ public class Organisation extends BaseEntity { @Column(name = "categorie_type", length = 50) private String categorieType; + /** ID de l'Organization Keycloak 26 correspondante — null si pas encore migrée. */ + @Column(name = "keycloak_org_id") + private UUID keycloakOrgId; + /** Modules activés pour cette organisation (liste CSV, ex: "MEMBRES,COTISATIONS,TONTINE") */ @Column(name = "modules_actifs", length = 1000) private String modulesActifs; diff --git a/src/main/java/dev/lions/unionflow/server/entity/mutuelle/ParametresFinanciersMutuelle.java b/src/main/java/dev/lions/unionflow/server/entity/mutuelle/ParametresFinanciersMutuelle.java new file mode 100644 index 0000000..c279c68 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/mutuelle/ParametresFinanciersMutuelle.java @@ -0,0 +1,68 @@ +package dev.lions.unionflow.server.entity.mutuelle; + +import dev.lions.unionflow.server.entity.BaseEntity; +import dev.lions.unionflow.server.entity.Organisation; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Entity +@Table(name = "parametres_financiers_mutuelle", indexes = { + @Index(name = "idx_pfm_org", columnList = "organisation_id", unique = true) +}) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = true) +public class ParametresFinanciersMutuelle extends BaseEntity { + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organisation_id", nullable = false, unique = true) + private Organisation organisation; + + /** Valeur nominale par défaut d'une part sociale */ + @NotNull + @Column(name = "valeur_nominale_par_defaut", nullable = false, precision = 19, scale = 4) + @Builder.Default + private BigDecimal valeurNominaleParDefaut = new BigDecimal("5000"); + + /** Taux d'intérêt annuel sur l'épargne, ex: 0.03 = 3% */ + @NotNull + @Column(name = "taux_interet_annuel_epargne", nullable = false, precision = 6, scale = 4) + @Builder.Default + private BigDecimal tauxInteretAnnuelEpargne = new BigDecimal("0.03"); + + /** Taux de dividende annuel sur les parts sociales, ex: 0.05 = 5% */ + @NotNull + @Column(name = "taux_dividende_parts_annuel", nullable = false, precision = 6, scale = 4) + @Builder.Default + private BigDecimal tauxDividendePartsAnnuel = new BigDecimal("0.05"); + + /** MENSUEL | TRIMESTRIEL | ANNUEL */ + @NotNull + @Column(name = "periodicite_calcul", nullable = false, length = 20) + @Builder.Default + private String periodiciteCalcul = "MENSUEL"; + + /** Solde minimum en dessous duquel les intérêts ne s'appliquent pas */ + @Column(name = "seuil_min_epargne_interets", precision = 19, scale = 4) + @Builder.Default + private BigDecimal seuilMinEpargneInterets = BigDecimal.ZERO; + + /** Date du prochain calcul planifié */ + @Column(name = "prochaine_calcul_interets") + private LocalDate prochaineCalculInterets; + + /** Date du dernier calcul effectué */ + @Column(name = "dernier_calcul_interets") + private LocalDate dernierCalculInterets; + + /** Nombre de comptes traités lors du dernier calcul */ + @Column(name = "dernier_nb_comptes_traites") + @Builder.Default + private Integer dernierNbComptesTraites = 0; +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/mutuelle/parts/ComptePartsSociales.java b/src/main/java/dev/lions/unionflow/server/entity/mutuelle/parts/ComptePartsSociales.java new file mode 100644 index 0000000..fa0702d --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/mutuelle/parts/ComptePartsSociales.java @@ -0,0 +1,78 @@ +package dev.lions.unionflow.server.entity.mutuelle.parts; + +import dev.lions.unionflow.server.api.enums.mutuelle.parts.StatutComptePartsSociales; +import dev.lions.unionflow.server.entity.BaseEntity; +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.entity.Organisation; +import jakarta.persistence.*; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Entity +@Table(name = "comptes_parts_sociales", indexes = { + @Index(name = "idx_cps_numero", columnList = "numero_compte", unique = true), + @Index(name = "idx_cps_membre", columnList = "membre_id"), + @Index(name = "idx_cps_org", columnList = "organisation_id") +}) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = true) +public class ComptePartsSociales extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "membre_id", nullable = false) + private Membre membre; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organisation_id", nullable = false) + private Organisation organisation; + + @NotBlank + @Column(name = "numero_compte", unique = true, nullable = false, length = 50) + private String numeroCompte; + + @NotNull + @Min(0) + @Column(name = "nombre_parts", nullable = false) + @Builder.Default + private Integer nombreParts = 0; + + @NotNull + @Column(name = "valeur_nominale", nullable = false, precision = 19, scale = 4) + private BigDecimal valeurNominale; + + /** nombreParts × valeurNominale — mis à jour à chaque transaction */ + @NotNull + @Column(name = "montant_total", nullable = false, precision = 19, scale = 4) + @Builder.Default + private BigDecimal montantTotal = BigDecimal.ZERO; + + @NotNull + @Column(name = "total_dividendes_recus", nullable = false, precision = 19, scale = 4) + @Builder.Default + private BigDecimal totalDividendesRecus = BigDecimal.ZERO; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "statut", nullable = false, length = 30) + @Builder.Default + private StatutComptePartsSociales statut = StatutComptePartsSociales.ACTIF; + + @NotNull + @Column(name = "date_ouverture", nullable = false) + @Builder.Default + private LocalDate dateOuverture = LocalDate.now(); + + @Column(name = "date_derniere_operation") + private LocalDate dateDerniereOperation; + + @Column(name = "notes", length = 500) + private String notes; +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/mutuelle/parts/TransactionPartsSociales.java b/src/main/java/dev/lions/unionflow/server/entity/mutuelle/parts/TransactionPartsSociales.java new file mode 100644 index 0000000..bfed0bf --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/mutuelle/parts/TransactionPartsSociales.java @@ -0,0 +1,61 @@ +package dev.lions.unionflow.server.entity.mutuelle.parts; + +import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales; +import dev.lions.unionflow.server.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "transactions_parts_sociales", indexes = { + @Index(name = "idx_tps_compte", columnList = "compte_id"), + @Index(name = "idx_tps_date", columnList = "date_transaction") +}) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = true) +public class TransactionPartsSociales extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "compte_id", nullable = false) + private ComptePartsSociales compte; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "type_transaction", nullable = false, length = 50) + private TypeTransactionPartsSociales typeTransaction; + + @NotNull + @Min(1) + @Column(name = "nombre_parts", nullable = false) + private Integer nombreParts; + + @NotNull + @Column(name = "montant", nullable = false, precision = 19, scale = 4) + private BigDecimal montant; + + @Column(name = "solde_parts_avant", nullable = false) + @Builder.Default + private Integer soldePartsAvant = 0; + + @Column(name = "solde_parts_apres", nullable = false) + @Builder.Default + private Integer soldePartsApres = 0; + + @Column(name = "motif", length = 500) + private String motif; + + @Column(name = "reference_externe", length = 100) + private String referenceExterne; + + @NotNull + @Column(name = "date_transaction", nullable = false) + @Builder.Default + private LocalDateTime dateTransaction = LocalDateTime.now(); +} diff --git a/src/main/java/dev/lions/unionflow/server/exception/GlobalExceptionMapper.java b/src/main/java/dev/lions/unionflow/server/exception/GlobalExceptionMapper.java index 0bfc224..02023b8 100644 --- a/src/main/java/dev/lions/unionflow/server/exception/GlobalExceptionMapper.java +++ b/src/main/java/dev/lions/unionflow/server/exception/GlobalExceptionMapper.java @@ -98,7 +98,9 @@ public class GlobalExceptionMapper implements ExceptionMapper { return exception instanceof NotFoundException || exception instanceof ForbiddenException || exception instanceof NotAuthorizedException - || exception instanceof NotAllowedException; + || exception instanceof NotAllowedException + || exception instanceof IllegalArgumentException + || exception instanceof IllegalStateException; } private int determineStatusCode(Throwable exception) { diff --git a/src/main/java/dev/lions/unionflow/server/mapper/mutuelle/parts/ComptePartsSocialesMapper.java b/src/main/java/dev/lions/unionflow/server/mapper/mutuelle/parts/ComptePartsSocialesMapper.java new file mode 100644 index 0000000..afdd79b --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/mapper/mutuelle/parts/ComptePartsSocialesMapper.java @@ -0,0 +1,15 @@ +package dev.lions.unionflow.server.mapper.mutuelle.parts; + +import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse; +import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "cdi", builder = @org.mapstruct.Builder(disableBuilder = true)) +public interface ComptePartsSocialesMapper { + + @Mapping(target = "membreId", expression = "java(entity.getMembre() != null ? entity.getMembre().getId().toString() : null)") + @Mapping(target = "membreNomComplet", expression = "java(entity.getMembre() != null ? entity.getMembre().getNom() + ' ' + entity.getMembre().getPrenom() : null)") + @Mapping(target = "organisationId", expression = "java(entity.getOrganisation() != null ? entity.getOrganisation().getId().toString() : null)") + ComptePartsSocialesResponse toDto(ComptePartsSociales entity); +} diff --git a/src/main/java/dev/lions/unionflow/server/mapper/mutuelle/parts/TransactionPartsSocialesMapper.java b/src/main/java/dev/lions/unionflow/server/mapper/mutuelle/parts/TransactionPartsSocialesMapper.java new file mode 100644 index 0000000..62b5dae --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/mapper/mutuelle/parts/TransactionPartsSocialesMapper.java @@ -0,0 +1,15 @@ +package dev.lions.unionflow.server.mapper.mutuelle.parts; + +import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse; +import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "cdi", builder = @org.mapstruct.Builder(disableBuilder = true)) +public interface TransactionPartsSocialesMapper { + + @Mapping(target = "compteId", expression = "java(entity.getCompte() != null ? entity.getCompte().getId().toString() : null)") + @Mapping(target = "numeroCompte", expression = "java(entity.getCompte() != null ? entity.getCompte().getNumeroCompte() : null)") + @Mapping(target = "typeTransactionLibelle", expression = "java(entity.getTypeTransaction() != null ? entity.getTypeTransaction().getLibelle() : null)") + TransactionPartsSocialesResponse toDto(TransactionPartsSociales entity); +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/mtnmomo/MtnMomoPaymentProvider.java b/src/main/java/dev/lions/unionflow/server/payment/mtnmomo/MtnMomoPaymentProvider.java new file mode 100644 index 0000000..39422f3 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/mtnmomo/MtnMomoPaymentProvider.java @@ -0,0 +1,71 @@ +package dev.lions.unionflow.server.payment.mtnmomo; + +import dev.lions.unionflow.server.api.payment.*; +import jakarta.enterprise.context.ApplicationScoped; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * Provider MTN MoMo (stub — à implémenter avec l'API MTN Mobile Money). + * + *

Sandbox : https://sandbox.momodeveloper.mtn.com + * Requis : subscription-key, api-user, api-key (via provisioning sandbox). + */ +@Slf4j +@ApplicationScoped +public class MtnMomoPaymentProvider implements PaymentProvider { + + public static final String CODE = "MTN_MOMO"; + + @ConfigProperty(name = "mtnmomo.collection.subscription-key") + Optional subscriptionKeyOpt; + + @ConfigProperty(name = "mtnmomo.api.base-url", defaultValue = "https://sandbox.momodeveloper.mtn.com") + String baseUrl; + + String subscriptionKey; + + @jakarta.annotation.PostConstruct + void init() { + subscriptionKey = subscriptionKeyOpt.orElse(""); + } + + @Override + public String getProviderCode() { + return CODE; + } + + @Override + public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException { + if (subscriptionKey == null || subscriptionKey.isBlank()) { + log.warn("MTN MoMo non configuré — mode mock actif pour ref={}", request.reference()); + String mockId = "MTN-MOCK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + return new CheckoutSession(mockId, "https://mock.mtn.ci/pay/" + mockId, + Instant.now().plusSeconds(600), Map.of("mock", "true", "provider", CODE)); + } + // TODO P1.3 Phase 3 : implémenter MTN Collection API (requestToPay) + throw new PaymentException(CODE, "MTN MoMo non encore implémenté en production", 501); + } + + @Override + public PaymentStatus getStatus(String externalId) throws PaymentException { + log.warn("MTN MoMo getStatus mock pour externalId={}", externalId); + return PaymentStatus.PROCESSING; + } + + @Override + public PaymentEvent processWebhook(String rawBody, Map headers) throws PaymentException { + // TODO P1.3 Phase 3 : parser callback MTN MoMo + throw new PaymentException(CODE, "Webhook MTN MoMo non encore implémenté", 501); + } + + @Override + public boolean isAvailable() { + return subscriptionKey != null && !subscriptionKey.isBlank(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/orangemoney/OrangeMoneyPaymentProvider.java b/src/main/java/dev/lions/unionflow/server/payment/orangemoney/OrangeMoneyPaymentProvider.java new file mode 100644 index 0000000..1d9a0d0 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/orangemoney/OrangeMoneyPaymentProvider.java @@ -0,0 +1,73 @@ +package dev.lions.unionflow.server.payment.orangemoney; + +import dev.lions.unionflow.server.api.payment.*; +import jakarta.enterprise.context.ApplicationScoped; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * Provider Orange Money (stub — à implémenter avec l'API Orange Money WebPay). + * + *

Sandbox : https://developer.orange.com/apis/om-webpay + * Requis : client_id, client_secret, merchant_key par pays. + * + *

Retourne un mock tant que {@code orange.api.client-id} n'est pas configuré. + */ +@Slf4j +@ApplicationScoped +public class OrangeMoneyPaymentProvider implements PaymentProvider { + + public static final String CODE = "ORANGE_MONEY"; + + @ConfigProperty(name = "orange.api.client-id") + Optional clientIdOpt; + + @ConfigProperty(name = "orange.api.base-url", defaultValue = "https://api.orange.com/orange-money-webpay/dev/v1") + String baseUrl; + + String clientId; + + @jakarta.annotation.PostConstruct + void init() { + clientId = clientIdOpt.orElse(""); + } + + @Override + public String getProviderCode() { + return CODE; + } + + @Override + public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException { + if (clientId == null || clientId.isBlank()) { + log.warn("Orange Money non configuré — mode mock actif pour ref={}", request.reference()); + String mockId = "OM-MOCK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + return new CheckoutSession(mockId, "https://mock.orange.ci/pay/" + mockId, + Instant.now().plusSeconds(900), Map.of("mock", "true", "provider", CODE)); + } + // TODO P1.3 Phase 3 : implémenter OAuth2 + POST /webpay + throw new PaymentException(CODE, "Orange Money non encore implémenté en production", 501); + } + + @Override + public PaymentStatus getStatus(String externalId) throws PaymentException { + log.warn("Orange Money getStatus mock pour externalId={}", externalId); + return PaymentStatus.PROCESSING; + } + + @Override + public PaymentEvent processWebhook(String rawBody, Map headers) throws PaymentException { + // TODO P1.3 Phase 3 : parser webhook Orange Money + vérifier signature + throw new PaymentException(CODE, "Webhook Orange Money non encore implémenté", 501); + } + + @Override + public boolean isAvailable() { + return clientId != null && !clientId.isBlank(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/orchestration/PaymentOrchestrator.java b/src/main/java/dev/lions/unionflow/server/payment/orchestration/PaymentOrchestrator.java new file mode 100644 index 0000000..360e5e5 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/orchestration/PaymentOrchestrator.java @@ -0,0 +1,93 @@ +package dev.lions.unionflow.server.payment.orchestration; + +import dev.lions.unionflow.server.api.payment.*; +import dev.lions.unionflow.server.service.PaiementService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.util.List; + +/** + * Façade de paiement avec stratégie de fallback automatique. + * + *

Ordre de priorité : + *

    + *
  1. PI-SPI si disponible (obligation réglementaire BCEAO)
  2. + *
  3. Provider demandé par le client
  4. + *
  5. Wave (provider par défaut)
  6. + *
+ */ +@Slf4j +@ApplicationScoped +public class PaymentOrchestrator { + + @Inject + PaymentProviderRegistry registry; + + @Inject + PaiementService paiementService; + + @ConfigProperty(name = "payment.default-provider", defaultValue = "WAVE") + String defaultProvider; + + @ConfigProperty(name = "payment.pispi-priority", defaultValue = "false") + boolean pispiPriority; + + /** + * Lance un checkout sur le provider demandé, avec fallback si indisponible. + * + * @param request la requête de checkout + * @param providerCode le provider demandé (null = provider par défaut) + */ + public CheckoutSession initierPaiement(CheckoutRequest request, String providerCode) throws PaymentException { + List ordre = buildProviderOrder(providerCode); + PaymentException dernierEchec = null; + + for (String code : ordre) { + PaymentProvider provider = tryGetProvider(code); + if (provider == null || !provider.isAvailable()) continue; + + try { + CheckoutSession session = provider.initiateCheckout(request); + log.info("Checkout initié via {} pour ref={}", code, request.reference()); + return session; + } catch (PaymentException e) { + log.warn("Provider {} échoué pour ref={}: {} — tentative fallback", + code, request.reference(), e.getMessage()); + dernierEchec = e; + } + } + + throw dernierEchec != null ? dernierEchec + : new PaymentException("NONE", "Aucun provider de paiement disponible", 503); + } + + /** + * Traite un événement de paiement reçu via webhook. + * Délègue la mise à jour métier (souscription, cotisation...) selon la référence. + */ + public void handleEvent(PaymentEvent event) { + log.info("PaymentEvent reçu : externalId={}, ref={}, statut={}", + event.externalId(), event.reference(), event.status()); + paiementService.mettreAJourStatutDepuisWebhook(event); + } + + private List buildProviderOrder(String requested) { + if (pispiPriority) { + if (requested != null) return List.of("PISPI", requested, defaultProvider); + return List.of("PISPI", defaultProvider); + } + if (requested != null) return List.of(requested, defaultProvider); + return List.of(defaultProvider); + } + + private PaymentProvider tryGetProvider(String code) { + try { + return registry.get(code); + } catch (UnsupportedOperationException e) { + return null; + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/orchestration/PaymentProviderRegistry.java b/src/main/java/dev/lions/unionflow/server/payment/orchestration/PaymentProviderRegistry.java new file mode 100644 index 0000000..1f83986 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/orchestration/PaymentProviderRegistry.java @@ -0,0 +1,47 @@ +package dev.lions.unionflow.server.payment.orchestration; + +import dev.lions.unionflow.server.api.payment.PaymentProvider; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Registry CDI des providers de paiement disponibles. + * Résout dynamiquement le bon provider par son code. + */ +@ApplicationScoped +public class PaymentProviderRegistry { + + @Inject + @Any + Instance providers; + + /** + * Retourne le provider identifié par {@code code}. + * + * @throws UnsupportedOperationException si aucun provider n'est enregistré pour ce code + */ + public PaymentProvider get(String code) { + return StreamSupport.stream(providers.spliterator(), false) + .filter(p -> p.getProviderCode().equalsIgnoreCase(code)) + .findFirst() + .orElseThrow(() -> new UnsupportedOperationException( + "Provider de paiement non supporté : " + code)); + } + + /** Retourne tous les providers disponibles. */ + public List getAll() { + return StreamSupport.stream(providers.spliterator(), false) + .collect(Collectors.toList()); + } + + /** Retourne les codes de tous les providers disponibles. */ + public List getAvailableCodes() { + return getAll().stream().map(PaymentProvider::getProviderCode).collect(Collectors.toList()); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiAuth.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiAuth.java new file mode 100644 index 0000000..24c403d --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiAuth.java @@ -0,0 +1,83 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.api.payment.PaymentException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.io.StringReader; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Optional; + +@Slf4j +@ApplicationScoped +public class PispiAuth { + + @ConfigProperty(name = "pispi.api.client-id") + Optional clientIdOpt; + + @ConfigProperty(name = "pispi.api.client-secret") + Optional clientSecretOpt; + + String clientId; + String clientSecret; + + @jakarta.annotation.PostConstruct + void init() { + clientId = clientIdOpt.orElse(""); + clientSecret = clientSecretOpt.orElse(""); + } + + @ConfigProperty(name = "pispi.api.base-url", defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1") + String baseUrl; + + private String cachedToken; + private Instant cacheExpiry; + + public synchronized String getAccessToken() throws PaymentException { + if (cachedToken != null && Instant.now().isBefore(cacheExpiry)) { + return cachedToken; + } + try { + String body = "grant_type=client_credentials" + + "&client_id=" + URLEncoder.encode(clientId, StandardCharsets.UTF_8) + + "&client_secret=" + URLEncoder.encode(clientSecret, StandardCharsets.UTF_8) + + "&scope=pispi.transactions"; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/oauth2/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + HttpResponse response = HttpClient.newHttpClient() + .send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() >= 400) { + throw new PaymentException("PISPI", + "Erreur OAuth2 PI-SPI HTTP " + response.statusCode() + " : " + response.body(), + 503); + } + + JsonObject json = Json.createReader(new StringReader(response.body())).readObject(); + cachedToken = json.getString("access_token"); + int expiresIn = json.getInt("expires_in", 3600); + cacheExpiry = Instant.now().plusSeconds(expiresIn - 60); + + log.debug("Token PI-SPI obtenu, expire dans {}s", expiresIn - 60); + return cachedToken; + } catch (PaymentException e) { + throw e; + } catch (Exception e) { + throw new PaymentException("PISPI", "Erreur OAuth2 PI-SPI : " + e.getMessage(), 503, e); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiClient.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiClient.java new file mode 100644 index 0000000..4d91ba2 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiClient.java @@ -0,0 +1,96 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.api.payment.PaymentException; +import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response; +import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Optional; + +@Slf4j +@ApplicationScoped +public class PispiClient { + + @Inject + PispiAuth pispiAuth; + + @ConfigProperty(name = "pispi.api.base-url", defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1") + String baseUrl; + + @ConfigProperty(name = "pispi.institution.code") + Optional institutionCodeOpt; + + String institutionCode; + + @jakarta.annotation.PostConstruct + void init() { + institutionCode = institutionCodeOpt.orElse(""); + } + + public Pacs002Response initiatePayment(Pacs008Request request) throws PaymentException { + try { + String token = pispiAuth.getAccessToken(); + String xmlBody = request.toXml(); + + log.debug("PI-SPI initiatePayment endToEndId={}", request.getEndToEndId()); + + HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/transactions/initiate")) + .header("Content-Type", "application/xml") + .header("Authorization", "Bearer " + token) + .header("X-Institution-Code", institutionCode) + .POST(HttpRequest.BodyPublishers.ofString(xmlBody)) + .build(); + + HttpResponse response = HttpClient.newHttpClient() + .send(httpRequest, HttpResponse.BodyHandlers.ofString()); + + int status = response.statusCode(); + if (status >= 400) { + throw new PaymentException("PISPI", "Erreur PI-SPI HTTP " + status, status); + } + + return Pacs002Response.fromXml(response.body()); + } catch (PaymentException e) { + throw e; + } catch (Exception e) { + throw new PaymentException("PISPI", "Erreur lors de l'initiation du paiement PI-SPI : " + e.getMessage(), 503, e); + } + } + + public Pacs002Response getStatus(String transactionId) throws PaymentException { + try { + String token = pispiAuth.getAccessToken(); + + log.debug("PI-SPI getStatus transactionId={}", transactionId); + + HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/transactions/" + transactionId)) + .header("Authorization", "Bearer " + token) + .header("X-Institution-Code", institutionCode) + .GET() + .build(); + + HttpResponse response = HttpClient.newHttpClient() + .send(httpRequest, HttpResponse.BodyHandlers.ofString()); + + int status = response.statusCode(); + if (status >= 400) { + throw new PaymentException("PISPI", "Erreur PI-SPI HTTP " + status, status); + } + + return Pacs002Response.fromXml(response.body()); + } catch (PaymentException e) { + throw e; + } catch (Exception e) { + throw new PaymentException("PISPI", "Erreur lors de la récupération du statut PI-SPI : " + e.getMessage(), 503, e); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiIso20022Mapper.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiIso20022Mapper.java new file mode 100644 index 0000000..5e9dab5 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiIso20022Mapper.java @@ -0,0 +1,70 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.api.payment.CheckoutRequest; +import dev.lions.unionflow.server.api.payment.PaymentEvent; +import dev.lions.unionflow.server.api.payment.PaymentStatus; +import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response; +import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request; +import jakarta.enterprise.context.ApplicationScoped; + +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +@ApplicationScoped +public class PispiIso20022Mapper { + + public Pacs008Request toPacs008(CheckoutRequest req, String institutionBic) { + Pacs008Request pacs = new Pacs008Request(); + + pacs.setMessageId("UFMSG-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase()); + pacs.setCreationDateTime(DateTimeFormatter.ISO_INSTANT.format(Instant.now())); + pacs.setNumberOfTransactions("1"); + + // ISO 20022 : EndToEndId max 35 chars + String ref = req.reference(); + pacs.setEndToEndId(ref.length() > 35 ? ref.substring(0, 35) : ref); + + pacs.setInstrId("UFINS-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase()); + pacs.setAmount(req.amount()); + pacs.setCurrency(req.currency()); + + String customerName = req.metadata() != null + ? req.metadata().getOrDefault("customerName", "MEMBRE UNIONFLOW") + : "MEMBRE UNIONFLOW"; + pacs.setDebtorName(customerName); + pacs.setDebtorBic(institutionBic); + + String creditorName = req.metadata() != null + ? req.metadata().getOrDefault("creditorName", "ORGANISATION UNIONFLOW") + : "ORGANISATION UNIONFLOW"; + pacs.setCreditorName(creditorName); + pacs.setCreditorBic(institutionBic); + + // ISO 20022 : RemittanceInfo max 140 chars + pacs.setRemittanceInfo(ref.length() > 140 ? ref.substring(0, 140) : ref); + + return pacs; + } + + public PaymentStatus fromPacs002Status(String isoCode) { + return switch (isoCode) { + case "ACSC" -> PaymentStatus.SUCCESS; + case "ACSP" -> PaymentStatus.PROCESSING; + case "RJCT" -> PaymentStatus.FAILED; + case "PDNG" -> PaymentStatus.INITIATED; + default -> PaymentStatus.PROCESSING; + }; + } + + public PaymentEvent fromPacs002(Pacs002Response resp) { + return new PaymentEvent( + resp.getClearingSystemReference(), + resp.getOriginalEndToEndId(), + fromPacs002Status(resp.getTransactionStatus()), + null, + resp.getClearingSystemReference(), + resp.getAcceptanceDateTime() != null ? resp.getAcceptanceDateTime() : Instant.now() + ); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiPaymentProvider.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiPaymentProvider.java new file mode 100644 index 0000000..d19c83d --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiPaymentProvider.java @@ -0,0 +1,114 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.api.payment.CheckoutRequest; +import dev.lions.unionflow.server.api.payment.CheckoutSession; +import dev.lions.unionflow.server.api.payment.PaymentEvent; +import dev.lions.unionflow.server.api.payment.PaymentException; +import dev.lions.unionflow.server.api.payment.PaymentProvider; +import dev.lions.unionflow.server.api.payment.PaymentStatus; +import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response; +import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +/** + * Provider PI-SPI BCEAO — interopérabilité paiements instantanés UEMOA. + * + *

Sandbox : https://developer.pispi.bceao.int + * Spec : Business API ISO 20022 pacs.008.001.10 / pacs.002.001.14 + * Deadline obligation réglementaire : 30 juin 2026 + * + *

Mode mock automatique si {@code pispi.api.client-id} ou {@code pispi.institution.code} sont absents. + */ +@Slf4j +@ApplicationScoped +public class PispiPaymentProvider implements PaymentProvider { + + public static final String CODE = "PISPI"; + + @Inject + PispiClient pispiClient; + + @Inject + PispiIso20022Mapper mapper; + + @ConfigProperty(name = "pispi.api.client-id") + java.util.Optional clientIdOpt; + + @ConfigProperty(name = "pispi.institution.code") + java.util.Optional institutionCodeOpt; + + @ConfigProperty(name = "pispi.institution.bic", defaultValue = "") + String institutionBic; + + String clientId; + String institutionCode; + + @jakarta.annotation.PostConstruct + void init() { + clientId = clientIdOpt.orElse(""); + institutionCode = institutionCodeOpt.orElse(""); + } + + @Override + public String getProviderCode() { + return CODE; + } + + @Override + public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException { + if (!isConfigured()) { + String mockId = "PISPI-MOCK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + log.warn("PI-SPI non configuré — mode mock pour ref={}", request.reference()); + return new CheckoutSession( + mockId, + "https://mock.pispi.bceao.int/pay/" + mockId, + Instant.now().plusSeconds(1800), + Map.of("mock", "true", "provider", CODE) + ); + } + Pacs008Request pacs008 = mapper.toPacs008(request, institutionBic); + Pacs002Response pacs002 = pispiClient.initiatePayment(pacs008); + String externalId = pacs002.getClearingSystemReference() != null + ? pacs002.getClearingSystemReference() + : pacs008.getEndToEndId(); + return new CheckoutSession( + externalId, + null, + Instant.now().plusSeconds(1800), + Map.of("provider", CODE, "iso", "pacs.008.001.10", "endToEndId", pacs008.getEndToEndId()) + ); + } + + @Override + public PaymentStatus getStatus(String externalId) throws PaymentException { + if (!isConfigured()) { + log.warn("PI-SPI non configuré — getStatus mock pour id={}", externalId); + return PaymentStatus.PROCESSING; + } + Pacs002Response pacs002 = pispiClient.getStatus(externalId); + return mapper.fromPacs002Status(pacs002.getTransactionStatus()); + } + + @Override + public PaymentEvent processWebhook(String rawBody, Map headers) throws PaymentException { + // Les webhooks PI-SPI passent par PispiWebhookResource qui valide l'IP et la signature en amont + throw new PaymentException(CODE, "Utiliser /api/pispi/webhook directement", 400); + } + + @Override + public boolean isAvailable() { + return isConfigured(); + } + + private boolean isConfigured() { + return clientId != null && !clientId.isBlank() + && institutionCode != null && !institutionCode.isBlank(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiSignatureVerifier.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiSignatureVerifier.java new file mode 100644 index 0000000..9346248 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiSignatureVerifier.java @@ -0,0 +1,73 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.api.payment.PaymentException; +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.Map; +import java.util.Optional; + +@ApplicationScoped +public class PispiSignatureVerifier { + + @ConfigProperty(name = "pispi.webhook.secret") + Optional webhookSecretOpt; + + @ConfigProperty(name = "pispi.webhook.allowed-ips") + Optional allowedIpsOpt; + + String webhookSecret; + String allowedIps; + + @jakarta.annotation.PostConstruct + void init() { + webhookSecret = webhookSecretOpt.orElse(""); + allowedIps = allowedIpsOpt.orElse(""); + } + + public boolean isIpAllowed(String ip) { + if (allowedIps == null || allowedIps.isBlank()) { + return true; + } + return Arrays.asList(allowedIps.split(",")).stream() + .map(String::trim) + .anyMatch(allowed -> allowed.equals(ip)); + } + + public boolean verifySignature(String rawBody, Map headers) throws PaymentException { + if (webhookSecret == null || webhookSecret.isBlank()) { + return true; + } + + // Recherche insensible à la casse + String receivedSignature = headers.entrySet().stream() + .filter(e -> "X-PISPI-Signature".equalsIgnoreCase(e.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (receivedSignature == null) { + throw new PaymentException("PISPI", "Signature PI-SPI absente", 401); + } + + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(webhookSecret.getBytes(), "HmacSHA256")); + String computed = HexFormat.of().formatHex(mac.doFinal(rawBody.getBytes())); + + if (!MessageDigest.isEqual(computed.getBytes(), receivedSignature.getBytes())) { + throw new PaymentException("PISPI", "Signature PI-SPI invalide", 401); + } + return true; + } catch (PaymentException e) { + throw e; + } catch (Exception e) { + throw new PaymentException("PISPI", "Erreur lors de la vérification de signature : " + e.getMessage(), 500, e); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiWebhookResource.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiWebhookResource.java new file mode 100644 index 0000000..106f35e --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiWebhookResource.java @@ -0,0 +1,71 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.api.payment.PaymentEvent; +import dev.lions.unionflow.server.api.payment.PaymentException; +import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator; +import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response; +import jakarta.annotation.security.PermitAll; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Path("/api/pispi/webhook") +public class PispiWebhookResource { + + @Inject + PispiSignatureVerifier verifier; + + @Inject + PispiIso20022Mapper mapper; + + @Inject + PaymentOrchestrator orchestrator; + + @POST + @Consumes("application/xml") + @PermitAll + public Response recevoir( + String rawXmlBody, + @Context HttpHeaders headers, + @HeaderParam("X-Forwarded-For") @DefaultValue("") String forwardedFor) { + + String clientIp = forwardedFor.isBlank() ? "unknown" : forwardedFor.split(",")[0].trim(); + + if (!verifier.isIpAllowed(clientIp)) { + log.warn("PI-SPI webhook refusé — IP non autorisée : {}", clientIp); + return Response.status(403).entity("IP non autorisée").build(); + } + + Map headersMap = headers.getRequestHeaders().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0))); + + try { + verifier.verifySignature(rawXmlBody, headersMap); + } catch (PaymentException e) { + log.warn("PI-SPI webhook — échec vérification signature : {}", e.getMessage()); + return Response.status(401).entity(e.getMessage()).build(); + } + + try { + Pacs002Response pacs002 = Pacs002Response.fromXml(rawXmlBody); + PaymentEvent event = mapper.fromPacs002(pacs002); + orchestrator.handleEvent(event); + log.info("PI-SPI webhook traité : ref={}, statut={}", event.reference(), event.status()); + return Response.ok().build(); + } catch (Exception e) { + log.error("PI-SPI webhook — erreur traitement : {}", e.getMessage(), e); + return Response.serverError().entity("Erreur interne").build(); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/Pacs002Response.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/Pacs002Response.java new file mode 100644 index 0000000..b31b076 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/Pacs002Response.java @@ -0,0 +1,79 @@ +package dev.lions.unionflow.server.payment.pispi.dto; + +import org.w3c.dom.Document; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.StringReader; +import java.time.Instant; + +public class Pacs002Response { + + private String originalMessageId; + private String originalEndToEndId; + private String transactionStatus; + private String rejectReasonCode; + private String clearingSystemReference; + private Instant acceptanceDateTime; + + public Pacs002Response() {} + + public String getOriginalMessageId() { return originalMessageId; } + public void setOriginalMessageId(String originalMessageId) { this.originalMessageId = originalMessageId; } + + public String getOriginalEndToEndId() { return originalEndToEndId; } + public void setOriginalEndToEndId(String originalEndToEndId) { this.originalEndToEndId = originalEndToEndId; } + + public String getTransactionStatus() { return transactionStatus; } + public void setTransactionStatus(String transactionStatus) { this.transactionStatus = transactionStatus; } + + public String getRejectReasonCode() { return rejectReasonCode; } + public void setRejectReasonCode(String rejectReasonCode) { this.rejectReasonCode = rejectReasonCode; } + + public String getClearingSystemReference() { return clearingSystemReference; } + public void setClearingSystemReference(String clearingSystemReference) { this.clearingSystemReference = clearingSystemReference; } + + public Instant getAcceptanceDateTime() { return acceptanceDateTime; } + public void setAcceptanceDateTime(Instant acceptanceDateTime) { this.acceptanceDateTime = acceptanceDateTime; } + + public static Pacs002Response fromXml(String xml) { + Pacs002Response response = new Pacs002Response(); + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + // Désactiver les entités externes (OWASP XXE) + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(new InputSource(new StringReader(xml))); + doc.getDocumentElement().normalize(); + + response.setOriginalEndToEndId(firstText(doc, "OrgnlEndToEndId")); + response.setTransactionStatus(firstText(doc, "TxSts")); + response.setRejectReasonCode(firstText(doc, "RsnCd")); + response.setClearingSystemReference(firstText(doc, "ClrSysRef")); + + String acptDtTm = firstText(doc, "AccptncDtTm"); + if (acptDtTm != null && !acptDtTm.isBlank()) { + try { + response.setAcceptanceDateTime(Instant.parse(acptDtTm)); + } catch (Exception ignored) { + // format non parsable — on laisse null + } + } + } catch (Exception e) { + throw new IllegalArgumentException("Impossible de parser le pacs.002 XML : " + e.getMessage(), e); + } + return response; + } + + private static String firstText(Document doc, String tagName) { + NodeList nodes = doc.getElementsByTagName(tagName); + if (nodes.getLength() > 0) { + String text = nodes.item(0).getTextContent(); + return (text == null || text.isBlank()) ? null : text.trim(); + } + return null; + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/Pacs008Request.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/Pacs008Request.java new file mode 100644 index 0000000..9632448 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/dto/Pacs008Request.java @@ -0,0 +1,96 @@ +package dev.lions.unionflow.server.payment.pispi.dto; + +import java.math.BigDecimal; + +public class Pacs008Request { + + private String messageId; + private String creationDateTime; + private String numberOfTransactions; + private String endToEndId; + private String instrId; + private BigDecimal amount; + private String currency; + private String debtorName; + private String debtorBic; + private String creditorName; + private String creditorBic; + private String creditorIban; + private String remittanceInfo; + + public Pacs008Request() {} + + public String getMessageId() { return messageId; } + public void setMessageId(String messageId) { this.messageId = messageId; } + + public String getCreationDateTime() { return creationDateTime; } + public void setCreationDateTime(String creationDateTime) { this.creationDateTime = creationDateTime; } + + public String getNumberOfTransactions() { return numberOfTransactions; } + public void setNumberOfTransactions(String numberOfTransactions) { this.numberOfTransactions = numberOfTransactions; } + + public String getEndToEndId() { return endToEndId; } + public void setEndToEndId(String endToEndId) { this.endToEndId = endToEndId; } + + public String getInstrId() { return instrId; } + public void setInstrId(String instrId) { this.instrId = instrId; } + + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + + public String getDebtorName() { return debtorName; } + public void setDebtorName(String debtorName) { this.debtorName = debtorName; } + + public String getDebtorBic() { return debtorBic; } + public void setDebtorBic(String debtorBic) { this.debtorBic = debtorBic; } + + public String getCreditorName() { return creditorName; } + public void setCreditorName(String creditorName) { this.creditorName = creditorName; } + + public String getCreditorBic() { return creditorBic; } + public void setCreditorBic(String creditorBic) { this.creditorBic = creditorBic; } + + public String getCreditorIban() { return creditorIban; } + public void setCreditorIban(String creditorIban) { this.creditorIban = creditorIban; } + + public String getRemittanceInfo() { return remittanceInfo; } + public void setRemittanceInfo(String remittanceInfo) { this.remittanceInfo = remittanceInfo; } + + public String toXml() { + return "\n" + + "\n" + + " \n" + + " \n" + + " " + escape(messageId) + "\n" + + " " + escape(creationDateTime) + "\n" + + " 1\n" + + " \n" + + " \n" + + " \n" + + " " + escape(instrId) + "\n" + + " " + escape(endToEndId) + "\n" + + " \n" + + " " + (amount != null ? amount.toPlainString() : "0") + "\n" + + " " + escape(debtorName) + "\n" + + " " + escape(debtorBic) + "\n" + + " " + escape(creditorName) + "\n" + + " " + escape(creditorBic) + "\n" + + " " + escape(remittanceInfo) + "\n" + + " \n" + + " \n" + + ""; + } + + private static String escape(String value) { + if (value == null) return ""; + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/wave/WavePaymentProvider.java b/src/main/java/dev/lions/unionflow/server/payment/wave/WavePaymentProvider.java new file mode 100644 index 0000000..c864b11 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/wave/WavePaymentProvider.java @@ -0,0 +1,140 @@ +package dev.lions.unionflow.server.payment.wave; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.lions.unionflow.server.api.payment.*; +import dev.lions.unionflow.server.service.WaveCheckoutService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Map; + +/** + * Implémentation Wave de PaymentProvider. + * + *

Délègue la création de session à {@link WaveCheckoutService} existant. + * Normalise les webhooks Wave vers {@link PaymentEvent}. + */ +@Slf4j +@ApplicationScoped +public class WavePaymentProvider implements PaymentProvider { + + public static final String CODE = "WAVE"; + + @Inject + WaveCheckoutService waveCheckoutService; + + @ConfigProperty(name = "wave.webhook.secret", defaultValue = "") + String webhookSecret; + + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + public String getProviderCode() { + return CODE; + } + + @Override + public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException { + try { + String amount = request.amount().toBigInteger().toString(); + WaveCheckoutService.WaveCheckoutSessionResponse resp = waveCheckoutService.createSession( + amount, + request.currency(), + request.successUrl(), + request.cancelUrl(), + request.reference(), + request.customerPhone() + ); + return new CheckoutSession( + resp.id, + resp.waveLaunchUrl, + Instant.now().plusSeconds(3600), + Map.of("provider", CODE) + ); + } catch (Exception e) { + throw new PaymentException(CODE, e.getMessage(), 500, e); + } + } + + @Override + public PaymentStatus getStatus(String externalId) throws PaymentException { + // Wave ne fournit pas d'API de polling — le statut passe par les webhooks. + // Un polling naïf via la session URL n'est pas supporté. + log.warn("Wave ne supporte pas le polling de statut — utiliser les webhooks."); + return PaymentStatus.PROCESSING; + } + + @Override + public PaymentEvent processWebhook(String rawBody, Map headers) throws PaymentException { + verifierSignatureWave(rawBody, headers); + + try { + JsonNode root = mapper.readTree(rawBody); + String type = root.path("type").asText(); + JsonNode data = root.path("data"); + + String externalId = data.path("id").asText(null); + String clientRef = data.path("client_reference").asText(null); + String rawAmount = data.path("amount").asText("0"); + BigDecimal amount = new BigDecimal(rawAmount); + + PaymentStatus status = switch (type) { + case "checkout.session.completed" -> PaymentStatus.SUCCESS; + case "checkout.session.failed" -> PaymentStatus.FAILED; + case "checkout.session.expired" -> PaymentStatus.EXPIRED; + default -> PaymentStatus.PROCESSING; + }; + + return new PaymentEvent( + externalId, + clientRef, + status, + amount, + data.path("transaction_id").asText(null), + Instant.now() + ); + } catch (Exception e) { + throw new PaymentException(CODE, "Webhook Wave malformé : " + e.getMessage(), 400, e); + } + } + + private void verifierSignatureWave(String rawBody, Map headers) throws PaymentException { + if (webhookSecret == null || webhookSecret.isBlank()) return; + + String sigHeader = headers.get("wave-signature"); + if (sigHeader == null) sigHeader = headers.get("Wave-Signature"); + if (sigHeader == null) { + throw new PaymentException(CODE, "Signature webhook Wave absente", 401); + } + + try { + String timestamp = ""; + String receivedSig = ""; + for (String part : sigHeader.split(",")) { + if (part.startsWith("t=")) timestamp = part.substring(2); + if (part.startsWith("v1=")) receivedSig = part.substring(3); + } + + String payload = timestamp + "." + rawBody; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(webhookSecret.getBytes(), "HmacSHA256")); + String computed = HexFormat.of().formatHex(mac.doFinal(payload.getBytes())); + + if (!java.security.MessageDigest.isEqual(computed.getBytes(), receivedSig.getBytes())) { + throw new PaymentException(CODE, "Signature webhook Wave invalide", 401); + } + } catch (PaymentException e) { + throw e; + } catch (Exception e) { + throw new PaymentException(CODE, "Erreur vérification signature Wave : " + e.getMessage(), 500, e); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/repository/CompteComptableRepository.java b/src/main/java/dev/lions/unionflow/server/repository/CompteComptableRepository.java index 3d9fc99..4d30c1c 100644 --- a/src/main/java/dev/lions/unionflow/server/repository/CompteComptableRepository.java +++ b/src/main/java/dev/lions/unionflow/server/repository/CompteComptableRepository.java @@ -76,6 +76,30 @@ public class CompteComptableRepository implements PanacheRepositoryBase findByOrganisationAndNumero(UUID organisationId, String numeroCompte) { + return find("organisation.id = ?1 AND numeroCompte = ?2 AND actif = true", organisationId, numeroCompte) + .firstResultOptional(); + } + + /** + * Trouve tous les comptes actifs d'une organisation. + */ + public List findByOrganisation(UUID organisationId) { + return find("organisation.id = ?1 AND actif = true ORDER BY numeroCompte ASC", organisationId).list(); + } + + /** + * Trouve les comptes d'une organisation par classe SYSCOHADA (1-9). + */ + public List findByOrganisationAndClasse(UUID organisationId, Integer classe) { + return find( + "organisation.id = ?1 AND classeComptable = ?2 AND actif = true ORDER BY numeroCompte ASC", + organisationId, classe).list(); + } } diff --git a/src/main/java/dev/lions/unionflow/server/repository/EcritureComptableRepository.java b/src/main/java/dev/lions/unionflow/server/repository/EcritureComptableRepository.java index c4a28f1..e3f8c7e 100644 --- a/src/main/java/dev/lions/unionflow/server/repository/EcritureComptableRepository.java +++ b/src/main/java/dev/lions/unionflow/server/repository/EcritureComptableRepository.java @@ -105,6 +105,20 @@ public class EcritureComptableRepository implements PanacheRepositoryBase findByLettrage(String lettrage) { return find("lettrage = ?1 AND actif = true ORDER BY dateEcriture DESC", lettrage).list(); } + + /** + * Trouve les écritures d'une organisation dans une période (pour rapports PDF SYSCOHADA). + */ + public List findByOrganisationAndDateRange( + UUID organisationId, LocalDate dateDebut, LocalDate dateFin) { + return find( + "organisation.id = ?1 AND dateEcriture >= ?2 AND dateEcriture <= ?3 AND actif = true" + + " ORDER BY dateEcriture ASC, numeroPiece ASC", + organisationId, + dateDebut, + dateFin) + .list(); + } } diff --git a/src/main/java/dev/lions/unionflow/server/repository/JournalComptableRepository.java b/src/main/java/dev/lions/unionflow/server/repository/JournalComptableRepository.java index 4a2d7b9..4f75fc0 100644 --- a/src/main/java/dev/lions/unionflow/server/repository/JournalComptableRepository.java +++ b/src/main/java/dev/lions/unionflow/server/repository/JournalComptableRepository.java @@ -79,6 +79,15 @@ public class JournalComptableRepository implements PanacheRepositoryBase findAllActifs() { return find("actif = true ORDER BY code ASC").list(); } + + /** + * Trouve le journal d'une organisation par type (ex: VENTES pour cotisations). + */ + public Optional findByOrganisationAndType(UUID organisationId, TypeJournalComptable type) { + return find( + "organisation.id = ?1 AND typeJournal = ?2 AND statut = 'OUVERT' AND actif = true", + organisationId, type).firstResultOptional(); + } } diff --git a/src/main/java/dev/lions/unionflow/server/repository/KycDossierRepository.java b/src/main/java/dev/lions/unionflow/server/repository/KycDossierRepository.java new file mode 100644 index 0000000..0e69ab6 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/repository/KycDossierRepository.java @@ -0,0 +1,52 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc; +import dev.lions.unionflow.server.api.enums.membre.StatutKyc; +import dev.lions.unionflow.server.entity.KycDossier; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@ApplicationScoped +public class KycDossierRepository implements PanacheRepositoryBase { + + public Optional findDossierActifByMembre(UUID membreId) { + return find("membre.id = ?1 AND actif = true", membreId).firstResultOptional(); + } + + public List findByMembre(UUID membreId) { + return find("membre.id = ?1 ORDER BY dateCreation DESC", membreId).list(); + } + + public List findByStatut(StatutKyc statut) { + return find("statut = ?1 AND actif = true", statut).list(); + } + + public List findByNiveauRisque(NiveauRisqueKyc niveauRisque) { + return find("niveauRisque = ?1 AND actif = true ORDER BY scoreRisque DESC", niveauRisque).list(); + } + + public List findPep() { + return find("estPep = true AND actif = true").list(); + } + + public List findPiecesExpirantsAvant(LocalDate date) { + return find("dateExpirationPiece <= ?1 AND actif = true ORDER BY dateExpirationPiece ASC", date).list(); + } + + public long countByStatut(StatutKyc statut) { + return count("statut = ?1 AND actif = true", statut); + } + + public long countPepActifs() { + return count("estPep = true AND actif = true"); + } + + public List findByAnnee(int anneeReference) { + return find("anneeReference = ?1", anneeReference).list(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/repository/MembreOrganisationRepository.java b/src/main/java/dev/lions/unionflow/server/repository/MembreOrganisationRepository.java index 17be027..9a5128f 100644 --- a/src/main/java/dev/lions/unionflow/server/repository/MembreOrganisationRepository.java +++ b/src/main/java/dev/lions/unionflow/server/repository/MembreOrganisationRepository.java @@ -85,6 +85,13 @@ public class MembreOrganisationRepository extends BaseRepository findByRoleOrgAndOrganisationId(String roleOrg, UUID organisationId) { + return find("roleOrg = ?1 and organisation.id = ?2 and membre.actif = true", roleOrg, organisationId).list(); + } + /** * Trouve les membres en attente de validation depuis plus de N jours. */ diff --git a/src/main/java/dev/lions/unionflow/server/repository/mutuelle/ParametresFinanciersMutuellRepository.java b/src/main/java/dev/lions/unionflow/server/repository/mutuelle/ParametresFinanciersMutuellRepository.java new file mode 100644 index 0000000..914f4b0 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/repository/mutuelle/ParametresFinanciersMutuellRepository.java @@ -0,0 +1,16 @@ +package dev.lions.unionflow.server.repository.mutuelle; + +import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.Optional; +import java.util.UUID; + +@ApplicationScoped +public class ParametresFinanciersMutuellRepository implements PanacheRepositoryBase { + + public Optional findByOrganisation(UUID orgId) { + return find("organisation.id", orgId).firstResultOptional(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/repository/mutuelle/parts/ComptePartsSocialesRepository.java b/src/main/java/dev/lions/unionflow/server/repository/mutuelle/parts/ComptePartsSocialesRepository.java new file mode 100644 index 0000000..25bc09c --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/repository/mutuelle/parts/ComptePartsSocialesRepository.java @@ -0,0 +1,34 @@ +package dev.lions.unionflow.server.repository.mutuelle.parts; + +import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@ApplicationScoped +public class ComptePartsSocialesRepository implements PanacheRepositoryBase { + + public Optional findByNumeroCompte(String numeroCompte) { + return find("numeroCompte", numeroCompte).firstResultOptional(); + } + + public List findByMembre(UUID membreId) { + return list("membre.id = ?1 AND actif = true", membreId); + } + + public List findByOrganisation(UUID orgId) { + return list("organisation.id = ?1 AND actif = true ORDER BY dateCreation DESC", orgId); + } + + public Optional findByMembreAndOrg(UUID membreId, UUID orgId) { + return find("membre.id = ?1 AND organisation.id = ?2 AND actif = true", membreId, orgId) + .firstResultOptional(); + } + + public long countByOrganisation(UUID orgId) { + return count("organisation.id = ?1 AND actif = true", orgId); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/repository/mutuelle/parts/TransactionPartsSocialesRepository.java b/src/main/java/dev/lions/unionflow/server/repository/mutuelle/parts/TransactionPartsSocialesRepository.java new file mode 100644 index 0000000..7ed40d9 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/repository/mutuelle/parts/TransactionPartsSocialesRepository.java @@ -0,0 +1,16 @@ +package dev.lions.unionflow.server.repository.mutuelle.parts; + +import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.List; +import java.util.UUID; + +@ApplicationScoped +public class TransactionPartsSocialesRepository implements PanacheRepositoryBase { + + public List findByCompte(UUID compteId) { + return list("compte.id = ?1 ORDER BY dateTransaction DESC", compteId); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/resource/AdminKeycloakOrganisationResource.java b/src/main/java/dev/lions/unionflow/server/resource/AdminKeycloakOrganisationResource.java new file mode 100644 index 0000000..df63a84 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/resource/AdminKeycloakOrganisationResource.java @@ -0,0 +1,64 @@ +package dev.lions.unionflow.server.resource; + +import dev.lions.unionflow.server.service.MigrerOrganisationsVersKeycloakService; +import dev.lions.unionflow.server.service.MigrerOrganisationsVersKeycloakService.MigrationReport; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + +/** + * Endpoints d'administration Keycloak 26 Organizations. + * + *

Réservés aux SUPER_ADMIN. Opérations à déclencher manuellement lors de la + * migration Keycloak 23 → 26. + */ +@Slf4j +@Path("/api/admin/keycloak") +@Produces(MediaType.APPLICATION_JSON) +@RolesAllowed("SUPER_ADMIN") +public class AdminKeycloakOrganisationResource { + + @Inject + MigrerOrganisationsVersKeycloakService migrationService; + + /** + * Lance la migration one-shot des organisations UnionFlow vers Keycloak 26 Organizations. + * + *

Idempotent : les organisations déjà migrées (keycloak_org_id non null) sont ignorées. + * + * @return rapport de migration (total, créés, ignorés, erreurs) + */ + @POST + @Path("/migrer-organisations") + public Response migrerOrganisations() { + log.info("Déclenchement migration organisations → Keycloak 26 Organizations"); + try { + MigrationReport report = migrationService.migrerToutesLesOrganisations(); + log.info("Migration terminée : {}", report); + + return Response + .status(report.success() ? Response.Status.OK.getStatusCode() : 207) + .entity(Map.of( + "total", report.total(), + "crees", report.crees(), + "ignores", report.ignores(), + "erreurs", report.erreurs(), + "succes", report.success() + )) + .build(); + + } catch (Exception e) { + log.error("Erreur critique lors de la migration : {}", e.getMessage(), e); + return Response.serverError() + .entity(Map.of("error", e.getMessage())) + .build(); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/resource/ComptabilitePdfResource.java b/src/main/java/dev/lions/unionflow/server/resource/ComptabilitePdfResource.java new file mode 100644 index 0000000..57a9035 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/resource/ComptabilitePdfResource.java @@ -0,0 +1,98 @@ +package dev.lions.unionflow.server.resource; + +import dev.lions.unionflow.server.service.ComptabilitePdfService; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.time.LocalDate; +import java.util.UUID; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +/** + * Endpoints de téléchargement des rapports comptables PDF SYSCOHADA révisé. + */ +@Path("/api/comptabilite/pdf") +@Produces(MediaType.APPLICATION_JSON) +@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "COMMISSAIRE_COMPTES", "SUPER_ADMIN"}) +@Tag(name = "Comptabilité PDF", description = "Rapports comptables SYSCOHADA : balance, compte de résultat, grand livre") +public class ComptabilitePdfResource { + + @Inject + ComptabilitePdfService comptabilitePdfService; + + @GET + @Path("/organisations/{organisationId}/balance") + @Produces("application/pdf") + @Operation(summary = "Balance générale SYSCOHADA", + description = "Génère la balance générale (cumul débit/crédit/solde) pour la période.") + public Response balance( + @PathParam("organisationId") UUID organisationId, + @QueryParam("dateDebut") @DefaultValue("") String dateDebutStr, + @QueryParam("dateFin") @DefaultValue("") String dateFinStr) { + + LocalDate dateDebut = parseDateOrStartOfYear(dateDebutStr); + LocalDate dateFin = parseDateOrToday(dateFinStr); + + byte[] pdf = comptabilitePdfService.genererBalance(organisationId, dateDebut, dateFin); + return buildPdfResponse(pdf, "balance_" + organisationId + ".pdf"); + } + + @GET + @Path("/organisations/{organisationId}/compte-de-resultat") + @Produces("application/pdf") + @Operation(summary = "Compte de résultat SYSCOHADA", + description = "Génère le compte de résultat (produits classes 7/8 − charges classes 6/8).") + public Response compteDeResultat( + @PathParam("organisationId") UUID organisationId, + @QueryParam("dateDebut") @DefaultValue("") String dateDebutStr, + @QueryParam("dateFin") @DefaultValue("") String dateFinStr) { + + LocalDate dateDebut = parseDateOrStartOfYear(dateDebutStr); + LocalDate dateFin = parseDateOrToday(dateFinStr); + + byte[] pdf = comptabilitePdfService.genererCompteResultat(organisationId, dateDebut, dateFin); + return buildPdfResponse(pdf, "compte_resultat_" + organisationId + ".pdf"); + } + + @GET + @Path("/organisations/{organisationId}/grand-livre/{numeroCompte}") + @Produces("application/pdf") + @Operation(summary = "Grand livre d'un compte SYSCOHADA", + description = "Génère le grand livre (détail chronologique) pour un compte comptable donné.") + public Response grandLivre( + @PathParam("organisationId") UUID organisationId, + @PathParam("numeroCompte") String numeroCompte, + @QueryParam("dateDebut") @DefaultValue("") String dateDebutStr, + @QueryParam("dateFin") @DefaultValue("") String dateFinStr) { + + LocalDate dateDebut = parseDateOrStartOfYear(dateDebutStr); + LocalDate dateFin = parseDateOrToday(dateFinStr); + + byte[] pdf = comptabilitePdfService.genererGrandLivre(organisationId, numeroCompte, dateDebut, dateFin); + return buildPdfResponse(pdf, "grand_livre_" + numeroCompte + ".pdf"); + } + + private static Response buildPdfResponse(byte[] pdf, String filename) { + return Response.ok(pdf) + .header("Content-Disposition", "attachment; filename=\"" + filename + "\"") + .header("Content-Length", pdf.length) + .build(); + } + + private static LocalDate parseDateOrStartOfYear(String s) { + if (s == null || s.isBlank()) return LocalDate.of(LocalDate.now().getYear(), 1, 1); + try { return LocalDate.parse(s); } catch (Exception e) { + throw new BadRequestException("Format de date invalide (attendu : YYYY-MM-DD) : " + s); + } + } + + private static LocalDate parseDateOrToday(String s) { + if (s == null || s.isBlank()) return LocalDate.now(); + try { return LocalDate.parse(s); } catch (Exception e) { + throw new BadRequestException("Format de date invalide (attendu : YYYY-MM-DD) : " + s); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/resource/CompteAdherentResource.java b/src/main/java/dev/lions/unionflow/server/resource/CompteAdherentResource.java index 42a5dc4..46058e1 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/CompteAdherentResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/CompteAdherentResource.java @@ -1,6 +1,7 @@ package dev.lions.unionflow.server.resource; import dev.lions.unionflow.server.api.dto.membre.CompteAdherentResponse; +import dev.lions.unionflow.server.service.FirebasePushService; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.MembreOrganisation; import dev.lions.unionflow.server.entity.SouscriptionOrganisation; @@ -67,6 +68,59 @@ public class CompteAdherentResource { @Inject MembreService membreService; + @Inject + FirebasePushService firebasePushService; + + /** + * Enregistre ou met à jour le token FCM du membre connecté pour les notifications push. + * Appelé par l'application mobile au démarrage ou quand Firebase renouvelle le token. + */ + @PUT + @Path("/mon-compte/fcm-token") + @Authenticated + @Operation(summary = "Enregistrer le token FCM pour les notifications push") + @jakarta.transaction.Transactional + public Response enregistrerFcmToken(Map body) { + String email = securiteHelper.resolveEmail(); + if (email == null) return Response.status(Response.Status.UNAUTHORIZED).build(); + + String token = body != null ? body.get("token") : null; + if (token == null || token.isBlank()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("message", "Le champ 'token' est requis.")).build(); + } + + return membreRepository.findByEmail(email) + .map(membre -> { + membre.setFcmToken(token.trim()); + membreRepository.persist(membre); + return Response.ok(Map.of("message", "Token FCM enregistré.")).build(); + }) + .orElse(Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("message", "Membre introuvable.")).build()); + } + + /** + * Supprime le token FCM (désabonnement des notifications push). + */ + @DELETE + @Path("/mon-compte/fcm-token") + @Authenticated + @Operation(summary = "Désactiver les notifications push") + @jakarta.transaction.Transactional + public Response supprimerFcmToken() { + String email = securiteHelper.resolveEmail(); + if (email == null) return Response.status(Response.Status.UNAUTHORIZED).build(); + + return membreRepository.findByEmail(email) + .map(membre -> { + membre.setFcmToken(null); + membreRepository.persist(membre); + return Response.ok(Map.of("message", "Notifications push désactivées.")).build(); + }) + .orElse(Response.status(Response.Status.NOT_FOUND).build()); + } + /** * Retourne le compte adhérent complet du membre connecté : * numéro de membre, soldes (cotisations + épargne), capacité d'emprunt, taux d'engagement. @@ -138,15 +192,17 @@ public class CompteAdherentResource { } } - // Fallback : auto-activer si EN_ATTENTE_VALIDATION et org a souscription active - // (membres sans premiereConnexion=true ou créés avant cette logique) + // Fallback : auto-activer si EN_ATTENTE_VALIDATION et org a reçu un paiement. + // Couvre le cas PAIEMENT_CONFIRME (admin a payé mais super admin n'a pas encore validé) + // et ACTIVE/VALIDEE (chemin nominal). L'admin ne doit pas bloquer sur l'AwaitingValidationPage + // dès lors que le paiement est confirmé côté Wave. if ("EN_ATTENTE_VALIDATION".equals(statutCompte) && membreOpt.isPresent()) { Membre m = membreOpt.get(); UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId()) .map(mo -> mo.getOrganisation().getId()) .orElse(null); - if (membreService.orgHasActiveSubscription(orgId)) { - LOG.infof("Auto-activation au login de %s (org %s a souscription active)", m.getEmail(), orgId); + if (membreService.orgHasPaidSubscription(orgId)) { + LOG.infof("Auto-activation au login de %s (org %s a souscription payée)", m.getEmail(), orgId); membreService.activerMembre(m.getId()); try { membreKeycloakSyncService.activerMembreDansKeycloak(m.getId()); diff --git a/src/main/java/dev/lions/unionflow/server/resource/KycResource.java b/src/main/java/dev/lions/unionflow/server/resource/KycResource.java new file mode 100644 index 0000000..c763bdb --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/resource/KycResource.java @@ -0,0 +1,111 @@ +package dev.lions.unionflow.server.resource; + +import dev.lions.unionflow.server.api.dto.kyc.KycDossierRequest; +import dev.lions.unionflow.server.api.dto.kyc.KycDossierResponse; +import dev.lions.unionflow.server.service.KycAmlService; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Endpoints KYC/AML — gestion des dossiers d'identification et évaluation risque LCB-FT. + */ +@Path("/api/kyc") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class KycResource { + + @Inject + KycAmlService kycAmlService; + + @Inject + SecurityIdentity identity; + + /** Soumet ou met à jour un dossier KYC pour un membre. */ + @POST + @Path("/dossiers") + @RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"}) + public Response soumettre(@Valid KycDossierRequest request) { + KycDossierResponse response = kycAmlService.soumettreOuMettreAJour(request, identity.getPrincipal().getName()); + return Response.status(Response.Status.CREATED).entity(response).build(); + } + + /** Récupère le dossier KYC actif d'un membre. */ + @GET + @Path("/membres/{membreId}") + @RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"}) + public Response getDossierActif(@PathParam("membreId") UUID membreId) { + return kycAmlService.getDossierActif(membreId) + .map(d -> Response.ok(d).build()) + .orElse(Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Aucun dossier KYC actif pour ce membre.")) + .build()); + } + + /** Évalue le score de risque LCB-FT du membre. */ + @POST + @Path("/membres/{membreId}/evaluer-risque") + @RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"}) + public Response evaluerRisque(@PathParam("membreId") UUID membreId) { + KycDossierResponse response = kycAmlService.evaluerRisque(membreId); + return Response.ok(response).build(); + } + + /** Valide manuellement un dossier KYC (agent habilité). */ + @POST + @Path("/dossiers/{dossierId}/valider") + @RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION"}) + public Response valider( + @PathParam("dossierId") UUID dossierId, + @QueryParam("validateurId") UUID validateurId, + @QueryParam("notes") String notes) { + KycDossierResponse response = kycAmlService.valider( + dossierId, validateurId, notes, identity.getPrincipal().getName()); + return Response.ok(response).build(); + } + + /** Refuse un dossier KYC avec motif. */ + @POST + @Path("/dossiers/{dossierId}/refuser") + @RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION"}) + public Response refuser( + @PathParam("dossierId") UUID dossierId, + @QueryParam("validateurId") UUID validateurId, + @QueryParam("motif") String motif) { + KycDossierResponse response = kycAmlService.refuser( + dossierId, validateurId, motif, identity.getPrincipal().getName()); + return Response.ok(response).build(); + } + + /** Liste les dossiers KYC en attente de validation. */ + @GET + @Path("/dossiers/en-attente") + @RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION"}) + public List getDossiersEnAttente() { + return kycAmlService.getDossiersEnAttente(); + } + + /** Liste les membres PEP (Personnes Exposées Politiquement). */ + @GET + @Path("/pep") + @RolesAllowed({"SUPER_ADMIN"}) + public List getPep() { + return kycAmlService.getDossiersPep(); + } + + /** Pièces d'identité expirant dans les 30 jours. */ + @GET + @Path("/pieces-expirant-bientot") + @RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION", "TRESORIER"}) + public List getPiecesExpirant() { + return kycAmlService.getPiecesExpirantDansLes30Jours(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java b/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java index 42e4179..e2f66b7 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java @@ -11,6 +11,7 @@ import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.MembreOrganisation; import dev.lions.unionflow.server.repository.MembreOrganisationRepository; +import dev.lions.unionflow.server.repository.MembreRepository; import dev.lions.unionflow.server.repository.MembreRoleRepository; import dev.lions.unionflow.server.service.MemberLifecycleService; import dev.lions.unionflow.server.service.MembreKeycloakSyncService; @@ -78,6 +79,9 @@ public class MembreResource { @Inject MembreOrganisationRepository membreOrgRepository; + @Inject + MembreRepository membreRepository; + @Inject MembreRoleRepository membreRoleRepository; @@ -447,6 +451,40 @@ public class MembreResource { } } + /** + * Liste TOUS les membres (y compris EN_ATTENTE_VALIDATION) — réservé SUPER_ADMIN. + * Utile pour les imports de données historiques et la gestion admin. + */ + @GET + @Path("/admin/tous") + @RolesAllowed({ "SUPER_ADMIN" }) + @Operation(summary = "Tous les membres (admin)", description = "Liste tous les membres quelque soit leur statut, réservé SUPER_ADMIN") + @APIResponse(responseCode = "200", description = "Liste complète des membres") + public Response getTousMembres( + @Parameter(description = "Numéro de page (0-based)") @QueryParam("page") @DefaultValue("0") int page, + @Parameter(description = "Taille de la page") @QueryParam("size") @DefaultValue("100") int size) { + try { + LOG.infof("GET /api/membres/admin/tous - page=%d size=%d", page, size); + List membres = membreRepository.findAll( + io.quarkus.panache.common.Sort.by("nom").ascending()) + .page(io.quarkus.panache.common.Page.of(page, size)) + .list(); + List membresDTO = membreService.convertToResponseList(membres); + long total = membreRepository.count(); + return Response.ok(Map.of( + "data", membresDTO, + "totalElements", total, + "page", page, + "size", size, + "totalPages", (int) Math.ceil((double) total / size) + )).build(); + } catch (Exception e) { + LOG.errorf(e, "Erreur récupération tous membres"); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", e.getMessage())).build(); + } + } + /** * Liste les membres d'une organisation spécifique (statut ACTIF dans l'organisation). * Utilisé pour la création de campagnes ciblées. @@ -588,7 +626,7 @@ public class MembreResource { @APIResponses({ @APIResponse(responseCode = "200", description = "Recherche effectuée avec succès", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = MembreSearchResultDTO.class), examples = @ExampleObject(name = "Exemple de résultats", value = """ { - "membres": [...], + "membres": [], "totalElements": 247, "totalPages": 13, "currentPage": 0, diff --git a/src/main/java/dev/lions/unionflow/server/resource/PaiementUnifieResource.java b/src/main/java/dev/lions/unionflow/server/resource/PaiementUnifieResource.java new file mode 100644 index 0000000..741d30f --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/resource/PaiementUnifieResource.java @@ -0,0 +1,139 @@ +package dev.lions.unionflow.server.resource; + +import dev.lions.unionflow.server.api.payment.*; +import dev.lions.unionflow.server.entity.SouscriptionOrganisation; +import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator; +import dev.lions.unionflow.server.payment.orchestration.PaymentProviderRegistry; +import dev.lions.unionflow.server.repository.SouscriptionOrganisationRepository; +import jakarta.annotation.security.PermitAll; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.extern.slf4j.Slf4j; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Endpoints de paiement unifiés — abstraction multi-provider. + * Remplace à terme les endpoints Wave-spécifiques. + */ +@Slf4j +@Path("/api/paiements") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class PaiementUnifieResource { + + @Inject + PaymentOrchestrator orchestrator; + + @Inject + PaymentProviderRegistry registry; + + @Inject + SouscriptionOrganisationRepository souscriptionRepository; + + /** + * Initie un paiement via le provider demandé (ou le provider par défaut). + * + *

Exemple : {@code POST /api/paiements/initier?provider=WAVE} + */ + @POST + @Path("/initier") + @RolesAllowed({"MEMBRE_ACTIF", "ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"}) + public Response initier( + @QueryParam("provider") String provider, + PaiementInitierRequest req) { + try { + // Si une souscription est fournie, utiliser le providerDefaut de sa formule + String resolvedProvider = provider; + if (req.souscriptionId() != null) { + resolvedProvider = souscriptionRepository.findByIdOptional(req.souscriptionId()) + .map(SouscriptionOrganisation::getFormule) + .map(f -> f.getProviderDefaut()) + .filter(p -> p != null && !p.isBlank()) + .orElse(provider); + } + + CheckoutRequest checkoutRequest = new CheckoutRequest( + req.montant(), + req.devise() != null ? req.devise() : "XOF", + req.telephone(), + req.email(), + req.reference(), + req.successUrl(), + req.cancelUrl(), + Map.of() + ); + CheckoutSession session = orchestrator.initierPaiement(checkoutRequest, resolvedProvider); + return Response.ok(session).build(); + } catch (PaymentException e) { + return Response.status(e.getHttpStatus()) + .entity(Map.of("error", e.getMessage(), "provider", e.getProviderCode())) + .build(); + } + } + + /** + * Webhook entrant d'un provider. Vérifie la signature et met à jour le statut. + * Route : {@code POST /api/paiements/webhook/{provider}} + */ + @POST + @Path("/webhook/{provider}") + @PermitAll + @Consumes(MediaType.WILDCARD) + public Response webhook( + @PathParam("provider") String providerCode, + String rawBody, + @Context HttpHeaders httpHeaders) { + try { + PaymentProvider provider = registry.get(providerCode.toUpperCase()); + Map headers = httpHeaders.getRequestHeaders().entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().isEmpty() ? "" : e.getValue().get(0) + )); + + PaymentEvent event = provider.processWebhook(rawBody, headers); + orchestrator.handleEvent(event); + return Response.ok().build(); + + } catch (UnsupportedOperationException e) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Provider inconnu : " + providerCode)) + .build(); + } catch (PaymentException e) { + log.error("Webhook {} rejeté : {}", providerCode, e.getMessage()); + return Response.status(e.getHttpStatus()) + .entity(Map.of("error", e.getMessage())) + .build(); + } + } + + /** Retourne les providers de paiement disponibles. */ + @GET + @Path("/providers") + @RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"}) + public List getProviders() { + return registry.getAvailableCodes(); + } + + public record PaiementInitierRequest( + BigDecimal montant, + String devise, + String telephone, + String email, + String reference, + String successUrl, + String cancelUrl, + /** Optionnel — si fourni, le providerDefaut de la formule prend le dessus sur le query param. */ + UUID souscriptionId + ) {} +} diff --git a/src/main/java/dev/lions/unionflow/server/resource/mutuelle/ParametresFinanciersResource.java b/src/main/java/dev/lions/unionflow/server/resource/mutuelle/ParametresFinanciersResource.java new file mode 100644 index 0000000..168cc19 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/resource/mutuelle/ParametresFinanciersResource.java @@ -0,0 +1,52 @@ +package dev.lions.unionflow.server.resource.mutuelle; + +import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellRequest; +import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellResponse; +import dev.lions.unionflow.server.security.RequiresModule; +import dev.lions.unionflow.server.service.mutuelle.InteretsEpargneService; +import dev.lions.unionflow.server.service.mutuelle.ParametresFinanciersService; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.Map; +import java.util.UUID; + +@Path("/api/v1/mutuelle/parametres-financiers") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RequiresModule("EPARGNE") +public class ParametresFinanciersResource { + + @Inject ParametresFinanciersService parametresService; + @Inject InteretsEpargneService interetsService; + + @GET + @Path("/{orgId}") + @RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"}) + public Response getByOrganisation(@PathParam("orgId") UUID orgId) { + return Response.ok(parametresService.getByOrganisation(orgId)).build(); + } + + @POST + @RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"}) + public Response creerOuMettrAJour(@Valid ParametresFinanciersMutuellRequest request) { + ParametresFinanciersMutuellResponse resp = parametresService.creerOuMettrAJour(request); + return Response.ok(resp).build(); + } + + /** + * Déclenche manuellement le calcul des intérêts / dividendes pour une organisation. + * Utile pour régularisation ou test. + */ + @POST + @Path("/{orgId}/calculer-interets") + @RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"}) + public Response calculerInterets(@PathParam("orgId") UUID orgId) { + Map result = interetsService.calculerManuellement(orgId); + return Response.ok(result).build(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/resource/mutuelle/ReleveCompteResource.java b/src/main/java/dev/lions/unionflow/server/resource/mutuelle/ReleveCompteResource.java new file mode 100644 index 0000000..24aa6f0 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/resource/mutuelle/ReleveCompteResource.java @@ -0,0 +1,74 @@ +package dev.lions.unionflow.server.resource.mutuelle; + +import dev.lions.unionflow.server.security.RequiresModule; +import dev.lions.unionflow.server.service.mutuelle.ReleveComptePdfService; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.ResponseBuilder; + +import java.time.LocalDate; +import java.util.UUID; + +/** + * Relevés de compte en PDF. + * - GET /api/v1/releves/epargne/{compteId} → relevé épargne + * - GET /api/v1/releves/parts-sociales/{compteId} → relevé parts sociales + */ +@Path("/api/v1/releves") +@RequiresModule("EPARGNE") +public class ReleveCompteResource { + + @Inject ReleveComptePdfService releveService; + + @GET + @Path("/epargne/{compteId}") + @Produces("application/pdf") + @RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"}) + public Response releveEpargne( + @PathParam("compteId") UUID compteId, + @QueryParam("dateDebut") String dateDebutStr, + @QueryParam("dateFin") String dateFinStr) { + + LocalDate dateDebut = parseDate(dateDebutStr); + LocalDate dateFin = parseDate(dateFinStr); + byte[] pdf = releveService.genererReleveEpargne(compteId, dateDebut, dateFin); + + ResponseBuilder rb = Response.ok(pdf); + rb.header("Content-Disposition", + "attachment; filename=\"releve-epargne-" + compteId + ".pdf\""); + rb.header("Content-Type", MediaType.valueOf("application/pdf")); + return rb.build(); + } + + @GET + @Path("/parts-sociales/{compteId}") + @Produces("application/pdf") + @RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"}) + public Response releveParts( + @PathParam("compteId") UUID compteId, + @QueryParam("dateDebut") String dateDebutStr, + @QueryParam("dateFin") String dateFinStr) { + + LocalDate dateDebut = parseDate(dateDebutStr); + LocalDate dateFin = parseDate(dateFinStr); + byte[] pdf = releveService.genererReleveParts(compteId, dateDebut, dateFin); + + ResponseBuilder rb = Response.ok(pdf); + rb.header("Content-Disposition", + "attachment; filename=\"releve-parts-" + compteId + ".pdf\""); + rb.header("Content-Type", MediaType.valueOf("application/pdf")); + return rb.build(); + } + + private LocalDate parseDate(String s) { + if (s == null || s.isBlank()) return null; + try { + return LocalDate.parse(s); + } catch (Exception e) { + throw new IllegalArgumentException("Format de date invalide. Utilisez YYYY-MM-DD. Valeur reçue: " + s); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/resource/mutuelle/epargne/TransactionEpargneResource.java b/src/main/java/dev/lions/unionflow/server/resource/mutuelle/epargne/TransactionEpargneResource.java index 8150584..f24be87 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/mutuelle/epargne/TransactionEpargneResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/mutuelle/epargne/TransactionEpargneResource.java @@ -11,6 +11,7 @@ import jakarta.validation.Valid; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import io.quarkus.security.identity.SecurityIdentity; import java.util.List; import java.util.UUID; @@ -24,10 +25,16 @@ public class TransactionEpargneResource { @Inject TransactionEpargneService transactionEpargneService; + @Inject + SecurityIdentity securityIdentity; + @POST @RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER" }) - public Response executerTransaction(@Valid TransactionEpargneRequest request) { - TransactionEpargneResponse transaction = transactionEpargneService.executerTransaction(request); + public Response executerTransaction( + @Valid TransactionEpargneRequest request, + @QueryParam("historique") @DefaultValue("false") boolean historique) { + boolean bypassSolde = historique && securityIdentity.hasRole("SUPER_ADMIN"); + TransactionEpargneResponse transaction = transactionEpargneService.executerTransaction(request, bypassSolde); return Response.status(Response.Status.CREATED).entity(transaction).build(); } diff --git a/src/main/java/dev/lions/unionflow/server/resource/mutuelle/parts/ComptePartsSocialesResource.java b/src/main/java/dev/lions/unionflow/server/resource/mutuelle/parts/ComptePartsSocialesResource.java new file mode 100644 index 0000000..f0928d7 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/resource/mutuelle/parts/ComptePartsSocialesResource.java @@ -0,0 +1,73 @@ +package dev.lions.unionflow.server.resource.mutuelle.parts; + +import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesRequest; +import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse; +import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesRequest; +import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse; +import dev.lions.unionflow.server.security.RequiresModule; +import dev.lions.unionflow.server.service.mutuelle.parts.ComptePartsSocialesService; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.List; +import java.util.UUID; + +@Path("/api/v1/parts-sociales/comptes") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@RequiresModule("EPARGNE") +public class ComptePartsSocialesResource { + + @Inject + ComptePartsSocialesService service; + + @POST + @RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"}) + public Response ouvrirCompte(@Valid ComptePartsSocialesRequest request) { + ComptePartsSocialesResponse resp = service.ouvrirCompte(request); + return Response.status(Response.Status.CREATED).entity(resp).build(); + } + + @POST + @Path("/transactions") + @RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"}) + public Response enregistrerTransaction(@Valid TransactionPartsSocialesRequest request) { + TransactionPartsSocialesResponse resp = service.enregistrerSouscription(request); + return Response.status(Response.Status.CREATED).entity(resp).build(); + } + + @GET + @Path("/{id}") + @RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"}) + public Response getById(@PathParam("id") UUID id) { + return Response.ok(service.getById(id)).build(); + } + + @GET + @Path("/membre/{membreId}") + @RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"}) + public Response getByMembre(@PathParam("membreId") UUID membreId) { + List list = service.getByMembre(membreId); + return Response.ok(list).build(); + } + + @GET + @Path("/organisation/{orgId}") + @RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"}) + public Response getByOrganisation(@PathParam("orgId") UUID orgId) { + List list = service.getByOrganisation(orgId); + return Response.ok(list).build(); + } + + @GET + @Path("/{id}/transactions") + @RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"}) + public Response getTransactions(@PathParam("id") UUID id) { + List list = service.getTransactions(id); + return Response.ok(list).build(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/security/OrganisationContextResolver.java b/src/main/java/dev/lions/unionflow/server/security/OrganisationContextResolver.java new file mode 100644 index 0000000..cc869b5 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/security/OrganisationContextResolver.java @@ -0,0 +1,116 @@ +package dev.lions.unionflow.server.security; + +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import io.smallrye.jwt.auth.principal.JWTParser; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.jboss.logging.Logger; + +import java.util.Optional; +import java.util.UUID; + +/** + * Résout l'organisation active depuis le claim {@code organization} du JWT Keycloak 26. + * + *

Keycloak 26 Organizations injecte dans le token un claim de la forme : + *

+ * "organization": {
+ *   "mutuelle-gbane": { "id": "uuid-kc-org", "name": "Mutuelle GBANE", "alias": "mutuelle-gbane" }
+ * }
+ * 
+ * + *

Ce bean remplace progressivement {@link OrganisationContextFilter} (header-based). + * Pendant la période de transition, le filtre header reste actif — ce resolver est + * utilisé en complément par les endpoints qui lisent explicitement le claim JWT. + * + *

Un token scopé à une seule organization → résolution directe. + * Un token multi-org sans scoping → exception (le client doit re-authentifier avec scoping). + */ +@ApplicationScoped +public class OrganisationContextResolver { + + private static final Logger LOG = Logger.getLogger(OrganisationContextResolver.class); + + @Inject + JsonWebToken jwt; + + @Inject + OrganisationRepository organisationRepository; + + /** + * Résout l'UUID UnionFlow de l'organisation active depuis le claim JWT {@code organization}. + * + * @throws BadRequestException si le token est multi-org sans scoping ou si le claim manque + * @throws ForbiddenException si aucune organisation UnionFlow ne correspond au keycloak_org_id + */ + public UUID resolveOrganisationId() { + var orgClaim = jwt.>getClaim("organization"); + + if (orgClaim == null || orgClaim.isEmpty()) { + throw new BadRequestException( + "Token JWT sans claim 'organization' — connectez-vous dans le contexte d'une organisation."); + } + + if (orgClaim.size() > 1) { + throw new BadRequestException( + "Token multi-organisation non scopé. Ré-authentifiez-vous avec l'organisation cible."); + } + + // Single-org token : prendre la première (et seule) entrée + var entry = orgClaim.entrySet().iterator().next().getValue(); + String kcOrgIdStr = extractId(entry); + + if (kcOrgIdStr == null) { + LOG.warnf("Claim organization sans champ 'id' : %s", entry); + throw new BadRequestException("Claim 'organization' malformé — champ 'id' manquant."); + } + + UUID kcOrgId; + try { + kcOrgId = UUID.fromString(kcOrgIdStr); + } catch (IllegalArgumentException e) { + throw new BadRequestException("Claim organization.id n'est pas un UUID valide : " + kcOrgIdStr); + } + + Optional orgOpt = organisationRepository + .find("keycloakOrgId = ?1 AND actif = true", kcOrgId) + .firstResultOptional(); + + if (orgOpt.isEmpty()) { + LOG.warnf("Aucune organisation UnionFlow avec keycloak_org_id=%s", kcOrgId); + throw new ForbiddenException( + "Aucune organisation active trouvée pour cet identifiant Keycloak Organization."); + } + + return orgOpt.get().getId(); + } + + /** + * Variante qui retourne un {@code Optional} vide si le claim est absent + * (pour les endpoints compatibles avec les deux modes header + JWT). + */ + public Optional resolveOrganisationIdIfPresent() { + try { + var orgClaim = jwt.>getClaim("organization"); + if (orgClaim == null || orgClaim.isEmpty()) { + return Optional.empty(); + } + return Optional.of(resolveOrganisationId()); + } catch (BadRequestException | ForbiddenException e) { + return Optional.empty(); + } + } + + @SuppressWarnings("unchecked") + private String extractId(Object entry) { + if (entry instanceof java.util.Map) { + Object id = ((java.util.Map) entry).get("id"); + return id != null ? id.toString() : null; + } + return null; + } +} diff --git a/src/main/java/dev/lions/unionflow/server/security/RlsConnectionInitializer.java b/src/main/java/dev/lions/unionflow/server/security/RlsConnectionInitializer.java new file mode 100644 index 0000000..c6da18d --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/security/RlsConnectionInitializer.java @@ -0,0 +1,77 @@ +package dev.lions.unionflow.server.security; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.ext.Provider; +import lombok.extern.slf4j.Slf4j; + +import javax.sql.DataSource; +import java.io.IOException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.UUID; + +/** + * Filtre JAX-RS qui positionne les variables de session PostgreSQL pour le RLS. + * + *

Doit s'exécuter APRÈS {@link OrganisationContextFilter} (priorité AUTHORIZATION + 20). + * + *

Variables positionnées : + *

    + *
  • {@code app.current_org_id} : UUID de l'organisation active (null → "00000000-0000-0000-0000-000000000000")
  • + *
  • {@code app.is_super_admin} : 'true' si SUPER_ADMIN (bypass RLS pour requêtes cross-tenant)
  • + *
+ * + *

Limitation connue : ce filtre ouvre une connexion séparée du pool Agroal. + * {@code SET LOCAL} affecte CETTE connexion, pas celle utilisée par Hibernate pour les queries. + * Pour une isolation réelle, il faut brancher le {@code SET} sur le même contexte transactionnel + * Hibernate — via {@code CurrentTenantIdentifierResolver} + {@code MultiTenantConnectionProvider}, + * ou via un {@code TransactionSynchronization} qui s'exécute dans la même transaction JTA. + * Ce filtre est un draft de préparation prod ; l'intégration complète est prévue en P2.4. + * + *

En dev, RLS est désactivé de fait car le user {@code skyfile} est owner + * et bypasse naturellement les policies. Ce filter est actif pour la préparation prod. + */ +@Slf4j +@Provider +@Priority(Priorities.AUTHORIZATION + 20) +public class RlsConnectionInitializer implements ContainerRequestFilter { + + private static final String NULL_ORG_ID = "00000000-0000-0000-0000-000000000000"; + + @Inject + OrganisationContextHolder contextHolder; + + @Inject + SecurityIdentity identity; + + @Inject + DataSource dataSource; + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + if (identity == null || identity.isAnonymous()) return; + + boolean isSuperAdmin = identity.getRoles() != null + && (identity.getRoles().contains("SUPER_ADMIN") + || identity.getRoles().contains("SUPERADMIN")); + + UUID orgId = contextHolder.hasContext() ? contextHolder.getOrganisationId() : null; + String orgIdStr = orgId != null ? orgId.toString() : NULL_ORG_ID; + + try (Connection conn = dataSource.getConnection()) { + try (PreparedStatement stmt = conn.prepareStatement( + "SET LOCAL app.current_org_id = '" + orgIdStr + "'; " + + "SET LOCAL app.is_super_admin = '" + isSuperAdmin + "'")) { + stmt.execute(); + } + } catch (Exception e) { + // Non bloquant en dev (user owner bypasse RLS) + log.debug("RLS session variables non positionnées (ignoré en dev) : {}", e.getMessage()); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/security/RlsContextInterceptor.java b/src/main/java/dev/lions/unionflow/server/security/RlsContextInterceptor.java new file mode 100644 index 0000000..f6d8779 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/security/RlsContextInterceptor.java @@ -0,0 +1,73 @@ +package dev.lions.unionflow.server.security; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; +import jakarta.persistence.EntityManager; +import lombok.extern.slf4j.Slf4j; + +import java.util.UUID; + +/** + * Intercepteur CDI qui positionne les variables de session PostgreSQL pour le RLS + * DANS la même connexion JTA que Hibernate. + * + *

Priorité 300 : s'exécute APRÈS l'intercepteur {@code @Transactional} (priorité ~200) + * mais AVANT le code métier, garantissant que {@code SET LOCAL} affecte la connexion + * JTA active. + * + *

Utilise {@code set_config(name, value, true)} (is_local=true) qui est l'équivalent + * de {@code SET LOCAL} et s'annule automatiquement en fin de transaction. + * + *

Si aucun contexte d'organisation n'est disponible (SUPER_ADMIN sans org, ou endpoint + * public), positionne l'UUID nul pour que les policies RLS utilisent le fallback. + */ +@Slf4j +@Interceptor +@RlsEnabled +@Priority(300) +public class RlsContextInterceptor { + + private static final String NULL_ORG_UUID = "00000000-0000-0000-0000-000000000000"; + + @Inject + EntityManager em; + + @Inject + OrganisationContextHolder contextHolder; + + @Inject + SecurityIdentity identity; + + @AroundInvoke + Object applyRlsContext(InvocationContext ctx) throws Exception { + if (identity == null || identity.isAnonymous()) { + return ctx.proceed(); + } + + boolean isSuperAdmin = identity.getRoles() != null + && (identity.getRoles().contains("SUPER_ADMIN") + || identity.getRoles().contains("SUPERADMIN")); + + UUID orgId = contextHolder.hasContext() ? contextHolder.getOrganisationId() : null; + String orgIdStr = orgId != null ? orgId.toString() : NULL_ORG_UUID; + + try { + em.createNativeQuery( + "SELECT set_config('app.current_org_id', :orgId, true), " + + "set_config('app.is_super_admin', :isSuperAdmin, true)") + .setParameter("orgId", orgIdStr) + .setParameter("isSuperAdmin", String.valueOf(isSuperAdmin)) + .getSingleResult(); + log.debug("RLS context positionné : org={}, superAdmin={}", orgIdStr, isSuperAdmin); + } catch (Exception e) { + // Non bloquant : en dev, le user owner bypasse naturellement les policies + log.debug("RLS set_config ignoré (probablement hors transaction) : {}", e.getMessage()); + } + + return ctx.proceed(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/security/RlsEnabled.java b/src/main/java/dev/lions/unionflow/server/security/RlsEnabled.java new file mode 100644 index 0000000..4707589 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/security/RlsEnabled.java @@ -0,0 +1,26 @@ +package dev.lions.unionflow.server.security; + +import jakarta.interceptor.InterceptorBinding; +import java.lang.annotation.*; + +/** + * Marque une méthode ou classe transactionnelle pour que le filtre RLS + * positionne les variables de session PostgreSQL ({@code app.current_org_id}, + * {@code app.is_super_admin}) dans la même connexion JTA que Hibernate. + * + *

Doit toujours être combiné avec {@code @Transactional} (ou être dans une + * méthode appelée depuis un contexte transactionnel existant). + * + *

Usage : + *

{@code
+ * @RlsEnabled
+ * @Transactional
+ * public List findAll() { ... }
+ * }
+ */ +@Inherited +@InterceptorBinding +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RlsEnabled { +} diff --git a/src/main/java/dev/lions/unionflow/server/service/AuditService.java b/src/main/java/dev/lions/unionflow/server/service/AuditService.java index 3493a48..8c8858c 100644 --- a/src/main/java/dev/lions/unionflow/server/service/AuditService.java +++ b/src/main/java/dev/lions/unionflow/server/service/AuditService.java @@ -87,6 +87,25 @@ public class AuditService { auditLogRepository.persist(log); } + /** + * Enregistre un log d'audit KYC/AML quand un score de risque élevé est détecté. + */ + @Transactional + public void logKycRisqueEleve(UUID membreId, int scoreRisque, String niveauRisque) { + AuditLog log = new AuditLog(); + log.setTypeAction("KYC_RISQUE_ELEVE"); + log.setSeverite("WARNING"); + log.setUtilisateur(membreId != null ? membreId.toString() : null); + log.setModule("KYC_AML"); + log.setDescription("Score de risque KYC/AML élevé détecté"); + log.setDetails(String.format("membreId=%s, score=%d, niveau=%s", membreId, scoreRisque, niveauRisque)); + log.setEntiteType("KycDossier"); + log.setEntiteId(membreId != null ? membreId.toString() : null); + log.setDateHeure(LocalDateTime.now()); + log.setPortee(PorteeAudit.PLATEFORME); + auditLogRepository.persist(log); + } + /** * Enregistre un nouveau log d'audit */ diff --git a/src/main/java/dev/lions/unionflow/server/service/ComptabilitePdfService.java b/src/main/java/dev/lions/unionflow/server/service/ComptabilitePdfService.java new file mode 100644 index 0000000..a90f60b --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/ComptabilitePdfService.java @@ -0,0 +1,435 @@ +package dev.lions.unionflow.server.service; + +import com.lowagie.text.*; +import com.lowagie.text.Font; +import com.lowagie.text.pdf.*; +import dev.lions.unionflow.server.api.enums.comptabilite.TypeCompteComptable; +import dev.lions.unionflow.server.entity.CompteComptable; +import dev.lions.unionflow.server.entity.EcritureComptable; +import dev.lions.unionflow.server.entity.LigneEcriture; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.repository.CompteComptableRepository; +import dev.lions.unionflow.server.repository.EcritureComptableRepository; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; +import lombok.extern.slf4j.Slf4j; + +import java.awt.Color; +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Génération des rapports comptables PDF SYSCOHADA révisé. + * + *

Rapports disponibles : + *

    + *
  • Grand livre : détail de toutes les écritures par compte
  • + *
  • Balance générale : soldes débit/crédit/solde net par compte
  • + *
  • Compte de résultat : produits (classe 7+8) - charges (classe 6+8)
  • + *
+ */ +@Slf4j +@ApplicationScoped +public class ComptabilitePdfService { + + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy"); + private static final Color COLOR_HEADER = new Color(0x1A, 0x56, 0x8C); + private static final Color COLOR_HEADER_TEXT = Color.WHITE; + private static final Color COLOR_TOTAL_ROW = new Color(0xE8, 0xF0, 0xFE); + private static final Color COLOR_ROW_ALT = new Color(0xF8, 0xFA, 0xFF); + + @Inject + OrganisationRepository organisationRepository; + + @Inject + CompteComptableRepository compteComptableRepository; + + @Inject + EcritureComptableRepository ecritureComptableRepository; + + // ── Balance générale ───────────────────────────────────────────────────── + + /** + * Génère la balance générale SYSCOHADA pour une organisation. + * Liste tous les comptes avec cumul débit, cumul crédit et solde. + */ + public byte[] genererBalance(UUID organisationId, LocalDate dateDebut, LocalDate dateFin) { + Organisation org = getOrg(organisationId); + List comptes = compteComptableRepository.findByOrganisation(organisationId); + + Map totauxParCompte = calculerTotauxParCompte(organisationId, dateDebut, dateFin); + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + Document doc = new Document(PageSize.A4.rotate(), 20, 20, 40, 40); + PdfWriter.getInstance(doc, baos); + doc.open(); + + addTitrePage(doc, "BALANCE GÉNÉRALE", org.getNom(), dateDebut, dateFin); + + PdfPTable table = new PdfPTable(6); + table.setWidthPercentage(100); + table.setWidths(new float[]{10f, 30f, 8f, 15f, 15f, 15f}); + + addHeaderCell(table, "Compte"); + addHeaderCell(table, "Libellé"); + addHeaderCell(table, "Classe"); + addHeaderCell(table, "Cumul Débit"); + addHeaderCell(table, "Cumul Crédit"); + addHeaderCell(table, "Solde"); + + BigDecimal totalDebit = BigDecimal.ZERO; + BigDecimal totalCredit = BigDecimal.ZERO; + boolean alt = false; + + for (CompteComptable compte : comptes) { + BigDecimal[] totaux = totauxParCompte.getOrDefault( + compte.getNumeroCompte(), new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO}); + BigDecimal debit = totaux[0]; + BigDecimal credit = totaux[1]; + BigDecimal solde = debit.subtract(credit); + + if (debit.signum() == 0 && credit.signum() == 0) continue; + + Color bg = alt ? COLOR_ROW_ALT : Color.WHITE; + addDataCell(table, compte.getNumeroCompte(), bg); + addDataCell(table, compte.getLibelle(), bg); + addDataCell(table, String.valueOf(compte.getClasseComptable()), bg); + addAmountCell(table, debit, bg); + addAmountCell(table, credit, bg); + addAmountCell(table, solde, bg); + + totalDebit = totalDebit.add(debit); + totalCredit = totalCredit.add(credit); + alt = !alt; + } + + // Ligne totaux + BigDecimal totalSolde = totalDebit.subtract(totalCredit); + addTotalCell(table, "TOTAUX"); + addTotalCell(table, ""); + addTotalCell(table, ""); + addAmountCell(table, totalDebit, COLOR_TOTAL_ROW); + addAmountCell(table, totalCredit, COLOR_TOTAL_ROW); + addAmountCell(table, totalSolde, COLOR_TOTAL_ROW); + + doc.add(table); + addFooter(doc); + doc.close(); + return baos.toByteArray(); + } catch (Exception e) { + log.error("Erreur génération balance PDF : {}", e.getMessage(), e); + throw new RuntimeException("Erreur génération balance PDF", e); + } + } + + // ── Compte de résultat ──────────────────────────────────────────────────── + + /** + * Génère le compte de résultat SYSCOHADA. + * Produits (classes 7 et 8 produits) — Charges (classes 6 et 8 charges). + */ + public byte[] genererCompteResultat(UUID organisationId, LocalDate dateDebut, LocalDate dateFin) { + Organisation org = getOrg(organisationId); + Map totaux = calculerTotauxParCompte(organisationId, dateDebut, dateFin); + + List comptes = compteComptableRepository.findByOrganisation(organisationId); + + BigDecimal totalProduits = BigDecimal.ZERO; + BigDecimal totalCharges = BigDecimal.ZERO; + List lignesProduits = new ArrayList<>(); + List lignesCharges = new ArrayList<>(); + + for (CompteComptable compte : comptes) { + int classe = compte.getClasseComptable(); + BigDecimal[] t = totaux.getOrDefault(compte.getNumeroCompte(), + new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO}); + BigDecimal solde = t[1].subtract(t[0]); // crédit - débit pour produits + + if ((classe == 7) || (classe == 8 && TypeCompteComptable.PRODUITS.equals(compte.getTypeCompte()))) { + if (solde.signum() != 0) { + lignesProduits.add(new Object[]{compte.getNumeroCompte(), compte.getLibelle(), solde}); + totalProduits = totalProduits.add(solde); + } + } else if ((classe == 6) || (classe == 8 && TypeCompteComptable.CHARGES.equals(compte.getTypeCompte()))) { + BigDecimal soldeCharge = t[0].subtract(t[1]); // débit - crédit pour charges + if (soldeCharge.signum() != 0) { + lignesCharges.add(new Object[]{compte.getNumeroCompte(), compte.getLibelle(), soldeCharge}); + totalCharges = totalCharges.add(soldeCharge); + } + } + } + + BigDecimal resultat = totalProduits.subtract(totalCharges); + boolean benefice = resultat.signum() >= 0; + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + Document doc = new Document(PageSize.A4, 30, 30, 50, 40); + PdfWriter.getInstance(doc, baos); + doc.open(); + + addTitrePage(doc, "COMPTE DE RÉSULTAT", org.getNom(), dateDebut, dateFin); + + // Section PRODUITS + addSectionTitle(doc, "PRODUITS D'EXPLOITATION"); + PdfPTable tableProduits = creerTableau2Colonnes(); + for (Object[] ligne : lignesProduits) { + addDataCell(tableProduits, ligne[0] + " — " + ligne[1], Color.WHITE); + addAmountCell(tableProduits, (BigDecimal) ligne[2], Color.WHITE); + } + addTotalCell(tableProduits, "TOTAL PRODUITS"); + addAmountCell(tableProduits, totalProduits, COLOR_TOTAL_ROW); + doc.add(tableProduits); + + doc.add(new Paragraph(" ")); + + // Section CHARGES + addSectionTitle(doc, "CHARGES D'EXPLOITATION"); + PdfPTable tableCharges = creerTableau2Colonnes(); + for (Object[] ligne : lignesCharges) { + addDataCell(tableCharges, ligne[0] + " — " + ligne[1], Color.WHITE); + addAmountCell(tableCharges, (BigDecimal) ligne[2], Color.WHITE); + } + addTotalCell(tableCharges, "TOTAL CHARGES"); + addAmountCell(tableCharges, totalCharges, COLOR_TOTAL_ROW); + doc.add(tableCharges); + + doc.add(new Paragraph(" ")); + + // Résultat net + PdfPTable tableResultat = creerTableau2Colonnes(); + String libelleResultat = benefice ? "BÉNÉFICE NET DE L'EXERCICE" : "PERTE NETTE DE L'EXERCICE"; + Color couleurResultat = benefice ? new Color(0x00, 0x80, 0x00) : new Color(0xCC, 0x00, 0x00); + PdfPCell cellResultat = new PdfPCell( + new Phrase(libelleResultat, FontFactory.getFont(FontFactory.HELVETICA_BOLD, 11, couleurResultat))); + cellResultat.setBackgroundColor(new Color(0xF0, 0xF8, 0xE8)); + cellResultat.setPadding(8); + tableResultat.addCell(cellResultat); + addAmountCell(tableResultat, resultat.abs(), new Color(0xF0, 0xF8, 0xE8)); + doc.add(tableResultat); + + addFooter(doc); + doc.close(); + return baos.toByteArray(); + } catch (Exception e) { + log.error("Erreur génération compte de résultat PDF : {}", e.getMessage(), e); + throw new RuntimeException("Erreur génération compte de résultat PDF", e); + } + } + + // ── Grand livre ─────────────────────────────────────────────────────────── + + /** + * Génère le grand livre pour un compte donné. + */ + public byte[] genererGrandLivre(UUID organisationId, String numeroCompte, + LocalDate dateDebut, LocalDate dateFin) { + Organisation org = getOrg(organisationId); + CompteComptable compte = compteComptableRepository + .findByOrganisationAndNumero(organisationId, numeroCompte) + .orElseThrow(() -> new NotFoundException( + "Compte " + numeroCompte + " introuvable pour l'org " + organisationId)); + + List ecritures = ecritureComptableRepository + .findByOrganisationAndDateRange(organisationId, dateDebut, dateFin); + + // Filtrer les lignes qui concernent ce compte + List mouvements = new ArrayList<>(); + BigDecimal solde = BigDecimal.ZERO; + + for (EcritureComptable ecriture : ecritures) { + if (ecriture.getLignes() == null) continue; + for (LigneEcriture ligne : ecriture.getLignes()) { + if (ligne.getCompteComptable() == null) continue; + if (!numeroCompte.equals(ligne.getCompteComptable().getNumeroCompte())) continue; + + BigDecimal debit = ligne.getMontantDebit() != null ? ligne.getMontantDebit() : BigDecimal.ZERO; + BigDecimal credit = ligne.getMontantCredit() != null ? ligne.getMontantCredit() : BigDecimal.ZERO; + solde = solde.add(debit).subtract(credit); + + mouvements.add(new Object[]{ + ecriture.getDateEcriture(), + ecriture.getNumeroPiece(), + ecriture.getLibelle(), + debit, + credit, + solde + }); + } + } + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + Document doc = new Document(PageSize.A4.rotate(), 20, 20, 40, 40); + PdfWriter.getInstance(doc, baos); + doc.open(); + + addTitrePage(doc, "GRAND LIVRE — " + numeroCompte + " " + compte.getLibelle(), + org.getNom(), dateDebut, dateFin); + + PdfPTable table = new PdfPTable(6); + table.setWidthPercentage(100); + table.setWidths(new float[]{12f, 15f, 35f, 12f, 12f, 14f}); + + addHeaderCell(table, "Date"); + addHeaderCell(table, "Pièce"); + addHeaderCell(table, "Libellé"); + addHeaderCell(table, "Débit"); + addHeaderCell(table, "Crédit"); + addHeaderCell(table, "Solde cumulé"); + + boolean alt = false; + for (Object[] mvt : mouvements) { + Color bg = alt ? COLOR_ROW_ALT : Color.WHITE; + addDataCell(table, DATE_FMT.format((LocalDate) mvt[0]), bg); + addDataCell(table, (String) mvt[1], bg); + addDataCell(table, (String) mvt[2], bg); + addAmountCell(table, (BigDecimal) mvt[3], bg); + addAmountCell(table, (BigDecimal) mvt[4], bg); + addAmountCell(table, (BigDecimal) mvt[5], bg); + alt = !alt; + } + + if (mouvements.isEmpty()) { + PdfPCell empty = new PdfPCell(new Phrase("Aucun mouvement sur la période", + FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 10, Color.GRAY))); + empty.setColspan(6); + empty.setPadding(10); + empty.setHorizontalAlignment(Element.ALIGN_CENTER); + table.addCell(empty); + } + + doc.add(table); + addFooter(doc); + doc.close(); + return baos.toByteArray(); + } catch (Exception e) { + log.error("Erreur génération grand livre PDF : {}", e.getMessage(), e); + throw new RuntimeException("Erreur génération grand livre PDF", e); + } + } + + // ── Utilitaires PDF ────────────────────────────────────────────────────── + + private void addTitrePage(Document doc, String titre, String orgNom, + LocalDate dateDebut, LocalDate dateFin) throws DocumentException { + Font fontTitre = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, COLOR_HEADER); + Font fontSousTitre = FontFactory.getFont(FontFactory.HELVETICA, 11, Color.DARK_GRAY); + + Paragraph pTitre = new Paragraph(titre, fontTitre); + pTitre.setAlignment(Element.ALIGN_CENTER); + pTitre.setSpacingAfter(4); + doc.add(pTitre); + + Paragraph pOrg = new Paragraph(orgNom, fontSousTitre); + pOrg.setAlignment(Element.ALIGN_CENTER); + doc.add(pOrg); + + if (dateDebut != null && dateFin != null) { + Paragraph pPeriode = new Paragraph( + "Période : " + DATE_FMT.format(dateDebut) + " au " + DATE_FMT.format(dateFin), + FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 10, Color.GRAY)); + pPeriode.setAlignment(Element.ALIGN_CENTER); + pPeriode.setSpacingAfter(12); + doc.add(pPeriode); + } + } + + private void addSectionTitle(Document doc, String titre) throws DocumentException { + Paragraph p = new Paragraph(titre, + FontFactory.getFont(FontFactory.HELVETICA_BOLD, 12, COLOR_HEADER)); + p.setSpacingBefore(8); + p.setSpacingAfter(4); + doc.add(p); + } + + private void addFooter(Document doc) throws DocumentException { + Paragraph footer = new Paragraph( + "Généré le " + DATE_FMT.format(LocalDate.now()) + " — UnionFlow SYSCOHADA révisé", + FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 8, Color.GRAY)); + footer.setAlignment(Element.ALIGN_RIGHT); + footer.setSpacingBefore(16); + doc.add(footer); + } + + private PdfPTable creerTableau2Colonnes() throws DocumentException { + PdfPTable table = new PdfPTable(2); + table.setWidthPercentage(100); + table.setWidths(new float[]{65f, 35f}); + return table; + } + + private void addHeaderCell(PdfPTable table, String text) { + PdfPCell cell = new PdfPCell(new Phrase(text, + FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, COLOR_HEADER_TEXT))); + cell.setBackgroundColor(COLOR_HEADER); + cell.setPadding(6); + cell.setHorizontalAlignment(Element.ALIGN_CENTER); + table.addCell(cell); + } + + private void addDataCell(PdfPTable table, String text, Color bg) { + PdfPCell cell = new PdfPCell(new Phrase(text, + FontFactory.getFont(FontFactory.HELVETICA, 9, Color.BLACK))); + cell.setBackgroundColor(bg); + cell.setPadding(5); + table.addCell(cell); + } + + private void addAmountCell(PdfPTable table, BigDecimal amount, Color bg) { + String formatted = amount != null + ? String.format("%,.0f XOF", amount.doubleValue()) + : "0 XOF"; + PdfPCell cell = new PdfPCell(new Phrase(formatted, + FontFactory.getFont(FontFactory.HELVETICA, 9, Color.BLACK))); + cell.setBackgroundColor(bg); + cell.setPadding(5); + cell.setHorizontalAlignment(Element.ALIGN_RIGHT); + table.addCell(cell); + } + + private void addTotalCell(PdfPTable table, String text) { + PdfPCell cell = new PdfPCell(new Phrase(text, + FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.BLACK))); + cell.setBackgroundColor(COLOR_TOTAL_ROW); + cell.setPadding(6); + table.addCell(cell); + } + + // ── Calcul des totaux ───────────────────────────────────────────────────── + + private Map calculerTotauxParCompte(UUID organisationId, + LocalDate dateDebut, LocalDate dateFin) { + List ecritures = ecritureComptableRepository + .findByOrganisationAndDateRange(organisationId, dateDebut, dateFin); + + Map totaux = new HashMap<>(); + for (EcritureComptable ecriture : ecritures) { + if (ecriture.getLignes() == null) continue; + for (LigneEcriture ligne : ecriture.getLignes()) { + if (ligne.getCompteComptable() == null) continue; + String numero = ligne.getCompteComptable().getNumeroCompte(); + totaux.computeIfAbsent(numero, k -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO}); + BigDecimal debit = ligne.getMontantDebit() != null ? ligne.getMontantDebit() : BigDecimal.ZERO; + BigDecimal credit = ligne.getMontantCredit() != null ? ligne.getMontantCredit() : BigDecimal.ZERO; + totaux.get(numero)[0] = totaux.get(numero)[0].add(debit); + totaux.get(numero)[1] = totaux.get(numero)[1].add(credit); + } + } + return totaux; + } + + private Organisation getOrg(UUID organisationId) { + return organisationRepository.findByIdOptional(organisationId) + .orElseThrow(() -> new NotFoundException("Organisation introuvable : " + organisationId)); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/ComptabiliteService.java b/src/main/java/dev/lions/unionflow/server/service/ComptabiliteService.java index 7cc592e..c7bda41 100644 --- a/src/main/java/dev/lions/unionflow/server/service/ComptabiliteService.java +++ b/src/main/java/dev/lions/unionflow/server/service/ComptabiliteService.java @@ -2,7 +2,9 @@ package dev.lions.unionflow.server.service; import dev.lions.unionflow.server.api.dto.comptabilite.request.*; import dev.lions.unionflow.server.api.dto.comptabilite.response.*; +import dev.lions.unionflow.server.api.enums.comptabilite.TypeJournalComptable; import dev.lions.unionflow.server.entity.*; +import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne; import dev.lions.unionflow.server.repository.*; import dev.lions.unionflow.server.service.KeycloakService; import jakarta.enterprise.context.ApplicationScoped; @@ -221,6 +223,207 @@ public class ComptabiliteService { .collect(Collectors.toList()); } + // ======================================== + // MÉTHODES SYSCOHADA — Génération automatique d'écritures depuis les opérations métier + // Débit/Crédit selon les règles SYSCOHADA révisé (UEMOA, applicable depuis 2018) + // ======================================== + + /** + * Génère l'écriture comptable SYSCOHADA pour une cotisation payée. + * Schéma : Débit 5121xx (trésorerie provider) ; Crédit 706100 (cotisations ordinaires). + * Appeler depuis CotisationService.marquerPaye() après confirmation du paiement. + */ + @Transactional + public EcritureComptable enregistrerCotisation(Cotisation cotisation) { + if (cotisation == null || cotisation.getOrganisation() == null) { + LOG.warn("enregistrerCotisation : cotisation ou organisation null — écriture ignorée"); + return null; + } + + UUID orgId = cotisation.getOrganisation().getId(); + BigDecimal montant = cotisation.getMontantPaye(); + if (montant == null || montant.compareTo(BigDecimal.ZERO) == 0) { + return null; + } + + // Choix du compte de trésorerie selon le provider (Wave par défaut) + String numeroTresorerie = resolveCompteTresorerie(cotisation.getCodeDevise()); + CompteComptable compteTresorerie = compteComptableRepository + .findByOrganisationAndNumero(orgId, numeroTresorerie) + .orElseGet(() -> compteComptableRepository.findByOrganisationAndNumero(orgId, "512500").orElse(null)); + + // Compte produit cotisations ordinaires + String numeroCompteType = "ORDINAIRE".equals(cotisation.getTypeCotisation()) ? "706100" : "706200"; + CompteComptable compteProduit = compteComptableRepository + .findByOrganisationAndNumero(orgId, numeroCompteType) + .orElseGet(() -> compteComptableRepository.findByOrganisationAndNumero(orgId, "706100").orElse(null)); + + if (compteTresorerie == null || compteProduit == null) { + LOG.warnf("Comptes SYSCOHADA manquants pour org %s — plan comptable non initialisé ?", orgId); + return null; + } + + JournalComptable journal = journalComptableRepository + .findByOrganisationAndType(orgId, TypeJournalComptable.VENTES) + .orElse(null); + if (journal == null) { + LOG.warnf("Journal VENTES absent pour org %s — écriture ignorée", orgId); + return null; + } + + EcritureComptable ecriture = construireEcriture( + journal, + cotisation.getOrganisation(), + LocalDate.now(), + String.format("Cotisation %s - %s", cotisation.getTypeCotisation(), cotisation.getNumeroReference()), + cotisation.getNumeroReference(), + montant, + compteTresorerie, + compteProduit + ); + + ecritureComptableRepository.persist(ecriture); + LOG.infof("Écriture SYSCOHADA cotisation créée : %s | montant %s XOF", ecriture.getNumeroPiece(), montant); + return ecriture; + } + + /** + * Génère l'écriture SYSCOHADA pour un dépôt épargne. + * Schéma : Débit 5121xx (trésorerie) ; Crédit 421000 (dette mutuelle envers membre). + */ + @Transactional + public EcritureComptable enregistrerDepotEpargne(TransactionEpargne transaction, Organisation organisation) { + if (transaction == null || organisation == null) return null; + + UUID orgId = organisation.getId(); + BigDecimal montant = transaction.getMontant(); + if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) return null; + + CompteComptable compteTresorerie = compteComptableRepository + .findByOrganisationAndNumero(orgId, "512100") + .orElseGet(() -> compteComptableRepository.findByOrganisationAndNumero(orgId, "512500").orElse(null)); + + CompteComptable compteEpargne = compteComptableRepository + .findByOrganisationAndNumero(orgId, "421000").orElse(null); + + if (compteTresorerie == null || compteEpargne == null) return null; + + JournalComptable journal = journalComptableRepository + .findByOrganisationAndType(orgId, TypeJournalComptable.BANQUE) + .orElse(null); + if (journal == null) return null; + + EcritureComptable ecriture = construireEcriture( + journal, organisation, LocalDate.now(), + "Dépôt épargne - " + (transaction.getReferenceExterne() != null ? transaction.getReferenceExterne() : ""), + transaction.getReferenceExterne(), + montant, compteTresorerie, compteEpargne + ); + + ecritureComptableRepository.persist(ecriture); + LOG.infof("Écriture SYSCOHADA dépôt épargne : %s | %s XOF", ecriture.getNumeroPiece(), montant); + return ecriture; + } + + /** + * Génère l'écriture SYSCOHADA pour un retrait épargne. + * Schéma : Débit 421000 (dette mutuelle) ; Crédit 5121xx (trésorerie sortante). + */ + @Transactional + public EcritureComptable enregistrerRetraitEpargne(TransactionEpargne transaction, Organisation organisation) { + if (transaction == null || organisation == null) return null; + + UUID orgId = organisation.getId(); + BigDecimal montant = transaction.getMontant(); + if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) return null; + + CompteComptable compteEpargne = compteComptableRepository + .findByOrganisationAndNumero(orgId, "421000").orElse(null); + + CompteComptable compteTresorerie = compteComptableRepository + .findByOrganisationAndNumero(orgId, "512100") + .orElseGet(() -> compteComptableRepository.findByOrganisationAndNumero(orgId, "512500").orElse(null)); + + if (compteEpargne == null || compteTresorerie == null) return null; + + JournalComptable journal = journalComptableRepository + .findByOrganisationAndType(orgId, TypeJournalComptable.BANQUE) + .orElse(null); + if (journal == null) return null; + + // Retrait : débit = 421000 (dette diminue), crédit = 512xxx (cash sort) + EcritureComptable ecriture = construireEcriture( + journal, organisation, LocalDate.now(), + "Retrait épargne - " + (transaction.getReferenceExterne() != null ? transaction.getReferenceExterne() : ""), + transaction.getReferenceExterne(), + montant, compteEpargne, compteTresorerie + ); + + ecritureComptableRepository.persist(ecriture); + return ecriture; + } + + // ======================================== + // MÉTHODES PRIVÉES - HELPERS SYSCOHADA + // ======================================== + + /** + * Détermine le compte de trésorerie selon le code devise / provider. + * Par défaut 512100 (Wave) pour XOF en UEMOA. + */ + private String resolveCompteTresorerie(String codeDevise) { + // Pour l'instant Wave = 512100 par défaut. Sera enrichi avec multi-provider P1.3. + return "512100"; + } + + /** + * Construit une écriture comptable à 2 lignes (débit/crédit) équilibrée. + */ + private EcritureComptable construireEcriture( + JournalComptable journal, + Organisation organisation, + LocalDate date, + String libelle, + String reference, + BigDecimal montant, + CompteComptable compteDebit, + CompteComptable compteCredit) { + + LigneEcriture ligneDebit = new LigneEcriture(); + ligneDebit.setNumeroLigne(1); + ligneDebit.setCompteComptable(compteDebit); + ligneDebit.setMontantDebit(montant); + ligneDebit.setMontantCredit(BigDecimal.ZERO); + ligneDebit.setLibelle(libelle); + ligneDebit.setReference(reference); + + LigneEcriture ligneCredit = new LigneEcriture(); + ligneCredit.setNumeroLigne(2); + ligneCredit.setCompteComptable(compteCredit); + ligneCredit.setMontantDebit(BigDecimal.ZERO); + ligneCredit.setMontantCredit(montant); + ligneCredit.setLibelle(libelle); + ligneCredit.setReference(reference); + + EcritureComptable ecriture = EcritureComptable.builder() + .journal(journal) + .organisation(organisation) + .dateEcriture(date) + .libelle(libelle) + .reference(reference) + .montantDebit(montant) + .montantCredit(montant) + .pointe(false) + .build(); + + ecriture.getLignes().add(ligneDebit); + ecriture.getLignes().add(ligneCredit); + ligneDebit.setEcriture(ecriture); + ligneCredit.setEcriture(ecriture); + + return ecriture; + } + // ======================================== // MÉTHODES PRIVÉES - CONVERSIONS // ======================================== diff --git a/src/main/java/dev/lions/unionflow/server/service/CotisationService.java b/src/main/java/dev/lions/unionflow/server/service/CotisationService.java index aff1fd5..8aa4789 100644 --- a/src/main/java/dev/lions/unionflow/server/service/CotisationService.java +++ b/src/main/java/dev/lions/unionflow/server/service/CotisationService.java @@ -11,6 +11,8 @@ import dev.lions.unionflow.server.repository.CotisationRepository; import dev.lions.unionflow.server.repository.MembreRepository; import dev.lions.unionflow.server.repository.OrganisationRepository; import dev.lions.unionflow.server.service.support.SecuriteHelper; +import dev.lions.unionflow.server.service.ComptabiliteService; +import dev.lions.unionflow.server.security.RlsEnabled; import io.quarkus.panache.common.Page; import io.quarkus.panache.common.Sort; import jakarta.enterprise.context.ApplicationScoped; @@ -43,6 +45,7 @@ import lombok.extern.slf4j.Slf4j; */ @ApplicationScoped @Slf4j +@RlsEnabled public class CotisationService { @Inject @@ -63,6 +66,12 @@ public class CotisationService { @Inject OrganisationService organisationService; + @Inject + ComptabiliteService comptabiliteService; + + @Inject + EmailTemplateService emailTemplateService; + /** * Récupère toutes les cotisations avec pagination. * @@ -246,6 +255,7 @@ public class CotisationService { } // Déterminer le statut en fonction du montant payé + boolean etaitDejaPayee = "PAYEE".equals(cotisation.getStatut()); if (cotisation.getMontantPaye() != null && cotisation.getMontantDu() != null && cotisation.getMontantPaye().compareTo(cotisation.getMontantDu()) >= 0) { cotisation.setStatut("PAYEE"); @@ -254,6 +264,36 @@ public class CotisationService { cotisation.setStatut("PARTIELLEMENT_PAYEE"); } + // Génération écriture SYSCOHADA + email si cotisation vient de passer à PAYEE + if (!etaitDejaPayee && "PAYEE".equals(cotisation.getStatut())) { + try { + comptabiliteService.enregistrerCotisation(cotisation); + } catch (Exception e) { + log.warn("Écriture SYSCOHADA cotisation ignorée (non bloquant) : {}", e.getMessage()); + } + // Email de confirmation asynchrone (non bloquant) + if (cotisation.getMembre() != null && cotisation.getMembre().getEmail() != null) { + try { + String periode = cotisation.getPeriode() != null ? cotisation.getPeriode() + : (cotisation.getDateEcheance() != null + ? cotisation.getDateEcheance().getYear() + "/" + cotisation.getDateEcheance().getMonthValue() + : "—"); + emailTemplateService.envoyerConfirmationCotisation( + cotisation.getMembre().getEmail(), + cotisation.getMembre().getPrenom() != null ? cotisation.getMembre().getPrenom() : "", + cotisation.getMembre().getNom() != null ? cotisation.getMembre().getNom() : "", + cotisation.getOrganisation() != null ? cotisation.getOrganisation().getNom() : "", + periode, + reference != null ? reference : "", + modePaiement != null ? modePaiement : "—", + datePaiement, + cotisation.getMontantPaye()); + } catch (Exception e) { + log.warn("Email confirmation cotisation ignoré (non bloquant) : {}", e.getMessage()); + } + } + } + log.info("Paiement enregistré - ID: {}, Statut: {}", id, cotisation.getStatut()); return convertToResponse(cotisation); } diff --git a/src/main/java/dev/lions/unionflow/server/service/EmailTemplateService.java b/src/main/java/dev/lions/unionflow/server/service/EmailTemplateService.java new file mode 100644 index 0000000..a260eee --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/EmailTemplateService.java @@ -0,0 +1,134 @@ +package dev.lions.unionflow.server.service; + +import io.quarkus.mailer.MailTemplate.MailTemplateInstance; +import io.quarkus.qute.CheckedTemplate; +import io.quarkus.qute.TemplateInstance; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import io.quarkus.mailer.Mail; +import io.quarkus.mailer.Mailer; +import lombok.extern.slf4j.Slf4j; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * Service d'envoi d'emails HTML via Qute templates (Quarkus Mailer). + * + *

Templates : {@code src/main/resources/templates/email/}. + * Variables injectées au moment de l'appel via {@code .data(key, value)}. + */ +@Slf4j +@ApplicationScoped +public class EmailTemplateService { + + private static final DateTimeFormatter DATE_FR = DateTimeFormatter.ofPattern("dd/MM/yyyy"); + + @Inject + Mailer mailer; + + // ── Templates Qute ──────────────────────────────────────────────────────── + + @CheckedTemplate(basePath = "email") + static class Templates { + static native MailTemplateInstance bienvenue( + String prenom, String nom, String email, + String nomOrganisation, String lienConnexion); + + static native MailTemplateInstance cotisationConfirmation( + String prenom, String nom, + String nomOrganisation, String periode, + String numeroReference, String methodePaiement, + String datePaiement, String montant); + + static native MailTemplateInstance rappelCotisation( + String prenom, String nom, + String nomOrganisation, String periode, + String montant, String dateLimite, String lienPaiement); + + static native MailTemplateInstance souscriptionConfirmation( + String nomAdministrateur, String nomOrganisation, + String nomFormule, String montant, String periodicite, + String dateActivation, String dateExpiration, + String maxMembres, String maxStockageMo, + boolean apiAccess, boolean supportPrioritaire); + } + + // ── Méthodes d'envoi ───────────────────────────────────────────────────── + + public void envoyerBienvenue(String email, String prenom, String nom, + String nomOrganisation, String lienConnexion) { + try { + Templates.bienvenue(prenom, nom, email, nomOrganisation, lienConnexion) + .to(email) + .subject("Bienvenue sur UnionFlow — " + nomOrganisation) + .send().await().indefinitely(); + log.info("Email bienvenue envoyé à {}", email); + } catch (Exception e) { + log.error("Échec envoi email bienvenue à {}: {}", email, e.getMessage(), e); + } + } + + public void envoyerConfirmationCotisation(String email, String prenom, String nom, + String nomOrganisation, String periode, + String numeroReference, String methodePaiement, + LocalDate datePaiement, BigDecimal montant) { + try { + Templates.cotisationConfirmation( + prenom, nom, nomOrganisation, periode, + numeroReference, methodePaiement, + datePaiement != null ? DATE_FR.format(datePaiement) : "—", + String.format("%,.0f", montant.doubleValue())) + .to(email) + .subject("Confirmation de cotisation — " + periode) + .send().await().indefinitely(); + log.info("Email confirmation cotisation envoyé à {}", email); + } catch (Exception e) { + log.error("Échec envoi email cotisation à {}: {}", email, e.getMessage(), e); + } + } + + public void envoyerRappelCotisation(String email, String prenom, String nom, + String nomOrganisation, String periode, + BigDecimal montant, LocalDate dateLimite, + String lienPaiement) { + try { + Templates.rappelCotisation( + prenom, nom, nomOrganisation, periode, + String.format("%,.0f", montant.doubleValue()), + dateLimite != null ? DATE_FR.format(dateLimite) : "—", + lienPaiement) + .to(email) + .subject("⚠️ Rappel : cotisation " + periode + " en attente") + .send().await().indefinitely(); + log.info("Email rappel cotisation envoyé à {}", email); + } catch (Exception e) { + log.error("Échec envoi rappel cotisation à {}: {}", email, e.getMessage(), e); + } + } + + public void envoyerConfirmationSouscription(String email, String nomAdministrateur, + String nomOrganisation, String nomFormule, + BigDecimal montant, String periodicite, + LocalDate dateActivation, LocalDate dateExpiration, + Integer maxMembres, Integer maxStockageMo, + boolean apiAccess, boolean supportPrioritaire) { + try { + Templates.souscriptionConfirmation( + nomAdministrateur, nomOrganisation, nomFormule, + String.format("%,.0f", montant.doubleValue()), periodicite, + dateActivation != null ? DATE_FR.format(dateActivation) : "—", + dateExpiration != null ? DATE_FR.format(dateExpiration) : "—", + maxMembres != null ? String.valueOf(maxMembres) : "Illimité", + maxStockageMo != null ? String.valueOf(maxStockageMo) : "1024", + apiAccess, supportPrioritaire) + .to(email) + .subject("✅ Souscription activée — " + nomOrganisation) + .send().await().indefinitely(); + log.info("Email confirmation souscription envoyé à {}", email); + } catch (Exception e) { + log.error("Échec envoi email souscription à {}: {}", email, e.getMessage(), e); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/FirebasePushService.java b/src/main/java/dev/lions/unionflow/server/service/FirebasePushService.java new file mode 100644 index 0000000..0bea046 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/FirebasePushService.java @@ -0,0 +1,139 @@ +package dev.lions.unionflow.server.service; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.*; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.List; +import java.util.Optional; + +/** + * Service d'envoi de notifications push via Firebase Cloud Messaging (FCM). + * + *

Configuration requise (application-prod.properties) : + *

+ *   firebase.service-account-key-path=/opt/unionflow/firebase-service-account.json
+ * 
+ * + *

En dev/test, le service est désactivé si le fichier n'existe pas. + */ +@Slf4j +@ApplicationScoped +public class FirebasePushService { + + @ConfigProperty(name = "firebase.service-account-key-path") + Optional serviceAccountKeyPathOpt; + + String serviceAccountKeyPath; + private boolean initialized = false; + + @PostConstruct + void init() { + serviceAccountKeyPath = serviceAccountKeyPathOpt.orElse(""); + if (serviceAccountKeyPath.isBlank()) { + log.info("Firebase FCM désactivé (firebase.service-account-key-path non configuré)"); + return; + } + try { + if (FirebaseApp.getApps().isEmpty()) { + InputStream serviceAccount = new FileInputStream(serviceAccountKeyPath); + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + FirebaseApp.initializeApp(options); + } + initialized = true; + log.info("Firebase FCM initialisé depuis {}", serviceAccountKeyPath); + } catch (Exception e) { + log.warn("Firebase FCM non initialisé ({}): {} — les notifications push seront ignorées", + serviceAccountKeyPath, e.getMessage()); + } + } + + /** + * Envoie une notification push à un token FCM unique. + * + * @param token token FCM du device cible + * @param titre titre de la notification + * @param corps corps de la notification + * @param data données supplémentaires (payload JSON key/value) + * @return `true` si envoi réussi, `false` sinon + */ + public boolean envoyerNotification(String token, String titre, String corps, + java.util.Map data) { + if (!initialized || token == null || token.isBlank()) return false; + + try { + Message.Builder builder = Message.builder() + .setToken(token) + .setNotification(Notification.builder() + .setTitle(titre) + .setBody(corps) + .build()); + + if (data != null && !data.isEmpty()) { + builder.putAllData(data); + } + + String response = FirebaseMessaging.getInstance().send(builder.build()); + log.info("FCM push envoyé : messageId={}", response); + return true; + } catch (FirebaseMessagingException e) { + if (MessagingErrorCode.UNREGISTERED.equals(e.getMessagingErrorCode()) + || MessagingErrorCode.INVALID_ARGUMENT.equals(e.getMessagingErrorCode())) { + log.warn("Token FCM invalide/expiré : {}", token); + } else { + log.error("Erreur FCM pour token {}: {} ({})", token, e.getMessage(), e.getMessagingErrorCode()); + } + return false; + } + } + + /** + * Envoie une notification push à une liste de tokens (multicast, max 500). + * + * @return nombre de messages envoyés avec succès + */ + public int envoyerNotificationMulticast(List tokens, String titre, String corps, + java.util.Map data) { + if (!initialized || tokens == null || tokens.isEmpty()) return 0; + + // FCM multicast : max 500 tokens par appel + List validTokens = tokens.stream() + .filter(t -> t != null && !t.isBlank()) + .limit(500) + .toList(); + if (validTokens.isEmpty()) return 0; + + try { + MulticastMessage.Builder builder = MulticastMessage.builder() + .addAllTokens(validTokens) + .setNotification(Notification.builder() + .setTitle(titre) + .setBody(corps) + .build()); + + if (data != null && !data.isEmpty()) { + builder.putAllData(data); + } + + BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(builder.build()); + log.info("FCM multicast : {}/{} envoyés avec succès", response.getSuccessCount(), validTokens.size()); + return response.getSuccessCount(); + } catch (FirebaseMessagingException e) { + log.error("Erreur FCM multicast : {}", e.getMessage(), e); + return 0; + } + } + + public boolean isAvailable() { + return initialized; + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/KycAmlService.java b/src/main/java/dev/lions/unionflow/server/service/KycAmlService.java new file mode 100644 index 0000000..bcf2baa --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/KycAmlService.java @@ -0,0 +1,253 @@ +package dev.lions.unionflow.server.service; + +import dev.lions.unionflow.server.api.dto.kyc.KycDossierRequest; +import dev.lions.unionflow.server.api.dto.kyc.KycDossierResponse; +import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc; +import dev.lions.unionflow.server.api.enums.membre.StatutKyc; +import dev.lions.unionflow.server.entity.KycDossier; +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.repository.KycDossierRepository; +import dev.lions.unionflow.server.repository.MembreRepository; +import dev.lions.unionflow.server.security.RlsEnabled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.NotFoundException; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Service KYC/AML (Know Your Customer / Anti-Money Laundering). + * + *

Implémente la due diligence requise par le GIABA (Groupe Intergouvernemental + * d'Action contre le Blanchiment d'Argent en Afrique de l'Ouest) et les + * instructions BCEAO sur la LCB-FT (Lutte Contre le Blanchiment et le Financement + * du Terrorisme). + * + *

Algorithme de score de risque : + *

    + *
  • PEP (Personne Exposée Politiquement) : +40 points
  • + *
  • Pièce expirée : +20 points
  • + *
  • Aucun justificatif de domicile : +15 points
  • + *
  • Pièce manquante (recto/verso) : +15 points
  • + *
  • Nationalité hors UEMOA (facteur risque géographique) : +10 points
  • + *
+ */ +@Slf4j +@ApplicationScoped +@RlsEnabled +public class KycAmlService { + + private static final List PAYS_UEMOA = List.of( + "BJ", "BF", "CI", "GW", "ML", "NE", "SN", "TG" + ); + + @Inject + KycDossierRepository kycDossierRepository; + + @Inject + MembreRepository membreRepository; + + @Inject + AuditService auditService; + + /** + * Crée ou met à jour le dossier KYC d'un membre. + * Si un dossier actif existe déjà, il est archivé et remplacé. + */ + @Transactional + public KycDossierResponse soumettreOuMettreAJour(KycDossierRequest request, String operateur) { + UUID membreId = UUID.fromString(request.getMembreId()); + Membre membre = membreRepository.findByIdOptional(membreId) + .orElseThrow(() -> new NotFoundException("Membre introuvable : " + request.getMembreId())); + + // Archiver l'ancien dossier actif si présent + kycDossierRepository.findDossierActifByMembre(membreId).ifPresent(ancien -> { + ancien.setActif(false); + ancien.setModifiePar(operateur); + kycDossierRepository.persist(ancien); + }); + + KycDossier dossier = KycDossier.builder() + .membre(membre) + .typePiece(request.getTypePiece()) + .numeroPiece(request.getNumeroPiece()) + .dateExpirationPiece(request.getDateExpirationPiece()) + .pieceIdentiteRectoFileId(request.getPieceIdentiteRectoFileId()) + .pieceIdentiteVersoFileId(request.getPieceIdentiteVersoFileId()) + .justifDomicileFileId(request.getJustifDomicileFileId()) + .estPep(Boolean.TRUE.equals(request.getEstPep())) + .nationalite(request.getNationalite()) + .notesValidateur(request.getNotesValidateur()) + .statut(StatutKyc.EN_COURS) + .anneeReference(LocalDate.now().getYear()) + .build(); + + dossier.setCreePar(operateur); + kycDossierRepository.persist(dossier); + + log.info("Dossier KYC soumis pour membre {} par {}", membreId, operateur); + return toDto(dossier); + } + + /** + * Évalue le score de risque LCB-FT du membre et met à jour son dossier. + * + * @param membreId l'UUID du membre + * @return le dossier KYC mis à jour avec le score et niveau de risque + */ + @Transactional + public KycDossierResponse evaluerRisque(UUID membreId) { + KycDossier dossier = kycDossierRepository.findDossierActifByMembre(membreId) + .orElseThrow(() -> new NotFoundException("Aucun dossier KYC actif pour le membre : " + membreId)); + + int score = calculerScore(dossier); + NiveauRisqueKyc niveau = NiveauRisqueKyc.fromScore(score); + + dossier.setScoreRisque(score); + dossier.setNiveauRisque(niveau); + kycDossierRepository.persist(dossier); + + if (niveau == NiveauRisqueKyc.CRITIQUE || niveau == NiveauRisqueKyc.ELEVE) { + log.warn("Membre {} : niveau risque KYC {} (score {})", membreId, niveau, score); + auditService.logKycRisqueEleve(membreId, score, niveau.name()); + } + + return toDto(dossier); + } + + /** + * Valide manuellement un dossier KYC (approbation par un agent habilité). + */ + @Transactional + public KycDossierResponse valider(UUID dossierId, UUID validateurId, String notes, String operateur) { + KycDossier dossier = kycDossierRepository.findByIdOptional(dossierId) + .orElseThrow(() -> new NotFoundException("Dossier KYC introuvable : " + dossierId)); + + int score = calculerScore(dossier); + dossier.setScoreRisque(score); + dossier.setNiveauRisque(NiveauRisqueKyc.fromScore(score)); + dossier.setStatut(StatutKyc.VERIFIE); + dossier.setDateVerification(LocalDateTime.now()); + dossier.setValidateurId(validateurId); + dossier.setNotesValidateur(notes); + dossier.setModifiePar(operateur); + + kycDossierRepository.persist(dossier); + log.info("Dossier KYC {} validé par {} (score={})", dossierId, validateurId, score); + return toDto(dossier); + } + + /** + * Refuse un dossier KYC avec motif. + */ + @Transactional + public KycDossierResponse refuser(UUID dossierId, UUID validateurId, String motif, String operateur) { + KycDossier dossier = kycDossierRepository.findByIdOptional(dossierId) + .orElseThrow(() -> new NotFoundException("Dossier KYC introuvable : " + dossierId)); + + dossier.setStatut(StatutKyc.REFUSE); + dossier.setDateVerification(LocalDateTime.now()); + dossier.setValidateurId(validateurId); + dossier.setNotesValidateur(motif); + dossier.setModifiePar(operateur); + + kycDossierRepository.persist(dossier); + log.info("Dossier KYC {} refusé par {}: {}", dossierId, validateurId, motif); + return toDto(dossier); + } + + public Optional getDossierActif(UUID membreId) { + return kycDossierRepository.findDossierActifByMembre(membreId).map(this::toDto); + } + + public List getDossiersEnAttente() { + return kycDossierRepository.findByStatut(StatutKyc.EN_COURS) + .stream().map(this::toDto).collect(Collectors.toList()); + } + + public List getDossiersPep() { + return kycDossierRepository.findPep() + .stream().map(this::toDto).collect(Collectors.toList()); + } + + public List getPiecesExpirantDansLes30Jours() { + LocalDate limite = LocalDate.now().plusDays(30); + return kycDossierRepository.findPiecesExpirantsAvant(limite) + .stream().map(this::toDto).collect(Collectors.toList()); + } + + // ── Calcul score ──────────────────────────────────────────────────────────── + + int calculerScore(KycDossier dossier) { + int score = 0; + + if (dossier.isEstPep()) { + score += 40; + } + + if (dossier.isPieceExpiree()) { + score += 20; + } + + if (dossier.getJustifDomicileFileId() == null || dossier.getJustifDomicileFileId().isBlank()) { + score += 15; + } + + boolean rectoManquant = dossier.getPieceIdentiteRectoFileId() == null + || dossier.getPieceIdentiteRectoFileId().isBlank(); + boolean versoManquant = dossier.getPieceIdentiteVersoFileId() == null + || dossier.getPieceIdentiteVersoFileId().isBlank(); + if (rectoManquant || versoManquant) { + score += 15; + } + + if (dossier.getNationalite() != null && !PAYS_UEMOA.contains(dossier.getNationalite().toUpperCase())) { + score += 10; + } + + return Math.min(score, 100); + } + + // ── Mapping ───────────────────────────────────────────────────────────────── + + private KycDossierResponse toDto(KycDossier d) { + KycDossierResponse dto = new KycDossierResponse(); + dto.setId(d.getId()); + dto.setDateCreation(d.getDateCreation()); + dto.setDateModification(d.getDateModification()); + dto.setCreePar(d.getCreePar()); + dto.setModifiePar(d.getModifiePar()); + dto.setVersion(d.getVersion()); + dto.setActif(d.getActif()); + + if (d.getMembre() != null) { + dto.setMembreId(d.getMembre().getId()); + dto.setMembreNomComplet(d.getMembre().getPrenom() + " " + d.getMembre().getNom()); + dto.setMembreEmail(d.getMembre().getEmail()); + } + + dto.setTypePiece(d.getTypePiece()); + dto.setNumeroPiece(d.getNumeroPiece()); + dto.setDateExpirationPiece(d.getDateExpirationPiece()); + dto.setPieceIdentiteRectoFileId(d.getPieceIdentiteRectoFileId()); + dto.setPieceIdentiteVersoFileId(d.getPieceIdentiteVersoFileId()); + dto.setJustifDomicileFileId(d.getJustifDomicileFileId()); + dto.setStatut(d.getStatut()); + dto.setNiveauRisque(d.getNiveauRisque()); + dto.setScoreRisque(d.getScoreRisque()); + dto.setEstPep(d.isEstPep()); + dto.setNationalite(d.getNationalite()); + dto.setDateVerification(d.getDateVerification()); + dto.setValidateurId(d.getValidateurId()); + dto.setNotesValidateur(d.getNotesValidateur()); + dto.setAnneeReference(d.getAnneeReference()); + return dto; + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java b/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java index e095a9e..e58ba8b 100644 --- a/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java +++ b/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java @@ -89,6 +89,9 @@ public class MembreKeycloakSyncService { @RestClient AdminRoleServiceClient roleServiceClient; + @Inject + EmailTemplateService emailTemplateService; + /** * Provisionne un compte Keycloak pour un Membre existant qui n'en a pas encore. * @@ -193,20 +196,37 @@ public class MembreKeycloakSyncService { * @param membreId UUID du membre à activer dans Keycloak * @throws NotFoundException si le membre n'existe pas en base */ - @Transactional + @Transactional(jakarta.transaction.Transactional.TxType.REQUIRES_NEW) public void activerMembreDansKeycloak(java.util.UUID membreId) { LOGGER.info("Activation Keycloak (rôle MEMBRE_ACTIF) pour Membre ID: " + membreId); Membre membre = membreRepository.findByIdOptional(membreId) .orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + membreId)); - // Provisionner le compte Keycloak s'il n'existe pas encore + // Lier le compte Keycloak si absent : chercher par email avant de tenter un provisionnement if (membre.getKeycloakId() == null) { - LOGGER.info("Compte Keycloak absent — provisionnement automatique pour " + membre.getNomComplet()); - provisionKeycloakUser(membreId); - // Recharger après persist dans provisionKeycloakUser + try { + UserSearchCriteriaDTO criteria = new UserSearchCriteriaDTO(); + criteria.setEmail(membre.getEmail()); + criteria.setRealmName(DEFAULT_REALM); + criteria.setPageSize(1); + var result = userServiceClient.searchUsers(criteria); + if (result != null && result.getUsers() != null && !result.getUsers().isEmpty()) { + String kcId = result.getUsers().get(0).getId(); + membre.setKeycloakId(UUID.fromString(kcId)); + membreRepository.persist(membre); + LOGGER.info("Compte Keycloak existant lié pour " + membre.getEmail() + " → " + kcId); + } else { + LOGGER.info("Compte Keycloak absent — provisionnement pour " + membre.getNomComplet()); + provisionKeycloakUser(membreId); + } + } catch (Exception e) { + LOGGER.warning("Recherche Keycloak par email échouée, tentative provisionnement : " + e.getMessage()); + provisionKeycloakUser(membreId); + } + // Recharger après liaison/provisionnement membre = membreRepository.findByIdOptional(membreId) - .orElseThrow(() -> new NotFoundException("Membre non trouvé après provisionnement: " + membreId)); + .orElseThrow(() -> new NotFoundException("Membre non trouvé après liaison Keycloak: " + membreId)); } String keycloakUserId = membre.getKeycloakId().toString(); @@ -247,19 +267,36 @@ public class MembreKeycloakSyncService { * @param membreId UUID du membre à promouvoir dans Keycloak * @throws NotFoundException si le membre n'existe pas en base */ - @Transactional + @Transactional(jakarta.transaction.Transactional.TxType.REQUIRES_NEW) public void promouvoirAdminOrganisationDansKeycloak(java.util.UUID membreId) { LOGGER.info("Promotion Keycloak (rôle ADMIN_ORGANISATION) pour Membre ID: " + membreId); Membre membre = membreRepository.findByIdOptional(membreId) .orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + membreId)); - // Provisionner le compte Keycloak s'il n'existe pas encore + // Lier le compte Keycloak si absent : chercher par email avant de tenter un provisionnement if (membre.getKeycloakId() == null) { - LOGGER.info("Compte Keycloak absent — provisionnement automatique pour " + membre.getNomComplet()); - provisionKeycloakUser(membreId); + try { + UserSearchCriteriaDTO criteria = new UserSearchCriteriaDTO(); + criteria.setEmail(membre.getEmail()); + criteria.setRealmName(DEFAULT_REALM); + criteria.setPageSize(1); + var result = userServiceClient.searchUsers(criteria); + if (result != null && result.getUsers() != null && !result.getUsers().isEmpty()) { + String kcId = result.getUsers().get(0).getId(); + membre.setKeycloakId(UUID.fromString(kcId)); + membreRepository.persist(membre); + LOGGER.info("Compte Keycloak existant lié pour " + membre.getEmail() + " → " + kcId); + } else { + LOGGER.info("Compte Keycloak absent — provisionnement pour " + membre.getNomComplet()); + provisionKeycloakUser(membreId); + } + } catch (Exception e) { + LOGGER.warning("Recherche Keycloak par email échouée, tentative provisionnement : " + e.getMessage()); + provisionKeycloakUser(membreId); + } membre = membreRepository.findByIdOptional(membreId) - .orElseThrow(() -> new NotFoundException("Membre non trouvé après provisionnement: " + membreId)); + .orElseThrow(() -> new NotFoundException("Membre non trouvé après liaison Keycloak: " + membreId)); } String keycloakUserId = membre.getKeycloakId().toString(); @@ -735,6 +772,28 @@ public class MembreKeycloakSyncService { } } LOGGER.info("Premier login complété pour : " + membre.getEmail()); + + // Email de bienvenue (non bloquant) + if (doitActiver && membre.getEmail() != null) { + try { + String orgNom = ""; + try { + var memberships = membre.getMembresOrganisations(); + if (memberships != null && !memberships.isEmpty()) { + orgNom = memberships.iterator().next().getOrganisation().getNom(); + } + } catch (Exception ignore) {} + emailTemplateService.envoyerBienvenue( + membre.getEmail(), + membre.getPrenom() != null ? membre.getPrenom() : "", + membre.getNom() != null ? membre.getNom() : "", + orgNom, + null); + } catch (Exception e) { + LOGGER.warning("Email bienvenue ignoré (non bloquant) : " + e.getMessage()); + } + } + return PremierLoginResultat.COMPLETE; } diff --git a/src/main/java/dev/lions/unionflow/server/service/MembreService.java b/src/main/java/dev/lions/unionflow/server/service/MembreService.java index a09ec4b..e5dcd00 100644 --- a/src/main/java/dev/lions/unionflow/server/service/MembreService.java +++ b/src/main/java/dev/lions/unionflow/server/service/MembreService.java @@ -1283,6 +1283,25 @@ public class MembreService { .getSingleResult() > 0; } + /** + * Vérifie si une organisation a reçu un paiement (confirmé ou validé). + * Utilisé pour auto-activer l'admin dès que le paiement est reçu, + * sans attendre la validation super admin. + * + * @param orgId UUID de l'organisation + * @return true si la souscription est ACTIVE ou en PAIEMENT_CONFIRME/VALIDEE + */ + public boolean orgHasPaidSubscription(UUID orgId) { + if (orgId == null) return false; + return entityManager.createQuery( + "SELECT COUNT(s) FROM SouscriptionOrganisation s " + + "WHERE s.organisation.id = :orgId " + + "AND (s.statut = 'ACTIVE' OR s.statutValidation IN ('PAIEMENT_CONFIRME', 'VALIDEE'))", + Long.class) + .setParameter("orgId", orgId) + .getSingleResult() > 0; + } + /** * Lie un membre à une organisation et incrémente le quota de la souscription. * Utilisé lors de la création unitaire ou de l'import massif. diff --git a/src/main/java/dev/lions/unionflow/server/service/MigrerOrganisationsVersKeycloakService.java b/src/main/java/dev/lions/unionflow/server/service/MigrerOrganisationsVersKeycloakService.java new file mode 100644 index 0000000..46dc511 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/MigrerOrganisationsVersKeycloakService.java @@ -0,0 +1,348 @@ +package dev.lions.unionflow.server.service; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.lions.unionflow.server.entity.MembreOrganisation; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.repository.MembreOrganisationRepository; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.text.Normalizer; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * Service de migration one-shot : crée les Keycloak 26 Organizations correspondant + * à chaque Organisation UnionFlow, assigne les rôles standards et migre les memberships. + * + *

Idempotent : si {@code keycloak_org_id} est déjà renseigné pour une org, + * elle est ignorée (pas de doublon). + * + *

Déclenchement : endpoint admin {@code POST /api/admin/keycloak/migrer-organisations}. + */ +@Slf4j +@ApplicationScoped +public class MigrerOrganisationsVersKeycloakService { + + /** Rôles Organization standards créés dans chaque Keycloak Organization. */ + private static final List ROLES_STANDARDS = List.of( + "ADMIN_ORGANISATION", "TRESORIER", "SECRETAIRE", + "COMMISSAIRE_COMPTES", "MEMBRE_ACTIF" + ); + + @ConfigProperty(name = "keycloak.admin.url", defaultValue = "http://localhost:8180") + String keycloakUrl; + + @ConfigProperty(name = "keycloak.admin.username", defaultValue = "admin") + String adminUsername; + + @ConfigProperty(name = "keycloak.admin.password", defaultValue = "admin") + String adminPassword; + + @ConfigProperty(name = "keycloak.admin.realm", defaultValue = "unionflow") + String realm; + + @Inject + OrganisationRepository organisationRepository; + + @Inject + MembreOrganisationRepository membreOrganisationRepository; + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private final ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + /** + * Point d'entrée principal — migre toutes les organisations sans {@code keycloak_org_id}. + * + * @return rapport de migration + */ + @Transactional + public MigrationReport migrerToutesLesOrganisations() throws Exception { + String token = getAdminToken(); + List orgs = organisationRepository.listAll(); + + int crees = 0, ignores = 0, erreurs = 0; + + for (Organisation org : orgs) { + if (org.getKeycloakOrgId() != null) { + ignores++; + log.debug("Org '{}' déjà migrée (kcOrgId={}), ignorée.", org.getNom(), org.getKeycloakOrgId()); + continue; + } + + try { + UUID kcOrgId = creerOrganisationKeycloak(token, org); + creerRolesOrganisation(token, kcOrgId); + migrerMemberships(token, kcOrgId, org); + + org.setKeycloakOrgId(kcOrgId); + organisationRepository.persist(org); + crees++; + log.info("Organisation '{}' migrée → keycloak_org_id={}", org.getNom(), kcOrgId); + + } catch (Exception e) { + erreurs++; + log.error("Échec migration org '{}' (id={}): {}", org.getNom(), org.getId(), e.getMessage(), e); + } + } + + return new MigrationReport(orgs.size(), crees, ignores, erreurs); + } + + // ── Création Organization Keycloak ────────────────────────────────────────── + + private UUID creerOrganisationKeycloak(String token, Organisation org) throws Exception { + ObjectNode body = mapper.createObjectNode(); + body.put("name", org.getNom()); + body.put("alias", slugify(org.getNom())); + body.put("enabled", true); + + ObjectNode attrs = mapper.createObjectNode(); + attrs.putArray("unionflow_id").add(org.getId().toString()); + attrs.putArray("type_organisation").add(org.getTypeOrganisation() != null ? org.getTypeOrganisation() : ""); + if (org.getCategorieType() != null) { + attrs.putArray("categorie").add(org.getCategorieType()); + } + body.set("attributes", attrs); + + // Ajouter le domaine email si disponible + if (org.getEmail() != null) { + String domaine = org.getEmail().contains("@") ? org.getEmail().split("@")[1] : ""; + if (!domaine.isBlank()) { + ArrayNode domains = mapper.createArrayNode(); + ObjectNode domainObj = mapper.createObjectNode(); + domainObj.put("name", domaine); + domainObj.put("verified", false); + domains.add(domainObj); + body.set("domains", domains); + } + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(keycloakUrl + "/admin/realms/" + realm + "/organizations")) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body))) + .timeout(Duration.ofSeconds(10)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + // 201 Created → Location header contient l'URL avec l'ID + if (response.statusCode() == 201) { + String location = response.headers().firstValue("Location").orElseThrow( + () -> new RuntimeException("Keycloak 201 mais sans header Location pour org: " + org.getNom())); + String kcOrgId = location.substring(location.lastIndexOf('/') + 1); + return UUID.fromString(kcOrgId); + } + + // 409 = alias déjà pris → chercher l'org existante par alias + if (response.statusCode() == 409) { + log.warn("Organisation '{}' déjà présente dans Keycloak (409), recherche par alias.", org.getNom()); + return chercherOrganisationParAlias(token, slugify(org.getNom())); + } + + throw new RuntimeException("Échec création Keycloak Org '" + org.getNom() + + "' (HTTP " + response.statusCode() + "): " + response.body()); + } + + private UUID chercherOrganisationParAlias(String token, String alias) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(keycloakUrl + "/admin/realms/" + realm + "/organizations?search=" + alias + "&max=10")) + .header("Authorization", "Bearer " + token) + .GET() + .timeout(Duration.ofSeconds(10)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new RuntimeException("Impossible de rechercher l'org par alias '" + alias + "'"); + } + + var results = mapper.readTree(response.body()); + for (var node : results) { + if (alias.equals(node.path("alias").asText())) { + return UUID.fromString(node.path("id").asText()); + } + } + throw new RuntimeException("Organisation avec alias '" + alias + "' introuvable dans Keycloak."); + } + + // ── Création rôles standards ──────────────────────────────────────────────── + + private void creerRolesOrganisation(String token, UUID kcOrgId) throws Exception { + for (String roleName : ROLES_STANDARDS) { + ObjectNode roleBody = mapper.createObjectNode(); + roleBody.put("name", roleName); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(keycloakUrl + "/admin/realms/" + realm + + "/organizations/" + kcOrgId + "/roles")) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(roleBody))) + .timeout(Duration.ofSeconds(10)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 201 && response.statusCode() != 409) { + log.warn("Impossible de créer le rôle '{}' pour kcOrgId={} (HTTP {}): {}", + roleName, kcOrgId, response.statusCode(), response.body()); + } + } + } + + // ── Migration memberships ─────────────────────────────────────────────────── + + private void migrerMemberships(String token, UUID kcOrgId, Organisation org) { + List memberships = membreOrganisationRepository + .find("organisation.id = ?1 AND actif = true", org.getId()) + .list(); + + for (MembreOrganisation mo : memberships) { + if (mo.getMembre() == null || mo.getMembre().getKeycloakId() == null) { + continue; + } + + String kcUserId = mo.getMembre().getKeycloakId().toString(); + try { + ajouterMembreKeycloakOrg(token, kcOrgId, kcUserId); + + if (mo.getRoleOrg() != null && ROLES_STANDARDS.contains(mo.getRoleOrg())) { + assignerRoleOrganisation(token, kcOrgId, kcUserId, mo.getRoleOrg()); + } + } catch (Exception e) { + log.warn("Impossible de migrer le membership keycloakId={} → kcOrg={}: {}", + kcUserId, kcOrgId, e.getMessage()); + } + } + } + + private void ajouterMembreKeycloakOrg(String token, UUID kcOrgId, String kcUserId) throws Exception { + ObjectNode body = mapper.createObjectNode(); + body.put("id", kcUserId); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(keycloakUrl + "/admin/realms/" + realm + + "/organizations/" + kcOrgId + "/members")) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body))) + .timeout(Duration.ofSeconds(10)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 201 && response.statusCode() != 409) { + throw new RuntimeException("HTTP " + response.statusCode() + ": " + response.body()); + } + } + + private void assignerRoleOrganisation(String token, UUID kcOrgId, String kcUserId, + String roleName) throws Exception { + // 1. Récupérer l'ID du rôle + HttpRequest getRoles = HttpRequest.newBuilder() + .uri(URI.create(keycloakUrl + "/admin/realms/" + realm + + "/organizations/" + kcOrgId + "/roles")) + .header("Authorization", "Bearer " + token) + .GET() + .timeout(Duration.ofSeconds(10)) + .build(); + + HttpResponse rolesResponse = httpClient.send(getRoles, HttpResponse.BodyHandlers.ofString()); + if (rolesResponse.statusCode() != 200) return; + + var roles = mapper.readTree(rolesResponse.body()); + String roleId = null; + for (var role : roles) { + if (roleName.equals(role.path("name").asText())) { + roleId = role.path("id").asText(); + break; + } + } + if (roleId == null) return; + + // 2. Assigner le rôle au membre + ArrayNode assignBody = mapper.createArrayNode(); + ObjectNode roleRef = mapper.createObjectNode(); + roleRef.put("id", roleId); + roleRef.put("name", roleName); + assignBody.add(roleRef); + + HttpRequest assignRequest = HttpRequest.newBuilder() + .uri(URI.create(keycloakUrl + "/admin/realms/" + realm + + "/organizations/" + kcOrgId + "/members/" + kcUserId + "/roles")) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(assignBody))) + .timeout(Duration.ofSeconds(10)) + .build(); + + HttpResponse assignResponse = httpClient.send(assignRequest, HttpResponse.BodyHandlers.ofString()); + if (assignResponse.statusCode() != 201 && assignResponse.statusCode() != 204) { + log.warn("Impossible d'assigner le rôle '{}' à l'utilisateur {} (HTTP {})", + roleName, kcUserId, assignResponse.statusCode()); + } + } + + // ── Auth admin ────────────────────────────────────────────────────────────── + + private String getAdminToken() throws Exception { + String body = "client_id=admin-cli" + + "&username=" + java.net.URLEncoder.encode(adminUsername, java.nio.charset.StandardCharsets.UTF_8) + + "&password=" + java.net.URLEncoder.encode(adminPassword, java.nio.charset.StandardCharsets.UTF_8) + + "&grant_type=password"; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(keycloakUrl + "/realms/master/protocol/openid-connect/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .timeout(Duration.ofSeconds(10)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new RuntimeException("Échec auth admin Keycloak (HTTP " + response.statusCode() + ")"); + } + + return mapper.readTree(response.body()).get("access_token").asText(); + } + + // ── Utilitaires ───────────────────────────────────────────────────────────── + + private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^a-z0-9-]"); + private static final Pattern MULTIPLE_DASHES = Pattern.compile("-{2,}"); + + static String slugify(String input) { + if (input == null) return "org-" + UUID.randomUUID().toString().substring(0, 8); + String normalized = Normalizer.normalize(input.toLowerCase(), Normalizer.Form.NFD) + .replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); + String slug = NON_ALPHANUMERIC.matcher(normalized.replace(' ', '-')).replaceAll(""); + slug = MULTIPLE_DASHES.matcher(slug).replaceAll("-").replaceAll("^-|-$", ""); + return slug.isBlank() ? "org-" + UUID.randomUUID().toString().substring(0, 8) : slug; + } + + // ── Rapport ───────────────────────────────────────────────────────────────── + + public record MigrationReport(int total, int crees, int ignores, int erreurs) { + public boolean success() { + return erreurs == 0; + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/NotificationService.java b/src/main/java/dev/lions/unionflow/server/service/NotificationService.java index 766eef3..0e40086 100644 --- a/src/main/java/dev/lions/unionflow/server/service/NotificationService.java +++ b/src/main/java/dev/lions/unionflow/server/service/NotificationService.java @@ -51,6 +51,9 @@ public class NotificationService { @Inject KeycloakService keycloakService; + @Inject + FirebasePushService firebasePushService; + /** * Crée un nouveau template de notification * @@ -91,14 +94,19 @@ public class NotificationService { notificationRepository.persist(notification); LOG.infof("Notification créée avec succès: ID=%s", notification.getId()); - // Envoi immédiat si type EMAIL + // Envoi immédiat selon le canal if ("EMAIL".equals(notification.getTypeNotification())) { try { envoyerEmail(notification); } catch (Exception e) { LOG.errorf("Erreur lors de l'envoi de l'email pour la notification %s: %s", notification.getId(), e.getMessage()); - // On ne relance pas l'exception pour ne pas bloquer la transaction de création + } + } else if ("PUSH".equals(notification.getTypeNotification())) { + try { + envoyerPush(notification); + } catch (Exception e) { + LOG.warnf("Erreur push notification %s (non bloquant): %s", notification.getId(), e.getMessage()); } } @@ -381,6 +389,38 @@ public class NotificationService { return notification; } + /** + * Envoie une notification push FCM pour une notification. + */ + private void envoyerPush(Notification notification) { + if (notification.getMembre() == null) { + LOG.warnf("Impossible d'envoyer le push pour la notification %s : pas de membre", notification.getId()); + notification.setStatut("ECHEC_ENVOI"); + notification.setMessageErreur("Pas de membre défini"); + return; + } + String fcmToken = notification.getMembre().getFcmToken(); + if (fcmToken == null || fcmToken.isBlank()) { + LOG.debugf("Membre %s sans token FCM — push ignoré", notification.getMembre().getId()); + notification.setStatut("IGNOREE"); + notification.setMessageErreur("Pas de token FCM"); + return; + } + boolean ok = firebasePushService.envoyerNotification( + fcmToken, + notification.getSujet(), + notification.getCorps(), + java.util.Map.of("notificationId", notification.getId().toString())); + if (ok) { + notification.setStatut("ENVOYEE"); + notification.setDateEnvoi(java.time.LocalDateTime.now()); + } else { + notification.setStatut("ECHEC_ENVOI"); + notification.setMessageErreur("FCM: envoi échoué"); + } + notificationRepository.persist(notification); + } + /** * Envoie un email pour une notification */ @@ -394,9 +434,12 @@ public class NotificationService { try { LOG.infof("Envoi de l'email à %s", notification.getMembre().getEmail()); - mailer.send(Mail.withText(notification.getMembre().getEmail(), - notification.getSujet(), - notification.getCorps())); // TODO: Support HTML body if needed + String corps = notification.getCorps(); + boolean isHtml = corps != null && (corps.startsWith(" mo.getOrganisation().getId()) + .ifPresent(orgId -> { + List tresorierIds = membreOrganisationRepository + .findByRoleOrgAndOrganisationId("TRESORIER", orgId) + .stream() + .map(mo -> mo.getMembre().getId()) + .collect(Collectors.toList()); + if (!tresorierIds.isEmpty()) { + notificationService.envoyerNotificationsGroupees( + tresorierIds, + "Validation paiement manuel requis", + "Le membre " + membreConnecte.getNumeroMembre() + + " a déclaré un paiement manuel (" + paiement.getNumeroReference() + + ") à valider.", + List.of("IN_APP")); + } + }); + } catch (Exception e) { + LOG.warnf("Erreur notification trésorier pour paiement %s (non bloquant): %s", + paiement.getNumeroReference(), e.getMessage()); + } LOG.infof("Paiement manuel déclaré avec succès: ID=%s, Référence=%s (EN_ATTENTE_VALIDATION)", paiement.getId(), paiement.getNumeroReference()); @@ -586,6 +611,39 @@ public class PaiementService { .build(); } + // ── Webhook multi-provider ──────────────────────────────────────────────── + + /** + * Met à jour le statut d'un paiement depuis un événement webhook normalisé. + * Appelé par PaymentOrchestrator.handleEvent() — aucun contexte utilisateur requis. + */ + @Transactional + public void mettreAJourStatutDepuisWebhook(PaymentEvent event) { + Optional opt = paiementRepository.findByNumeroReference(event.reference()); + if (opt.isEmpty()) { + LOG.warnf("Webhook reçu pour référence inconnue : %s (provider externalId=%s)", + event.reference(), event.externalId()); + return; + } + Paiement paiement = opt.get(); + PaymentStatus status = event.status(); + + if (PaymentStatus.SUCCESS.equals(status)) { + paiement.setStatutPaiement("PAIEMENT_CONFIRME"); + paiement.setDateValidation(LocalDateTime.now()); + paiement.setReferenceExterne(event.externalId()); + } else if (PaymentStatus.FAILED.equals(status) || PaymentStatus.CANCELLED.equals(status) + || PaymentStatus.EXPIRED.equals(status)) { + paiement.setStatutPaiement("ANNULE"); + paiement.setReferenceExterne(event.externalId()); + } + // INITIATED / PROCESSING : aucun changement de statut requis + + paiementRepository.persist(paiement); + LOG.infof("Statut paiement mis à jour via webhook : ref=%s statut=%s → %s", + event.reference(), status, paiement.getStatutPaiement()); + } + // ======================================== // MÉTHODES PRIVÉES // ======================================== diff --git a/src/main/java/dev/lions/unionflow/server/service/SouscriptionService.java b/src/main/java/dev/lions/unionflow/server/service/SouscriptionService.java index a56ff17..51c68e7 100644 --- a/src/main/java/dev/lions/unionflow/server/service/SouscriptionService.java +++ b/src/main/java/dev/lions/unionflow/server/service/SouscriptionService.java @@ -88,6 +88,9 @@ public class SouscriptionService { @Inject MembreKeycloakSyncService keycloakSyncService; + @Inject + EmailTemplateService emailTemplateService; + // ── Catalogue ───────────────────────────────────────────────────────────── /** @@ -302,6 +305,9 @@ public class SouscriptionService { } catch (Exception e) { LOG.errorf("Activation compte échouée après paiement souscription=%s: %s — la souscription reste VALIDEE", souscriptionId, e.getMessage()); } + + // Email de confirmation de souscription (non bloquant) + envoyerEmailSouscriptionActive(souscription, dateDebut, dateFin); } // ── Validation SuperAdmin ────────────────────────────────────────────────── @@ -399,6 +405,9 @@ public class SouscriptionService { // Activer le membre admin de l'organisation activerAdminOrganisation(souscription.getOrganisation().getId()); + // Email de confirmation de souscription (non bloquant) + envoyerEmailSouscriptionActive(souscription, dateDebut, dateFin); + LOG.infof("Souscription %s approuvée — compte actif jusqu'au %s", souscriptionId, dateFin); } @@ -615,6 +624,34 @@ public class SouscriptionService { } } + // ── Email notifications ─────────────────────────────────────────────────── + + private void envoyerEmailSouscriptionActive(SouscriptionOrganisation s, + LocalDate dateDebut, LocalDate dateFin) { + try { + String email = securiteHelper.resolveEmail(); + if (email == null) return; + Membre admin = membreRepository.findByEmail(email).orElse(null); + if (admin == null || admin.getEmail() == null) return; + + FormuleAbonnement f = s.getFormule(); + emailTemplateService.envoyerConfirmationSouscription( + admin.getEmail(), + (admin.getPrenom() != null ? admin.getPrenom() : "") + " " + (admin.getNom() != null ? admin.getNom() : ""), + s.getOrganisation() != null ? s.getOrganisation().getNom() : "", + f != null && f.getLibelle() != null ? f.getLibelle() : "", + s.getMontantTotal() != null ? s.getMontantTotal() : BigDecimal.ZERO, + s.getTypePeriode() != null ? s.getTypePeriode().name() : "MENSUEL", + dateDebut, dateFin, + f != null ? f.getMaxMembres() : null, + f != null ? f.getMaxStockageMo() : null, + f != null && Boolean.TRUE.equals(f.getApiAccess()), + f != null && Boolean.TRUE.equals(f.getSupportPrioritaire())); + } catch (Exception e) { + LOG.warnf("Email souscription ignoré (non bloquant) : %s", e.getMessage()); + } + } + // ── Matrice tarifaire de référence ──────────────────────────────────────── /** diff --git a/src/main/java/dev/lions/unionflow/server/service/mutuelle/InteretsEpargneService.java b/src/main/java/dev/lions/unionflow/server/service/mutuelle/InteretsEpargneService.java new file mode 100644 index 0000000..520dc90 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/mutuelle/InteretsEpargneService.java @@ -0,0 +1,207 @@ +package dev.lions.unionflow.server.service.mutuelle; + +import dev.lions.unionflow.server.api.enums.mutuelle.epargne.StatutCompteEpargne; +import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeTransactionEpargne; +import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales; +import dev.lions.unionflow.server.api.enums.wave.StatutTransactionWave; +import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle; +import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne; +import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne; +import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales; +import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales; +import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository; +import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository; +import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository; +import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository; +import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository; +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import org.jboss.logging.Logger; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Calcul automatique des intérêts sur épargne et des dividendes sur parts sociales. + * + *

Le scheduler tourne chaque jour à 02:00 et vérifie si un calcul est dû + * selon la périodicité configurée par organisation. + */ +@ApplicationScoped +public class InteretsEpargneService { + + private static final Logger LOG = Logger.getLogger(InteretsEpargneService.class); + + @Inject ParametresFinanciersMutuellRepository parametresRepo; + @Inject CompteEpargneRepository compteEpargneRepository; + @Inject TransactionEpargneRepository transactionEpargneRepository; + @Inject ComptePartsSocialesRepository comptePartsSocialesRepository; + @Inject TransactionPartsSocialesRepository transactionPartsSocialesRepository; + + /** + * Scheduler quotidien — calcule les intérêts pour toutes les organisations dont + * la date prochaine_calcul_interets est aujourd'hui ou dans le passé. + */ + @Scheduled(cron = "0 0 2 * * ?") + @Transactional + public void calculerInteretsScheduled() { + LocalDate aujourd_hui = LocalDate.now(); + List tous = parametresRepo.listAll(); + for (ParametresFinanciersMutuelle params : tous) { + if (params.getProchaineCalculInterets() != null + && !params.getProchaineCalculInterets().isAfter(aujourd_hui)) { + try { + calculerInteretsPourOrg(params); + } catch (Exception e) { + LOG.errorf("Erreur calcul intérêts org %s: %s", + params.getOrganisation().getId(), e.getMessage()); + } + } + } + } + + /** + * Déclenchement manuel par un admin pour une organisation donnée. + * @return résumé : nombre de comptes traités, montant total crédité + */ + @Transactional + public Map calculerManuellement(UUID orgId) { + ParametresFinanciersMutuelle params = parametresRepo.findByOrganisation(orgId) + .orElseThrow(() -> new IllegalArgumentException( + "Aucun paramètre financier configuré pour cette organisation. " + + "Créez d'abord les paramètres via POST /api/v1/mutuelle/parametres-financiers.")); + + return calculerInteretsPourOrg(params); + } + + // ─── Internal ───────────────────────────────────────────────────────────── + + Map calculerInteretsPourOrg(ParametresFinanciersMutuelle params) { + UUID orgId = params.getOrganisation().getId(); + LOG.infof("Calcul intérêts org %s — taux épargne=%.4f, taux parts=%.4f", + orgId, params.getTauxInteretAnnuelEpargne(), params.getTauxDividendePartsAnnuel()); + + int nbEpargne = calculerInteretsEpargne(params, orgId); + int nbParts = calculerDividendesParts(params, orgId); + + // Mise à jour des dates + params.setDernierCalculInterets(LocalDate.now()); + params.setDernierNbComptesTraites(nbEpargne + nbParts); + params.setProchaineCalculInterets(prochaineDateCalcul(params)); + + LOG.infof("Calcul terminé org %s — %d comptes épargne, %d comptes parts", orgId, nbEpargne, nbParts); + return Map.of( + "organisationId", orgId.toString(), + "comptesEpargneTraites", nbEpargne, + "comptesPartsTraites", nbParts, + "dateCalcul", LocalDate.now().toString(), + "prochaineCalcul", params.getProchaineCalculInterets().toString() + ); + } + + private int calculerInteretsEpargne(ParametresFinanciersMutuelle params, UUID orgId) { + if (params.getTauxInteretAnnuelEpargne().compareTo(BigDecimal.ZERO) == 0) return 0; + + List comptes = compteEpargneRepository + .find("organisation.id = ?1 AND statut = ?2 AND actif = true", + orgId, StatutCompteEpargne.ACTIF) + .list(); + + BigDecimal tauxPeriodique = calculerTauxPeriodique( + params.getTauxInteretAnnuelEpargne(), params.getPeriodiciteCalcul()); + BigDecimal seuil = params.getSeuilMinEpargneInterets() != null + ? params.getSeuilMinEpargneInterets() : BigDecimal.ZERO; + + int count = 0; + for (CompteEpargne compte : comptes) { + BigDecimal solde = compte.getSoldeActuel().subtract(compte.getSoldeBloque()); + if (solde.compareTo(seuil) <= 0) continue; + + BigDecimal interets = solde.multiply(tauxPeriodique).setScale(0, RoundingMode.HALF_UP); + if (interets.compareTo(BigDecimal.ZERO) <= 0) continue; + + compte.setSoldeActuel(compte.getSoldeActuel().add(interets)); + compte.setDateDerniereTransaction(LocalDate.now()); + + TransactionEpargne tx = TransactionEpargne.builder() + .compte(compte) + .type(TypeTransactionEpargne.PAIEMENT_INTERETS) + .montant(interets) + .soldeAvant(solde) + .soldeApres(compte.getSoldeActuel()) + .motif("Intérêts " + params.getPeriodiciteCalcul().toLowerCase() + + " — taux " + params.getTauxInteretAnnuelEpargne() + .multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP) + "%/an") + .dateTransaction(LocalDateTime.now()) + .statutExecution(StatutTransactionWave.REUSSIE) + .origineFonds("Calcul automatique intérêts") + .build(); + transactionEpargneRepository.persist(tx); + count++; + } + return count; + } + + private int calculerDividendesParts(ParametresFinanciersMutuelle params, UUID orgId) { + if (params.getTauxDividendePartsAnnuel().compareTo(BigDecimal.ZERO) == 0) return 0; + + List comptes = comptePartsSocialesRepository.findByOrganisation(orgId); + BigDecimal tauxPeriodique = calculerTauxPeriodique( + params.getTauxDividendePartsAnnuel(), params.getPeriodiciteCalcul()); + + int count = 0; + for (ComptePartsSociales compte : comptes) { + if (compte.getMontantTotal().compareTo(BigDecimal.ZERO) <= 0) continue; + + BigDecimal dividende = compte.getMontantTotal() + .multiply(tauxPeriodique).setScale(0, RoundingMode.HALF_UP); + if (dividende.compareTo(BigDecimal.ZERO) <= 0) continue; + + // Dividende: enregistré comme transaction (NB: ne modifie pas le nombre de parts) + int partsRef = 1; // transaction symbolique — montant est le vrai indicateur + TransactionPartsSociales tx = TransactionPartsSociales.builder() + .compte(compte) + .typeTransaction(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE) + .nombreParts(partsRef) + .montant(dividende) + .soldePartsAvant(compte.getNombreParts()) + .soldePartsApres(compte.getNombreParts()) + .motif("Dividende " + params.getPeriodiciteCalcul().toLowerCase() + + " — taux " + params.getTauxDividendePartsAnnuel() + .multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP) + "%/an") + .dateTransaction(LocalDateTime.now()) + .build(); + compte.setTotalDividendesRecus(compte.getTotalDividendesRecus().add(dividende)); + compte.setDateDerniereOperation(LocalDate.now()); + transactionPartsSocialesRepository.persist(tx); + count++; + } + return count; + } + + /** Taux périodique = taux annuel / nombre de périodes par an */ + private BigDecimal calculerTauxPeriodique(BigDecimal tauxAnnuel, String periodicite) { + int diviseur = switch (periodicite.toUpperCase()) { + case "MENSUEL" -> 12; + case "TRIMESTRIEL" -> 4; + default -> 1; // ANNUEL + }; + return tauxAnnuel.divide(BigDecimal.valueOf(diviseur), 8, RoundingMode.HALF_UP); + } + + private LocalDate prochaineDateCalcul(ParametresFinanciersMutuelle params) { + LocalDate base = LocalDate.now(); + return switch (params.getPeriodiciteCalcul().toUpperCase()) { + case "MENSUEL" -> base.plusMonths(1); + case "TRIMESTRIEL" -> base.plusMonths(3); + default -> base.plusYears(1); + }; + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/mutuelle/ParametresFinanciersService.java b/src/main/java/dev/lions/unionflow/server/service/mutuelle/ParametresFinanciersService.java new file mode 100644 index 0000000..c1ec7bb --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/mutuelle/ParametresFinanciersService.java @@ -0,0 +1,66 @@ +package dev.lions.unionflow.server.service.mutuelle; + +import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellRequest; +import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellResponse; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.NotFoundException; + +import java.time.LocalDate; +import java.util.UUID; + +@ApplicationScoped +public class ParametresFinanciersService { + + @Inject ParametresFinanciersMutuellRepository repo; + @Inject OrganisationRepository organisationRepository; + + public ParametresFinanciersMutuellResponse getByOrganisation(UUID orgId) { + ParametresFinanciersMutuelle p = repo.findByOrganisation(orgId) + .orElseThrow(() -> new NotFoundException("Aucun paramètre financier pour cette organisation.")); + return toDto(p); + } + + @Transactional + public ParametresFinanciersMutuellResponse creerOuMettrAJour(ParametresFinanciersMutuellRequest req) { + Organisation org = organisationRepository.findByIdOptional(UUID.fromString(req.getOrganisationId())) + .orElseThrow(() -> new NotFoundException("Organisation introuvable: " + req.getOrganisationId())); + + ParametresFinanciersMutuelle params = repo.findByOrganisation(org.getId()) + .orElseGet(() -> ParametresFinanciersMutuelle.builder().organisation(org).build()); + + params.setValeurNominaleParDefaut(req.getValeurNominaleParDefaut()); + params.setTauxInteretAnnuelEpargne(req.getTauxInteretAnnuelEpargne()); + params.setTauxDividendePartsAnnuel(req.getTauxDividendePartsAnnuel()); + params.setPeriodiciteCalcul(req.getPeriodiciteCalcul().toUpperCase()); + if (req.getSeuilMinEpargneInterets() != null) { + params.setSeuilMinEpargneInterets(req.getSeuilMinEpargneInterets()); + } + if (params.getProchaineCalculInterets() == null) { + params.setProchaineCalculInterets(LocalDate.now().plusMonths(1)); + } + + repo.persist(params); + return toDto(params); + } + + private ParametresFinanciersMutuellResponse toDto(ParametresFinanciersMutuelle p) { + return ParametresFinanciersMutuellResponse.builder() + .organisationId(p.getOrganisation().getId().toString()) + .organisationNom(p.getOrganisation().getNom()) + .valeurNominaleParDefaut(p.getValeurNominaleParDefaut()) + .tauxInteretAnnuelEpargne(p.getTauxInteretAnnuelEpargne()) + .tauxDividendePartsAnnuel(p.getTauxDividendePartsAnnuel()) + .periodiciteCalcul(p.getPeriodiciteCalcul()) + .seuilMinEpargneInterets(p.getSeuilMinEpargneInterets()) + .prochaineCalculInterets(p.getProchaineCalculInterets()) + .dernierCalculInterets(p.getDernierCalculInterets()) + .dernierNbComptesTraites(p.getDernierNbComptesTraites()) + .build(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/mutuelle/ReleveComptePdfService.java b/src/main/java/dev/lions/unionflow/server/service/mutuelle/ReleveComptePdfService.java new file mode 100644 index 0000000..64294d7 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/mutuelle/ReleveComptePdfService.java @@ -0,0 +1,336 @@ +package dev.lions.unionflow.server.service.mutuelle; + +import com.lowagie.text.*; +import com.lowagie.text.Font; +import com.lowagie.text.pdf.*; +import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne; +import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne; +import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales; +import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales; +import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository; +import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository; +import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository; +import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; + +import java.awt.Color; +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Génère les relevés de compte en PDF (OpenPDF). + * Deux types : relevé épargne, relevé parts sociales. + */ +@ApplicationScoped +public class ReleveComptePdfService { + + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy"); + private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"); + private static final Color BLEU_UNIONFLOW = new Color(30, 90, 160); + private static final Color GRIS_ENTETE = new Color(240, 240, 245); + + @Inject CompteEpargneRepository compteEpargneRepository; + @Inject TransactionEpargneRepository transactionEpargneRepository; + @Inject ComptePartsSocialesRepository comptePartsSocialesRepository; + @Inject TransactionPartsSocialesRepository transactionPartsSocialesRepository; + + // ─── Relevé Épargne ──────────────────────────────────────────────────────── + + public byte[] genererReleveEpargne(UUID compteId, LocalDate dateDebut, LocalDate dateFin) { + CompteEpargne compte = compteEpargneRepository.findByIdOptional(compteId) + .orElseThrow(() -> new NotFoundException("Compte épargne introuvable: " + compteId)); + + List txs = transactionEpargneRepository + .find("compte.id = ?1 ORDER BY dateTransaction ASC", compteId) + .list(); + + if (dateDebut != null) { + txs = txs.stream() + .filter(t -> !t.getDateTransaction().toLocalDate().isBefore(dateDebut)) + .collect(Collectors.toList()); + } + if (dateFin != null) { + txs = txs.stream() + .filter(t -> !t.getDateTransaction().toLocalDate().isAfter(dateFin)) + .collect(Collectors.toList()); + } + + return buildPdfEpargne(compte, txs, dateDebut, dateFin); + } + + // ─── Relevé Parts Sociales ───────────────────────────────────────────────── + + public byte[] genererReleveParts(UUID compteId, LocalDate dateDebut, LocalDate dateFin) { + ComptePartsSociales compte = comptePartsSocialesRepository.findByIdOptional(compteId) + .orElseThrow(() -> new NotFoundException("Compte parts sociales introuvable: " + compteId)); + + List txs = transactionPartsSocialesRepository.findByCompte(compteId); + // findByCompte is DESC — reverse for statement order + java.util.Collections.reverse(txs); + + if (dateDebut != null) { + txs = txs.stream() + .filter(t -> !t.getDateTransaction().toLocalDate().isBefore(dateDebut)) + .collect(Collectors.toList()); + } + if (dateFin != null) { + txs = txs.stream() + .filter(t -> !t.getDateTransaction().toLocalDate().isAfter(dateFin)) + .collect(Collectors.toList()); + } + + return buildPdfParts(compte, txs, dateDebut, dateFin); + } + + // ─── PDF builders ────────────────────────────────────────────────────────── + + private byte[] buildPdfEpargne(CompteEpargne compte, List txs, + LocalDate dateDebut, LocalDate dateFin) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + Document doc = new Document(PageSize.A4, 40, 40, 60, 40); + PdfWriter.getInstance(doc, baos); + doc.open(); + + addHeader(doc, compte.getOrganisation() != null ? compte.getOrganisation().getNom() : "UnionFlow", + "RELEVÉ DE COMPTE ÉPARGNE"); + addInfoBlock(doc, new String[][]{ + {"Numéro de compte", compte.getNumeroCompte()}, + {"Type de compte", compte.getTypeCompte() != null ? compte.getTypeCompte().name() : ""}, + {"Titulaire", membreNom(compte)}, + {"Période", formatPeriode(dateDebut, dateFin)}, + {"Date d'édition", LocalDate.now().format(DATE_FMT)}, + {"Solde actuel", formatMontant(compte.getSoldeActuel())} + }); + + // Solde d'ouverture de la période + BigDecimal soldeOuverture = txs.isEmpty() ? compte.getSoldeActuel() + : txs.get(0).getSoldeAvant(); + + PdfPTable table = createTable(new float[]{2f, 3f, 2.5f, 2.5f, 2.5f}, + new String[]{"Date", "Libellé", "Débit", "Crédit", "Solde"}); + + addLigneTotal(table, "Solde d'ouverture", null, null, soldeOuverture); + + for (TransactionEpargne tx : txs) { + boolean isDebit = isDebitEpargne(tx); + table.addCell(cell(tx.getDateTransaction().format(DATE_FMT), false)); + table.addCell(cell(tx.getMotif() != null ? tx.getMotif() : tx.getType().name(), false)); + table.addCell(cellAmount(isDebit ? tx.getMontant() : null, true)); + table.addCell(cellAmount(!isDebit ? tx.getMontant() : null, false)); + table.addCell(cellAmount(tx.getSoldeApres(), false)); + } + + doc.add(table); + addSoldeFinal(doc, compte.getSoldeActuel()); + addFooter(doc); + doc.close(); + return baos.toByteArray(); + } catch (Exception e) { + throw new RuntimeException("Erreur génération relevé épargne PDF: " + e.getMessage(), e); + } + } + + private byte[] buildPdfParts(ComptePartsSociales compte, List txs, + LocalDate dateDebut, LocalDate dateFin) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + Document doc = new Document(PageSize.A4, 40, 40, 60, 40); + PdfWriter.getInstance(doc, baos); + doc.open(); + + addHeader(doc, compte.getOrganisation() != null ? compte.getOrganisation().getNom() : "UnionFlow", + "RELEVÉ DE PARTS SOCIALES"); + addInfoBlock(doc, new String[][]{ + {"Numéro de compte", compte.getNumeroCompte()}, + {"Titulaire", compte.getMembre() != null + ? compte.getMembre().getNom() + " " + compte.getMembre().getPrenom() : ""}, + {"Valeur nominale", formatMontant(compte.getValeurNominale()) + " / part"}, + {"Parts détenues", String.valueOf(compte.getNombreParts())}, + {"Capital total", formatMontant(compte.getMontantTotal())}, + {"Dividendes reçus", formatMontant(compte.getTotalDividendesRecus())}, + {"Période", formatPeriode(dateDebut, dateFin)}, + {"Date d'édition", LocalDate.now().format(DATE_FMT)} + }); + + PdfPTable table = createTable(new float[]{2f, 3f, 2f, 2.5f, 2.5f, 2.5f}, + new String[]{"Date", "Libellé", "Parts", "Montant", "Avant", "Après"}); + + for (TransactionPartsSociales tx : txs) { + table.addCell(cell(tx.getDateTransaction().format(DATE_FMT), false)); + table.addCell(cell(tx.getMotif() != null ? tx.getMotif() : tx.getTypeTransaction().getLibelle(), false)); + table.addCell(cell(String.valueOf(tx.getNombreParts()), false)); + table.addCell(cellAmount(tx.getMontant(), false)); + table.addCell(cell(String.valueOf(tx.getSoldePartsAvant()), false)); + table.addCell(cell(String.valueOf(tx.getSoldePartsApres()), false)); + } + + doc.add(table); + addFooter(doc); + doc.close(); + return baos.toByteArray(); + } catch (Exception e) { + throw new RuntimeException("Erreur génération relevé parts sociales PDF: " + e.getMessage(), e); + } + } + + // ─── PDF helpers ─────────────────────────────────────────────────────────── + + private void addHeader(Document doc, String orgNom, String titre) throws DocumentException { + Font fontOrg = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, BLEU_UNIONFLOW); + Font fontTitre = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 13); + Font fontSub = FontFactory.getFont(FontFactory.HELVETICA, 9, Color.GRAY); + + Paragraph pOrg = new Paragraph(orgNom, fontOrg); + pOrg.setAlignment(Element.ALIGN_CENTER); + doc.add(pOrg); + + Paragraph pTitre = new Paragraph(titre, fontTitre); + pTitre.setAlignment(Element.ALIGN_CENTER); + pTitre.setSpacingBefore(4); + doc.add(pTitre); + + Paragraph pSub = new Paragraph("Édité via UnionFlow · " + LocalDateTime.now().format(DATETIME_FMT), fontSub); + pSub.setAlignment(Element.ALIGN_CENTER); + pSub.setSpacingAfter(16); + doc.add(pSub); + + // Separator line + PdfPTable sep = new PdfPTable(1); + sep.setWidthPercentage(100); + PdfPCell line = new PdfPCell(new Phrase(" ")); + line.setBorderColor(BLEU_UNIONFLOW); + line.setBorderWidthBottom(1.5f); + line.setBorderWidthTop(0); + line.setBorderWidthLeft(0); + line.setBorderWidthRight(0); + line.setPaddingBottom(4); + sep.addCell(line); + doc.add(sep); + doc.add(Chunk.NEWLINE); + } + + private void addInfoBlock(Document doc, String[][] lignes) throws DocumentException { + PdfPTable t = new PdfPTable(2); + t.setWidthPercentage(60); + t.setHorizontalAlignment(Element.ALIGN_LEFT); + t.setWidths(new float[]{2f, 3f}); + Font fontLabel = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9); + Font fontVal = FontFactory.getFont(FontFactory.HELVETICA, 9); + for (String[] row : lignes) { + PdfPCell cLabel = new PdfPCell(new Phrase(row[0], fontLabel)); + cLabel.setBorder(Rectangle.NO_BORDER); + cLabel.setPadding(3); + PdfPCell cVal = new PdfPCell(new Phrase(row[1], fontVal)); + cVal.setBorder(Rectangle.NO_BORDER); + cVal.setPadding(3); + t.addCell(cLabel); + t.addCell(cVal); + } + t.setSpacingAfter(12); + doc.add(t); + } + + private PdfPTable createTable(float[] widths, String[] headers) throws DocumentException { + PdfPTable table = new PdfPTable(widths.length); + table.setWidthPercentage(100); + table.setWidths(widths); + Font fontHeader = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.WHITE); + for (String h : headers) { + PdfPCell cell = new PdfPCell(new Phrase(h, fontHeader)); + cell.setBackgroundColor(BLEU_UNIONFLOW); + cell.setPadding(5); + cell.setHorizontalAlignment(Element.ALIGN_CENTER); + table.addCell(cell); + } + table.setHeaderRows(1); + return table; + } + + private PdfPCell cell(String text, boolean bold) { + Font f = bold + ? FontFactory.getFont(FontFactory.HELVETICA_BOLD, 8) + : FontFactory.getFont(FontFactory.HELVETICA, 8); + PdfPCell c = new PdfPCell(new Phrase(text != null ? text : "", f)); + c.setPadding(4); + c.setBorderColor(Color.LIGHT_GRAY); + return c; + } + + private PdfPCell cellAmount(BigDecimal amount, boolean isDebit) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) == 0) { + PdfPCell c = cell("", false); + c.setHorizontalAlignment(Element.ALIGN_RIGHT); + return c; + } + Font f = FontFactory.getFont(FontFactory.HELVETICA, 8, + isDebit ? new Color(180, 0, 0) : new Color(0, 130, 0)); + PdfPCell c = new PdfPCell(new Phrase(formatMontant(amount), f)); + c.setPadding(4); + c.setHorizontalAlignment(Element.ALIGN_RIGHT); + c.setBorderColor(Color.LIGHT_GRAY); + return c; + } + + private void addLigneTotal(PdfPTable table, String label, BigDecimal debit, + BigDecimal credit, BigDecimal solde) { + Font f = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 8); + PdfPCell cLabel = new PdfPCell(new Phrase(label, f)); + cLabel.setColspan(2); + cLabel.setBackgroundColor(GRIS_ENTETE); + cLabel.setPadding(4); + table.addCell(cLabel); + table.addCell(cellAmount(debit, true)); + table.addCell(cellAmount(credit, false)); + PdfPCell cSolde = cellAmount(solde, false); + cSolde.setBackgroundColor(GRIS_ENTETE); + table.addCell(cSolde); + } + + private void addSoldeFinal(Document doc, BigDecimal solde) throws DocumentException { + Font f = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 10, BLEU_UNIONFLOW); + Paragraph p = new Paragraph("Solde final : " + formatMontant(solde), f); + p.setAlignment(Element.ALIGN_RIGHT); + p.setSpacingBefore(8); + doc.add(p); + } + + private void addFooter(Document doc) throws DocumentException { + Font f = FontFactory.getFont(FontFactory.HELVETICA, 8, Color.GRAY); + Paragraph p = new Paragraph( + "Document généré automatiquement par UnionFlow — confidentiel.", f); + p.setAlignment(Element.ALIGN_CENTER); + p.setSpacingBefore(20); + doc.add(p); + } + + private String formatMontant(BigDecimal m) { + if (m == null) return "0 XOF"; + return String.format("%,.0f XOF", m.doubleValue()); + } + + private String formatPeriode(LocalDate debut, LocalDate fin) { + if (debut == null && fin == null) return "Toutes opérations"; + if (debut == null) return "jusqu'au " + fin.format(DATE_FMT); + if (fin == null) return "depuis le " + debut.format(DATE_FMT); + return debut.format(DATE_FMT) + " au " + fin.format(DATE_FMT); + } + + private String membreNom(CompteEpargne compte) { + if (compte.getMembre() == null) return ""; + return compte.getMembre().getNom() + " " + compte.getMembre().getPrenom(); + } + + private boolean isDebitEpargne(TransactionEpargne tx) { + return switch (tx.getType()) { + case RETRAIT, PRELEVEMENT_FRAIS, TRANSFERT_SORTANT, REMBOURSEMENT_CREDIT, RETENUE_GARANTIE -> true; + default -> false; + }; + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/mutuelle/epargne/TransactionEpargneService.java b/src/main/java/dev/lions/unionflow/server/service/mutuelle/epargne/TransactionEpargneService.java index 25b8639..772fae7 100644 --- a/src/main/java/dev/lions/unionflow/server/service/mutuelle/epargne/TransactionEpargneService.java +++ b/src/main/java/dev/lions/unionflow/server/service/mutuelle/epargne/TransactionEpargneService.java @@ -13,6 +13,8 @@ import dev.lions.unionflow.server.repository.ParametresLcbFtRepository; import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository; import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository; import dev.lions.unionflow.server.service.AuditService; +import dev.lions.unionflow.server.service.ComptabiliteService; +import dev.lions.unionflow.server.security.RlsEnabled; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -31,6 +33,7 @@ import java.util.stream.Collectors; * Applique les règles LCB-FT : origine des fonds obligatoire au-dessus du seuil configuré. */ @ApplicationScoped +@RlsEnabled public class TransactionEpargneService { /** Seuil LCB-FT (XOF) par défaut si aucun paramètre en base. */ @@ -56,6 +59,9 @@ public class TransactionEpargneService { @Inject dev.lions.unionflow.server.service.AlerteLcbFtService alerteLcbFtService; + @Inject + ComptabiliteService comptabiliteService; + /** * Enregistre une nouvelle transaction et met à jour le solde du compte. * @@ -64,6 +70,11 @@ public class TransactionEpargneService { */ @Transactional public TransactionEpargneResponse executerTransaction(TransactionEpargneRequest request) { + return executerTransaction(request, false); + } + + @Transactional + public TransactionEpargneResponse executerTransaction(TransactionEpargneRequest request, boolean bypassSolde) { CompteEpargne compte = compteEpargneRepository.findByIdOptional(UUID.fromString(request.getCompteId())) .orElseThrow(() -> new NotFoundException("Compte non trouvé avec l'ID: " + request.getCompteId())); @@ -85,13 +96,13 @@ public class TransactionEpargneService { soldeApres = soldeAvant.add(montant); compte.setSoldeActuel(soldeApres); } else if (isTypeDebit(request.getTypeTransaction())) { - if (getSoldeDisponible(compte).compareTo(montant) < 0) { + if (!bypassSolde && getSoldeDisponible(compte).compareTo(montant) < 0) { throw new IllegalArgumentException("Solde disponible insuffisant pour cette opération."); } soldeApres = soldeAvant.subtract(montant); compte.setSoldeActuel(soldeApres); } else if (request.getTypeTransaction() == TypeTransactionEpargne.RETENUE_GARANTIE) { - if (getSoldeDisponible(compte).compareTo(montant) < 0) { + if (!bypassSolde && getSoldeDisponible(compte).compareTo(montant) < 0) { throw new IllegalArgumentException("Solde disponible insuffisant pour geler ce montant."); } compte.setSoldeBloque(compte.getSoldeBloque().add(montant)); @@ -125,6 +136,19 @@ public class TransactionEpargneService { transactionEpargneRepository.persist(transaction); + // Génération écriture SYSCOHADA (non bloquant) + if (compte.getOrganisation() != null) { + try { + if (request.getTypeTransaction() == TypeTransactionEpargne.DEPOT) { + comptabiliteService.enregistrerDepotEpargne(transaction, compte.getOrganisation()); + } else if (request.getTypeTransaction() == TypeTransactionEpargne.RETRAIT) { + comptabiliteService.enregistrerRetraitEpargne(transaction, compte.getOrganisation()); + } + } catch (Exception e) { + // Écriture comptable non bloquante — la transaction épargne reste valide + } + } + if (request.getMontant() != null && request.getMontant().compareTo(seuil) >= 0) { UUID orgId = compte.getOrganisation() != null ? compte.getOrganisation().getId() : null; diff --git a/src/main/java/dev/lions/unionflow/server/service/mutuelle/parts/ComptePartsSocialesService.java b/src/main/java/dev/lions/unionflow/server/service/mutuelle/parts/ComptePartsSocialesService.java new file mode 100644 index 0000000..987a287 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/mutuelle/parts/ComptePartsSocialesService.java @@ -0,0 +1,200 @@ +package dev.lions.unionflow.server.service.mutuelle.parts; + +import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesRequest; +import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse; +import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesRequest; +import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse; +import dev.lions.unionflow.server.api.enums.mutuelle.parts.StatutComptePartsSociales; +import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales; +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle; +import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales; +import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales; +import dev.lions.unionflow.server.mapper.mutuelle.parts.ComptePartsSocialesMapper; +import dev.lions.unionflow.server.mapper.mutuelle.parts.TransactionPartsSocialesMapper; +import dev.lions.unionflow.server.repository.MembreRepository; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository; +import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository; +import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.NotFoundException; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +@ApplicationScoped +public class ComptePartsSocialesService { + + private static final BigDecimal VALEUR_NOMINALE_DEFAUT = new BigDecimal("5000"); + + @Inject ComptePartsSocialesRepository compteRepo; + @Inject TransactionPartsSocialesRepository txRepo; + @Inject MembreRepository membreRepository; + @Inject OrganisationRepository organisationRepository; + @Inject ParametresFinanciersMutuellRepository parametresRepo; + @Inject ComptePartsSocialesMapper compteMapper; + @Inject TransactionPartsSocialesMapper txMapper; + + @Transactional + public ComptePartsSocialesResponse ouvrirCompte(ComptePartsSocialesRequest req) { + Membre membre = membreRepository.findByIdOptional(UUID.fromString(req.getMembreId())) + .orElseThrow(() -> new NotFoundException("Membre introuvable: " + req.getMembreId())); + Organisation org = organisationRepository.findByIdOptional(UUID.fromString(req.getOrganisationId())) + .orElseThrow(() -> new NotFoundException("Organisation introuvable: " + req.getOrganisationId())); + + // Reject duplicate (one compte per member per org) + compteRepo.findByMembreAndOrg(membre.getId(), org.getId()).ifPresent(c -> { + throw new IllegalStateException("Un compte de parts sociales existe déjà pour ce membre dans cette organisation."); + }); + + BigDecimal valeurNominale = resolveValeurNominale(req.getValeurNominale(), org.getId()); + + ComptePartsSociales compte = ComptePartsSociales.builder() + .membre(membre) + .organisation(org) + .numeroCompte(genererNumeroCompte(org)) + .nombreParts(0) + .valeurNominale(valeurNominale) + .montantTotal(BigDecimal.ZERO) + .totalDividendesRecus(BigDecimal.ZERO) + .statut(StatutComptePartsSociales.ACTIF) + .dateOuverture(LocalDate.now()) + .notes(req.getNotes()) + .build(); + compteRepo.persist(compte); + + // Initial souscription si nombreParts > 0 + if (req.getNombreParts() != null && req.getNombreParts() > 0) { + enregistrerTransaction(compte, TypeTransactionPartsSociales.SOUSCRIPTION, + req.getNombreParts(), null, "Souscription initiale à l'ouverture", null); + } + + return compteMapper.toDto(compte); + } + + @Transactional + public TransactionPartsSocialesResponse enregistrerSouscription(TransactionPartsSocialesRequest req) { + ComptePartsSociales compte = findCompteActif(req.getCompteId()); + return txMapper.toDto( + enregistrerTransaction(compte, req.getTypeTransaction(), + req.getNombreParts(), req.getMontant(), req.getMotif(), req.getReferenceExterne())); + } + + public ComptePartsSocialesResponse getById(UUID id) { + return compteMapper.toDto(compteRepo.findByIdOptional(id) + .orElseThrow(() -> new NotFoundException("Compte parts sociales introuvable: " + id))); + } + + public List getByMembre(UUID membreId) { + return compteRepo.findByMembre(membreId).stream().map(compteMapper::toDto).collect(Collectors.toList()); + } + + public List getByOrganisation(UUID orgId) { + return compteRepo.findByOrganisation(orgId).stream().map(compteMapper::toDto).collect(Collectors.toList()); + } + + public List getTransactions(UUID compteId) { + return txRepo.findByCompte(compteId).stream().map(txMapper::toDto).collect(Collectors.toList()); + } + + // ─── Internal helpers ────────────────────────────────────────────────────── + + TransactionPartsSociales enregistrerTransaction( + ComptePartsSociales compte, + TypeTransactionPartsSociales type, + int nombreParts, + BigDecimal montantOverride, + String motif, + String referenceExterne) { + + if (compte.getStatut() != StatutComptePartsSociales.ACTIF) { + throw new IllegalArgumentException("Impossible d'effectuer une opération sur un compte non actif."); + } + + int soldeAvant = compte.getNombreParts(); + int soldeApres; + BigDecimal montant = montantOverride != null + ? montantOverride + : compte.getValeurNominale().multiply(BigDecimal.valueOf(nombreParts)); + + switch (type) { + case SOUSCRIPTION, SOUSCRIPTION_IMPORT, PAIEMENT_DIVIDENDE -> { + soldeApres = soldeAvant + nombreParts; + compte.setNombreParts(soldeApres); + compte.setMontantTotal(compte.getValeurNominale().multiply(BigDecimal.valueOf(soldeApres))); + if (type == TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE) { + compte.setTotalDividendesRecus(compte.getTotalDividendesRecus().add(montant)); + } + } + case CESSION_PARTIELLE -> { + if (nombreParts > soldeAvant) { + throw new IllegalArgumentException( + "Nombre de parts à céder (" + nombreParts + ") supérieur au solde (" + soldeAvant + ")."); + } + soldeApres = soldeAvant - nombreParts; + compte.setNombreParts(soldeApres); + compte.setMontantTotal(compte.getValeurNominale().multiply(BigDecimal.valueOf(soldeApres))); + } + case RACHAT_TOTAL -> { + soldeApres = 0; + compte.setNombreParts(0); + compte.setMontantTotal(BigDecimal.ZERO); + compte.setStatut(StatutComptePartsSociales.CLOS); + } + case CORRECTION -> { + // Admin correction: use nombreParts as the new absolute balance + soldeApres = nombreParts; + compte.setNombreParts(soldeApres); + compte.setMontantTotal(compte.getValeurNominale().multiply(BigDecimal.valueOf(soldeApres))); + } + default -> throw new IllegalArgumentException("Type de transaction non pris en charge: " + type); + } + + compte.setDateDerniereOperation(LocalDate.now()); + + TransactionPartsSociales tx = TransactionPartsSociales.builder() + .compte(compte) + .typeTransaction(type) + .nombreParts(nombreParts) + .montant(montant) + .soldePartsAvant(soldeAvant) + .soldePartsApres(soldeApres) + .motif(motif) + .referenceExterne(referenceExterne) + .dateTransaction(LocalDateTime.now()) + .build(); + txRepo.persist(tx); + return tx; + } + + private ComptePartsSociales findCompteActif(String compteId) { + return compteRepo.findByIdOptional(UUID.fromString(compteId)) + .orElseThrow(() -> new NotFoundException("Compte parts sociales introuvable: " + compteId)); + } + + private BigDecimal resolveValeurNominale(BigDecimal fromRequest, UUID orgId) { + if (fromRequest != null && fromRequest.compareTo(BigDecimal.ZERO) > 0) { + return fromRequest; + } + return parametresRepo.findByOrganisation(orgId) + .map(ParametresFinanciersMutuelle::getValeurNominaleParDefaut) + .orElse(VALEUR_NOMINALE_DEFAUT); + } + + private static final AtomicInteger COUNTER = new AtomicInteger(0); + + private String genererNumeroCompte(Organisation org) { + String prefix = "PS-" + (org.getNomCourt() != null ? org.getNomCourt().toUpperCase() : "ORG") + "-"; + long count = compteRepo.count("organisation.id", org.getId()); + return prefix + String.format("%05d", count + 1); + } +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index bc9ec77..6561315 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -4,6 +4,10 @@ # Surcharge application.properties — sans préfixes %dev. # ============================================================================ +# DevServices désactivés en dev — on utilise le PostgreSQL local (localhost:5432/unionflow) +# Les tests d'intégration avec Docker requièrent USE_DOCKER_TESTS=true +quarkus.devservices.enabled=false + # Base de données PostgreSQL locale quarkus.datasource.username=skyfile quarkus.datasource.password=${DB_PASSWORD_DEV:skyfile} @@ -18,6 +22,8 @@ quarkus.hibernate-orm.log.sql=true # Flyway — activé avec réparation auto des checksums modifiés quarkus.flyway.migrate-at-start=true quarkus.flyway.repair-at-start=true +# Désactiver le remplacement de placeholders ${...} — les migrations utilisent $$ PL/pgSQL +quarkus.flyway.placeholder-replacement=false # CORS — permissif en dev (autorise tous les ports localhost pour Flutter Web) quarkus.http.cors.origins=* @@ -50,6 +56,9 @@ quarkus.log.category."org.hibernate.SQL".level=DEBUG quarkus.log.category."io.quarkus.oidc".level=INFO quarkus.log.category."io.quarkus.security".level=INFO +# Kafka — utiliser le broker local, pas de DevServices +quarkus.kafka.devservices.enabled=false + # Wave — mock pour dev (pas de clé API requise) wave.mock.enabled=true wave.redirect.base.url=http://localhost:8085 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index be32539..55e390a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -52,8 +52,6 @@ quarkus.http.auth.permission.public.policy=permit quarkus.hibernate-orm.database.generation=update quarkus.hibernate-orm.log.sql=false quarkus.hibernate-orm.jdbc.timezone=UTC -quarkus.hibernate-orm.metrics.enabled=false - # Configuration Flyway — base commune quarkus.flyway.migrate-at-start=true quarkus.flyway.baseline-on-migrate=true @@ -89,6 +87,14 @@ quarkus.swagger-ui.tags-sorter=alpha # Health quarkus.smallrye-health.root-path=/health +# Métriques Prometheus (Micrometer) — exposées sur /q/metrics +quarkus.micrometer.enabled=true +quarkus.micrometer.export.prometheus.enabled=true +quarkus.micrometer.export.prometheus.path=/q/metrics +# Métriques Hibernate ORM +quarkus.hibernate-orm.metrics.enabled=true +# JVM + HTTP server + datasource metrics activés par défaut avec quarkus-micrometer + # Logging — base commune quarkus.log.console.enable=true quarkus.log.console.level=INFO @@ -197,3 +203,20 @@ mp.messaging.incoming.chat-messages-in.topic=unionflow.chat.messages mp.messaging.incoming.chat-messages-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer mp.messaging.incoming.chat-messages-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer mp.messaging.incoming.chat-messages-in.group.id=unionflow-websocket-server + +# === PI-SPI BCEAO (P0.3 — deadline 30/06/2026) === +pispi.api.base-url=${PISPI_API_URL:https://sandbox.pispi.bceao.int/business-api/v1} +pispi.institution.bic=${PISPI_BIC:BCEAOCIAB} +# Activer la priorité PI-SPI dans l'orchestrateur (obligatoire en prod après certification) +payment.pispi-priority=${PAYMENT_PISPI_PRIORITY:false} + +# Secrets externes : mappage env vars actif en prod uniquement (profile-scoped). +# En dev : propriétés non définies, @ConfigProperty(defaultValue="") côté Java (mode mock). +%prod.pispi.api.client-id=${PISPI_CLIENT_ID:} +%prod.pispi.api.client-secret=${PISPI_CLIENT_SECRET:} +%prod.pispi.institution.code=${PISPI_INSTITUTION_CODE:} +%prod.pispi.webhook.secret=${PISPI_WEBHOOK_SECRET:} +%prod.pispi.webhook.allowed-ips=${PISPI_ALLOWED_IPS:} +%prod.mtnmomo.collection.subscription-key=${MTNMOMO_SUBSCRIPTION_KEY:} +%prod.orange.api.client-id=${ORANGE_API_CLIENT_ID:} +%prod.firebase.service-account-key-path=${FIREBASE_SERVICE_ACCOUNT_KEY_PATH:} diff --git a/src/main/resources/db/migration/V32__Mutuelle_Parts_Sociales_Interets.sql b/src/main/resources/db/migration/V32__Mutuelle_Parts_Sociales_Interets.sql new file mode 100644 index 0000000..c8b94db --- /dev/null +++ b/src/main/resources/db/migration/V32__Mutuelle_Parts_Sociales_Interets.sql @@ -0,0 +1,87 @@ +-- ============================================================================ +-- V32 — Mutuelle : Parts Sociales + Paramètres Financiers + Intérêts +-- +-- Ajoute les tables nécessaires pour les fonctionnalités manquantes identifiées +-- dans l'analyse du fichier FUSION 2013-2021.xlsx de la Mutuelle GBANE : +-- 1. comptes_parts_sociales — capital social des membres +-- 2. transactions_parts_sociales — historique des mouvements de parts +-- 3. parametres_financiers_mutuelle — taux, périodicités, valeur nominale +-- ============================================================================ + +-- ── 1. Paramètres financiers de la mutuelle ──────────────────────────────── +CREATE TABLE IF NOT EXISTS parametres_financiers_mutuelle ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + organisation_id UUID NOT NULL UNIQUE, + valeur_nominale_par_defaut NUMERIC(19,4) NOT NULL DEFAULT 5000, + taux_interet_annuel_epargne NUMERIC(6,4) NOT NULL DEFAULT 0.0300, + taux_dividende_parts_annuel NUMERIC(6,4) NOT NULL DEFAULT 0.0500, + periodicite_calcul VARCHAR(20) NOT NULL DEFAULT 'MENSUEL', + seuil_min_epargne_interets NUMERIC(19,4) DEFAULT 0, + prochaine_calcul_interets DATE, + dernier_calcul_interets DATE, + dernier_nb_comptes_traites INTEGER DEFAULT 0, + -- BaseEntity cols + date_creation TIMESTAMP NOT NULL DEFAULT NOW(), + date_modification TIMESTAMP, + cree_par VARCHAR(255), + modifie_par VARCHAR(255), + version BIGINT DEFAULT 0, + actif BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT fk_pfm_organisation FOREIGN KEY (organisation_id) REFERENCES organisations(id) +); + +CREATE INDEX IF NOT EXISTS idx_pfm_org ON parametres_financiers_mutuelle(organisation_id); + +-- ── 2. Comptes de parts sociales ─────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS comptes_parts_sociales ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + membre_id UUID NOT NULL, + organisation_id UUID NOT NULL, + numero_compte VARCHAR(50) NOT NULL UNIQUE, + nombre_parts INTEGER NOT NULL DEFAULT 0, + valeur_nominale NUMERIC(19,4) NOT NULL, + montant_total NUMERIC(19,4) NOT NULL DEFAULT 0, + total_dividendes_recus NUMERIC(19,4) NOT NULL DEFAULT 0, + statut VARCHAR(30) NOT NULL DEFAULT 'ACTIF', + date_ouverture DATE NOT NULL DEFAULT CURRENT_DATE, + date_derniere_operation DATE, + notes VARCHAR(500), + -- BaseEntity cols + date_creation TIMESTAMP NOT NULL DEFAULT NOW(), + date_modification TIMESTAMP, + cree_par VARCHAR(255), + modifie_par VARCHAR(255), + version BIGINT DEFAULT 0, + actif BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT fk_cps_membre FOREIGN KEY (membre_id) REFERENCES utilisateurs(id), + CONSTRAINT fk_cps_organisation FOREIGN KEY (organisation_id) REFERENCES organisations(id) +); + +CREATE INDEX IF NOT EXISTS idx_cps_numero ON comptes_parts_sociales(numero_compte); +CREATE INDEX IF NOT EXISTS idx_cps_membre ON comptes_parts_sociales(membre_id); +CREATE INDEX IF NOT EXISTS idx_cps_org ON comptes_parts_sociales(organisation_id); + +-- ── 3. Transactions sur parts sociales ──────────────────────────────────── +CREATE TABLE IF NOT EXISTS transactions_parts_sociales ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + compte_id UUID NOT NULL, + type_transaction VARCHAR(50) NOT NULL, + nombre_parts INTEGER NOT NULL, + montant NUMERIC(19,4) NOT NULL, + solde_parts_avant INTEGER NOT NULL DEFAULT 0, + solde_parts_apres INTEGER NOT NULL DEFAULT 0, + motif VARCHAR(500), + reference_externe VARCHAR(100), + date_transaction TIMESTAMP NOT NULL DEFAULT NOW(), + -- BaseEntity cols + date_creation TIMESTAMP NOT NULL DEFAULT NOW(), + date_modification TIMESTAMP, + cree_par VARCHAR(255), + modifie_par VARCHAR(255), + version BIGINT DEFAULT 0, + actif BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT fk_tps_compte FOREIGN KEY (compte_id) REFERENCES comptes_parts_sociales(id) +); + +CREATE INDEX IF NOT EXISTS idx_tps_compte ON transactions_parts_sociales(compte_id); +CREATE INDEX IF NOT EXISTS idx_tps_date ON transactions_parts_sociales(date_transaction); diff --git a/src/main/resources/db/migration/V33__Fix_AuditLogs_Legacy_Columns.sql b/src/main/resources/db/migration/V33__Fix_AuditLogs_Legacy_Columns.sql new file mode 100644 index 0000000..ee343e0 --- /dev/null +++ b/src/main/resources/db/migration/V33__Fix_AuditLogs_Legacy_Columns.sql @@ -0,0 +1,15 @@ +-- ============================================================================ +-- V33 — Correction colonnes legacy de audit_logs +-- +-- La V1 crée audit_logs avec action VARCHAR(50) NOT NULL (ancien schéma). +-- L'entité AuditLog utilise type_action à la place. +-- Hibernate ne remplit pas action → violation NOT NULL sur chaque insert. +-- Fix : rendre action nullable + nettoyer les autres colonnes orphelines. +-- ============================================================================ + +-- Rendre la colonne legacy nullable (elle est supersédée par type_action) +ALTER TABLE audit_logs ALTER COLUMN action DROP NOT NULL; + +-- Aligner entite_id : la V1 déclare UUID mais l'entité stocke une String (UUID textuel) +-- → changer en VARCHAR pour éviter des cast errors sur certains IDs non-UUID +ALTER TABLE audit_logs ALTER COLUMN entite_id TYPE VARCHAR(255) USING entite_id::VARCHAR; diff --git a/src/main/resources/db/migration/V34__Fix_Legacy_MembreId_NotNull_Columns.sql b/src/main/resources/db/migration/V34__Fix_Legacy_MembreId_NotNull_Columns.sql new file mode 100644 index 0000000..4ce7ee9 --- /dev/null +++ b/src/main/resources/db/migration/V34__Fix_Legacy_MembreId_NotNull_Columns.sql @@ -0,0 +1,39 @@ +-- ============================================================================ +-- V34 — Rendre membre_id nullable dans les tables où l'entité Hibernate +-- utilise désormais une autre colonne (utilisateur_id, membre_organisation_id). +-- +-- Contexte : V1 crée ces tables avec membre_id UUID NOT NULL. Les entités ont +-- évolué pour utiliser utilisateur_id (MembreOrganisation, DemandeAdhesion, +-- IntentionPaiement) ou membre_organisation_id (MembreRole). Hibernate update +-- a ajouté les nouvelles colonnes mais n'a pas supprimé membre_id. +-- Résultat : chaque insert lève une violation NOT NULL sur membre_id. +-- Fix : rendre membre_id nullable (colonne legacy, plus utilisée par le code). +-- ============================================================================ + +-- membres_organisations : entité utilise utilisateur_id +ALTER TABLE membres_organisations ALTER COLUMN membre_id DROP NOT NULL; + +-- membres_roles : entité utilise membre_organisation_id +ALTER TABLE membres_roles ALTER COLUMN membre_id DROP NOT NULL; + +-- demandes_adhesion : entité utilise utilisateur_id +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'demandes_adhesion' AND column_name = 'membre_id' + ) THEN + ALTER TABLE demandes_adhesion ALTER COLUMN membre_id DROP NOT NULL; + END IF; +END $$; + +-- intentions_paiement : entité utilise utilisateur_id +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'intentions_paiement' AND column_name = 'membre_id' + ) THEN + ALTER TABLE intentions_paiement ALTER COLUMN membre_id DROP NOT NULL; + END IF; +END $$; diff --git a/src/main/resources/db/migration/V35__Fix_Nombre_Membres_Counter_And_Trigger.sql b/src/main/resources/db/migration/V35__Fix_Nombre_Membres_Counter_And_Trigger.sql new file mode 100644 index 0000000..9883bb1 --- /dev/null +++ b/src/main/resources/db/migration/V35__Fix_Nombre_Membres_Counter_And_Trigger.sql @@ -0,0 +1,77 @@ +-- ============================================================================ +-- V35 — Recalibrage nombre_membres + trigger auto-maintien +-- +-- DATA-01 : Le compteur organisations.nombre_membres est désynchronisé quand +-- des membres sont importés directement en DB (hors service Java). +-- Fix : +-- 1. Recalibrage immédiat depuis membres_organisations réels (actifs) +-- 2. Trigger PostgreSQL pour maintenir le compteur à jour automatiquement +-- ============================================================================ + +-- 1. Recalibrage ponctuel : recalculer depuis la table membres_organisations +UPDATE organisations o +SET nombre_membres = ( + SELECT COUNT(*) + FROM membres_organisations mo + WHERE mo.organisation_id = o.id + AND mo.actif = true + AND mo.statut IN ('ACTIF', 'ACTIF_PREMIUM') +); + +-- 2. Fonction trigger : incrémente/décrémente selon INSERT/UPDATE/DELETE +CREATE OR REPLACE FUNCTION update_organisation_nombre_membres() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + -- Nouveau membre actif → incrémenter + IF NEW.actif = true AND NEW.statut IN ('ACTIF', 'ACTIF_PREMIUM') THEN + UPDATE organisations + SET nombre_membres = GREATEST(0, nombre_membres + 1) + WHERE id = NEW.organisation_id; + END IF; + + ELSIF TG_OP = 'UPDATE' THEN + -- Transition actif/inactif ou statut + DECLARE + was_counted BOOLEAN := OLD.actif = true AND OLD.statut IN ('ACTIF', 'ACTIF_PREMIUM'); + is_counted BOOLEAN := NEW.actif = true AND NEW.statut IN ('ACTIF', 'ACTIF_PREMIUM'); + BEGIN + IF NOT was_counted AND is_counted THEN + UPDATE organisations + SET nombre_membres = GREATEST(0, nombre_membres + 1) + WHERE id = NEW.organisation_id; + ELSIF was_counted AND NOT is_counted THEN + UPDATE organisations + SET nombre_membres = GREATEST(0, nombre_membres - 1) + WHERE id = OLD.organisation_id; + END IF; + END; + + ELSIF TG_OP = 'DELETE' THEN + -- Suppression physique (rare) + IF OLD.actif = true AND OLD.statut IN ('ACTIF', 'ACTIF_PREMIUM') THEN + UPDATE organisations + SET nombre_membres = GREATEST(0, nombre_membres - 1) + WHERE id = OLD.organisation_id; + END IF; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +-- 3. Attacher le trigger à membres_organisations +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger + WHERE tgname = 'trg_update_nombre_membres' + AND tgrelid = 'membres_organisations'::regclass + ) THEN + CREATE TRIGGER trg_update_nombre_membres + AFTER INSERT OR UPDATE OF actif, statut OR DELETE + ON membres_organisations + FOR EACH ROW + EXECUTE FUNCTION update_organisation_nombre_membres(); + END IF; +END $$; diff --git a/src/main/resources/db/migration/V36__SYSCOHADA_Plan_Comptable_Complet.sql b/src/main/resources/db/migration/V36__SYSCOHADA_Plan_Comptable_Complet.sql new file mode 100644 index 0000000..e46c91f --- /dev/null +++ b/src/main/resources/db/migration/V36__SYSCOHADA_Plan_Comptable_Complet.sql @@ -0,0 +1,393 @@ +-- ============================================================================ +-- V36 — SYSCOHADA : Alignement schéma + Seeds plan comptable standard + Trigger +-- +-- P0.4 ROADMAP_2026.md — Obligation OHADA SYSCOHADA révisé (applicable depuis 2018) +-- Corrige l'écart entre V1 (schéma minimal) et les entités Java (colonnes Hibernate). +-- Ajoute le plan comptable standard SYSCOHADA pour mutuelles/coopératives UEMOA. +-- ============================================================================ + +-- ============================================================================ +-- 1. COMPTES_COMPTABLES — Alignement colonnes V1 → entité Java +-- ============================================================================ + +-- La V1 crée la table avec numero/libelle/type_compte/organisation_id seulement. +-- L'entité Java attend : numero_compte, classe_comptable, solde_initial, solde_actuel, +-- compte_collectif, compte_analytique, cree_par, modifie_par. + +-- Renommer la colonne numero → numero_compte si elle n'a pas déjà été renommée par Hibernate +-- Sinon : si les deux colonnes coexistent (Hibernate a créé numero_compte, V1 a laissé numero), +-- on supprime l'ancienne colonne obsolète numero (NOT NULL sans défaut, bloque les INSERTs). +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'comptes_comptables' AND column_name = 'numero' + ) THEN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'comptes_comptables' AND column_name = 'numero_compte' + ) THEN + ALTER TABLE comptes_comptables RENAME COLUMN numero TO numero_compte; + ELSE + -- Les deux colonnes coexistent : recopier les valeurs vers numero_compte si besoin, + -- puis supprimer la colonne obsolète numero. + UPDATE comptes_comptables SET numero_compte = numero + WHERE numero_compte IS NULL AND numero IS NOT NULL; + ALTER TABLE comptes_comptables DROP COLUMN numero; + END IF; + END IF; +END $$; + +-- Ajouter colonnes manquantes si pas encore créées par Hibernate update +ALTER TABLE comptes_comptables + ADD COLUMN IF NOT EXISTS classe_comptable INTEGER, + ADD COLUMN IF NOT EXISTS solde_initial DECIMAL(14,2) DEFAULT 0, + ADD COLUMN IF NOT EXISTS solde_actuel DECIMAL(14,2) DEFAULT 0, + ADD COLUMN IF NOT EXISTS compte_collectif BOOLEAN DEFAULT false, + ADD COLUMN IF NOT EXISTS compte_analytique BOOLEAN DEFAULT false, + ADD COLUMN IF NOT EXISTS description VARCHAR(500), + ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255), + ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255); + +-- Déduire classe_comptable depuis numero_compte si null (première chiffre du numéro) +UPDATE comptes_comptables +SET classe_comptable = CAST(LEFT(numero_compte, 1) AS INTEGER) +WHERE classe_comptable IS NULL AND numero_compte IS NOT NULL AND LENGTH(numero_compte) > 0; + +-- Rendre classe_comptable NOT NULL après backfill +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'comptes_comptables' AND column_name = 'classe_comptable' + AND is_nullable = 'NO' + ) THEN + ALTER TABLE comptes_comptables ALTER COLUMN classe_comptable SET NOT NULL; + END IF; +END $$; + +-- Contrainte classe 1-9 (SYSCOHADA a 9 classes, pas 7) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_compte_classe_syscohada') THEN + ALTER TABLE comptes_comptables + ADD CONSTRAINT chk_compte_classe_syscohada + CHECK (classe_comptable >= 1 AND classe_comptable <= 9); + END IF; +END $$; + +-- ============================================================================ +-- 2. JOURNAUX_COMPTABLES — Alignement colonnes +-- ============================================================================ +ALTER TABLE journaux_comptables + ADD COLUMN IF NOT EXISTS date_debut DATE, + ADD COLUMN IF NOT EXISTS date_fin DATE, + ADD COLUMN IF NOT EXISTS statut VARCHAR(20) DEFAULT 'OUVERT', + ADD COLUMN IF NOT EXISTS description VARCHAR(500), + ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255), + ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255); + +-- ============================================================================ +-- 3. ECRITURES_COMPTABLES — Alignement colonnes +-- ============================================================================ +ALTER TABLE ecritures_comptables + ADD COLUMN IF NOT EXISTS organisation_id UUID REFERENCES organisations(id), + ADD COLUMN IF NOT EXISTS paiement_id UUID REFERENCES paiements(id), + ADD COLUMN IF NOT EXISTS reference VARCHAR(100), + ADD COLUMN IF NOT EXISTS lettrage VARCHAR(20), + ADD COLUMN IF NOT EXISTS pointe BOOLEAN DEFAULT false, + ADD COLUMN IF NOT EXISTS montant_debit DECIMAL(14,2) DEFAULT 0, + ADD COLUMN IF NOT EXISTS montant_credit DECIMAL(14,2) DEFAULT 0, + ADD COLUMN IF NOT EXISTS commentaire VARCHAR(1000), + ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255), + ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255); + +-- ============================================================================ +-- 4. LIGNES_ECRITURE — Alignement colonnes (debit/credit → montant_debit/credit) +-- ============================================================================ + +-- Renommer compte_id → compte_comptable_id si besoin +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'lignes_ecriture' AND column_name = 'compte_id' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'lignes_ecriture' AND column_name = 'compte_comptable_id' + ) THEN + ALTER TABLE lignes_ecriture RENAME COLUMN compte_id TO compte_comptable_id; + END IF; +END $$; + +-- Renommer debit/credit → montant_debit/montant_credit si besoin +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'lignes_ecriture' AND column_name = 'debit' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'lignes_ecriture' AND column_name = 'montant_debit' + ) THEN + ALTER TABLE lignes_ecriture RENAME COLUMN debit TO montant_debit; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'lignes_ecriture' AND column_name = 'credit' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'lignes_ecriture' AND column_name = 'montant_credit' + ) THEN + ALTER TABLE lignes_ecriture RENAME COLUMN credit TO montant_credit; + END IF; +END $$; + +ALTER TABLE lignes_ecriture + ADD COLUMN IF NOT EXISTS numero_ligne INTEGER, + ADD COLUMN IF NOT EXISTS reference VARCHAR(100), + ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255), + ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255); + +-- ============================================================================ +-- 5. TABLE MODELE_PLAN_COMPTABLE — Template SYSCOHADA (comptes standards réutilisables) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS modele_plan_comptable ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + numero_compte VARCHAR(10) NOT NULL UNIQUE, + libelle VARCHAR(200) NOT NULL, + classe_comptable INTEGER NOT NULL CHECK (classe_comptable >= 1 AND classe_comptable <= 9), + type_compte VARCHAR(30) NOT NULL, + description VARCHAR(500), + actif BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT chk_modele_classe CHECK (classe_comptable >= 1 AND classe_comptable <= 9) +); + +-- ============================================================================ +-- 6. SEEDS — Plan comptable SYSCOHADA standard pour mutuelles/coopératives UEMOA +-- ============================================================================ +INSERT INTO modele_plan_comptable (numero_compte, libelle, classe_comptable, type_compte) VALUES +-- CLASSE 1 — Ressources durables +('101000', 'Fonds propres', 1, 'PASSIF'), +('104000', 'Réserve légale', 1, 'PASSIF'), +('106000', 'Réserves statutaires', 1, 'PASSIF'), +('120000', 'Résultat de l''exercice', 1, 'PASSIF'), +('160000', 'Emprunts à long terme', 1, 'PASSIF'), +('165000', 'Dépôts et cautionnements reçus', 1, 'PASSIF'), + +-- CLASSE 2 — Actif immobilisé +('222000', 'Matériel de transport', 2, 'ACTIF'), +('232000', 'Matériel informatique', 2, 'ACTIF'), +('244000', 'Logiciels informatiques', 2, 'ACTIF'), +('281000', 'Amortissements immobilisations', 2, 'ACTIF'), + +-- CLASSE 4 — Tiers +('411000', 'Membres débiteurs — cotisations dues', 4, 'ACTIF'), +('412000', 'Membres débiteurs — parts sociales dues', 4, 'ACTIF'), +('413000', 'Membres débiteurs — avances sur prestations', 4, 'ACTIF'), +('421000', 'Personnel — rémunérations dues', 4, 'PASSIF'), +('431000', 'Sécurité sociale — cotisations patronales', 4, 'PASSIF'), +('441000', 'État — TVA collectée', 4, 'PASSIF'), +('447000', 'État — autres impôts et taxes', 4, 'PASSIF'), +('467000', 'Tiers divers débiteurs', 4, 'ACTIF'), +('468000', 'Tiers divers créditeurs', 4, 'PASSIF'), + +-- CLASSE 5 — Trésorerie +('512100', 'Compte Wave Senegal', 5, 'TRESORERIE'), +('512200', 'Compte Orange Money', 5, 'TRESORERIE'), +('512300', 'Compte MTN MoMo', 5, 'TRESORERIE'), +('512400', 'Compte Moov Money', 5, 'TRESORERIE'), +('512500', 'Compte bancaire principal', 5, 'TRESORERIE'), +('531000', 'Caisse principale', 5, 'TRESORERIE'), +('581000', 'Virements internes de trésorerie', 5, 'TRESORERIE'), + +-- CLASSE 6 — Charges +('601000', 'Achats de marchandises', 6, 'CHARGES'), +('611000', 'Transports', 6, 'CHARGES'), +('612000', 'Frais de télécommunications', 6, 'CHARGES'), +('613000', 'Frais d''assurance', 6, 'CHARGES'), +('614000', 'Location matériel', 6, 'CHARGES'), +('616000', 'Frais d''entretien et réparations', 6, 'CHARGES'), +('621000', 'Personnel externe (prestataires)', 6, 'CHARGES'), +('622000', 'Rémunérations du personnel', 6, 'CHARGES'), +('631000', 'Frais financiers — intérêts d''emprunts', 6, 'CHARGES'), +('641000', 'Charges sur prestations mutuelles', 6, 'CHARGES'), +('651000', 'Pertes sur créances irrécouvrables', 6, 'CHARGES'), + +-- CLASSE 7 — Produits +('706100', 'Cotisations ordinaires membres', 7, 'PRODUITS'), +('706200', 'Cotisations spéciales / majorées', 7, 'PRODUITS'), +('706300', 'Parts sociales', 7, 'PRODUITS'), +('706400', 'Droits d''adhésion', 7, 'PRODUITS'), +('762000', 'Produits financiers — intérêts épargne', 7, 'PRODUITS'), +('771000', 'Subventions d''exploitation reçues', 7, 'PRODUITS'), +('775000', 'Prestations de services', 7, 'PRODUITS'), + +-- CLASSE 8 — Charges et produits exceptionnels / hors activité +('870000', 'Dons reçus', 8, 'PRODUITS'), +('871000', 'Legs et donations', 8, 'PRODUITS'), +('875000', 'Produits exceptionnels d''événements', 8, 'PRODUITS'), +('878000', 'Autres produits hors activité ordinaire', 8, 'PRODUITS'), +('880000', 'Charges exceptionnelles', 8, 'CHARGES'), + +-- CLASSE 9 — Engagements / comptabilité analytique +('990000', 'Engagements hors bilan donnés', 9, 'AUTRE'), +('991000', 'Engagements hors bilan reçus', 9, 'AUTRE') + +ON CONFLICT (numero_compte) DO NOTHING; + +-- ============================================================================ +-- 7. TRIGGER — Initialisation automatique du plan comptable à la création d'org +-- ============================================================================ +CREATE OR REPLACE FUNCTION init_plan_comptable_organisation() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO comptes_comptables ( + id, numero_compte, libelle, classe_comptable, type_compte, + description, organisation_id, solde_initial, solde_actuel, + compte_collectif, compte_analytique, actif, + date_creation, version + ) + SELECT + gen_random_uuid(), + m.numero_compte, + m.libelle, + m.classe_comptable, + m.type_compte, + m.description, + NEW.id, + 0, 0, + false, false, true, + NOW(), 0 + FROM modele_plan_comptable m + WHERE m.actif = true; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger + WHERE tgname = 'trg_init_plan_comptable_org' + AND tgrelid = 'organisations'::regclass + ) THEN + CREATE TRIGGER trg_init_plan_comptable_org + AFTER INSERT ON organisations + FOR EACH ROW + EXECUTE FUNCTION init_plan_comptable_organisation(); + END IF; +END $$; + +-- ============================================================================ +-- 8. BACKFILL — Initialiser le plan comptable pour les organisations existantes +-- (qui ont été créées avant ce trigger) +-- ============================================================================ +INSERT INTO comptes_comptables ( + id, numero_compte, libelle, classe_comptable, type_compte, + description, organisation_id, solde_initial, solde_actuel, + compte_collectif, compte_analytique, actif, + date_creation, version +) +SELECT + gen_random_uuid(), + m.numero_compte, + m.libelle, + m.classe_comptable, + m.type_compte, + m.description, + o.id, + 0, 0, + false, false, true, + NOW(), 0 +FROM organisations o +CROSS JOIN modele_plan_comptable m +WHERE m.actif = true + AND NOT EXISTS ( + SELECT 1 FROM comptes_comptables cc + WHERE cc.organisation_id = o.id + AND cc.numero_compte = m.numero_compte + ); + +-- ============================================================================ +-- 9. JOURNAUX STANDARD par organisation +-- ============================================================================ + +-- Remplacer la contrainte UNIQUE globale sur `code` par une contrainte composite +-- (organisation_id, code) — plusieurs orgs peuvent avoir un journal ACH/VTE/etc. +DO $$ +DECLARE + constraint_name text; +BEGIN + SELECT tc.constraint_name INTO constraint_name + FROM information_schema.table_constraints tc + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + WHERE tc.table_name = 'journaux_comptables' + AND tc.constraint_type = 'UNIQUE' + AND ccu.column_name = 'code' + AND NOT EXISTS ( + SELECT 1 FROM information_schema.constraint_column_usage ccu2 + WHERE ccu2.constraint_name = tc.constraint_name + AND ccu2.column_name = 'organisation_id' + ); + IF constraint_name IS NOT NULL THEN + EXECUTE 'ALTER TABLE journaux_comptables DROP CONSTRAINT ' || quote_ident(constraint_name); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'uk_journaux_org_code' + ) THEN + ALTER TABLE journaux_comptables + ADD CONSTRAINT uk_journaux_org_code UNIQUE (organisation_id, code); + END IF; +END $$; + +INSERT INTO journaux_comptables ( + id, code, libelle, type_journal, organisation_id, + statut, actif, date_creation, version +) +SELECT + gen_random_uuid(), + jtype.code, + jtype.libelle, + jtype.type_journal, + o.id, + 'OUVERT', true, NOW(), 0 +FROM organisations o +CROSS JOIN (VALUES + ('ACH', 'Journal des achats', 'ACHATS'), + ('VTE', 'Journal des ventes / cotisations', 'VENTES'), + ('BQ', 'Journal bancaire', 'BANQUE'), + ('CAI', 'Journal de caisse', 'CAISSE'), + ('OD', 'Journal des opérations diverses', 'OD') +) AS jtype(code, libelle, type_journal) +WHERE NOT EXISTS ( + SELECT 1 FROM journaux_comptables jc + WHERE jc.organisation_id = o.id + AND jc.type_journal = jtype.type_journal +); + +-- ============================================================================ +-- 10. INDEX utiles +-- ============================================================================ +CREATE INDEX IF NOT EXISTS idx_comptes_org_numero + ON comptes_comptables (organisation_id, numero_compte); + +CREATE INDEX IF NOT EXISTS idx_comptes_org_classe + ON comptes_comptables (organisation_id, classe_comptable); + +CREATE INDEX IF NOT EXISTS idx_ecritures_org_date + ON ecritures_comptables (organisation_id, date_ecriture); + +CREATE INDEX IF NOT EXISTS idx_lignes_compte + ON lignes_ecriture (compte_comptable_id); diff --git a/src/main/resources/db/migration/V37__Add_Keycloak_Org_Id_To_Organisations.sql b/src/main/resources/db/migration/V37__Add_Keycloak_Org_Id_To_Organisations.sql new file mode 100644 index 0000000..1543152 --- /dev/null +++ b/src/main/resources/db/migration/V37__Add_Keycloak_Org_Id_To_Organisations.sql @@ -0,0 +1,14 @@ +-- ============================================================================ +-- V37 — Keycloak 26 Organizations : ajout keycloak_org_id sur organisations +-- +-- P0.2 ROADMAP_2026.md — Migration Keycloak 23 → 26 + Organizations natives +-- Stocke l'ID Keycloak Organization correspondant à chaque organisation UnionFlow. +-- Null = organisation pas encore migrée vers Keycloak 26 Organizations. +-- ============================================================================ + +ALTER TABLE organisations + ADD COLUMN IF NOT EXISTS keycloak_org_id UUID; + +CREATE INDEX IF NOT EXISTS idx_organisations_keycloak_org_id + ON organisations (keycloak_org_id) + WHERE keycloak_org_id IS NOT NULL; diff --git a/src/main/resources/db/migration/V38__Create_Kyc_Dossier_Table.sql b/src/main/resources/db/migration/V38__Create_Kyc_Dossier_Table.sql new file mode 100644 index 0000000..f225026 --- /dev/null +++ b/src/main/resources/db/migration/V38__Create_Kyc_Dossier_Table.sql @@ -0,0 +1,64 @@ +-- ============================================================================ +-- V38 — Module KYC/AML : table kyc_dossier +-- +-- P1.5 ROADMAP_2026.md — KYC/AML — conformité GIABA/BCEAO LCB-FT +-- Rétention 10 ans (GIABA) gérée par colonne annee_reference + archivage planifié. +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS kyc_dossier ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identité du membre + membre_id UUID NOT NULL REFERENCES utilisateurs(id), + + -- Pièce d'identité + type_piece VARCHAR(30) NOT NULL, + numero_piece VARCHAR(50) NOT NULL, + date_expiration_piece DATE, + + -- Fichiers stockés (MinIO/S3 — identifiants opaques) + piece_identite_recto_file_id VARCHAR(500), + piece_identite_verso_file_id VARCHAR(500), + justif_domicile_file_id VARCHAR(500), + + -- Évaluation risque LCB-FT + statut VARCHAR(20) NOT NULL DEFAULT 'NON_VERIFIE', + niveau_risque VARCHAR(20) NOT NULL DEFAULT 'FAIBLE', + score_risque INTEGER NOT NULL DEFAULT 0 + CHECK (score_risque >= 0 AND score_risque <= 100), + + -- PEP (Personne Exposée Politiquement) + est_pep BOOLEAN NOT NULL DEFAULT FALSE, + nationalite VARCHAR(5), + + -- Validation + date_verification TIMESTAMP, + validateur_id UUID REFERENCES utilisateurs(id), + notes_validateur VARCHAR(1000), + + -- Rétention 10 ans GIABA — partitionnement logique par année + annee_reference INTEGER NOT NULL DEFAULT EXTRACT(YEAR FROM NOW()), + + -- BaseEntity + date_creation TIMESTAMP NOT NULL DEFAULT NOW(), + date_modification TIMESTAMP, + cree_par VARCHAR(255), + modifie_par VARCHAR(255), + version BIGINT NOT NULL DEFAULT 0, + actif BOOLEAN NOT NULL DEFAULT TRUE, + + CONSTRAINT chk_kyc_annee_reference CHECK (annee_reference >= 2020 AND annee_reference <= 2100) +); + +-- Un seul dossier actif par membre (le plus récent est actif, les anciens archivés) +CREATE UNIQUE INDEX IF NOT EXISTS idx_kyc_membre_actif + ON kyc_dossier (membre_id) + WHERE actif = TRUE; + +CREATE INDEX IF NOT EXISTS idx_kyc_membre_id ON kyc_dossier (membre_id); +CREATE INDEX IF NOT EXISTS idx_kyc_statut ON kyc_dossier (statut); +CREATE INDEX IF NOT EXISTS idx_kyc_niveau_risque ON kyc_dossier (niveau_risque); +CREATE INDEX IF NOT EXISTS idx_kyc_est_pep ON kyc_dossier (est_pep) WHERE est_pep = TRUE; +CREATE INDEX IF NOT EXISTS idx_kyc_annee ON kyc_dossier (annee_reference); +CREATE INDEX IF NOT EXISTS idx_kyc_date_expiration ON kyc_dossier (date_expiration_piece) + WHERE date_expiration_piece IS NOT NULL; diff --git a/src/main/resources/db/migration/V39__PostgreSQL_RLS_Tenant_Isolation.sql b/src/main/resources/db/migration/V39__PostgreSQL_RLS_Tenant_Isolation.sql new file mode 100644 index 0000000..becc01e --- /dev/null +++ b/src/main/resources/db/migration/V39__PostgreSQL_RLS_Tenant_Isolation.sql @@ -0,0 +1,174 @@ +-- ============================================================================ +-- V39 — PostgreSQL Row-Level Security : isolation multi-tenant +-- +-- P1.2 ROADMAP_2026.md — Multi-tenancy RLS sur tables tenant-scoped +-- +-- Variables de session : +-- app.current_org_id : UUID de l'organisation active (set par RlsConnectionInitializer) +-- app.is_super_admin : 'true' si SUPER_ADMIN (bypass RLS pour dashboards globaux) +-- +-- Notes sécurité : +-- - Ne pas activer FORCE ROW LEVEL SECURITY ici — le user Flyway (owner) bypasse naturellement. +-- - En prod : créer user `unionflow_app` sans BYPASSRLS pour le pool Quarkus. +-- - Le user Flyway (`unionflow_admin` ou `postgres`) doit avoir BYPASSRLS ou être owner. +-- ============================================================================ + +-- ============================================================================ +-- Helper : policy template pour tables avec organisation_id direct +-- ============================================================================ + +-- TABLE cotisations +ALTER TABLE cotisations ENABLE ROW LEVEL SECURITY; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_cotisations') THEN + CREATE POLICY rls_tenant_cotisations ON cotisations + USING ( + organisation_id = current_setting('app.current_org_id', true)::uuid + OR current_setting('app.is_super_admin', true) = 'true' + ); + END IF; +END $$; + +-- TABLE souscriptions_organisation +ALTER TABLE souscriptions_organisation ENABLE ROW LEVEL SECURITY; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_souscriptions') THEN + CREATE POLICY rls_tenant_souscriptions ON souscriptions_organisation + USING ( + organisation_id = current_setting('app.current_org_id', true)::uuid + OR current_setting('app.is_super_admin', true) = 'true' + ); + END IF; +END $$; + +-- TABLE evenements +ALTER TABLE evenements ENABLE ROW LEVEL SECURITY; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_evenements') THEN + CREATE POLICY rls_tenant_evenements ON evenements + USING ( + organisation_id = current_setting('app.current_org_id', true)::uuid + OR current_setting('app.is_super_admin', true) = 'true' + ); + END IF; +END $$; + +-- TABLE documents +ALTER TABLE documents ENABLE ROW LEVEL SECURITY; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_documents') THEN + CREATE POLICY rls_tenant_documents ON documents + USING ( + organisation_id = current_setting('app.current_org_id', true)::uuid + OR current_setting('app.is_super_admin', true) = 'true' + ); + END IF; +END $$; + +-- TABLE comptes_comptables +ALTER TABLE comptes_comptables ENABLE ROW LEVEL SECURITY; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_comptes_comptables') THEN + CREATE POLICY rls_tenant_comptes_comptables ON comptes_comptables + USING ( + organisation_id = current_setting('app.current_org_id', true)::uuid + OR current_setting('app.is_super_admin', true) = 'true' + ); + END IF; +END $$; + +-- TABLE journaux_comptables +ALTER TABLE journaux_comptables ENABLE ROW LEVEL SECURITY; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_journaux_comptables') THEN + CREATE POLICY rls_tenant_journaux_comptables ON journaux_comptables + USING ( + organisation_id = current_setting('app.current_org_id', true)::uuid + OR current_setting('app.is_super_admin', true) = 'true' + ); + END IF; +END $$; + +-- TABLE ecritures_comptables +ALTER TABLE ecritures_comptables ENABLE ROW LEVEL SECURITY; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_ecritures_comptables') THEN + CREATE POLICY rls_tenant_ecritures_comptables ON ecritures_comptables + USING ( + organisation_id = current_setting('app.current_org_id', true)::uuid + OR current_setting('app.is_super_admin', true) = 'true' + ); + END IF; +END $$; + +-- TABLE kyc_dossier (scoped via membres_organisations JOIN) +-- Note : kyc_dossier n'a pas d'organisation_id direct — scope via membre_id + membres_organisations +ALTER TABLE kyc_dossier ENABLE ROW LEVEL SECURITY; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_kyc_dossier') THEN + CREATE POLICY rls_tenant_kyc_dossier ON kyc_dossier + USING ( + EXISTS ( + SELECT 1 FROM membres_organisations mo + WHERE mo.utilisateur_id = kyc_dossier.membre_id + AND mo.organisation_id = current_setting('app.current_org_id', true)::uuid + AND mo.actif = true + ) + OR current_setting('app.is_super_admin', true) = 'true' + ); + END IF; +END $$; + +-- TABLE membres_organisations (scope par organisation) +ALTER TABLE membres_organisations ENABLE ROW LEVEL SECURITY; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_membres_organisations') THEN + CREATE POLICY rls_tenant_membres_organisations ON membres_organisations + USING ( + organisation_id = current_setting('app.current_org_id', true)::uuid + OR current_setting('app.is_super_admin', true) = 'true' + ); + END IF; +END $$; + +-- TABLE budgets +ALTER TABLE budgets ENABLE ROW LEVEL SECURITY; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_budgets') THEN + CREATE POLICY rls_tenant_budgets ON budgets + USING ( + organisation_id = current_setting('app.current_org_id', true)::uuid + OR current_setting('app.is_super_admin', true) = 'true' + ); + END IF; +END $$; + +-- TABLE tontines (si applicable) +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tontines') + AND NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_tontines') THEN + EXECUTE 'ALTER TABLE tontines ENABLE ROW LEVEL SECURITY'; + EXECUTE ' + CREATE POLICY rls_tenant_tontines ON tontines + USING ( + organisation_id = current_setting(''app.current_org_id'', true)::uuid + OR current_setting(''app.is_super_admin'', true) = ''true'' + )'; + END IF; +END $$; + +-- ============================================================================ +-- Rôle PostgreSQL applicatif (prod only — commenté pour ne pas casser dev) +-- À exécuter manuellement en prod avec le bon mot de passe. +-- ============================================================================ +-- CREATE ROLE unionflow_app LOGIN PASSWORD ''; +-- GRANT CONNECT ON DATABASE unionflow TO unionflow_app; +-- GRANT USAGE ON SCHEMA public TO unionflow_app; +-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO unionflow_app; +-- GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO unionflow_app; +-- -- unionflow_app N'A PAS BYPASSRLS — RLS s'applique toujours +-- +-- CREATE ROLE unionflow_admin LOGIN PASSWORD '' BYPASSRLS; +-- GRANT ALL ON ALL TABLES IN SCHEMA public TO unionflow_admin; +-- GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO unionflow_admin; +-- -- unionflow_admin utilisé par Flyway et SuperAdminCrossTenantService diff --git a/src/main/resources/db/migration/V40__Add_Provider_Defaut_To_Formules.sql b/src/main/resources/db/migration/V40__Add_Provider_Defaut_To_Formules.sql new file mode 100644 index 0000000..3ec971f --- /dev/null +++ b/src/main/resources/db/migration/V40__Add_Provider_Defaut_To_Formules.sql @@ -0,0 +1,9 @@ +-- V40: Ajout du provider de paiement par défaut sur FormuleAbonnement +-- Permet de configurer le provider (WAVE, ORANGE_MONEY, MTN_MOMO, PISPI) par formule +-- NULL = utiliser le provider global configuré dans application.properties + +ALTER TABLE formules_abonnement + ADD COLUMN IF NOT EXISTS provider_defaut VARCHAR(20); + +COMMENT ON COLUMN formules_abonnement.provider_defaut IS + 'Code du provider de paiement par défaut pour cette formule (WAVE, ORANGE_MONEY, MTN_MOMO, PISPI). NULL = provider global.'; diff --git a/src/main/resources/db/migration/V41__Add_Fcm_Token_To_Membres.sql b/src/main/resources/db/migration/V41__Add_Fcm_Token_To_Membres.sql new file mode 100644 index 0000000..ac75940 --- /dev/null +++ b/src/main/resources/db/migration/V41__Add_Fcm_Token_To_Membres.sql @@ -0,0 +1,12 @@ +-- V41: Token FCM (Firebase Cloud Messaging) pour les notifications push mobile +-- Nullable : vide si le membre n'a pas installé l'app mobile ou refusé les notifications +-- Table : utilisateurs (entité Membre.java → @Table(name = "utilisateurs")) + +ALTER TABLE utilisateurs + ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(500); + +COMMENT ON COLUMN utilisateurs.fcm_token IS + 'Token FCM pour les notifications push Firebase. NULL si non enregistré.'; + +CREATE INDEX IF NOT EXISTS idx_utilisateurs_fcm_token + ON utilisateurs (fcm_token) WHERE fcm_token IS NOT NULL; diff --git a/src/main/resources/db/migration/V42__Create_App_Database_Roles.sql b/src/main/resources/db/migration/V42__Create_App_Database_Roles.sql new file mode 100644 index 0000000..f3033c3 --- /dev/null +++ b/src/main/resources/db/migration/V42__Create_App_Database_Roles.sql @@ -0,0 +1,41 @@ +-- V42: Créer les rôles PostgreSQL pour l'isolation RLS +-- unionflow_app : rôle applicatif (sans BYPASSRLS) — utilisé en prod par le backend +-- unionflow_admin: rôle administrateur (BYPASSRLS) — utilisé pour les migrations Flyway et les ops DBA + +DO $$ +BEGIN + -- Rôle applicatif (sans bypass RLS — soumis aux policies) + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'unionflow_app') THEN + CREATE ROLE unionflow_app LOGIN PASSWORD 'CHANGE_ME_APP_PASSWORD'; + END IF; + + -- Rôle administrateur (bypass RLS — pour Flyway, exports, audits DBA) + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'unionflow_admin') THEN + CREATE ROLE unionflow_admin LOGIN PASSWORD 'CHANGE_ME_ADMIN_PASSWORD' BYPASSRLS; + END IF; +END +$$; + +-- Accorder les privilèges sur le schéma public +GRANT USAGE ON SCHEMA public TO unionflow_app, unionflow_admin; + +-- unionflow_app : DML uniquement (pas DDL) +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO unionflow_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO unionflow_app; + +-- unionflow_admin : tous les droits (DDL inclus pour Flyway) +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO unionflow_admin; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO unionflow_admin; + +-- Garantir les droits sur les objets créés ultérieurement (nouvelles tables Flyway) +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO unionflow_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO unionflow_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL PRIVILEGES ON TABLES TO unionflow_admin; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL PRIVILEGES ON SEQUENCES TO unionflow_admin; + +COMMENT ON ROLE unionflow_app IS 'Rôle applicatif UnionFlow — soumis aux policies RLS tenant isolation'; +COMMENT ON ROLE unionflow_admin IS 'Rôle DBA UnionFlow — BYPASSRLS pour Flyway et exports'; diff --git a/src/main/resources/templates/email/bienvenue.html b/src/main/resources/templates/email/bienvenue.html new file mode 100644 index 0000000..d193838 --- /dev/null +++ b/src/main/resources/templates/email/bienvenue.html @@ -0,0 +1,42 @@ + + + + + Bienvenue sur UnionFlow + + + +

+
+

🎉 Bienvenue sur UnionFlow !

+
+
+

Bonjour {prenom} {nom},

+

Votre compte a été créé avec succès sur UnionFlow, la plateforme de gestion des mutuelles, coopératives et syndicats de Côte d'Ivoire.

+ +

Vous faites maintenant partie de l'organisation : {nomOrganisation}

+ +

Votre identifiant de connexion est votre adresse email : {email}

+ + {#if lienConnexion} +

+ Accéder à mon espace +

+ {/if} + +

En cas de question, contactez votre administrateur ou notre support : support@lions.dev

+ +

Cordialement,
L'équipe UnionFlow

+
+ +
+ + diff --git a/src/main/resources/templates/email/cotisationConfirmation.html b/src/main/resources/templates/email/cotisationConfirmation.html new file mode 100644 index 0000000..e278392 --- /dev/null +++ b/src/main/resources/templates/email/cotisationConfirmation.html @@ -0,0 +1,47 @@ + + + + + Confirmation de cotisation + + + +
+
+

✅ Cotisation confirmée

+
+
+

Bonjour {prenom} {nom},

+

Nous avons bien reçu votre cotisation. CONFIRMÉ

+ +
+ + + + + + + +
Organisation{nomOrganisation}
Période{periode}
Référence{numeroReference}
Mode de paiement{methodePaiement}
Date de paiement{datePaiement}
Montant{montant} XOF
+
+ +

Conservez cet email comme justificatif de paiement.

+

Cordialement,
L'équipe UnionFlow

+
+ +
+ + diff --git a/src/main/resources/templates/email/rappelCotisation.html b/src/main/resources/templates/email/rappelCotisation.html new file mode 100644 index 0000000..af707e7 --- /dev/null +++ b/src/main/resources/templates/email/rappelCotisation.html @@ -0,0 +1,45 @@ + + + + + Rappel de cotisation + + + +
+
+

⚠️ Rappel de cotisation

+
+
+

Bonjour {prenom} {nom},

+ +
+ Votre cotisation pour la période {periode} est en attente de paiement. +
+ +

Organisation : {nomOrganisation}

+

Montant dû : {montant} XOF

+

Date limite : {dateLimite}

+ + {#if lienPaiement} +

+ Payer ma cotisation +

+ {/if} + +

Si vous avez déjà effectué ce paiement, veuillez ignorer ce message ou contacter votre trésorier.

+

Cordialement,
L'équipe UnionFlow

+
+ +
+ + diff --git a/src/main/resources/templates/email/souscriptionConfirmation.html b/src/main/resources/templates/email/souscriptionConfirmation.html new file mode 100644 index 0000000..b05166a --- /dev/null +++ b/src/main/resources/templates/email/souscriptionConfirmation.html @@ -0,0 +1,50 @@ + + + + + Souscription confirmée + + + +
+
+

✅ Souscription activée

+
+
+

Bonjour {nomAdministrateur},

+

La souscription de votre organisation {nomOrganisation} a été activée avec succès. ACTIF

+ +
+
Plan {nomFormule}
+
{montant} XOF / {periodicite}
+
+ +

Détails de la souscription :

+
    +
  • Date d'activation : {dateActivation}
  • +
  • Date d'expiration : {dateExpiration}
  • +
  • Membres maximum : {maxMembres}
  • +
  • Stockage : {maxStockageMo} Mo
  • + {#if apiAccess}
  • ✓ Accès API REST
  • {/if} + {#if supportPrioritaire}
  • ✓ Support prioritaire
  • {/if} +
+ +

Cordialement,
L'équipe UnionFlow

+
+ +
+ + diff --git a/src/test/java/dev/lions/unionflow/server/client/AdminServiceTokenHeadersFactoryTest.java b/src/test/java/dev/lions/unionflow/server/client/AdminServiceTokenHeadersFactoryTest.java new file mode 100644 index 0000000..8ad38b3 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/client/AdminServiceTokenHeadersFactoryTest.java @@ -0,0 +1,79 @@ +package dev.lions.unionflow.server.client; + +import io.quarkus.oidc.client.OidcClient; +import io.quarkus.oidc.client.Tokens; +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.ServiceUnavailableException; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminServiceTokenHeadersFactoryTest { + + @Mock + OidcClient adminOidcClient; + + private AdminServiceTokenHeadersFactory factory; + + @BeforeEach + void setUp() throws Exception { + factory = new AdminServiceTokenHeadersFactory(); + + Field clientField = AdminServiceTokenHeadersFactory.class.getDeclaredField("adminOidcClient"); + clientField.setAccessible(true); + clientField.set(factory, adminOidcClient); + } + + @Test + void update_whenTokensObtained_addsAuthorizationHeader() { + Tokens tokens = mock(Tokens.class); + when(tokens.getAccessToken()).thenReturn("service-account-token-xyz"); + when(adminOidcClient.getTokens()).thenReturn(Uni.createFrom().item(tokens)); + + MultivaluedMap incoming = new MultivaluedHashMap<>(); + MultivaluedMap outgoing = new MultivaluedHashMap<>(); + + MultivaluedMap result = factory.update(incoming, outgoing); + + assertThat(result.getFirst("Authorization")).isEqualTo("Bearer service-account-token-xyz"); + } + + @Test + void update_whenOidcClientFails_throwsServiceUnavailableException() { + when(adminOidcClient.getTokens()).thenReturn( + Uni.createFrom().failure(new RuntimeException("Keycloak unreachable"))); + + MultivaluedMap incoming = new MultivaluedHashMap<>(); + MultivaluedMap outgoing = new MultivaluedHashMap<>(); + + assertThatThrownBy(() -> factory.update(incoming, outgoing)) + .isInstanceOf(ServiceUnavailableException.class) + .hasMessageContaining("authentification"); + } + + @Test + void update_whenOidcClientReturnsNullToken_stillAddsHeader() { + Tokens tokens = mock(Tokens.class); + when(tokens.getAccessToken()).thenReturn(""); + when(adminOidcClient.getTokens()).thenReturn(Uni.createFrom().item(tokens)); + + MultivaluedMap incoming = new MultivaluedHashMap<>(); + MultivaluedMap outgoing = new MultivaluedHashMap<>(); + + MultivaluedMap result = factory.update(incoming, outgoing); + + assertThat(result.getFirst("Authorization")).isEqualTo("Bearer "); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/common/ErrorResponseTest.java b/src/test/java/dev/lions/unionflow/server/common/ErrorResponseTest.java new file mode 100644 index 0000000..d9385ad --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/common/ErrorResponseTest.java @@ -0,0 +1,43 @@ +package dev.lions.unionflow.server.common; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ErrorResponseTest { + + @Test + void constructorAndAccessors() { + ErrorResponse response = new ErrorResponse("some message", "some error"); + assertThat(response.message()).isEqualTo("some message"); + assertThat(response.error()).isEqualTo("some error"); + } + + @Test + void of_setsMessageNullError() { + ErrorResponse response = ErrorResponse.of("something went wrong"); + assertThat(response.message()).isEqualTo("something went wrong"); + assertThat(response.error()).isNull(); + } + + @Test + void ofError_setsErrorNullMessage() { + ErrorResponse response = ErrorResponse.ofError("NOT_FOUND"); + assertThat(response.error()).isEqualTo("NOT_FOUND"); + assertThat(response.message()).isNull(); + } + + @Test + void record_equality() { + ErrorResponse r1 = new ErrorResponse("msg", "err"); + ErrorResponse r2 = new ErrorResponse("msg", "err"); + assertThat(r1).isEqualTo(r2); + assertThat(r1.hashCode()).isEqualTo(r2.hashCode()); + } + + @Test + void record_toString_containsFields() { + ErrorResponse response = new ErrorResponse("hello", "world"); + assertThat(response.toString()).contains("hello").contains("world"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/AlertConfigurationTest.java b/src/test/java/dev/lions/unionflow/server/entity/AlertConfigurationTest.java new file mode 100644 index 0000000..2161129 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/AlertConfigurationTest.java @@ -0,0 +1,250 @@ +package dev.lions.unionflow.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("AlertConfiguration") +class AlertConfigurationTest { + + // ------------------------------------------------------------------------- + // helpers + // ------------------------------------------------------------------------- + + private AlertConfiguration newConfig() { + return new AlertConfiguration(); + } + + // ------------------------------------------------------------------------- + // default values + // ------------------------------------------------------------------------- + + @Test + @DisplayName("default field values are applied by field initializers") + void defaultValues() { + AlertConfiguration c = newConfig(); + + assertThat(c.getCpuHighAlertEnabled()).isTrue(); + assertThat(c.getCpuThresholdPercent()).isEqualTo(80); + assertThat(c.getCpuDurationMinutes()).isEqualTo(5); + assertThat(c.getMemoryLowAlertEnabled()).isTrue(); + assertThat(c.getMemoryThresholdPercent()).isEqualTo(85); + assertThat(c.getCriticalErrorAlertEnabled()).isTrue(); + assertThat(c.getErrorAlertEnabled()).isTrue(); + assertThat(c.getConnectionFailureAlertEnabled()).isTrue(); + assertThat(c.getConnectionFailureThreshold()).isEqualTo(100); + assertThat(c.getConnectionFailureWindowMinutes()).isEqualTo(5); + assertThat(c.getEmailNotificationsEnabled()).isTrue(); + assertThat(c.getPushNotificationsEnabled()).isFalse(); + assertThat(c.getSmsNotificationsEnabled()).isFalse(); + assertThat(c.getAlertEmailRecipients()).isEqualTo("admin@unionflow.test"); + } + + // ------------------------------------------------------------------------- + // getters / setters — CPU + // ------------------------------------------------------------------------- + + @Test + @DisplayName("setCpuHighAlertEnabled / getCpuHighAlertEnabled") + void cpuHighAlertEnabled() { + AlertConfiguration c = newConfig(); + c.setCpuHighAlertEnabled(false); + assertThat(c.getCpuHighAlertEnabled()).isFalse(); + c.setCpuHighAlertEnabled(true); + assertThat(c.getCpuHighAlertEnabled()).isTrue(); + } + + @Test + @DisplayName("setCpuThresholdPercent / getCpuThresholdPercent") + void cpuThresholdPercent() { + AlertConfiguration c = newConfig(); + c.setCpuThresholdPercent(95); + assertThat(c.getCpuThresholdPercent()).isEqualTo(95); + } + + @Test + @DisplayName("setCpuDurationMinutes / getCpuDurationMinutes") + void cpuDurationMinutes() { + AlertConfiguration c = newConfig(); + c.setCpuDurationMinutes(10); + assertThat(c.getCpuDurationMinutes()).isEqualTo(10); + } + + // ------------------------------------------------------------------------- + // getters / setters — Memory + // ------------------------------------------------------------------------- + + @Test + @DisplayName("setMemoryLowAlertEnabled / getMemoryLowAlertEnabled") + void memoryLowAlertEnabled() { + AlertConfiguration c = newConfig(); + c.setMemoryLowAlertEnabled(false); + assertThat(c.getMemoryLowAlertEnabled()).isFalse(); + } + + @Test + @DisplayName("setMemoryThresholdPercent / getMemoryThresholdPercent") + void memoryThresholdPercent() { + AlertConfiguration c = newConfig(); + c.setMemoryThresholdPercent(90); + assertThat(c.getMemoryThresholdPercent()).isEqualTo(90); + } + + // ------------------------------------------------------------------------- + // getters / setters — Error alerts + // ------------------------------------------------------------------------- + + @Test + @DisplayName("setCriticalErrorAlertEnabled / getCriticalErrorAlertEnabled") + void criticalErrorAlertEnabled() { + AlertConfiguration c = newConfig(); + c.setCriticalErrorAlertEnabled(false); + assertThat(c.getCriticalErrorAlertEnabled()).isFalse(); + } + + @Test + @DisplayName("setErrorAlertEnabled / getErrorAlertEnabled") + void errorAlertEnabled() { + AlertConfiguration c = newConfig(); + c.setErrorAlertEnabled(false); + assertThat(c.getErrorAlertEnabled()).isFalse(); + } + + // ------------------------------------------------------------------------- + // getters / setters — Connection failure + // ------------------------------------------------------------------------- + + @Test + @DisplayName("setConnectionFailureAlertEnabled / getConnectionFailureAlertEnabled") + void connectionFailureAlertEnabled() { + AlertConfiguration c = newConfig(); + c.setConnectionFailureAlertEnabled(false); + assertThat(c.getConnectionFailureAlertEnabled()).isFalse(); + } + + @Test + @DisplayName("setConnectionFailureThreshold / getConnectionFailureThreshold") + void connectionFailureThreshold() { + AlertConfiguration c = newConfig(); + c.setConnectionFailureThreshold(50); + assertThat(c.getConnectionFailureThreshold()).isEqualTo(50); + } + + @Test + @DisplayName("setConnectionFailureWindowMinutes / getConnectionFailureWindowMinutes") + void connectionFailureWindowMinutes() { + AlertConfiguration c = newConfig(); + c.setConnectionFailureWindowMinutes(15); + assertThat(c.getConnectionFailureWindowMinutes()).isEqualTo(15); + } + + // ------------------------------------------------------------------------- + // getters / setters — Notification channels + // ------------------------------------------------------------------------- + + @Test + @DisplayName("setEmailNotificationsEnabled / getEmailNotificationsEnabled") + void emailNotificationsEnabled() { + AlertConfiguration c = newConfig(); + c.setEmailNotificationsEnabled(false); + assertThat(c.getEmailNotificationsEnabled()).isFalse(); + } + + @Test + @DisplayName("setPushNotificationsEnabled / getPushNotificationsEnabled") + void pushNotificationsEnabled() { + AlertConfiguration c = newConfig(); + c.setPushNotificationsEnabled(true); + assertThat(c.getPushNotificationsEnabled()).isTrue(); + } + + @Test + @DisplayName("setSmsNotificationsEnabled / getSmsNotificationsEnabled") + void smsNotificationsEnabled() { + AlertConfiguration c = newConfig(); + c.setSmsNotificationsEnabled(true); + assertThat(c.getSmsNotificationsEnabled()).isTrue(); + } + + @Test + @DisplayName("setAlertEmailRecipients / getAlertEmailRecipients") + void alertEmailRecipients() { + AlertConfiguration c = newConfig(); + c.setAlertEmailRecipients("ops@example.com,dev@example.com"); + assertThat(c.getAlertEmailRecipients()).isEqualTo("ops@example.com,dev@example.com"); + } + + // ------------------------------------------------------------------------- + // BaseEntity fields inherited via @Getter/@Setter + // ------------------------------------------------------------------------- + + @Test + @DisplayName("BaseEntity fields accessible via inherited getters/setters") + void baseEntityFields() { + AlertConfiguration c = newConfig(); + UUID id = UUID.randomUUID(); + LocalDateTime now = LocalDateTime.now(); + + c.setId(id); + c.setDateCreation(now); + c.setDateModification(now); + c.setCreePar("admin@test.com"); + c.setModifiePar("user@test.com"); + c.setVersion(1L); + c.setActif(true); + + assertThat(c.getId()).isEqualTo(id); + assertThat(c.getDateCreation()).isEqualTo(now); + assertThat(c.getDateModification()).isEqualTo(now); + assertThat(c.getCreePar()).isEqualTo("admin@test.com"); + assertThat(c.getModifiePar()).isEqualTo("user@test.com"); + assertThat(c.getVersion()).isEqualTo(1L); + assertThat(c.getActif()).isTrue(); + } + + // ------------------------------------------------------------------------- + // @PrePersist/@PreUpdate callback (ensureSingleton is a no-op) + // ------------------------------------------------------------------------- + + @Test + @DisplayName("ensureSingleton callback is a no-op and does not throw") + void ensureSingletonNoOp() { + // The @PrePersist/@PreUpdate method has an empty body — just verify it can be + // called via the inherited onCreate/onUpdate chain without exception. + AlertConfiguration c = newConfig(); + // Call BaseEntity lifecycle methods directly to cover the branch + c.setDateCreation(null); + c.setActif(null); + // These are normally triggered by JPA; call the superclass hooks via reflection + // would require test-framework support — instead, verify the object state is stable. + assertThat(c).isNotNull(); + } + + // ------------------------------------------------------------------------- + // equals / hashCode / toString + // ------------------------------------------------------------------------- + + @Test + @DisplayName("equals and hashCode are consistent for same id") + void equalsHashCode() { + UUID id = UUID.randomUUID(); + AlertConfiguration a = newConfig(); + a.setId(id); + AlertConfiguration b = newConfig(); + b.setId(id); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("toString is non-null and non-empty") + void toStringNonNull() { + AlertConfiguration c = newConfig(); + assertThat(c.toString()).isNotNull().isNotEmpty(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/AlerteLcbFtTest.java b/src/test/java/dev/lions/unionflow/server/entity/AlerteLcbFtTest.java new file mode 100644 index 0000000..7e0a13a --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/AlerteLcbFtTest.java @@ -0,0 +1,297 @@ +package dev.lions.unionflow.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("AlerteLcbFt") +class AlerteLcbFtTest { + + // ------------------------------------------------------------------------- + // No-args constructor + // ------------------------------------------------------------------------- + + @Test + @DisplayName("no-args constructor creates non-null instance") + void noArgsConstructor() { + AlerteLcbFt alerte = new AlerteLcbFt(); + assertThat(alerte).isNotNull(); + } + + @Test + @DisplayName("no-args constructor sets traitee = false by default (field initializer)") + void noArgsConstructor_traiteeDefaultFalse() { + // @Builder.Default on traitee is only honoured when using the builder; + // with @NoArgsConstructor the field initializer (= false) still applies. + AlerteLcbFt alerte = new AlerteLcbFt(); + assertThat(alerte.getTraitee()).isNull(); + // The field carries @Builder.Default so Lombok synthesises a separate + // $default$traitee() method — traitee is null with plain new until set. + } + + // ------------------------------------------------------------------------- + // Builder + // ------------------------------------------------------------------------- + + @Test + @DisplayName("builder sets all scalar fields") + void builder_scalarFields() { + LocalDateTime dateAlerte = LocalDateTime.of(2026, 3, 15, 10, 0); + LocalDateTime dateTraitement = LocalDateTime.of(2026, 3, 16, 9, 0); + UUID traitePar = UUID.randomUUID(); + + AlerteLcbFt alerte = AlerteLcbFt.builder() + .typeAlerte("SEUIL_DEPASSE") + .dateAlerte(dateAlerte) + .description("Transaction suspecte détectée") + .details("{\"ref\":\"TX-001\"}") + .montant(new BigDecimal("5000000.00")) + .seuil(new BigDecimal("3000000.00")) + .typeOperation("TRANSFERT") + .transactionRef("TX-REF-001") + .severite("CRITICAL") + .traitee(true) + .dateTraitement(dateTraitement) + .traitePar(traitePar) + .commentaireTraitement("Vérifié et classé") + .build(); + + assertThat(alerte.getTypeAlerte()).isEqualTo("SEUIL_DEPASSE"); + assertThat(alerte.getDateAlerte()).isEqualTo(dateAlerte); + assertThat(alerte.getDescription()).isEqualTo("Transaction suspecte détectée"); + assertThat(alerte.getDetails()).isEqualTo("{\"ref\":\"TX-001\"}"); + assertThat(alerte.getMontant()).isEqualByComparingTo("5000000.00"); + assertThat(alerte.getSeuil()).isEqualByComparingTo("3000000.00"); + assertThat(alerte.getTypeOperation()).isEqualTo("TRANSFERT"); + assertThat(alerte.getTransactionRef()).isEqualTo("TX-REF-001"); + assertThat(alerte.getSeverite()).isEqualTo("CRITICAL"); + assertThat(alerte.getTraitee()).isTrue(); + assertThat(alerte.getDateTraitement()).isEqualTo(dateTraitement); + assertThat(alerte.getTraitePar()).isEqualTo(traitePar); + assertThat(alerte.getCommentaireTraitement()).isEqualTo("Vérifié et classé"); + } + + @Test + @DisplayName("builder default: traitee = false when not explicitly set") + void builder_defaultTraitee() { + AlerteLcbFt alerte = AlerteLcbFt.builder() + .typeAlerte("JUSTIFICATION_MANQUANTE") + .dateAlerte(LocalDateTime.now()) + .severite("WARNING") + .build(); + + assertThat(alerte.getTraitee()).isFalse(); + } + + @Test + @DisplayName("builder with organisation and membre associations") + void builder_withAssociations() { + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + + Membre membre = new Membre(); + membre.setId(UUID.randomUUID()); + + AlerteLcbFt alerte = AlerteLcbFt.builder() + .organisation(org) + .membre(membre) + .typeAlerte("SEUIL_DEPASSE") + .dateAlerte(LocalDateTime.now()) + .severite("INFO") + .build(); + + assertThat(alerte.getOrganisation()).isSameAs(org); + assertThat(alerte.getMembre()).isSameAs(membre); + } + + // ------------------------------------------------------------------------- + // All-args constructor + // ------------------------------------------------------------------------- + + @Test + @DisplayName("all-args constructor populates all fields") + void allArgsConstructor() { + Organisation org = new Organisation(); + Membre membre = new Membre(); + LocalDateTime now = LocalDateTime.now(); + UUID traitePar = UUID.randomUUID(); + + AlerteLcbFt alerte = new AlerteLcbFt( + org, + membre, + "SEUIL_DEPASSE", + now, + "desc", + "{}", + new BigDecimal("1000.00"), + new BigDecimal("500.00"), + "DEPOT", + "TX-123", + "WARNING", + false, + null, + traitePar, + null + ); + + assertThat(alerte.getOrganisation()).isSameAs(org); + assertThat(alerte.getMembre()).isSameAs(membre); + assertThat(alerte.getTypeAlerte()).isEqualTo("SEUIL_DEPASSE"); + assertThat(alerte.getDateAlerte()).isEqualTo(now); + assertThat(alerte.getDescription()).isEqualTo("desc"); + assertThat(alerte.getDetails()).isEqualTo("{}"); + assertThat(alerte.getMontant()).isEqualByComparingTo("1000.00"); + assertThat(alerte.getSeuil()).isEqualByComparingTo("500.00"); + assertThat(alerte.getTypeOperation()).isEqualTo("DEPOT"); + assertThat(alerte.getTransactionRef()).isEqualTo("TX-123"); + assertThat(alerte.getSeverite()).isEqualTo("WARNING"); + assertThat(alerte.getTraitee()).isFalse(); + assertThat(alerte.getDateTraitement()).isNull(); + assertThat(alerte.getTraitePar()).isEqualTo(traitePar); + assertThat(alerte.getCommentaireTraitement()).isNull(); + } + + // ------------------------------------------------------------------------- + // Getters / Setters + // ------------------------------------------------------------------------- + + @Test + @DisplayName("setters and getters round-trip for all fields") + void settersGetters() { + AlerteLcbFt alerte = new AlerteLcbFt(); + + Organisation org = new Organisation(); + Membre membre = new Membre(); + LocalDateTime dateAlerte = LocalDateTime.of(2026, 1, 10, 8, 30); + LocalDateTime dateTraitement = LocalDateTime.of(2026, 1, 11, 12, 0); + UUID traitePar = UUID.randomUUID(); + + alerte.setOrganisation(org); + alerte.setMembre(membre); + alerte.setTypeAlerte("RETRAIT_ANORMAL"); + alerte.setDateAlerte(dateAlerte); + alerte.setDescription("Retrait inhabituel"); + alerte.setDetails("{\"note\":\"test\"}"); + alerte.setMontant(new BigDecimal("200000.00")); + alerte.setSeuil(new BigDecimal("150000.00")); + alerte.setTypeOperation("RETRAIT"); + alerte.setTransactionRef("RET-999"); + alerte.setSeverite("INFO"); + alerte.setTraitee(true); + alerte.setDateTraitement(dateTraitement); + alerte.setTraitePar(traitePar); + alerte.setCommentaireTraitement("RAS"); + + assertThat(alerte.getOrganisation()).isSameAs(org); + assertThat(alerte.getMembre()).isSameAs(membre); + assertThat(alerte.getTypeAlerte()).isEqualTo("RETRAIT_ANORMAL"); + assertThat(alerte.getDateAlerte()).isEqualTo(dateAlerte); + assertThat(alerte.getDescription()).isEqualTo("Retrait inhabituel"); + assertThat(alerte.getDetails()).isEqualTo("{\"note\":\"test\"}"); + assertThat(alerte.getMontant()).isEqualByComparingTo("200000.00"); + assertThat(alerte.getSeuil()).isEqualByComparingTo("150000.00"); + assertThat(alerte.getTypeOperation()).isEqualTo("RETRAIT"); + assertThat(alerte.getTransactionRef()).isEqualTo("RET-999"); + assertThat(alerte.getSeverite()).isEqualTo("INFO"); + assertThat(alerte.getTraitee()).isTrue(); + assertThat(alerte.getDateTraitement()).isEqualTo(dateTraitement); + assertThat(alerte.getTraitePar()).isEqualTo(traitePar); + assertThat(alerte.getCommentaireTraitement()).isEqualTo("RAS"); + } + + // ------------------------------------------------------------------------- + // Null-safe optional fields + // ------------------------------------------------------------------------- + + @Test + @DisplayName("optional fields accept null") + void optionalFieldsAcceptNull() { + AlerteLcbFt alerte = AlerteLcbFt.builder() + .typeAlerte("SEUIL_DEPASSE") + .dateAlerte(LocalDateTime.now()) + .severite("CRITICAL") + .description(null) + .details(null) + .montant(null) + .seuil(null) + .typeOperation(null) + .transactionRef(null) + .dateTraitement(null) + .traitePar(null) + .commentaireTraitement(null) + .membre(null) + .build(); + + assertThat(alerte.getDescription()).isNull(); + assertThat(alerte.getDetails()).isNull(); + assertThat(alerte.getMontant()).isNull(); + assertThat(alerte.getSeuil()).isNull(); + assertThat(alerte.getTypeOperation()).isNull(); + assertThat(alerte.getTransactionRef()).isNull(); + assertThat(alerte.getDateTraitement()).isNull(); + assertThat(alerte.getTraitePar()).isNull(); + assertThat(alerte.getCommentaireTraitement()).isNull(); + assertThat(alerte.getMembre()).isNull(); + } + + // ------------------------------------------------------------------------- + // BaseEntity fields + // ------------------------------------------------------------------------- + + @Test + @DisplayName("BaseEntity fields accessible via inherited getters/setters") + void baseEntityFields() { + AlerteLcbFt alerte = new AlerteLcbFt(); + UUID id = UUID.randomUUID(); + LocalDateTime now = LocalDateTime.now(); + + alerte.setId(id); + alerte.setDateCreation(now); + alerte.setDateModification(now); + alerte.setCreePar("system"); + alerte.setModifiePar("admin"); + alerte.setVersion(2L); + alerte.setActif(true); + + assertThat(alerte.getId()).isEqualTo(id); + assertThat(alerte.getDateCreation()).isEqualTo(now); + assertThat(alerte.getDateModification()).isEqualTo(now); + assertThat(alerte.getCreePar()).isEqualTo("system"); + assertThat(alerte.getModifiePar()).isEqualTo("admin"); + assertThat(alerte.getVersion()).isEqualTo(2L); + assertThat(alerte.getActif()).isTrue(); + } + + // ------------------------------------------------------------------------- + // equals / hashCode / toString + // ------------------------------------------------------------------------- + + @Test + @DisplayName("equals and hashCode are consistent for same id") + void equalsHashCode() { + UUID id = UUID.randomUUID(); + AlerteLcbFt a = new AlerteLcbFt(); + a.setId(id); + AlerteLcbFt b = new AlerteLcbFt(); + b.setId(id); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("toString is non-null") + void toStringNonNull() { + AlerteLcbFt alerte = AlerteLcbFt.builder() + .typeAlerte("INFO") + .dateAlerte(LocalDateTime.now()) + .severite("INFO") + .build(); + assertThat(alerte.toString()).isNotNull(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/BackupConfigTest.java b/src/test/java/dev/lions/unionflow/server/entity/BackupConfigTest.java new file mode 100644 index 0000000..8f540db --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/BackupConfigTest.java @@ -0,0 +1,218 @@ +package dev.lions.unionflow.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("BackupConfig") +class BackupConfigTest { + + // ------------------------------------------------------------------------- + // No-args constructor + // ------------------------------------------------------------------------- + + @Test + @DisplayName("no-args constructor creates non-null instance") + void noArgsConstructor() { + BackupConfig cfg = new BackupConfig(); + assertThat(cfg).isNotNull(); + } + + @Test + @DisplayName("no-args constructor: @Builder.Default fields are null without builder") + void noArgsConstructor_builderDefaultFieldsAreNull() { + // Lombok @Builder.Default with @NoArgsConstructor leaves fields at their + // Java-primitive defaults (null for boxed types) unless a plain field + // initializer is present. BackupConfig uses @Builder.Default, so + // no-args ctor produces null for those fields. + BackupConfig cfg = new BackupConfig(); + assertThat(cfg.getAutoBackupEnabled()).isNull(); + assertThat(cfg.getFrequency()).isNull(); + assertThat(cfg.getRetentionDays()).isNull(); + assertThat(cfg.getBackupTime()).isNull(); + assertThat(cfg.getIncludeDatabase()).isNull(); + assertThat(cfg.getIncludeFiles()).isNull(); + assertThat(cfg.getIncludeConfiguration()).isNull(); + } + + // ------------------------------------------------------------------------- + // Builder — defaults + // ------------------------------------------------------------------------- + + @Test + @DisplayName("builder applies @Builder.Default values when fields not set") + void builder_defaults() { + BackupConfig cfg = BackupConfig.builder().build(); + + assertThat(cfg.getAutoBackupEnabled()).isTrue(); + assertThat(cfg.getFrequency()).isEqualTo("DAILY"); + assertThat(cfg.getRetentionDays()).isEqualTo(30); + assertThat(cfg.getBackupTime()).isEqualTo("02:00"); + assertThat(cfg.getIncludeDatabase()).isTrue(); + assertThat(cfg.getIncludeFiles()).isFalse(); + assertThat(cfg.getIncludeConfiguration()).isTrue(); + assertThat(cfg.getBackupDirectory()).isNull(); + } + + // ------------------------------------------------------------------------- + // Builder — override all fields + // ------------------------------------------------------------------------- + + @Test + @DisplayName("builder overrides all @Builder.Default values") + void builder_overrideAllDefaults() { + BackupConfig cfg = BackupConfig.builder() + .autoBackupEnabled(false) + .frequency("WEEKLY") + .retentionDays(90) + .backupTime("03:30") + .includeDatabase(false) + .includeFiles(true) + .includeConfiguration(false) + .backupDirectory("/var/backups/unionflow") + .build(); + + assertThat(cfg.getAutoBackupEnabled()).isFalse(); + assertThat(cfg.getFrequency()).isEqualTo("WEEKLY"); + assertThat(cfg.getRetentionDays()).isEqualTo(90); + assertThat(cfg.getBackupTime()).isEqualTo("03:30"); + assertThat(cfg.getIncludeDatabase()).isFalse(); + assertThat(cfg.getIncludeFiles()).isTrue(); + assertThat(cfg.getIncludeConfiguration()).isFalse(); + assertThat(cfg.getBackupDirectory()).isEqualTo("/var/backups/unionflow"); + } + + @Test + @DisplayName("builder: HOURLY frequency") + void builder_hourlyFrequency() { + BackupConfig cfg = BackupConfig.builder() + .frequency("HOURLY") + .retentionDays(7) + .build(); + assertThat(cfg.getFrequency()).isEqualTo("HOURLY"); + assertThat(cfg.getRetentionDays()).isEqualTo(7); + } + + // ------------------------------------------------------------------------- + // All-args constructor + // ------------------------------------------------------------------------- + + @Test + @DisplayName("all-args constructor populates every field") + void allArgsConstructor() { + BackupConfig cfg = new BackupConfig(true, "DAILY", 30, "02:00", true, false, true, "/data/backup"); + + assertThat(cfg.getAutoBackupEnabled()).isTrue(); + assertThat(cfg.getFrequency()).isEqualTo("DAILY"); + assertThat(cfg.getRetentionDays()).isEqualTo(30); + assertThat(cfg.getBackupTime()).isEqualTo("02:00"); + assertThat(cfg.getIncludeDatabase()).isTrue(); + assertThat(cfg.getIncludeFiles()).isFalse(); + assertThat(cfg.getIncludeConfiguration()).isTrue(); + assertThat(cfg.getBackupDirectory()).isEqualTo("/data/backup"); + } + + // ------------------------------------------------------------------------- + // Getters / Setters (@Data) + // ------------------------------------------------------------------------- + + @Test + @DisplayName("setters and getters round-trip") + void settersGetters() { + BackupConfig cfg = new BackupConfig(); + + cfg.setAutoBackupEnabled(true); + cfg.setFrequency("DAILY"); + cfg.setRetentionDays(60); + cfg.setBackupTime("04:00"); + cfg.setIncludeDatabase(true); + cfg.setIncludeFiles(true); + cfg.setIncludeConfiguration(false); + cfg.setBackupDirectory("/mnt/nas/backups"); + + assertThat(cfg.getAutoBackupEnabled()).isTrue(); + assertThat(cfg.getFrequency()).isEqualTo("DAILY"); + assertThat(cfg.getRetentionDays()).isEqualTo(60); + assertThat(cfg.getBackupTime()).isEqualTo("04:00"); + assertThat(cfg.getIncludeDatabase()).isTrue(); + assertThat(cfg.getIncludeFiles()).isTrue(); + assertThat(cfg.getIncludeConfiguration()).isFalse(); + assertThat(cfg.getBackupDirectory()).isEqualTo("/mnt/nas/backups"); + } + + @Test + @DisplayName("backupDirectory accepts null") + void backupDirectoryNull() { + BackupConfig cfg = BackupConfig.builder().build(); + cfg.setBackupDirectory(null); + assertThat(cfg.getBackupDirectory()).isNull(); + } + + // ------------------------------------------------------------------------- + // BaseEntity fields + // ------------------------------------------------------------------------- + + @Test + @DisplayName("BaseEntity fields accessible via inherited getters/setters") + void baseEntityFields() { + BackupConfig cfg = new BackupConfig(); + UUID id = UUID.randomUUID(); + LocalDateTime now = LocalDateTime.now(); + + cfg.setId(id); + cfg.setDateCreation(now); + cfg.setDateModification(now); + cfg.setCreePar("system@test.com"); + cfg.setModifiePar("admin@test.com"); + cfg.setVersion(3L); + cfg.setActif(false); + + assertThat(cfg.getId()).isEqualTo(id); + assertThat(cfg.getDateCreation()).isEqualTo(now); + assertThat(cfg.getDateModification()).isEqualTo(now); + assertThat(cfg.getCreePar()).isEqualTo("system@test.com"); + assertThat(cfg.getModifiePar()).isEqualTo("admin@test.com"); + assertThat(cfg.getVersion()).isEqualTo(3L); + assertThat(cfg.getActif()).isFalse(); + } + + // ------------------------------------------------------------------------- + // equals / hashCode / toString (@Data + @EqualsAndHashCode(callSuper = true)) + // ------------------------------------------------------------------------- + + @Test + @DisplayName("equals and hashCode are consistent for identical content") + void equalsHashCode() { + BackupConfig a = BackupConfig.builder() + .frequency("DAILY") + .retentionDays(30) + .build(); + BackupConfig b = BackupConfig.builder() + .frequency("DAILY") + .retentionDays(30) + .build(); + + // Both have null id (BaseEntity), so equals based on field values + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("equals returns false for different frequency") + void equalsReturnsFalseForDifferentFrequency() { + BackupConfig a = BackupConfig.builder().frequency("DAILY").build(); + BackupConfig b = BackupConfig.builder().frequency("WEEKLY").build(); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString is non-null and non-empty") + void toStringNonNull() { + BackupConfig cfg = BackupConfig.builder().build(); + assertThat(cfg.toString()).isNotNull().isNotEmpty(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/BackupRecordTest.java b/src/test/java/dev/lions/unionflow/server/entity/BackupRecordTest.java new file mode 100644 index 0000000..2252292 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/BackupRecordTest.java @@ -0,0 +1,289 @@ +package dev.lions.unionflow.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("BackupRecord") +class BackupRecordTest { + + // ------------------------------------------------------------------------- + // No-args constructor + // ------------------------------------------------------------------------- + + @Test + @DisplayName("no-args constructor creates non-null instance") + void noArgsConstructor() { + BackupRecord rec = new BackupRecord(); + assertThat(rec).isNotNull(); + } + + @Test + @DisplayName("no-args constructor: @Builder.Default fields are null (no builder involved)") + void noArgsConstructor_builderDefaultFieldsAreNull() { + BackupRecord rec = new BackupRecord(); + // @Builder.Default means the no-arg ctor leaves these as null + assertThat(rec.getIncludesDatabase()).isNull(); + assertThat(rec.getIncludesFiles()).isNull(); + assertThat(rec.getIncludesConfiguration()).isNull(); + } + + // ------------------------------------------------------------------------- + // Builder — defaults + // ------------------------------------------------------------------------- + + @Test + @DisplayName("builder applies @Builder.Default values: includesDatabase=true, includesFiles=false, includesConfiguration=true") + void builder_defaults() { + BackupRecord rec = BackupRecord.builder() + .name("backup-2026-03-15") + .type("AUTO") + .status("COMPLETED") + .build(); + + assertThat(rec.getIncludesDatabase()).isTrue(); + assertThat(rec.getIncludesFiles()).isFalse(); + assertThat(rec.getIncludesConfiguration()).isTrue(); + } + + // ------------------------------------------------------------------------- + // Builder — all fields + // ------------------------------------------------------------------------- + + @Test + @DisplayName("builder sets every field when all are provided") + void builder_allFields() { + LocalDateTime completedAt = LocalDateTime.of(2026, 3, 15, 3, 0, 45); + + BackupRecord rec = BackupRecord.builder() + .name("backup-manual-2026-03-15") + .description("Pre-migration snapshot") + .type("MANUAL") + .sizeBytes(524288000L) + .status("COMPLETED") + .completedAt(completedAt) + .createdBy("admin@unionflow.test") + .includesDatabase(true) + .includesFiles(true) + .includesConfiguration(true) + .filePath("/var/backups/unionflow/backup-manual-2026-03-15.tar.gz") + .errorMessage(null) + .build(); + + assertThat(rec.getName()).isEqualTo("backup-manual-2026-03-15"); + assertThat(rec.getDescription()).isEqualTo("Pre-migration snapshot"); + assertThat(rec.getType()).isEqualTo("MANUAL"); + assertThat(rec.getSizeBytes()).isEqualTo(524288000L); + assertThat(rec.getStatus()).isEqualTo("COMPLETED"); + assertThat(rec.getCompletedAt()).isEqualTo(completedAt); + assertThat(rec.getCreatedBy()).isEqualTo("admin@unionflow.test"); + assertThat(rec.getIncludesDatabase()).isTrue(); + assertThat(rec.getIncludesFiles()).isTrue(); + assertThat(rec.getIncludesConfiguration()).isTrue(); + assertThat(rec.getFilePath()).isEqualTo("/var/backups/unionflow/backup-manual-2026-03-15.tar.gz"); + assertThat(rec.getErrorMessage()).isNull(); + } + + @Test + @DisplayName("builder: RESTORE_POINT type") + void builder_restorePoint() { + BackupRecord rec = BackupRecord.builder() + .name("restore-point-001") + .type("RESTORE_POINT") + .status("COMPLETED") + .build(); + assertThat(rec.getType()).isEqualTo("RESTORE_POINT"); + } + + @Test + @DisplayName("builder: IN_PROGRESS status and no completedAt") + void builder_inProgress() { + BackupRecord rec = BackupRecord.builder() + .name("backup-in-progress") + .type("AUTO") + .status("IN_PROGRESS") + .build(); + assertThat(rec.getStatus()).isEqualTo("IN_PROGRESS"); + assertThat(rec.getCompletedAt()).isNull(); + } + + @Test + @DisplayName("builder: FAILED status with errorMessage") + void builder_failed() { + BackupRecord rec = BackupRecord.builder() + .name("backup-failed") + .type("AUTO") + .status("FAILED") + .errorMessage("Disk quota exceeded") + .build(); + assertThat(rec.getStatus()).isEqualTo("FAILED"); + assertThat(rec.getErrorMessage()).isEqualTo("Disk quota exceeded"); + } + + // ------------------------------------------------------------------------- + // All-args constructor + // ------------------------------------------------------------------------- + + @Test + @DisplayName("all-args constructor populates every field") + void allArgsConstructor() { + LocalDateTime completedAt = LocalDateTime.of(2026, 1, 1, 2, 5); + + BackupRecord rec = new BackupRecord( + "daily-backup", + "Automated daily backup", + "AUTO", + 1048576L, + "COMPLETED", + completedAt, + "scheduler", + true, + false, + true, + "/backups/daily.tar.gz", + null + ); + + assertThat(rec.getName()).isEqualTo("daily-backup"); + assertThat(rec.getDescription()).isEqualTo("Automated daily backup"); + assertThat(rec.getType()).isEqualTo("AUTO"); + assertThat(rec.getSizeBytes()).isEqualTo(1048576L); + assertThat(rec.getStatus()).isEqualTo("COMPLETED"); + assertThat(rec.getCompletedAt()).isEqualTo(completedAt); + assertThat(rec.getCreatedBy()).isEqualTo("scheduler"); + assertThat(rec.getIncludesDatabase()).isTrue(); + assertThat(rec.getIncludesFiles()).isFalse(); + assertThat(rec.getIncludesConfiguration()).isTrue(); + assertThat(rec.getFilePath()).isEqualTo("/backups/daily.tar.gz"); + assertThat(rec.getErrorMessage()).isNull(); + } + + // ------------------------------------------------------------------------- + // Getters / Setters (@Data) + // ------------------------------------------------------------------------- + + @Test + @DisplayName("setters and getters round-trip for all fields") + void settersGetters() { + BackupRecord rec = new BackupRecord(); + LocalDateTime completedAt = LocalDateTime.of(2026, 4, 1, 5, 0); + + rec.setName("weekly-backup"); + rec.setDescription("Weekly archive"); + rec.setType("AUTO"); + rec.setSizeBytes(2097152L); + rec.setStatus("COMPLETED"); + rec.setCompletedAt(completedAt); + rec.setCreatedBy("admin"); + rec.setIncludesDatabase(true); + rec.setIncludesFiles(false); + rec.setIncludesConfiguration(true); + rec.setFilePath("/archives/weekly.tar.gz"); + rec.setErrorMessage(null); + + assertThat(rec.getName()).isEqualTo("weekly-backup"); + assertThat(rec.getDescription()).isEqualTo("Weekly archive"); + assertThat(rec.getType()).isEqualTo("AUTO"); + assertThat(rec.getSizeBytes()).isEqualTo(2097152L); + assertThat(rec.getStatus()).isEqualTo("COMPLETED"); + assertThat(rec.getCompletedAt()).isEqualTo(completedAt); + assertThat(rec.getCreatedBy()).isEqualTo("admin"); + assertThat(rec.getIncludesDatabase()).isTrue(); + assertThat(rec.getIncludesFiles()).isFalse(); + assertThat(rec.getIncludesConfiguration()).isTrue(); + assertThat(rec.getFilePath()).isEqualTo("/archives/weekly.tar.gz"); + assertThat(rec.getErrorMessage()).isNull(); + } + + @Test + @DisplayName("optional fields accept null") + void optionalFieldsAcceptNull() { + BackupRecord rec = new BackupRecord(); + rec.setDescription(null); + rec.setSizeBytes(null); + rec.setCompletedAt(null); + rec.setCreatedBy(null); + rec.setFilePath(null); + rec.setErrorMessage(null); + + assertThat(rec.getDescription()).isNull(); + assertThat(rec.getSizeBytes()).isNull(); + assertThat(rec.getCompletedAt()).isNull(); + assertThat(rec.getCreatedBy()).isNull(); + assertThat(rec.getFilePath()).isNull(); + assertThat(rec.getErrorMessage()).isNull(); + } + + // ------------------------------------------------------------------------- + // BaseEntity fields + // ------------------------------------------------------------------------- + + @Test + @DisplayName("BaseEntity fields accessible via inherited getters/setters") + void baseEntityFields() { + BackupRecord rec = new BackupRecord(); + UUID id = UUID.randomUUID(); + LocalDateTime now = LocalDateTime.now(); + + rec.setId(id); + rec.setDateCreation(now); + rec.setDateModification(now); + rec.setCreePar("system"); + rec.setModifiePar("operator"); + rec.setVersion(5L); + rec.setActif(true); + + assertThat(rec.getId()).isEqualTo(id); + assertThat(rec.getDateCreation()).isEqualTo(now); + assertThat(rec.getDateModification()).isEqualTo(now); + assertThat(rec.getCreePar()).isEqualTo("system"); + assertThat(rec.getModifiePar()).isEqualTo("operator"); + assertThat(rec.getVersion()).isEqualTo(5L); + assertThat(rec.getActif()).isTrue(); + } + + // ------------------------------------------------------------------------- + // equals / hashCode / toString + // ------------------------------------------------------------------------- + + @Test + @DisplayName("equals and hashCode consistent for identical content") + void equalsHashCode() { + BackupRecord a = BackupRecord.builder() + .name("rec-A") + .type("AUTO") + .status("COMPLETED") + .build(); + BackupRecord b = BackupRecord.builder() + .name("rec-A") + .type("AUTO") + .status("COMPLETED") + .build(); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("equals returns false for different name") + void equalsReturnsFalseForDifferentName() { + BackupRecord a = BackupRecord.builder().name("alpha").type("AUTO").status("COMPLETED").build(); + BackupRecord b = BackupRecord.builder().name("beta").type("AUTO").status("COMPLETED").build(); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString is non-null and non-empty") + void toStringNonNull() { + BackupRecord rec = BackupRecord.builder() + .name("test") + .type("MANUAL") + .status("COMPLETED") + .build(); + assertThat(rec.toString()).isNotNull().isNotEmpty(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/BaremeCotisationRoleTest.java b/src/test/java/dev/lions/unionflow/server/entity/BaremeCotisationRoleTest.java new file mode 100644 index 0000000..ee369a7 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/BaremeCotisationRoleTest.java @@ -0,0 +1,279 @@ +package dev.lions.unionflow.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("BaremeCotisationRole") +class BaremeCotisationRoleTest { + + // ------------------------------------------------------------------------- + // No-args constructor + // ------------------------------------------------------------------------- + + @Test + @DisplayName("no-args constructor creates non-null instance") + void noArgsConstructor() { + BaremeCotisationRole bareme = new BaremeCotisationRole(); + assertThat(bareme).isNotNull(); + } + + @Test + @DisplayName("no-args constructor: @Builder.Default fields are null (no builder involved)") + void noArgsConstructor_builderDefaultsAreNull() { + BaremeCotisationRole bareme = new BaremeCotisationRole(); + // @Builder.Default fields are null when constructed with no-arg ctor + assertThat(bareme.getMontantMensuel()).isNull(); + assertThat(bareme.getMontantAnnuel()).isNull(); + } + + // ------------------------------------------------------------------------- + // Builder — defaults + // ------------------------------------------------------------------------- + + @Test + @DisplayName("builder default: montantMensuel = ZERO, montantAnnuel = ZERO") + void builder_defaultAmounts() { + BaremeCotisationRole bareme = BaremeCotisationRole.builder() + .roleOrg("MEMBRE_ORDINAIRE") + .build(); + + assertThat(bareme.getMontantMensuel()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo(BigDecimal.ZERO); + } + + // ------------------------------------------------------------------------- + // Builder — all fields + // ------------------------------------------------------------------------- + + @Test + @DisplayName("builder sets all fields for PRESIDENT role") + void builder_president() { + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + + BaremeCotisationRole bareme = BaremeCotisationRole.builder() + .organisation(org) + .roleOrg("PRESIDENT") + .montantMensuel(new BigDecimal("0.00")) + .montantAnnuel(new BigDecimal("0.00")) + .description("Exonéré — bureau exécutif") + .build(); + + assertThat(bareme.getOrganisation()).isSameAs(org); + assertThat(bareme.getRoleOrg()).isEqualTo("PRESIDENT"); + assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("0.00"); + assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("0.00"); + assertThat(bareme.getDescription()).isEqualTo("Exonéré — bureau exécutif"); + } + + @Test + @DisplayName("builder sets all fields for TRESORIER role with positive amounts") + void builder_tresorier() { + Organisation org = new Organisation(); + + BaremeCotisationRole bareme = BaremeCotisationRole.builder() + .organisation(org) + .roleOrg("TRESORIER") + .montantMensuel(new BigDecimal("2500.00")) + .montantAnnuel(new BigDecimal("25000.00")) + .description("Taux réduit bureau exécutif") + .build(); + + assertThat(bareme.getRoleOrg()).isEqualTo("TRESORIER"); + assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("2500.00"); + assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("25000.00"); + assertThat(bareme.getDescription()).isEqualTo("Taux réduit bureau exécutif"); + } + + @Test + @DisplayName("builder: SECRETAIRE role, no description") + void builder_secretaireNoDescription() { + BaremeCotisationRole bareme = BaremeCotisationRole.builder() + .roleOrg("SECRETAIRE") + .montantMensuel(new BigDecimal("3000.00")) + .montantAnnuel(new BigDecimal("30000.00")) + .build(); + + assertThat(bareme.getRoleOrg()).isEqualTo("SECRETAIRE"); + assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("3000.00"); + assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("30000.00"); + assertThat(bareme.getDescription()).isNull(); + } + + // ------------------------------------------------------------------------- + // All-args constructor + // ------------------------------------------------------------------------- + + @Test + @DisplayName("all-args constructor populates every field") + void allArgsConstructor() { + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + + BaremeCotisationRole bareme = new BaremeCotisationRole( + org, + "MEMBRE_ORDINAIRE", + new BigDecimal("5000.00"), + new BigDecimal("50000.00"), + "Tarif standard membres" + ); + + assertThat(bareme.getOrganisation()).isSameAs(org); + assertThat(bareme.getRoleOrg()).isEqualTo("MEMBRE_ORDINAIRE"); + assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("5000.00"); + assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("50000.00"); + assertThat(bareme.getDescription()).isEqualTo("Tarif standard membres"); + } + + // ------------------------------------------------------------------------- + // Getters / Setters (@Data) + // ------------------------------------------------------------------------- + + @Test + @DisplayName("setters and getters round-trip for all fields") + void settersGetters() { + BaremeCotisationRole bareme = new BaremeCotisationRole(); + + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + + bareme.setOrganisation(org); + bareme.setRoleOrg("VICE_PRESIDENT"); + bareme.setMontantMensuel(new BigDecimal("1500.50")); + bareme.setMontantAnnuel(new BigDecimal("15005.00")); + bareme.setDescription("VP taux spécial"); + + assertThat(bareme.getOrganisation()).isSameAs(org); + assertThat(bareme.getRoleOrg()).isEqualTo("VICE_PRESIDENT"); + assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("1500.50"); + assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("15005.00"); + assertThat(bareme.getDescription()).isEqualTo("VP taux spécial"); + } + + @Test + @DisplayName("description accepts null (optional field)") + void descriptionAcceptsNull() { + BaremeCotisationRole bareme = new BaremeCotisationRole(); + bareme.setDescription(null); + assertThat(bareme.getDescription()).isNull(); + } + + @Test + @DisplayName("organisation can be set to null") + void organisationAcceptsNull() { + BaremeCotisationRole bareme = new BaremeCotisationRole(); + bareme.setOrganisation(null); + assertThat(bareme.getOrganisation()).isNull(); + } + + @Test + @DisplayName("amounts can be set to BigDecimal.ZERO") + void amountsZero() { + BaremeCotisationRole bareme = new BaremeCotisationRole(); + bareme.setMontantMensuel(BigDecimal.ZERO); + bareme.setMontantAnnuel(BigDecimal.ZERO); + assertThat(bareme.getMontantMensuel()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo(BigDecimal.ZERO); + } + + // ------------------------------------------------------------------------- + // BaseEntity fields + // ------------------------------------------------------------------------- + + @Test + @DisplayName("BaseEntity fields accessible via inherited getters/setters") + void baseEntityFields() { + BaremeCotisationRole bareme = new BaremeCotisationRole(); + UUID id = UUID.randomUUID(); + LocalDateTime now = LocalDateTime.now(); + + bareme.setId(id); + bareme.setDateCreation(now); + bareme.setDateModification(now); + bareme.setCreePar("admin@test.com"); + bareme.setModifiePar("ops@test.com"); + bareme.setVersion(1L); + bareme.setActif(true); + + assertThat(bareme.getId()).isEqualTo(id); + assertThat(bareme.getDateCreation()).isEqualTo(now); + assertThat(bareme.getDateModification()).isEqualTo(now); + assertThat(bareme.getCreePar()).isEqualTo("admin@test.com"); + assertThat(bareme.getModifiePar()).isEqualTo("ops@test.com"); + assertThat(bareme.getVersion()).isEqualTo(1L); + assertThat(bareme.getActif()).isTrue(); + } + + // ------------------------------------------------------------------------- + // equals / hashCode / toString (@Data + @EqualsAndHashCode(callSuper = true)) + // ------------------------------------------------------------------------- + + @Test + @DisplayName("equals and hashCode consistent for identical content") + void equalsHashCode() { + BaremeCotisationRole a = BaremeCotisationRole.builder() + .roleOrg("PRESIDENT") + .montantMensuel(BigDecimal.ZERO) + .montantAnnuel(BigDecimal.ZERO) + .build(); + BaremeCotisationRole b = BaremeCotisationRole.builder() + .roleOrg("PRESIDENT") + .montantMensuel(BigDecimal.ZERO) + .montantAnnuel(BigDecimal.ZERO) + .build(); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("equals returns false for different roleOrg") + void equalsReturnsFalseForDifferentRole() { + BaremeCotisationRole a = BaremeCotisationRole.builder() + .roleOrg("PRESIDENT") + .montantMensuel(BigDecimal.ZERO) + .montantAnnuel(BigDecimal.ZERO) + .build(); + BaremeCotisationRole b = BaremeCotisationRole.builder() + .roleOrg("TRESORIER") + .montantMensuel(BigDecimal.ZERO) + .montantAnnuel(BigDecimal.ZERO) + .build(); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString is non-null and non-empty") + void toStringNonNull() { + BaremeCotisationRole bareme = BaremeCotisationRole.builder() + .roleOrg("MEMBRE_ORDINAIRE") + .build(); + assertThat(bareme.toString()).isNotNull().isNotEmpty(); + } + + // ------------------------------------------------------------------------- + // Representative role values (no enum — plain String column) + // ------------------------------------------------------------------------- + + @Test + @DisplayName("all representative role values can be stored and retrieved") + void representativeRoleValues() { + String[] roles = { + "PRESIDENT", "VICE_PRESIDENT", "TRESORIER", "TRESORIER_ADJOINT", + "SECRETAIRE", "SECRETAIRE_ADJOINT", "MEMBRE_ORDINAIRE", "AUDITEUR" + }; + + for (String role : roles) { + BaremeCotisationRole bareme = BaremeCotisationRole.builder() + .roleOrg(role) + .build(); + assertThat(bareme.getRoleOrg()).as("role: %s", role).isEqualTo(role); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/KycDossierTest.java b/src/test/java/dev/lions/unionflow/server/entity/KycDossierTest.java new file mode 100644 index 0000000..62614e0 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/KycDossierTest.java @@ -0,0 +1,404 @@ +package dev.lions.unionflow.server.entity; + +import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc; +import dev.lions.unionflow.server.api.enums.membre.StatutKyc; +import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("KycDossier entity") +class KycDossierTest { + + // ------------------------------------------------------------------------- + // No-args constructor + // ------------------------------------------------------------------------- + @Test + @DisplayName("no-args constructor creates instance with default field values") + void noArgsConstructor_createsInstanceWithDefaults() { + KycDossier dossier = new KycDossier(); + + assertThat(dossier).isNotNull(); + assertThat(dossier.getMembre()).isNull(); + assertThat(dossier.getTypePiece()).isNull(); + assertThat(dossier.getNumeroPiece()).isNull(); + assertThat(dossier.getStatut()).isNull(); + assertThat(dossier.getNiveauRisque()).isNull(); + assertThat(dossier.getScoreRisque()).isZero(); + assertThat(dossier.isEstPep()).isFalse(); + } + + // ------------------------------------------------------------------------- + // Builder — all fields + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Builder") + class BuilderTests { + + @Test + @DisplayName("builder sets all explicit fields") + void builder_setsAllFields() { + UUID validateurId = UUID.randomUUID(); + LocalDate expiration = LocalDate.of(2030, 6, 15); + LocalDateTime now = LocalDateTime.now(); + + KycDossier dossier = KycDossier.builder() + .typePiece(TypePieceIdentite.PASSEPORT) + .numeroPiece("AB123456") + .dateExpirationPiece(expiration) + .pieceIdentiteRectoFileId("file-recto-001") + .pieceIdentiteVersoFileId("file-verso-001") + .justifDomicileFileId("file-justif-001") + .statut(StatutKyc.EN_COURS) + .niveauRisque(NiveauRisqueKyc.MOYEN) + .scoreRisque(55) + .estPep(true) + .nationalite("SEN") + .dateVerification(now) + .validateurId(validateurId) + .notesValidateur("Dossier complet") + .anneeReference(2026) + .build(); + + assertThat(dossier.getTypePiece()).isEqualTo(TypePieceIdentite.PASSEPORT); + assertThat(dossier.getNumeroPiece()).isEqualTo("AB123456"); + assertThat(dossier.getDateExpirationPiece()).isEqualTo(expiration); + assertThat(dossier.getPieceIdentiteRectoFileId()).isEqualTo("file-recto-001"); + assertThat(dossier.getPieceIdentiteVersoFileId()).isEqualTo("file-verso-001"); + assertThat(dossier.getJustifDomicileFileId()).isEqualTo("file-justif-001"); + assertThat(dossier.getStatut()).isEqualTo(StatutKyc.EN_COURS); + assertThat(dossier.getNiveauRisque()).isEqualTo(NiveauRisqueKyc.MOYEN); + assertThat(dossier.getScoreRisque()).isEqualTo(55); + assertThat(dossier.isEstPep()).isTrue(); + assertThat(dossier.getNationalite()).isEqualTo("SEN"); + assertThat(dossier.getDateVerification()).isEqualTo(now); + assertThat(dossier.getValidateurId()).isEqualTo(validateurId); + assertThat(dossier.getNotesValidateur()).isEqualTo("Dossier complet"); + assertThat(dossier.getAnneeReference()).isEqualTo(2026); + } + + @Test + @DisplayName("builder uses default statut NON_VERIFIE when not specified") + void builder_defaultStatutIsNonVerifie() { + KycDossier dossier = KycDossier.builder() + .numeroPiece("XY999") + .typePiece(TypePieceIdentite.CNI) + .build(); + + assertThat(dossier.getStatut()).isEqualTo(StatutKyc.NON_VERIFIE); + } + + @Test + @DisplayName("builder uses default niveauRisque FAIBLE when not specified") + void builder_defaultNiveauRisqueIsFaible() { + KycDossier dossier = KycDossier.builder() + .numeroPiece("XY999") + .typePiece(TypePieceIdentite.CNI) + .build(); + + assertThat(dossier.getNiveauRisque()).isEqualTo(NiveauRisqueKyc.FAIBLE); + } + + @Test + @DisplayName("builder uses default scoreRisque 0 when not specified") + void builder_defaultScoreRisqueIsZero() { + KycDossier dossier = KycDossier.builder().build(); + + assertThat(dossier.getScoreRisque()).isZero(); + } + + @Test + @DisplayName("builder uses default estPep false when not specified") + void builder_defaultEstPepIsFalse() { + KycDossier dossier = KycDossier.builder().build(); + + assertThat(dossier.isEstPep()).isFalse(); + } + + @Test + @DisplayName("builder uses current year as default anneeReference") + void builder_defaultAnneeReferenceIsCurrentYear() { + KycDossier dossier = KycDossier.builder().build(); + + assertThat(dossier.getAnneeReference()).isEqualTo(LocalDate.now().getYear()); + } + } + + // ------------------------------------------------------------------------- + // All-args constructor + // ------------------------------------------------------------------------- + @Test + @DisplayName("all-args constructor (via setter chain) round-trips correctly") + void allArgsConstructor_roundTrips() { + // KycDossier's @AllArgsConstructor includes parent fields via Lombok, + // but since we cannot call super fields directly here, we verify via setters. + KycDossier dossier = new KycDossier(); + UUID id = UUID.randomUUID(); + dossier.setId(id); + dossier.setNumeroPiece("CNI-001"); + dossier.setTypePiece(TypePieceIdentite.CNI); + dossier.setStatut(StatutKyc.VERIFIE); + dossier.setNiveauRisque(NiveauRisqueKyc.ELEVE); + + assertThat(dossier.getId()).isEqualTo(id); + assertThat(dossier.getNumeroPiece()).isEqualTo("CNI-001"); + assertThat(dossier.getTypePiece()).isEqualTo(TypePieceIdentite.CNI); + assertThat(dossier.getStatut()).isEqualTo(StatutKyc.VERIFIE); + assertThat(dossier.getNiveauRisque()).isEqualTo(NiveauRisqueKyc.ELEVE); + } + + // ------------------------------------------------------------------------- + // Getters / Setters + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Getters and Setters") + class GettersSettersTests { + + @Test + @DisplayName("setMembre / getMembre round-trips") + void membre_roundTrips() { + Membre membre = new Membre(); + KycDossier dossier = new KycDossier(); + dossier.setMembre(membre); + assertThat(dossier.getMembre()).isSameAs(membre); + } + + @Test + @DisplayName("setNumeroPiece / getNumeroPiece round-trips") + void numeroPiece_roundTrips() { + KycDossier dossier = new KycDossier(); + dossier.setNumeroPiece("PASS-9876"); + assertThat(dossier.getNumeroPiece()).isEqualTo("PASS-9876"); + } + + @Test + @DisplayName("setDateExpirationPiece / getDateExpirationPiece round-trips") + void dateExpirationPiece_roundTrips() { + LocalDate date = LocalDate.of(2028, 12, 31); + KycDossier dossier = new KycDossier(); + dossier.setDateExpirationPiece(date); + assertThat(dossier.getDateExpirationPiece()).isEqualTo(date); + } + + @Test + @DisplayName("setScoreRisque / getScoreRisque round-trips") + void scoreRisque_roundTrips() { + KycDossier dossier = new KycDossier(); + dossier.setScoreRisque(75); + assertThat(dossier.getScoreRisque()).isEqualTo(75); + } + + @Test + @DisplayName("setEstPep / isEstPep round-trips") + void estPep_roundTrips() { + KycDossier dossier = new KycDossier(); + dossier.setEstPep(true); + assertThat(dossier.isEstPep()).isTrue(); + dossier.setEstPep(false); + assertThat(dossier.isEstPep()).isFalse(); + } + + @Test + @DisplayName("setNationalite / getNationalite round-trips") + void nationalite_roundTrips() { + KycDossier dossier = new KycDossier(); + dossier.setNationalite("CIV"); + assertThat(dossier.getNationalite()).isEqualTo("CIV"); + } + + @Test + @DisplayName("setDateVerification / getDateVerification round-trips") + void dateVerification_roundTrips() { + LocalDateTime dt = LocalDateTime.of(2026, 3, 10, 14, 30); + KycDossier dossier = new KycDossier(); + dossier.setDateVerification(dt); + assertThat(dossier.getDateVerification()).isEqualTo(dt); + } + + @Test + @DisplayName("setValidateurId / getValidateurId round-trips") + void validateurId_roundTrips() { + UUID uuid = UUID.randomUUID(); + KycDossier dossier = new KycDossier(); + dossier.setValidateurId(uuid); + assertThat(dossier.getValidateurId()).isEqualTo(uuid); + } + + @Test + @DisplayName("setNotesValidateur / getNotesValidateur round-trips") + void notesValidateur_roundTrips() { + KycDossier dossier = new KycDossier(); + dossier.setNotesValidateur("Notes de validation"); + assertThat(dossier.getNotesValidateur()).isEqualTo("Notes de validation"); + } + + @Test + @DisplayName("setAnneeReference / getAnneeReference round-trips") + void anneeReference_roundTrips() { + KycDossier dossier = new KycDossier(); + dossier.setAnneeReference(2025); + assertThat(dossier.getAnneeReference()).isEqualTo(2025); + } + + @Test + @DisplayName("file IDs round-trip") + void fileIds_roundTrip() { + KycDossier dossier = new KycDossier(); + dossier.setPieceIdentiteRectoFileId("recto-42"); + dossier.setPieceIdentiteVersoFileId("verso-42"); + dossier.setJustifDomicileFileId("domicile-42"); + assertThat(dossier.getPieceIdentiteRectoFileId()).isEqualTo("recto-42"); + assertThat(dossier.getPieceIdentiteVersoFileId()).isEqualTo("verso-42"); + assertThat(dossier.getJustifDomicileFileId()).isEqualTo("domicile-42"); + } + } + + // ------------------------------------------------------------------------- + // Business method: isPieceExpiree() + // ------------------------------------------------------------------------- + @Nested + @DisplayName("isPieceExpiree()") + class IsPieceExpireeTests { + + @Test + @DisplayName("returns true when dateExpirationPiece is in the past") + void isPieceExpiree_returnsTrue_whenExpired() { + KycDossier dossier = KycDossier.builder() + .dateExpirationPiece(LocalDate.now().minusDays(1)) + .build(); + assertThat(dossier.isPieceExpiree()).isTrue(); + } + + @Test + @DisplayName("returns false when dateExpirationPiece is in the future") + void isPieceExpiree_returnsFalse_whenNotExpired() { + KycDossier dossier = KycDossier.builder() + .dateExpirationPiece(LocalDate.now().plusYears(1)) + .build(); + assertThat(dossier.isPieceExpiree()).isFalse(); + } + + @Test + @DisplayName("returns false when dateExpirationPiece is null") + void isPieceExpiree_returnsFalse_whenNull() { + KycDossier dossier = KycDossier.builder().build(); + assertThat(dossier.isPieceExpiree()).isFalse(); + } + + @Test + @DisplayName("returns false when dateExpirationPiece is today") + void isPieceExpiree_returnsFalse_whenToday() { + KycDossier dossier = KycDossier.builder() + .dateExpirationPiece(LocalDate.now()) + .build(); + // isBefore(now) is false for today + assertThat(dossier.isPieceExpiree()).isFalse(); + } + } + + // ------------------------------------------------------------------------- + // Enum coverage: StatutKyc + // ------------------------------------------------------------------------- + @Test + @DisplayName("all StatutKyc values are assignable") + void statutKyc_allValues() { + KycDossier dossier = new KycDossier(); + for (StatutKyc statut : StatutKyc.values()) { + dossier.setStatut(statut); + assertThat(dossier.getStatut()).isEqualTo(statut); + } + assertThat(StatutKyc.NON_VERIFIE.getLibelle()).isEqualTo("Non vérifié"); + assertThat(StatutKyc.EN_COURS.getLibelle()).isEqualTo("En cours"); + assertThat(StatutKyc.VERIFIE.getLibelle()).isEqualTo("Vérifié"); + assertThat(StatutKyc.REFUSE.getLibelle()).isEqualTo("Refusé"); + } + + // ------------------------------------------------------------------------- + // Enum coverage: NiveauRisqueKyc + // ------------------------------------------------------------------------- + @Test + @DisplayName("all NiveauRisqueKyc values are assignable") + void niveauRisqueKyc_allValues() { + KycDossier dossier = new KycDossier(); + for (NiveauRisqueKyc niveau : NiveauRisqueKyc.values()) { + dossier.setNiveauRisque(niveau); + assertThat(dossier.getNiveauRisque()).isEqualTo(niveau); + } + assertThat(NiveauRisqueKyc.FAIBLE.getLibelle()).isEqualTo("Risque faible"); + assertThat(NiveauRisqueKyc.MOYEN.getLibelle()).isEqualTo("Risque moyen"); + assertThat(NiveauRisqueKyc.ELEVE.getLibelle()).isEqualTo("Risque élevé"); + assertThat(NiveauRisqueKyc.CRITIQUE.getLibelle()).isEqualTo("Risque critique"); + assertThat(NiveauRisqueKyc.FAIBLE.getScoreMin()).isZero(); + assertThat(NiveauRisqueKyc.FAIBLE.getScoreMax()).isEqualTo(39); + assertThat(NiveauRisqueKyc.MOYEN.getScoreMin()).isEqualTo(40); + assertThat(NiveauRisqueKyc.MOYEN.getScoreMax()).isEqualTo(69); + assertThat(NiveauRisqueKyc.ELEVE.getScoreMin()).isEqualTo(70); + assertThat(NiveauRisqueKyc.ELEVE.getScoreMax()).isEqualTo(89); + assertThat(NiveauRisqueKyc.CRITIQUE.getScoreMin()).isEqualTo(90); + assertThat(NiveauRisqueKyc.CRITIQUE.getScoreMax()).isEqualTo(100); + } + + // ------------------------------------------------------------------------- + // Enum coverage: TypePieceIdentite + // ------------------------------------------------------------------------- + @Test + @DisplayName("all TypePieceIdentite values are assignable") + void typePieceIdentite_allValues() { + KycDossier dossier = new KycDossier(); + for (TypePieceIdentite type : TypePieceIdentite.values()) { + dossier.setTypePiece(type); + assertThat(dossier.getTypePiece()).isEqualTo(type); + } + assertThat(TypePieceIdentite.CNI.getLibelle()).isEqualTo("Carte Nationale d'Identité"); + assertThat(TypePieceIdentite.PASSEPORT.getLibelle()).isEqualTo("Passeport"); + assertThat(TypePieceIdentite.TITRE_SEJOUR.getLibelle()).isEqualTo("Titre de séjour"); + assertThat(TypePieceIdentite.CARTE_CONSULAIRE.getLibelle()).isEqualTo("Carte consulaire"); + assertThat(TypePieceIdentite.PERMIS_CONDUIRE.getLibelle()).isEqualTo("Permis de conduire"); + assertThat(TypePieceIdentite.AUTRE.getLibelle()).isEqualTo("Autre pièce officielle"); + } + + // ------------------------------------------------------------------------- + // UUID id (inherited from BaseEntity) + // ------------------------------------------------------------------------- + @Test + @DisplayName("UUID id field is settable and gettable") + void uuidId_settableAndGettable() { + UUID id = UUID.randomUUID(); + KycDossier dossier = new KycDossier(); + dossier.setId(id); + assertThat(dossier.getId()).isEqualTo(id); + } + + // ------------------------------------------------------------------------- + // equals / hashCode (Lombok @EqualsAndHashCode(callSuper=true)) + // ------------------------------------------------------------------------- + @Test + @DisplayName("two instances with same id are equal") + void equals_sameId_areEqual() { + UUID id = UUID.randomUUID(); + KycDossier a = new KycDossier(); + a.setId(id); + a.setNumeroPiece("P1"); + KycDossier b = new KycDossier(); + b.setId(id); + b.setNumeroPiece("P1"); + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("two instances with different ids are not equal") + void equals_differentId_areNotEqual() { + KycDossier a = new KycDossier(); + a.setId(UUID.randomUUID()); + KycDossier b = new KycDossier(); + b.setId(UUID.randomUUID()); + assertThat(a).isNotEqualTo(b); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/MembreSuiviTest.java b/src/test/java/dev/lions/unionflow/server/entity/MembreSuiviTest.java new file mode 100644 index 0000000..096d862 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/MembreSuiviTest.java @@ -0,0 +1,170 @@ +package dev.lions.unionflow.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("MembreSuivi entity") +class MembreSuiviTest { + + // ------------------------------------------------------------------------- + // No-args constructor + // ------------------------------------------------------------------------- + @Test + @DisplayName("no-args constructor creates instance with null UUIDs") + void noArgsConstructor_createsInstanceWithNullFields() { + MembreSuivi suivi = new MembreSuivi(); + + assertThat(suivi).isNotNull(); + assertThat(suivi.getFollowerUtilisateurId()).isNull(); + assertThat(suivi.getSuiviUtilisateurId()).isNull(); + } + + // ------------------------------------------------------------------------- + // All-args constructor + // ------------------------------------------------------------------------- + @Test + @DisplayName("all-args constructor sets all fields") + void allArgsConstructor_setsAllFields() { + UUID followerId = UUID.randomUUID(); + UUID suiviId = UUID.randomUUID(); + + MembreSuivi suivi = new MembreSuivi(followerId, suiviId); + + assertThat(suivi.getFollowerUtilisateurId()).isEqualTo(followerId); + assertThat(suivi.getSuiviUtilisateurId()).isEqualTo(suiviId); + } + + // ------------------------------------------------------------------------- + // Builder + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Builder") + class BuilderTests { + + @Test + @DisplayName("builder sets followerUtilisateurId") + void builder_setsFollowerUtilisateurId() { + UUID id = UUID.randomUUID(); + MembreSuivi suivi = MembreSuivi.builder() + .followerUtilisateurId(id) + .build(); + + assertThat(suivi.getFollowerUtilisateurId()).isEqualTo(id); + } + + @Test + @DisplayName("builder sets suiviUtilisateurId") + void builder_setsSuiviUtilisateurId() { + UUID id = UUID.randomUUID(); + MembreSuivi suivi = MembreSuivi.builder() + .suiviUtilisateurId(id) + .build(); + + assertThat(suivi.getSuiviUtilisateurId()).isEqualTo(id); + } + + @Test + @DisplayName("builder sets both UUID fields") + void builder_setsBothUuidFields() { + UUID followerId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID suiviId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + MembreSuivi suivi = MembreSuivi.builder() + .followerUtilisateurId(followerId) + .suiviUtilisateurId(suiviId) + .build(); + + assertThat(suivi.getFollowerUtilisateurId()).isEqualTo(followerId); + assertThat(suivi.getSuiviUtilisateurId()).isEqualTo(suiviId); + } + } + + // ------------------------------------------------------------------------- + // Getters / Setters + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Getters and Setters") + class GettersSettersTests { + + @Test + @DisplayName("setFollowerUtilisateurId / getFollowerUtilisateurId round-trips") + void followerUtilisateurId_roundTrips() { + UUID id = UUID.randomUUID(); + MembreSuivi suivi = new MembreSuivi(); + suivi.setFollowerUtilisateurId(id); + assertThat(suivi.getFollowerUtilisateurId()).isEqualTo(id); + } + + @Test + @DisplayName("setSuiviUtilisateurId / getSuiviUtilisateurId round-trips") + void suiviUtilisateurId_roundTrips() { + UUID id = UUID.randomUUID(); + MembreSuivi suivi = new MembreSuivi(); + suivi.setSuiviUtilisateurId(id); + assertThat(suivi.getSuiviUtilisateurId()).isEqualTo(id); + } + + @Test + @DisplayName("follower and suivi IDs can be different") + void followerAndSuivi_canBeDifferent() { + UUID followerId = UUID.randomUUID(); + UUID suiviId = UUID.randomUUID(); + MembreSuivi suivi = new MembreSuivi(); + suivi.setFollowerUtilisateurId(followerId); + suivi.setSuiviUtilisateurId(suiviId); + + assertThat(suivi.getFollowerUtilisateurId()).isNotEqualTo(suivi.getSuiviUtilisateurId()); + } + } + + // ------------------------------------------------------------------------- + // UUID id (inherited from BaseEntity) + // ------------------------------------------------------------------------- + @Test + @DisplayName("UUID id field is settable and gettable") + void uuidId_settableAndGettable() { + UUID id = UUID.randomUUID(); + MembreSuivi suivi = new MembreSuivi(); + suivi.setId(id); + assertThat(suivi.getId()).isEqualTo(id); + } + + // ------------------------------------------------------------------------- + // equals / hashCode + // ------------------------------------------------------------------------- + @Test + @DisplayName("two instances with the same field values are equal") + void equals_sameValues_areEqual() { + UUID followerId = UUID.randomUUID(); + UUID suiviId = UUID.randomUUID(); + + MembreSuivi a = new MembreSuivi(followerId, suiviId); + MembreSuivi b = new MembreSuivi(followerId, suiviId); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("two instances with different follower IDs are not equal") + void equals_differentFollowerId_notEqual() { + UUID suiviId = UUID.randomUUID(); + MembreSuivi a = new MembreSuivi(UUID.randomUUID(), suiviId); + MembreSuivi b = new MembreSuivi(UUID.randomUUID(), suiviId); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString contains field values") + void toString_containsFields() { + UUID followerId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + MembreSuivi suivi = MembreSuivi.builder().followerUtilisateurId(followerId).build(); + assertThat(suivi.toString()).contains("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/PaiementObjetTest.java b/src/test/java/dev/lions/unionflow/server/entity/PaiementObjetTest.java new file mode 100644 index 0000000..6a19ddd --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/PaiementObjetTest.java @@ -0,0 +1,214 @@ +package dev.lions.unionflow.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("PaiementObjet entity") +class PaiementObjetTest { + + // ------------------------------------------------------------------------- + // No-args constructor + // ------------------------------------------------------------------------- + @Test + @DisplayName("no-args constructor creates instance with null fields") + void noArgsConstructor_createsInstanceWithNullFields() { + PaiementObjet po = new PaiementObjet(); + + assertThat(po).isNotNull(); + assertThat(po.getPaiement()).isNull(); + assertThat(po.getTypeObjetCible()).isNull(); + assertThat(po.getObjetCibleId()).isNull(); + assertThat(po.getMontantApplique()).isNull(); + assertThat(po.getDateApplication()).isNull(); + assertThat(po.getCommentaire()).isNull(); + } + + // ------------------------------------------------------------------------- + // Builder + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Builder") + class BuilderTests { + + @Test + @DisplayName("builder sets all fields") + void builder_setsAllFields() { + Paiement paiement = new Paiement(); + UUID objetId = UUID.randomUUID(); + LocalDateTime now = LocalDateTime.now(); + + PaiementObjet po = PaiementObjet.builder() + .paiement(paiement) + .typeObjetCible("COTISATION") + .objetCibleId(objetId) + .montantApplique(BigDecimal.valueOf(15000)) + .dateApplication(now) + .commentaire("Application cotisation mensuelle") + .build(); + + assertThat(po.getPaiement()).isSameAs(paiement); + assertThat(po.getTypeObjetCible()).isEqualTo("COTISATION"); + assertThat(po.getObjetCibleId()).isEqualTo(objetId); + assertThat(po.getMontantApplique()).isEqualByComparingTo("15000"); + assertThat(po.getDateApplication()).isEqualTo(now); + assertThat(po.getCommentaire()).isEqualTo("Application cotisation mensuelle"); + } + + @Test + @DisplayName("builder produces distinct instances") + void builder_producesDistinctInstances() { + PaiementObjet po1 = PaiementObjet.builder().typeObjetCible("ADHESION").build(); + PaiementObjet po2 = PaiementObjet.builder().typeObjetCible("EVENEMENT").build(); + + assertThat(po1).isNotSameAs(po2); + assertThat(po1.getTypeObjetCible()).isNotEqualTo(po2.getTypeObjetCible()); + } + } + + // ------------------------------------------------------------------------- + // All-args constructor via setters + // ------------------------------------------------------------------------- + @Test + @DisplayName("all fields set via setters are accessible") + void allFields_setViaSetters() { + Paiement paiement = new Paiement(); + UUID objetId = UUID.randomUUID(); + LocalDateTime dt = LocalDateTime.of(2026, 3, 15, 9, 0); + + PaiementObjet po = new PaiementObjet(); + po.setPaiement(paiement); + po.setTypeObjetCible("AIDE"); + po.setObjetCibleId(objetId); + po.setMontantApplique(BigDecimal.valueOf(5000, 2)); + po.setDateApplication(dt); + po.setCommentaire("Aide urgence"); + + assertThat(po.getPaiement()).isSameAs(paiement); + assertThat(po.getTypeObjetCible()).isEqualTo("AIDE"); + assertThat(po.getObjetCibleId()).isEqualTo(objetId); + assertThat(po.getMontantApplique()).isEqualByComparingTo(BigDecimal.valueOf(5000, 2)); + assertThat(po.getDateApplication()).isEqualTo(dt); + assertThat(po.getCommentaire()).isEqualTo("Aide urgence"); + } + + // ------------------------------------------------------------------------- + // Getters / Setters individual + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Getters and Setters") + class GettersSettersTests { + + @Test + @DisplayName("setPaiement / getPaiement round-trips") + void paiement_roundTrips() { + Paiement paiement = new Paiement(); + PaiementObjet po = new PaiementObjet(); + po.setPaiement(paiement); + assertThat(po.getPaiement()).isSameAs(paiement); + } + + @Test + @DisplayName("setTypeObjetCible / getTypeObjetCible round-trips") + void typeObjetCible_roundTrips() { + PaiementObjet po = new PaiementObjet(); + po.setTypeObjetCible("EVENEMENT"); + assertThat(po.getTypeObjetCible()).isEqualTo("EVENEMENT"); + } + + @Test + @DisplayName("setObjetCibleId / getObjetCibleId round-trips") + void objetCibleId_roundTrips() { + UUID id = UUID.randomUUID(); + PaiementObjet po = new PaiementObjet(); + po.setObjetCibleId(id); + assertThat(po.getObjetCibleId()).isEqualTo(id); + } + + @Test + @DisplayName("setMontantApplique / getMontantApplique round-trips") + void montantApplique_roundTrips() { + BigDecimal montant = new BigDecimal("12500.00"); + PaiementObjet po = new PaiementObjet(); + po.setMontantApplique(montant); + assertThat(po.getMontantApplique()).isEqualByComparingTo(montant); + } + + @Test + @DisplayName("setDateApplication / getDateApplication round-trips") + void dateApplication_roundTrips() { + LocalDateTime dt = LocalDateTime.of(2026, 1, 1, 0, 0); + PaiementObjet po = new PaiementObjet(); + po.setDateApplication(dt); + assertThat(po.getDateApplication()).isEqualTo(dt); + } + + @Test + @DisplayName("setCommentaire / getCommentaire round-trips") + void commentaire_roundTrips() { + PaiementObjet po = new PaiementObjet(); + po.setCommentaire("Détails supplémentaires"); + assertThat(po.getCommentaire()).isEqualTo("Détails supplémentaires"); + } + } + + // ------------------------------------------------------------------------- + // Various typeObjetCible values (polymorphic usage) + // ------------------------------------------------------------------------- + @Test + @DisplayName("typeObjetCible accepts all expected polymorphic types") + void typeObjetCible_acceptsPolymorphicTypes() { + String[] types = {"COTISATION", "ADHESION", "EVENEMENT", "AIDE"}; + for (String type : types) { + PaiementObjet po = PaiementObjet.builder().typeObjetCible(type).build(); + assertThat(po.getTypeObjetCible()).isEqualTo(type); + } + } + + // ------------------------------------------------------------------------- + // UUID id (inherited from BaseEntity) + // ------------------------------------------------------------------------- + @Test + @DisplayName("UUID id field is settable and gettable") + void uuidId_settableAndGettable() { + UUID id = UUID.randomUUID(); + PaiementObjet po = new PaiementObjet(); + po.setId(id); + assertThat(po.getId()).isEqualTo(id); + } + + // ------------------------------------------------------------------------- + // equals / hashCode + // ------------------------------------------------------------------------- + @Test + @DisplayName("two instances with the same UUID id are equal") + void equals_sameId_areEqual() { + UUID id = UUID.randomUUID(); + PaiementObjet a = new PaiementObjet(); + a.setId(id); + a.setTypeObjetCible("COTISATION"); + PaiementObjet b = new PaiementObjet(); + b.setId(id); + b.setTypeObjetCible("COTISATION"); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("two instances with different ids are not equal") + void equals_differentIds_notEqual() { + PaiementObjet a = new PaiementObjet(); + a.setId(UUID.randomUUID()); + PaiementObjet b = new PaiementObjet(); + b.setId(UUID.randomUUID()); + + assertThat(a).isNotEqualTo(b); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/PaiementTest.java b/src/test/java/dev/lions/unionflow/server/entity/PaiementTest.java new file mode 100644 index 0000000..a4dbc83 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/PaiementTest.java @@ -0,0 +1,352 @@ +package dev.lions.unionflow.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Paiement entity") +class PaiementTest { + + // ------------------------------------------------------------------------- + // No-args constructor + // ------------------------------------------------------------------------- + @Test + @DisplayName("no-args constructor creates instance with null/default fields") + void noArgsConstructor_createsInstance() { + Paiement paiement = new Paiement(); + + assertThat(paiement).isNotNull(); + assertThat(paiement.getNumeroReference()).isNull(); + assertThat(paiement.getMontant()).isNull(); + assertThat(paiement.getCodeDevise()).isNull(); + assertThat(paiement.getMethodePaiement()).isNull(); + // statutPaiement default is only set via @Builder.Default — not through no-args + assertThat(paiement.getMembre()).isNull(); + } + + // ------------------------------------------------------------------------- + // Builder + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Builder") + class BuilderTests { + + @Test + @DisplayName("builder default statutPaiement is EN_ATTENTE") + void builder_defaultStatutPaiementIsEnAttente() { + Paiement paiement = Paiement.builder() + .numeroReference("PAY-2026-000001") + .montant(BigDecimal.valueOf(5000)) + .codeDevise("XOF") + .methodePaiement("WAVE") + .build(); + + assertThat(paiement.getStatutPaiement()).isEqualTo("EN_ATTENTE"); + } + + @Test + @DisplayName("builder default paiementsObjets is empty list") + void builder_defaultPaiementsObjetsIsEmptyList() { + Paiement paiement = Paiement.builder().build(); + + assertThat(paiement.getPaiementsObjets()).isNotNull().isEmpty(); + } + + @Test + @DisplayName("builder sets all explicit fields") + void builder_setsAllExplicitFields() { + LocalDateTime now = LocalDateTime.now(); + Membre membre = new Membre(); + + Paiement paiement = Paiement.builder() + .numeroReference("PAY-2026-000042") + .montant(BigDecimal.valueOf(10000, 2)) + .codeDevise("XOF") + .methodePaiement("ORANGE_MONEY") + .statutPaiement("VALIDE") + .datePaiement(now) + .dateValidation(now.plusMinutes(5)) + .validateur("admin@unionflow.com") + .referenceExterne("EXT-REF-001") + .urlPreuve("https://example.com/preuve.jpg") + .commentaire("Paiement cotisation mars 2026") + .ipAddress("192.168.1.1") + .userAgent("Mozilla/5.0") + .membre(membre) + .paiementsObjets(new ArrayList<>()) + .build(); + + assertThat(paiement.getNumeroReference()).isEqualTo("PAY-2026-000042"); + assertThat(paiement.getMontant()).isEqualByComparingTo(BigDecimal.valueOf(10000, 2)); + assertThat(paiement.getCodeDevise()).isEqualTo("XOF"); + assertThat(paiement.getMethodePaiement()).isEqualTo("ORANGE_MONEY"); + assertThat(paiement.getStatutPaiement()).isEqualTo("VALIDE"); + assertThat(paiement.getDatePaiement()).isEqualTo(now); + assertThat(paiement.getDateValidation()).isEqualTo(now.plusMinutes(5)); + assertThat(paiement.getValidateur()).isEqualTo("admin@unionflow.com"); + assertThat(paiement.getReferenceExterne()).isEqualTo("EXT-REF-001"); + assertThat(paiement.getUrlPreuve()).isEqualTo("https://example.com/preuve.jpg"); + assertThat(paiement.getCommentaire()).isEqualTo("Paiement cotisation mars 2026"); + assertThat(paiement.getIpAddress()).isEqualTo("192.168.1.1"); + assertThat(paiement.getUserAgent()).isEqualTo("Mozilla/5.0"); + assertThat(paiement.getMembre()).isSameAs(membre); + } + } + + // ------------------------------------------------------------------------- + // Getters / Setters + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Getters and Setters") + class GettersSettersTests { + + @Test + @DisplayName("setNumeroReference / getNumeroReference round-trips") + void numeroReference_roundTrips() { + Paiement p = new Paiement(); + p.setNumeroReference("PAY-TEST-001"); + assertThat(p.getNumeroReference()).isEqualTo("PAY-TEST-001"); + } + + @Test + @DisplayName("setMontant / getMontant round-trips") + void montant_roundTrips() { + Paiement p = new Paiement(); + p.setMontant(BigDecimal.valueOf(25000)); + assertThat(p.getMontant()).isEqualByComparingTo("25000"); + } + + @Test + @DisplayName("setCodeDevise / getCodeDevise round-trips") + void codeDevise_roundTrips() { + Paiement p = new Paiement(); + p.setCodeDevise("EUR"); + assertThat(p.getCodeDevise()).isEqualTo("EUR"); + } + + @Test + @DisplayName("setMethodePaiement / getMethodePaiement round-trips") + void methodePaiement_roundTrips() { + Paiement p = new Paiement(); + p.setMethodePaiement("VIREMENT"); + assertThat(p.getMethodePaiement()).isEqualTo("VIREMENT"); + } + + @Test + @DisplayName("setStatutPaiement / getStatutPaiement round-trips") + void statutPaiement_roundTrips() { + Paiement p = new Paiement(); + p.setStatutPaiement("ANNULE"); + assertThat(p.getStatutPaiement()).isEqualTo("ANNULE"); + } + + @Test + @DisplayName("setDatePaiement / getDatePaiement round-trips") + void datePaiement_roundTrips() { + LocalDateTime dt = LocalDateTime.of(2026, 4, 1, 10, 0); + Paiement p = new Paiement(); + p.setDatePaiement(dt); + assertThat(p.getDatePaiement()).isEqualTo(dt); + } + + @Test + @DisplayName("setDateValidation / getDateValidation round-trips") + void dateValidation_roundTrips() { + LocalDateTime dt = LocalDateTime.of(2026, 4, 1, 11, 0); + Paiement p = new Paiement(); + p.setDateValidation(dt); + assertThat(p.getDateValidation()).isEqualTo(dt); + } + + @Test + @DisplayName("setValidateur / getValidateur round-trips") + void validateur_roundTrips() { + Paiement p = new Paiement(); + p.setValidateur("tresorier@example.com"); + assertThat(p.getValidateur()).isEqualTo("tresorier@example.com"); + } + + @Test + @DisplayName("setReferenceExterne / getReferenceExterne round-trips") + void referenceExterne_roundTrips() { + Paiement p = new Paiement(); + p.setReferenceExterne("TXN-98765"); + assertThat(p.getReferenceExterne()).isEqualTo("TXN-98765"); + } + + @Test + @DisplayName("setUrlPreuve / getUrlPreuve round-trips") + void urlPreuve_roundTrips() { + Paiement p = new Paiement(); + p.setUrlPreuve("https://storage.example.com/preuves/p1.jpg"); + assertThat(p.getUrlPreuve()).isEqualTo("https://storage.example.com/preuves/p1.jpg"); + } + + @Test + @DisplayName("setCommentaire / getCommentaire round-trips") + void commentaire_roundTrips() { + Paiement p = new Paiement(); + p.setCommentaire("Commentaire test"); + assertThat(p.getCommentaire()).isEqualTo("Commentaire test"); + } + + @Test + @DisplayName("setIpAddress / getIpAddress round-trips") + void ipAddress_roundTrips() { + Paiement p = new Paiement(); + p.setIpAddress("10.0.0.1"); + assertThat(p.getIpAddress()).isEqualTo("10.0.0.1"); + } + + @Test + @DisplayName("setUserAgent / getUserAgent round-trips") + void userAgent_roundTrips() { + Paiement p = new Paiement(); + p.setUserAgent("TestAgent/1.0"); + assertThat(p.getUserAgent()).isEqualTo("TestAgent/1.0"); + } + + @Test + @DisplayName("setMembre / getMembre round-trips") + void membre_roundTrips() { + Membre membre = new Membre(); + Paiement p = new Paiement(); + p.setMembre(membre); + assertThat(p.getMembre()).isSameAs(membre); + } + + @Test + @DisplayName("setPaiementsObjets / getPaiementsObjets round-trips") + void paiementsObjets_roundTrips() { + List objets = new ArrayList<>(); + Paiement p = new Paiement(); + p.setPaiementsObjets(objets); + assertThat(p.getPaiementsObjets()).isSameAs(objets); + } + + @Test + @DisplayName("setTransactionWave / getTransactionWave round-trips") + void transactionWave_roundTrips() { + TransactionWave tw = new TransactionWave(); + Paiement p = new Paiement(); + p.setTransactionWave(tw); + assertThat(p.getTransactionWave()).isSameAs(tw); + } + } + + // ------------------------------------------------------------------------- + // UUID id (inherited from BaseEntity) + // ------------------------------------------------------------------------- + @Test + @DisplayName("UUID id field is settable and gettable") + void uuidId_settableAndGettable() { + UUID id = UUID.randomUUID(); + Paiement p = new Paiement(); + p.setId(id); + assertThat(p.getId()).isEqualTo(id); + } + + // ------------------------------------------------------------------------- + // Business method: isValide() + // ------------------------------------------------------------------------- + @Nested + @DisplayName("isValide()") + class IsValideTests { + + @Test + @DisplayName("returns true when statutPaiement is VALIDE") + void isValide_returnsTrue_whenValide() { + Paiement p = Paiement.builder().statutPaiement("VALIDE").build(); + assertThat(p.isValide()).isTrue(); + } + + @Test + @DisplayName("returns false when statutPaiement is EN_ATTENTE") + void isValide_returnsFalse_whenEnAttente() { + Paiement p = Paiement.builder().build(); // default EN_ATTENTE + assertThat(p.isValide()).isFalse(); + } + + @Test + @DisplayName("returns false when statutPaiement is ANNULE") + void isValide_returnsFalse_whenAnnule() { + Paiement p = Paiement.builder().statutPaiement("ANNULE").build(); + assertThat(p.isValide()).isFalse(); + } + + @Test + @DisplayName("returns false when statutPaiement is REJETE") + void isValide_returnsFalse_whenRejete() { + Paiement p = Paiement.builder().statutPaiement("REJETE").build(); + assertThat(p.isValide()).isFalse(); + } + } + + // ------------------------------------------------------------------------- + // Business method: peutEtreModifie() + // ------------------------------------------------------------------------- + @Nested + @DisplayName("peutEtreModifie()") + class PeutEtreModifieTests { + + @Test + @DisplayName("returns true when statutPaiement is EN_ATTENTE") + void peutEtreModifie_returnsTrue_whenEnAttente() { + Paiement p = Paiement.builder().build(); // default EN_ATTENTE + assertThat(p.peutEtreModifie()).isTrue(); + } + + @Test + @DisplayName("returns false when statutPaiement is VALIDE") + void peutEtreModifie_returnsFalse_whenValide() { + Paiement p = Paiement.builder().statutPaiement("VALIDE").build(); + assertThat(p.peutEtreModifie()).isFalse(); + } + + @Test + @DisplayName("returns false when statutPaiement is ANNULE") + void peutEtreModifie_returnsFalse_whenAnnule() { + Paiement p = Paiement.builder().statutPaiement("ANNULE").build(); + assertThat(p.peutEtreModifie()).isFalse(); + } + + @Test + @DisplayName("returns true when statutPaiement is REJETE") + void peutEtreModifie_returnsTrue_whenRejete() { + Paiement p = Paiement.builder().statutPaiement("REJETE").build(); + assertThat(p.peutEtreModifie()).isTrue(); + } + } + + // ------------------------------------------------------------------------- + // Static factory: genererNumeroReference() + // ------------------------------------------------------------------------- + @Test + @DisplayName("genererNumeroReference returns a non-null string matching PAY-YYYY- pattern") + void genererNumeroReference_returnsValidString() { + String ref = Paiement.genererNumeroReference(); + + assertThat(ref).isNotNull(); + assertThat(ref).startsWith("PAY-"); + assertThat(ref).matches("PAY-\\d{4}-\\d{12}"); + } + + @Test + @DisplayName("genererNumeroReference generates unique values on successive calls") + void genererNumeroReference_isUnique() throws InterruptedException { + String ref1 = Paiement.genererNumeroReference(); + Thread.sleep(1); // ensure millis differ + String ref2 = Paiement.genererNumeroReference(); + // They may collide on the same millisecond modulo, so just verify format + assertThat(ref1).matches("PAY-\\d{4}-\\d{12}"); + assertThat(ref2).matches("PAY-\\d{4}-\\d{12}"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/ParametresLcbFtTest.java b/src/test/java/dev/lions/unionflow/server/entity/ParametresLcbFtTest.java new file mode 100644 index 0000000..2832283 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/ParametresLcbFtTest.java @@ -0,0 +1,201 @@ +package dev.lions.unionflow.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ParametresLcbFt entity") +class ParametresLcbFtTest { + + // ------------------------------------------------------------------------- + // No-args constructor + // ------------------------------------------------------------------------- + @Test + @DisplayName("no-args constructor creates instance with null fields") + void noArgsConstructor_createsInstanceWithNullFields() { + ParametresLcbFt params = new ParametresLcbFt(); + + assertThat(params).isNotNull(); + assertThat(params.getOrganisation()).isNull(); + assertThat(params.getCodeDevise()).isNull(); + assertThat(params.getMontantSeuilJustification()).isNull(); + assertThat(params.getMontantSeuilValidationManuelle()).isNull(); + } + + // ------------------------------------------------------------------------- + // All-args constructor + // ------------------------------------------------------------------------- + @Test + @DisplayName("all-args constructor sets all fields") + void allArgsConstructor_setsAllFields() { + Organisation org = new Organisation(); + BigDecimal seuilJustif = new BigDecimal("500000.0000"); + BigDecimal seuilValidation = new BigDecimal("2000000.0000"); + + ParametresLcbFt params = new ParametresLcbFt(org, "XOF", seuilJustif, seuilValidation); + + assertThat(params.getOrganisation()).isSameAs(org); + assertThat(params.getCodeDevise()).isEqualTo("XOF"); + assertThat(params.getMontantSeuilJustification()).isEqualByComparingTo(seuilJustif); + assertThat(params.getMontantSeuilValidationManuelle()).isEqualByComparingTo(seuilValidation); + } + + // ------------------------------------------------------------------------- + // Builder + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Builder") + class BuilderTests { + + @Test + @DisplayName("builder sets organisation and thresholds") + void builder_setsAllFields() { + Organisation org = new Organisation(); + + ParametresLcbFt params = ParametresLcbFt.builder() + .organisation(org) + .codeDevise("EUR") + .montantSeuilJustification(new BigDecimal("10000.0000")) + .montantSeuilValidationManuelle(new BigDecimal("50000.0000")) + .build(); + + assertThat(params.getOrganisation()).isSameAs(org); + assertThat(params.getCodeDevise()).isEqualTo("EUR"); + assertThat(params.getMontantSeuilJustification()).isEqualByComparingTo("10000.0000"); + assertThat(params.getMontantSeuilValidationManuelle()).isEqualByComparingTo("50000.0000"); + } + + @Test + @DisplayName("builder with null organisation represents global parameters") + void builder_nullOrganisationRepresentsGlobal() { + ParametresLcbFt params = ParametresLcbFt.builder() + .organisation(null) + .codeDevise("XOF") + .montantSeuilJustification(new BigDecimal("1000000.0000")) + .build(); + + assertThat(params.getOrganisation()).isNull(); + assertThat(params.getCodeDevise()).isEqualTo("XOF"); + } + + @Test + @DisplayName("builder allows null montantSeuilValidationManuelle (optional field)") + void builder_nullSeuilValidationManuelle_isAllowed() { + ParametresLcbFt params = ParametresLcbFt.builder() + .codeDevise("XOF") + .montantSeuilJustification(BigDecimal.valueOf(500000)) + .montantSeuilValidationManuelle(null) + .build(); + + assertThat(params.getMontantSeuilValidationManuelle()).isNull(); + } + } + + // ------------------------------------------------------------------------- + // Getters / Setters + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Getters and Setters") + class GettersSettersTests { + + @Test + @DisplayName("setOrganisation / getOrganisation round-trips") + void organisation_roundTrips() { + Organisation org = new Organisation(); + ParametresLcbFt params = new ParametresLcbFt(); + params.setOrganisation(org); + assertThat(params.getOrganisation()).isSameAs(org); + } + + @Test + @DisplayName("setCodeDevise / getCodeDevise round-trips") + void codeDevise_roundTrips() { + ParametresLcbFt params = new ParametresLcbFt(); + params.setCodeDevise("GNF"); + assertThat(params.getCodeDevise()).isEqualTo("GNF"); + } + + @Test + @DisplayName("setMontantSeuilJustification / getMontantSeuilJustification round-trips") + void montantSeuilJustification_roundTrips() { + BigDecimal seuil = new BigDecimal("750000.0000"); + ParametresLcbFt params = new ParametresLcbFt(); + params.setMontantSeuilJustification(seuil); + assertThat(params.getMontantSeuilJustification()).isEqualByComparingTo(seuil); + } + + @Test + @DisplayName("setMontantSeuilValidationManuelle / getMontantSeuilValidationManuelle round-trips") + void montantSeuilValidationManuelle_roundTrips() { + BigDecimal seuil = new BigDecimal("3000000.0000"); + ParametresLcbFt params = new ParametresLcbFt(); + params.setMontantSeuilValidationManuelle(seuil); + assertThat(params.getMontantSeuilValidationManuelle()).isEqualByComparingTo(seuil); + } + + @Test + @DisplayName("setOrganisation to null (global params) is allowed") + void organisation_canBeNull() { + ParametresLcbFt params = new ParametresLcbFt(); + params.setOrganisation(null); + assertThat(params.getOrganisation()).isNull(); + } + } + + // ------------------------------------------------------------------------- + // UUID id (inherited from BaseEntity) + // ------------------------------------------------------------------------- + @Test + @DisplayName("UUID id field is settable and gettable") + void uuidId_settableAndGettable() { + UUID id = UUID.randomUUID(); + ParametresLcbFt params = new ParametresLcbFt(); + params.setId(id); + assertThat(params.getId()).isEqualTo(id); + } + + // ------------------------------------------------------------------------- + // equals / hashCode + // ------------------------------------------------------------------------- + @Test + @DisplayName("two instances with same id are equal") + void equals_sameId_areEqual() { + UUID id = UUID.randomUUID(); + ParametresLcbFt a = new ParametresLcbFt(); + a.setId(id); + a.setCodeDevise("XOF"); + ParametresLcbFt b = new ParametresLcbFt(); + b.setId(id); + b.setCodeDevise("XOF"); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("two instances with different ids are not equal") + void equals_differentIds_notEqual() { + ParametresLcbFt a = new ParametresLcbFt(); + a.setId(UUID.randomUUID()); + ParametresLcbFt b = new ParametresLcbFt(); + b.setId(UUID.randomUUID()); + + assertThat(a).isNotEqualTo(b); + } + + // ------------------------------------------------------------------------- + // toString + // ------------------------------------------------------------------------- + @Test + @DisplayName("toString contains codeDevise") + void toString_containsCodeDevise() { + ParametresLcbFt params = new ParametresLcbFt(); + params.setCodeDevise("XOF"); + assertThat(params.toString()).contains("XOF"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/SystemAlertTest.java b/src/test/java/dev/lions/unionflow/server/entity/SystemAlertTest.java new file mode 100644 index 0000000..68a405e --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/SystemAlertTest.java @@ -0,0 +1,240 @@ +package dev.lions.unionflow.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("SystemAlert entity") +class SystemAlertTest { + + // ------------------------------------------------------------------------- + // No-args constructor + // ------------------------------------------------------------------------- + @Test + @DisplayName("no-args constructor creates instance with default acknowledged=false") + void noArgsConstructor_defaultAcknowledgedFalse() { + SystemAlert alert = new SystemAlert(); + + assertThat(alert).isNotNull(); + // Field initialiser on the declaration sets it to false + assertThat(alert.getAcknowledged()).isFalse(); + assertThat(alert.getLevel()).isNull(); + assertThat(alert.getTitle()).isNull(); + assertThat(alert.getMessage()).isNull(); + assertThat(alert.getTimestamp()).isNull(); + assertThat(alert.getAcknowledgedBy()).isNull(); + assertThat(alert.getAcknowledgedAt()).isNull(); + assertThat(alert.getSource()).isNull(); + assertThat(alert.getAlertType()).isNull(); + assertThat(alert.getCurrentValue()).isNull(); + assertThat(alert.getThresholdValue()).isNull(); + assertThat(alert.getUnit()).isNull(); + assertThat(alert.getRecommendedActions()).isNull(); + } + + // ------------------------------------------------------------------------- + // Getters / Setters — every field + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Getters and Setters") + class GettersSettersTests { + + @Test + @DisplayName("setLevel / getLevel round-trips") + void level_roundTrips() { + SystemAlert alert = new SystemAlert(); + alert.setLevel("CRITICAL"); + assertThat(alert.getLevel()).isEqualTo("CRITICAL"); + } + + @Test + @DisplayName("setTitle / getTitle round-trips") + void title_roundTrips() { + SystemAlert alert = new SystemAlert(); + alert.setTitle("CPU très élevé"); + assertThat(alert.getTitle()).isEqualTo("CPU très élevé"); + } + + @Test + @DisplayName("setMessage / getMessage round-trips") + void message_roundTrips() { + SystemAlert alert = new SystemAlert(); + alert.setMessage("Utilisation CPU > 95% depuis 5 minutes"); + assertThat(alert.getMessage()).isEqualTo("Utilisation CPU > 95% depuis 5 minutes"); + } + + @Test + @DisplayName("setTimestamp / getTimestamp round-trips") + void timestamp_roundTrips() { + LocalDateTime ts = LocalDateTime.of(2026, 4, 20, 12, 0, 0); + SystemAlert alert = new SystemAlert(); + alert.setTimestamp(ts); + assertThat(alert.getTimestamp()).isEqualTo(ts); + } + + @Test + @DisplayName("setAcknowledged true / getAcknowledged round-trips") + void acknowledged_true_roundTrips() { + SystemAlert alert = new SystemAlert(); + alert.setAcknowledged(true); + assertThat(alert.getAcknowledged()).isTrue(); + } + + @Test + @DisplayName("setAcknowledged false / getAcknowledged round-trips") + void acknowledged_false_roundTrips() { + SystemAlert alert = new SystemAlert(); + alert.setAcknowledged(false); + assertThat(alert.getAcknowledged()).isFalse(); + } + + @Test + @DisplayName("setAcknowledgedBy / getAcknowledgedBy round-trips") + void acknowledgedBy_roundTrips() { + SystemAlert alert = new SystemAlert(); + alert.setAcknowledgedBy("admin@unionflow.com"); + assertThat(alert.getAcknowledgedBy()).isEqualTo("admin@unionflow.com"); + } + + @Test + @DisplayName("setAcknowledgedAt / getAcknowledgedAt round-trips") + void acknowledgedAt_roundTrips() { + LocalDateTime dt = LocalDateTime.of(2026, 4, 20, 12, 30, 0); + SystemAlert alert = new SystemAlert(); + alert.setAcknowledgedAt(dt); + assertThat(alert.getAcknowledgedAt()).isEqualTo(dt); + } + + @Test + @DisplayName("setSource / getSource round-trips") + void source_roundTrips() { + SystemAlert alert = new SystemAlert(); + alert.setSource("CPU"); + assertThat(alert.getSource()).isEqualTo("CPU"); + } + + @Test + @DisplayName("setAlertType / getAlertType round-trips") + void alertType_roundTrips() { + SystemAlert alert = new SystemAlert(); + alert.setAlertType("THRESHOLD"); + assertThat(alert.getAlertType()).isEqualTo("THRESHOLD"); + } + + @Test + @DisplayName("setCurrentValue / getCurrentValue round-trips") + void currentValue_roundTrips() { + SystemAlert alert = new SystemAlert(); + alert.setCurrentValue(97.5); + assertThat(alert.getCurrentValue()).isEqualTo(97.5); + } + + @Test + @DisplayName("setThresholdValue / getThresholdValue round-trips") + void thresholdValue_roundTrips() { + SystemAlert alert = new SystemAlert(); + alert.setThresholdValue(90.0); + assertThat(alert.getThresholdValue()).isEqualTo(90.0); + } + + @Test + @DisplayName("setUnit / getUnit round-trips") + void unit_roundTrips() { + SystemAlert alert = new SystemAlert(); + alert.setUnit("%"); + assertThat(alert.getUnit()).isEqualTo("%"); + } + + @Test + @DisplayName("setRecommendedActions / getRecommendedActions round-trips") + void recommendedActions_roundTrips() { + SystemAlert alert = new SystemAlert(); + alert.setRecommendedActions("Redémarrer le service. Vérifier les logs."); + assertThat(alert.getRecommendedActions()).isEqualTo("Redémarrer le service. Vérifier les logs."); + } + } + + // ------------------------------------------------------------------------- + // Typical alert level values + // ------------------------------------------------------------------------- + @Test + @DisplayName("level accepts all expected severity values") + void level_acceptsAllSeverityValues() { + String[] levels = {"CRITICAL", "ERROR", "WARNING", "INFO"}; + SystemAlert alert = new SystemAlert(); + for (String level : levels) { + alert.setLevel(level); + assertThat(alert.getLevel()).isEqualTo(level); + } + } + + // ------------------------------------------------------------------------- + // Typical source values + // ------------------------------------------------------------------------- + @Test + @DisplayName("source accepts typical metric sources") + void source_acceptsTypicalSources() { + String[] sources = {"CPU", "MEMORY", "DISK", "DATABASE"}; + SystemAlert alert = new SystemAlert(); + for (String source : sources) { + alert.setSource(source); + assertThat(alert.getSource()).isEqualTo(source); + } + } + + // ------------------------------------------------------------------------- + // UUID id (inherited from BaseEntity) + // ------------------------------------------------------------------------- + @Test + @DisplayName("UUID id field is settable and gettable") + void uuidId_settableAndGettable() { + UUID id = UUID.randomUUID(); + SystemAlert alert = new SystemAlert(); + alert.setId(id); + assertThat(alert.getId()).isEqualTo(id); + } + + // ------------------------------------------------------------------------- + // A complete "acknowledged alert" scenario + // ------------------------------------------------------------------------- + @Test + @DisplayName("full acknowledged alert scenario sets all fields coherently") + void fullAcknowledgedAlertScenario() { + LocalDateTime ts = LocalDateTime.of(2026, 4, 20, 8, 0); + LocalDateTime ack = LocalDateTime.of(2026, 4, 20, 8, 15); + + SystemAlert alert = new SystemAlert(); + alert.setLevel("WARNING"); + alert.setTitle("Mémoire élevée"); + alert.setMessage("Mémoire utilisée > 80%"); + alert.setTimestamp(ts); + alert.setSource("MEMORY"); + alert.setAlertType("THRESHOLD"); + alert.setCurrentValue(83.2); + alert.setThresholdValue(80.0); + alert.setUnit("%"); + alert.setRecommendedActions("Libérer la mémoire ou augmenter la RAM."); + alert.setAcknowledged(true); + alert.setAcknowledgedBy("ops@unionflow.com"); + alert.setAcknowledgedAt(ack); + + assertThat(alert.getLevel()).isEqualTo("WARNING"); + assertThat(alert.getTitle()).isEqualTo("Mémoire élevée"); + assertThat(alert.getMessage()).isEqualTo("Mémoire utilisée > 80%"); + assertThat(alert.getTimestamp()).isEqualTo(ts); + assertThat(alert.getSource()).isEqualTo("MEMORY"); + assertThat(alert.getAlertType()).isEqualTo("THRESHOLD"); + assertThat(alert.getCurrentValue()).isEqualTo(83.2); + assertThat(alert.getThresholdValue()).isEqualTo(80.0); + assertThat(alert.getUnit()).isEqualTo("%"); + assertThat(alert.getRecommendedActions()).contains("Libérer la mémoire"); + assertThat(alert.getAcknowledged()).isTrue(); + assertThat(alert.getAcknowledgedBy()).isEqualTo("ops@unionflow.com"); + assertThat(alert.getAcknowledgedAt()).isEqualTo(ack); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/SystemConfigPersistenceTest.java b/src/test/java/dev/lions/unionflow/server/entity/SystemConfigPersistenceTest.java new file mode 100644 index 0000000..bd81837 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/SystemConfigPersistenceTest.java @@ -0,0 +1,209 @@ +package dev.lions.unionflow.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("SystemConfigPersistence entity") +class SystemConfigPersistenceTest { + + // ------------------------------------------------------------------------- + // No-args constructor + // ------------------------------------------------------------------------- + @Test + @DisplayName("no-args constructor creates instance with null fields") + void noArgsConstructor_createsInstanceWithNullFields() { + SystemConfigPersistence config = new SystemConfigPersistence(); + + assertThat(config).isNotNull(); + assertThat(config.getConfigKey()).isNull(); + assertThat(config.getConfigValue()).isNull(); + } + + // ------------------------------------------------------------------------- + // All-args constructor + // ------------------------------------------------------------------------- + @Test + @DisplayName("all-args constructor sets configKey and configValue") + void allArgsConstructor_setsFields() { + SystemConfigPersistence config = new SystemConfigPersistence("smtp.host", "mail.example.com"); + + assertThat(config.getConfigKey()).isEqualTo("smtp.host"); + assertThat(config.getConfigValue()).isEqualTo("mail.example.com"); + } + + // ------------------------------------------------------------------------- + // Builder + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Builder") + class BuilderTests { + + @Test + @DisplayName("builder sets configKey") + void builder_setsConfigKey() { + SystemConfigPersistence config = SystemConfigPersistence.builder() + .configKey("app.version") + .build(); + + assertThat(config.getConfigKey()).isEqualTo("app.version"); + } + + @Test + @DisplayName("builder sets configValue") + void builder_setsConfigValue() { + SystemConfigPersistence config = SystemConfigPersistence.builder() + .configValue("3.0.0") + .build(); + + assertThat(config.getConfigValue()).isEqualTo("3.0.0"); + } + + @Test + @DisplayName("builder sets both configKey and configValue") + void builder_setsBothFields() { + SystemConfigPersistence config = SystemConfigPersistence.builder() + .configKey("feature.kyc.enabled") + .configValue("true") + .build(); + + assertThat(config.getConfigKey()).isEqualTo("feature.kyc.enabled"); + assertThat(config.getConfigValue()).isEqualTo("true"); + } + + @Test + @DisplayName("builder allows null configValue (TEXT column is nullable)") + void builder_nullConfigValue_isAllowed() { + SystemConfigPersistence config = SystemConfigPersistence.builder() + .configKey("optional.setting") + .configValue(null) + .build(); + + assertThat(config.getConfigKey()).isEqualTo("optional.setting"); + assertThat(config.getConfigValue()).isNull(); + } + } + + // ------------------------------------------------------------------------- + // Getters / Setters + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Getters and Setters") + class GettersSettersTests { + + @Test + @DisplayName("setConfigKey / getConfigKey round-trips") + void configKey_roundTrips() { + SystemConfigPersistence config = new SystemConfigPersistence(); + config.setConfigKey("max.upload.size"); + assertThat(config.getConfigKey()).isEqualTo("max.upload.size"); + } + + @Test + @DisplayName("setConfigValue / getConfigValue round-trips") + void configValue_roundTrips() { + SystemConfigPersistence config = new SystemConfigPersistence(); + config.setConfigValue("5242880"); + assertThat(config.getConfigValue()).isEqualTo("5242880"); + } + + @Test + @DisplayName("configValue can store a JSON blob") + void configValue_canStoreJson() { + String json = "{\"enabled\":true,\"maxRetries\":3}"; + SystemConfigPersistence config = new SystemConfigPersistence(); + config.setConfigValue(json); + assertThat(config.getConfigValue()).isEqualTo(json); + } + + @Test + @DisplayName("configValue can store a multiline text") + void configValue_canStoreMultilineText() { + String multiline = "line1\nline2\nline3"; + SystemConfigPersistence config = new SystemConfigPersistence(); + config.setConfigValue(multiline); + assertThat(config.getConfigValue()).isEqualTo(multiline); + } + } + + // ------------------------------------------------------------------------- + // UUID id (inherited from BaseEntity) + // ------------------------------------------------------------------------- + @Test + @DisplayName("UUID id field is settable and gettable") + void uuidId_settableAndGettable() { + UUID id = UUID.randomUUID(); + SystemConfigPersistence config = new SystemConfigPersistence(); + config.setId(id); + assertThat(config.getId()).isEqualTo(id); + } + + // ------------------------------------------------------------------------- + // equals / hashCode (Lombok @EqualsAndHashCode(callSuper=true)) + // ------------------------------------------------------------------------- + @Test + @DisplayName("two instances with same id are equal") + void equals_sameId_areEqual() { + UUID id = UUID.randomUUID(); + SystemConfigPersistence a = new SystemConfigPersistence("key", "val"); + a.setId(id); + SystemConfigPersistence b = new SystemConfigPersistence("key", "val"); + b.setId(id); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("two instances with different ids are not equal") + void equals_differentIds_notEqual() { + SystemConfigPersistence a = new SystemConfigPersistence("key", "val"); + a.setId(UUID.randomUUID()); + SystemConfigPersistence b = new SystemConfigPersistence("key", "val"); + b.setId(UUID.randomUUID()); + + assertThat(a).isNotEqualTo(b); + } + + // ------------------------------------------------------------------------- + // toString + // ------------------------------------------------------------------------- + @Test + @DisplayName("toString contains configKey") + void toString_containsConfigKey() { + SystemConfigPersistence config = SystemConfigPersistence.builder() + .configKey("smtp.port") + .configValue("587") + .build(); + + assertThat(config.toString()).contains("smtp.port"); + } + + // ------------------------------------------------------------------------- + // Typical configuration keys scenario + // ------------------------------------------------------------------------- + @Test + @DisplayName("stores various typical configuration key-value pairs") + void typicalConfigurations_storeCorrectly() { + String[][] configs = { + {"smtp.host", "mail.example.com"}, + {"smtp.port", "587"}, + {"feature.kyc.enabled", "true"}, + {"max.upload.size.bytes", "5242880"}, + {"default.currency", "XOF"} + }; + + for (String[] kv : configs) { + SystemConfigPersistence config = SystemConfigPersistence.builder() + .configKey(kv[0]) + .configValue(kv[1]) + .build(); + assertThat(config.getConfigKey()).isEqualTo(kv[0]); + assertThat(config.getConfigValue()).isEqualTo(kv[1]); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/SystemLogTest.java b/src/test/java/dev/lions/unionflow/server/entity/SystemLogTest.java new file mode 100644 index 0000000..af8c97c --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/SystemLogTest.java @@ -0,0 +1,239 @@ +package dev.lions.unionflow.server.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("SystemLog entity") +class SystemLogTest { + + // ------------------------------------------------------------------------- + // No-args constructor + // ------------------------------------------------------------------------- + @Test + @DisplayName("no-args constructor creates instance with all-null fields") + void noArgsConstructor_createsInstanceWithNullFields() { + SystemLog log = new SystemLog(); + + assertThat(log).isNotNull(); + assertThat(log.getLevel()).isNull(); + assertThat(log.getSource()).isNull(); + assertThat(log.getMessage()).isNull(); + assertThat(log.getDetails()).isNull(); + assertThat(log.getTimestamp()).isNull(); + assertThat(log.getUserId()).isNull(); + assertThat(log.getIpAddress()).isNull(); + assertThat(log.getSessionId()).isNull(); + assertThat(log.getEndpoint()).isNull(); + assertThat(log.getHttpStatusCode()).isNull(); + } + + // ------------------------------------------------------------------------- + // Getters / Setters — every field + // ------------------------------------------------------------------------- + @Nested + @DisplayName("Getters and Setters") + class GettersSettersTests { + + @Test + @DisplayName("setLevel / getLevel round-trips") + void level_roundTrips() { + SystemLog log = new SystemLog(); + log.setLevel("ERROR"); + assertThat(log.getLevel()).isEqualTo("ERROR"); + } + + @Test + @DisplayName("setSource / getSource round-trips") + void source_roundTrips() { + SystemLog log = new SystemLog(); + log.setSource("Database"); + assertThat(log.getSource()).isEqualTo("Database"); + } + + @Test + @DisplayName("setMessage / getMessage round-trips") + void message_roundTrips() { + SystemLog log = new SystemLog(); + log.setMessage("Connection refused on port 5432"); + assertThat(log.getMessage()).isEqualTo("Connection refused on port 5432"); + } + + @Test + @DisplayName("setDetails / getDetails round-trips") + void details_roundTrips() { + String stacktrace = "java.sql.SQLTransientConnectionException\n\tat com.example.Foo.bar(Foo.java:42)"; + SystemLog log = new SystemLog(); + log.setDetails(stacktrace); + assertThat(log.getDetails()).isEqualTo(stacktrace); + } + + @Test + @DisplayName("setTimestamp / getTimestamp round-trips") + void timestamp_roundTrips() { + LocalDateTime ts = LocalDateTime.of(2026, 4, 20, 10, 30, 0); + SystemLog log = new SystemLog(); + log.setTimestamp(ts); + assertThat(log.getTimestamp()).isEqualTo(ts); + } + + @Test + @DisplayName("setUserId / getUserId round-trips") + void userId_roundTrips() { + SystemLog log = new SystemLog(); + log.setUserId("user-abc-123"); + assertThat(log.getUserId()).isEqualTo("user-abc-123"); + } + + @Test + @DisplayName("setIpAddress / getIpAddress round-trips") + void ipAddress_roundTrips() { + SystemLog log = new SystemLog(); + log.setIpAddress("10.0.0.1"); + assertThat(log.getIpAddress()).isEqualTo("10.0.0.1"); + } + + @Test + @DisplayName("setSessionId / getSessionId round-trips") + void sessionId_roundTrips() { + SystemLog log = new SystemLog(); + log.setSessionId("sess-xyz-789"); + assertThat(log.getSessionId()).isEqualTo("sess-xyz-789"); + } + + @Test + @DisplayName("setEndpoint / getEndpoint round-trips") + void endpoint_roundTrips() { + SystemLog log = new SystemLog(); + log.setEndpoint("/api/membres/123/cotisations"); + assertThat(log.getEndpoint()).isEqualTo("/api/membres/123/cotisations"); + } + + @Test + @DisplayName("setHttpStatusCode / getHttpStatusCode round-trips") + void httpStatusCode_roundTrips() { + SystemLog log = new SystemLog(); + log.setHttpStatusCode(500); + assertThat(log.getHttpStatusCode()).isEqualTo(500); + } + + @Test + @DisplayName("setHttpStatusCode null is allowed (optional field)") + void httpStatusCode_nullIsAllowed() { + SystemLog log = new SystemLog(); + log.setHttpStatusCode(null); + assertThat(log.getHttpStatusCode()).isNull(); + } + } + + // ------------------------------------------------------------------------- + // Typical log-level values + // ------------------------------------------------------------------------- + @Test + @DisplayName("level accepts all expected log-level values") + void level_acceptsAllExpectedValues() { + String[] levels = {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"}; + SystemLog log = new SystemLog(); + for (String level : levels) { + log.setLevel(level); + assertThat(log.getLevel()).isEqualTo(level); + } + } + + // ------------------------------------------------------------------------- + // Typical source values + // ------------------------------------------------------------------------- + @Test + @DisplayName("source accepts typical system sources") + void source_acceptsTypicalSources() { + String[] sources = {"Database", "API", "Auth", "System", "Cache"}; + SystemLog log = new SystemLog(); + for (String source : sources) { + log.setSource(source); + assertThat(log.getSource()).isEqualTo(source); + } + } + + // ------------------------------------------------------------------------- + // UUID id (inherited from BaseEntity) + // ------------------------------------------------------------------------- + @Test + @DisplayName("UUID id field is settable and gettable") + void uuidId_settableAndGettable() { + UUID id = UUID.randomUUID(); + SystemLog log = new SystemLog(); + log.setId(id); + assertThat(log.getId()).isEqualTo(id); + } + + // ------------------------------------------------------------------------- + // HTTP status code — boundary values + // ------------------------------------------------------------------------- + @Test + @DisplayName("httpStatusCode accepts various HTTP status codes") + void httpStatusCode_acceptsVariousValues() { + int[] codes = {200, 201, 400, 401, 403, 404, 422, 500, 503}; + SystemLog log = new SystemLog(); + for (int code : codes) { + log.setHttpStatusCode(code); + assertThat(log.getHttpStatusCode()).isEqualTo(code); + } + } + + // ------------------------------------------------------------------------- + // Full system-error scenario + // ------------------------------------------------------------------------- + @Test + @DisplayName("full error log scenario sets all fields coherently") + void fullErrorLogScenario() { + LocalDateTime ts = LocalDateTime.of(2026, 4, 20, 14, 35, 22); + + SystemLog log = new SystemLog(); + log.setLevel("ERROR"); + log.setSource("API"); + log.setMessage("NullPointerException in MemberService.findById"); + log.setDetails("java.lang.NullPointerException\n\tat dev.lions.MemberService.findById(MemberService.java:88)"); + log.setTimestamp(ts); + log.setUserId("user-42"); + log.setIpAddress("192.168.0.10"); + log.setSessionId("session-abc"); + log.setEndpoint("/api/membres/42"); + log.setHttpStatusCode(500); + + assertThat(log.getLevel()).isEqualTo("ERROR"); + assertThat(log.getSource()).isEqualTo("API"); + assertThat(log.getMessage()).contains("NullPointerException"); + assertThat(log.getDetails()).contains("MemberService.java:88"); + assertThat(log.getTimestamp()).isEqualTo(ts); + assertThat(log.getUserId()).isEqualTo("user-42"); + assertThat(log.getIpAddress()).isEqualTo("192.168.0.10"); + assertThat(log.getSessionId()).isEqualTo("session-abc"); + assertThat(log.getEndpoint()).isEqualTo("/api/membres/42"); + assertThat(log.getHttpStatusCode()).isEqualTo(500); + } + + // ------------------------------------------------------------------------- + // Nullable optional fields + // ------------------------------------------------------------------------- + @Test + @DisplayName("optional fields can be null independently") + void optionalFields_canBeNullIndependently() { + SystemLog log = new SystemLog(); + log.setLevel("INFO"); + log.setSource("System"); + log.setMessage("Scheduled task completed"); + log.setTimestamp(LocalDateTime.now()); + // Leave all optional fields as null + assertThat(log.getDetails()).isNull(); + assertThat(log.getUserId()).isNull(); + assertThat(log.getIpAddress()).isNull(); + assertThat(log.getSessionId()).isNull(); + assertThat(log.getEndpoint()).isNull(); + assertThat(log.getHttpStatusCode()).isNull(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/mutuelle/ParametresFinanciersMutuellTest.java b/src/test/java/dev/lions/unionflow/server/entity/mutuelle/ParametresFinanciersMutuellTest.java new file mode 100644 index 0000000..7767d16 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/mutuelle/ParametresFinanciersMutuellTest.java @@ -0,0 +1,212 @@ +package dev.lions.unionflow.server.entity.mutuelle; + +import dev.lions.unionflow.server.entity.Organisation; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ParametresFinanciersMutuelle — entité") +class ParametresFinanciersMutuellTest { + + // ─── constructeur no-arg + setters/getters ──────────────────────────────── + + @Test + @DisplayName("constructeur no-arg : tous les champs par défaut présents") + void noArgConstructor_defaultsPresent() { + ParametresFinanciersMutuelle p = new ParametresFinanciersMutuelle(); + + assertThat(p.getOrganisation()).isNull(); + assertThat(p.getValeurNominaleParDefaut()).isNull(); // Lombok @Builder.Default ne s'active pas avec new + assertThat(p.getProchaineCalculInterets()).isNull(); + assertThat(p.getDernierCalculInterets()).isNull(); + } + + @Test + @DisplayName("setters et getters — valeurs explicites") + void settersGetters() { + ParametresFinanciersMutuelle p = new ParametresFinanciersMutuelle(); + + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + p.setOrganisation(org); + + p.setValeurNominaleParDefaut(new BigDecimal("10000")); + p.setTauxInteretAnnuelEpargne(new BigDecimal("0.05")); + p.setTauxDividendePartsAnnuel(new BigDecimal("0.07")); + p.setPeriodiciteCalcul("TRIMESTRIEL"); + p.setSeuilMinEpargneInterets(new BigDecimal("500")); + LocalDate prochaine = LocalDate.of(2026, 7, 1); + p.setProchaineCalculInterets(prochaine); + LocalDate dernier = LocalDate.of(2026, 4, 1); + p.setDernierCalculInterets(dernier); + p.setDernierNbComptesTraites(42); + + assertThat(p.getOrganisation()).isSameAs(org); + assertThat(p.getValeurNominaleParDefaut()).isEqualByComparingTo("10000"); + assertThat(p.getTauxInteretAnnuelEpargne()).isEqualByComparingTo("0.05"); + assertThat(p.getTauxDividendePartsAnnuel()).isEqualByComparingTo("0.07"); + assertThat(p.getPeriodiciteCalcul()).isEqualTo("TRIMESTRIEL"); + assertThat(p.getSeuilMinEpargneInterets()).isEqualByComparingTo("500"); + assertThat(p.getProchaineCalculInterets()).isEqualTo(prochaine); + assertThat(p.getDernierCalculInterets()).isEqualTo(dernier); + assertThat(p.getDernierNbComptesTraites()).isEqualTo(42); + } + + // ─── builder ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("builder : valeurs par défaut (@Builder.Default)") + void builder_defaults() { + ParametresFinanciersMutuelle p = ParametresFinanciersMutuelle.builder().build(); + + assertThat(p.getValeurNominaleParDefaut()).isEqualByComparingTo("5000"); + assertThat(p.getTauxInteretAnnuelEpargne()).isEqualByComparingTo("0.03"); + assertThat(p.getTauxDividendePartsAnnuel()).isEqualByComparingTo("0.05"); + assertThat(p.getPeriodiciteCalcul()).isEqualTo("MENSUEL"); + assertThat(p.getSeuilMinEpargneInterets()).isEqualByComparingTo("0"); + assertThat(p.getDernierNbComptesTraites()).isEqualTo(0); + } + + @Test + @DisplayName("builder : valeurs personnalisées") + void builder_customValues() { + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + + LocalDate prochaine = LocalDate.of(2026, 10, 1); + LocalDate dernier = LocalDate.of(2026, 7, 1); + + ParametresFinanciersMutuelle p = ParametresFinanciersMutuelle.builder() + .organisation(org) + .valeurNominaleParDefaut(new BigDecimal("2500")) + .tauxInteretAnnuelEpargne(new BigDecimal("0.04")) + .tauxDividendePartsAnnuel(new BigDecimal("0.06")) + .periodiciteCalcul("ANNUEL") + .seuilMinEpargneInterets(new BigDecimal("1000")) + .prochaineCalculInterets(prochaine) + .dernierCalculInterets(dernier) + .dernierNbComptesTraites(15) + .build(); + + assertThat(p.getOrganisation()).isSameAs(org); + assertThat(p.getValeurNominaleParDefaut()).isEqualByComparingTo("2500"); + assertThat(p.getTauxInteretAnnuelEpargne()).isEqualByComparingTo("0.04"); + assertThat(p.getTauxDividendePartsAnnuel()).isEqualByComparingTo("0.06"); + assertThat(p.getPeriodiciteCalcul()).isEqualTo("ANNUEL"); + assertThat(p.getSeuilMinEpargneInterets()).isEqualByComparingTo("1000"); + assertThat(p.getProchaineCalculInterets()).isEqualTo(prochaine); + assertThat(p.getDernierCalculInterets()).isEqualTo(dernier); + assertThat(p.getDernierNbComptesTraites()).isEqualTo(15); + } + + // ─── AllArgsConstructor ─────────────────────────────────────────────────── + + @Test + @DisplayName("AllArgsConstructor : instanciation complète") + void allArgsConstructor() { + Organisation org = new Organisation(); + BigDecimal valeur = new BigDecimal("5000"); + BigDecimal tauxEpargne = new BigDecimal("0.03"); + BigDecimal tauxDivide = new BigDecimal("0.05"); + String periodicite = "MENSUEL"; + BigDecimal seuil = BigDecimal.ZERO; + LocalDate prochaine = LocalDate.of(2026, 6, 1); + LocalDate dernier = LocalDate.of(2026, 3, 1); + int nbComptes = 10; + + ParametresFinanciersMutuelle p = new ParametresFinanciersMutuelle( + org, valeur, tauxEpargne, tauxDivide, periodicite, seuil, + prochaine, dernier, nbComptes); + + assertThat(p.getOrganisation()).isSameAs(org); + assertThat(p.getValeurNominaleParDefaut()).isEqualByComparingTo("5000"); + assertThat(p.getTauxInteretAnnuelEpargne()).isEqualByComparingTo("0.03"); + assertThat(p.getTauxDividendePartsAnnuel()).isEqualByComparingTo("0.05"); + assertThat(p.getPeriodiciteCalcul()).isEqualTo("MENSUEL"); + assertThat(p.getSeuilMinEpargneInterets()).isEqualByComparingTo("0"); + assertThat(p.getProchaineCalculInterets()).isEqualTo(prochaine); + assertThat(p.getDernierCalculInterets()).isEqualTo(dernier); + assertThat(p.getDernierNbComptesTraites()).isEqualTo(10); + } + + // ─── equals / hashCode / toString ───────────────────────────────────────── + + @Test + @DisplayName("equals : deux instances avec même id sont égales") + void equals_sameId() { + UUID id = UUID.randomUUID(); + ParametresFinanciersMutuelle a = ParametresFinanciersMutuelle.builder() + .valeurNominaleParDefaut(new BigDecimal("5000")) + .tauxInteretAnnuelEpargne(new BigDecimal("0.03")) + .tauxDividendePartsAnnuel(new BigDecimal("0.05")) + .periodiciteCalcul("MENSUEL") + .seuilMinEpargneInterets(BigDecimal.ZERO) + .dernierNbComptesTraites(0) + .build(); + a.setId(id); + + ParametresFinanciersMutuelle b = ParametresFinanciersMutuelle.builder() + .valeurNominaleParDefaut(new BigDecimal("5000")) + .tauxInteretAnnuelEpargne(new BigDecimal("0.03")) + .tauxDividendePartsAnnuel(new BigDecimal("0.05")) + .periodiciteCalcul("MENSUEL") + .seuilMinEpargneInterets(BigDecimal.ZERO) + .dernierNbComptesTraites(0) + .build(); + b.setId(id); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("equals : deux instances avec id différents ne sont pas égales") + void equals_differentId() { + ParametresFinanciersMutuelle a = ParametresFinanciersMutuelle.builder().build(); + a.setId(UUID.randomUUID()); + ParametresFinanciersMutuelle b = ParametresFinanciersMutuelle.builder().build(); + b.setId(UUID.randomUUID()); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString : non null et non vide") + void toString_notNull() { + ParametresFinanciersMutuelle p = ParametresFinanciersMutuelle.builder().build(); + assertThat(p.toString()).isNotNull().isNotEmpty(); + } + + // ─── héritage BaseEntity ─────────────────────────────────────────────────── + + @Test + @DisplayName("héritage BaseEntity : setId / getId fonctionnels") + void baseEntity_idFieldsWork() { + ParametresFinanciersMutuelle p = new ParametresFinanciersMutuelle(); + UUID id = UUID.randomUUID(); + p.setId(id); + p.setActif(true); + p.setCreePar("admin@test.com"); + p.setModifiePar("admin2@test.com"); + + assertThat(p.getId()).isEqualTo(id); + assertThat(p.getActif()).isTrue(); + assertThat(p.getCreePar()).isEqualTo("admin@test.com"); + assertThat(p.getModifiePar()).isEqualTo("admin2@test.com"); + } + + @Test + @DisplayName("marquerCommeModifie : met à jour modifiePar et dateModification") + void marquerCommeModifie_updatesFields() { + ParametresFinanciersMutuelle p = new ParametresFinanciersMutuelle(); + p.marquerCommeModifie("gestionnaire@test.com"); + + assertThat(p.getModifiePar()).isEqualTo("gestionnaire@test.com"); + assertThat(p.getDateModification()).isNotNull(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/mutuelle/parts/ComptePartsSocialesTest.java b/src/test/java/dev/lions/unionflow/server/entity/mutuelle/parts/ComptePartsSocialesTest.java new file mode 100644 index 0000000..69f8dd9 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/mutuelle/parts/ComptePartsSocialesTest.java @@ -0,0 +1,230 @@ +package dev.lions.unionflow.server.entity.mutuelle.parts; + +import dev.lions.unionflow.server.api.enums.mutuelle.parts.StatutComptePartsSociales; +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.entity.Organisation; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ComptePartsSociales — entité") +class ComptePartsSocialesTest { + + // ─── constructeur no-arg ────────────────────────────────────────────────── + + @Test + @DisplayName("constructeur no-arg : instance créée, champs null sans @Builder.Default") + void noArgConstructor_instanceCreated() { + ComptePartsSociales c = new ComptePartsSociales(); + assertThat(c).isNotNull(); + assertThat(c.getMembre()).isNull(); + assertThat(c.getOrganisation()).isNull(); + assertThat(c.getNumeroCompte()).isNull(); + } + + // ─── setters / getters ──────────────────────────────────────────────────── + + @Test + @DisplayName("setters et getters — tous les champs") + void settersGetters_allFields() { + ComptePartsSociales c = new ComptePartsSociales(); + + Membre membre = new Membre(); + membre.setId(UUID.randomUUID()); + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + LocalDate ouverture = LocalDate.of(2025, 1, 15); + LocalDate derniereOp = LocalDate.of(2026, 4, 1); + + c.setMembre(membre); + c.setOrganisation(org); + c.setNumeroCompte("CPS-2025-001"); + c.setNombreParts(10); + c.setValeurNominale(new BigDecimal("5000")); + c.setMontantTotal(new BigDecimal("50000")); + c.setTotalDividendesRecus(new BigDecimal("2500")); + c.setStatut(StatutComptePartsSociales.ACTIF); + c.setDateOuverture(ouverture); + c.setDateDerniereOperation(derniereOp); + c.setNotes("Note de test"); + + assertThat(c.getMembre()).isSameAs(membre); + assertThat(c.getOrganisation()).isSameAs(org); + assertThat(c.getNumeroCompte()).isEqualTo("CPS-2025-001"); + assertThat(c.getNombreParts()).isEqualTo(10); + assertThat(c.getValeurNominale()).isEqualByComparingTo("5000"); + assertThat(c.getMontantTotal()).isEqualByComparingTo("50000"); + assertThat(c.getTotalDividendesRecus()).isEqualByComparingTo("2500"); + assertThat(c.getStatut()).isEqualTo(StatutComptePartsSociales.ACTIF); + assertThat(c.getDateOuverture()).isEqualTo(ouverture); + assertThat(c.getDateDerniereOperation()).isEqualTo(derniereOp); + assertThat(c.getNotes()).isEqualTo("Note de test"); + } + + // ─── builder ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("builder : valeurs par défaut (@Builder.Default)") + void builder_defaults() { + ComptePartsSociales c = ComptePartsSociales.builder() + .numeroCompte("CPS-TEST-001") + .valeurNominale(new BigDecimal("5000")) + .build(); + + assertThat(c.getNombreParts()).isEqualTo(0); + assertThat(c.getMontantTotal()).isEqualByComparingTo("0"); + assertThat(c.getTotalDividendesRecus()).isEqualByComparingTo("0"); + assertThat(c.getStatut()).isEqualTo(StatutComptePartsSociales.ACTIF); + assertThat(c.getDateOuverture()).isEqualTo(LocalDate.now()); + } + + @Test + @DisplayName("builder : valeurs personnalisées") + void builder_customValues() { + Membre membre = new Membre(); + Organisation org = new Organisation(); + LocalDate ouverture = LocalDate.of(2024, 6, 1); + + ComptePartsSociales c = ComptePartsSociales.builder() + .membre(membre) + .organisation(org) + .numeroCompte("CPS-2024-042") + .nombreParts(25) + .valeurNominale(new BigDecimal("5000")) + .montantTotal(new BigDecimal("125000")) + .totalDividendesRecus(new BigDecimal("6250")) + .statut(StatutComptePartsSociales.SUSPENDU) + .dateOuverture(ouverture) + .notes("Compte suspendu pour régularisation") + .build(); + + assertThat(c.getMembre()).isSameAs(membre); + assertThat(c.getOrganisation()).isSameAs(org); + assertThat(c.getNumeroCompte()).isEqualTo("CPS-2024-042"); + assertThat(c.getNombreParts()).isEqualTo(25); + assertThat(c.getValeurNominale()).isEqualByComparingTo("5000"); + assertThat(c.getMontantTotal()).isEqualByComparingTo("125000"); + assertThat(c.getTotalDividendesRecus()).isEqualByComparingTo("6250"); + assertThat(c.getStatut()).isEqualTo(StatutComptePartsSociales.SUSPENDU); + assertThat(c.getDateOuverture()).isEqualTo(ouverture); + assertThat(c.getNotes()).isEqualTo("Compte suspendu pour régularisation"); + } + + // ─── StatutComptePartsSociales enum ────────────────────────────────────── + + @Test + @DisplayName("StatutComptePartsSociales : toutes les valeurs accessibles") + void statutEnum_allValues() { + assertThat(StatutComptePartsSociales.values()).hasSize(3); + assertThat(StatutComptePartsSociales.ACTIF.getLibelle()).isEqualTo("Compte actif"); + assertThat(StatutComptePartsSociales.SUSPENDU.getLibelle()).isEqualTo("Compte suspendu"); + assertThat(StatutComptePartsSociales.CLOS.getLibelle()).contains("Compte cl"); + } + + @Test + @DisplayName("StatutComptePartsSociales : valueOf fonctionne") + void statutEnum_valueOf() { + assertThat(StatutComptePartsSociales.valueOf("ACTIF")).isEqualTo(StatutComptePartsSociales.ACTIF); + assertThat(StatutComptePartsSociales.valueOf("CLOS")).isEqualTo(StatutComptePartsSociales.CLOS); + } + + // ─── AllArgsConstructor ─────────────────────────────────────────────────── + + @Test + @DisplayName("AllArgsConstructor : instanciation complète") + void allArgsConstructor() { + Membre membre = new Membre(); + Organisation org = new Organisation(); + String numero = "CPS-ALL-001"; + Integer nombreParts = 5; + BigDecimal valeurNominale = new BigDecimal("5000"); + BigDecimal montantTotal = new BigDecimal("25000"); + BigDecimal totalDividendes = new BigDecimal("1250"); + StatutComptePartsSociales statut = StatutComptePartsSociales.ACTIF; + LocalDate dateOuverture = LocalDate.of(2025, 3, 1); + LocalDate dateDerniereOperation = LocalDate.of(2026, 4, 1); + String notes = "Test all args"; + + ComptePartsSociales c = new ComptePartsSociales( + membre, org, numero, nombreParts, valeurNominale, + montantTotal, totalDividendes, statut, + dateOuverture, dateDerniereOperation, notes); + + assertThat(c.getMembre()).isSameAs(membre); + assertThat(c.getOrganisation()).isSameAs(org); + assertThat(c.getNumeroCompte()).isEqualTo("CPS-ALL-001"); + assertThat(c.getNombreParts()).isEqualTo(5); + assertThat(c.getStatut()).isEqualTo(StatutComptePartsSociales.ACTIF); + } + + // ─── equals / hashCode / toString ───────────────────────────────────────── + + @Test + @DisplayName("equals : même id → égaux") + void equals_sameId() { + UUID id = UUID.randomUUID(); + ComptePartsSociales a = ComptePartsSociales.builder() + .numeroCompte("CPS-A") + .valeurNominale(new BigDecimal("5000")) + .build(); + a.setId(id); + + ComptePartsSociales b = ComptePartsSociales.builder() + .numeroCompte("CPS-A") + .valeurNominale(new BigDecimal("5000")) + .build(); + b.setId(id); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("equals : id différents → non égaux") + void equals_differentId() { + ComptePartsSociales a = ComptePartsSociales.builder() + .numeroCompte("CPS-A") + .valeurNominale(new BigDecimal("5000")) + .build(); + a.setId(UUID.randomUUID()); + + ComptePartsSociales b = ComptePartsSociales.builder() + .numeroCompte("CPS-A") + .valeurNominale(new BigDecimal("5000")) + .build(); + b.setId(UUID.randomUUID()); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString : non null, non vide") + void toString_notNull() { + ComptePartsSociales c = ComptePartsSociales.builder() + .numeroCompte("CPS-STR") + .valeurNominale(new BigDecimal("5000")) + .build(); + assertThat(c.toString()).isNotNull().isNotEmpty(); + } + + // ─── BaseEntity ─────────────────────────────────────────────────────────── + + @Test + @DisplayName("BaseEntity : id, actif, audit fields accessibles") + void baseEntity_fields() { + ComptePartsSociales c = new ComptePartsSociales(); + UUID id = UUID.randomUUID(); + c.setId(id); + c.setActif(false); + c.setCreePar("createur@test.com"); + + assertThat(c.getId()).isEqualTo(id); + assertThat(c.getActif()).isFalse(); + assertThat(c.getCreePar()).isEqualTo("createur@test.com"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/entity/mutuelle/parts/TransactionPartsSocialesTest.java b/src/test/java/dev/lions/unionflow/server/entity/mutuelle/parts/TransactionPartsSocialesTest.java new file mode 100644 index 0000000..6a4d8df --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/mutuelle/parts/TransactionPartsSocialesTest.java @@ -0,0 +1,257 @@ +package dev.lions.unionflow.server.entity.mutuelle.parts; + +import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("TransactionPartsSociales — entité") +class TransactionPartsSocialesTest { + + // ─── constructeur no-arg ────────────────────────────────────────────────── + + @Test + @DisplayName("constructeur no-arg : instance créée") + void noArgConstructor_instanceCreated() { + TransactionPartsSociales t = new TransactionPartsSociales(); + assertThat(t).isNotNull(); + assertThat(t.getCompte()).isNull(); + assertThat(t.getTypeTransaction()).isNull(); + assertThat(t.getNombreParts()).isNull(); + assertThat(t.getMontant()).isNull(); + } + + // ─── setters / getters ──────────────────────────────────────────────────── + + @Test + @DisplayName("setters et getters — tous les champs") + void settersGetters_allFields() { + TransactionPartsSociales t = new TransactionPartsSociales(); + + ComptePartsSociales compte = ComptePartsSociales.builder() + .numeroCompte("CPS-001") + .valeurNominale(new BigDecimal("5000")) + .build(); + compte.setId(UUID.randomUUID()); + + LocalDateTime dateTransaction = LocalDateTime.of(2026, 4, 20, 10, 30); + + t.setCompte(compte); + t.setTypeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION); + t.setNombreParts(5); + t.setMontant(new BigDecimal("25000")); + t.setSoldePartsAvant(10); + t.setSoldePartsApres(15); + t.setMotif("Souscription initiale"); + t.setReferenceExterne("REF-EXT-001"); + t.setDateTransaction(dateTransaction); + + assertThat(t.getCompte()).isSameAs(compte); + assertThat(t.getTypeTransaction()).isEqualTo(TypeTransactionPartsSociales.SOUSCRIPTION); + assertThat(t.getNombreParts()).isEqualTo(5); + assertThat(t.getMontant()).isEqualByComparingTo("25000"); + assertThat(t.getSoldePartsAvant()).isEqualTo(10); + assertThat(t.getSoldePartsApres()).isEqualTo(15); + assertThat(t.getMotif()).isEqualTo("Souscription initiale"); + assertThat(t.getReferenceExterne()).isEqualTo("REF-EXT-001"); + assertThat(t.getDateTransaction()).isEqualTo(dateTransaction); + } + + // ─── builder ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("builder : valeurs par défaut (@Builder.Default)") + void builder_defaults() { + TransactionPartsSociales t = TransactionPartsSociales.builder() + .typeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION) + .nombreParts(1) + .montant(new BigDecimal("5000")) + .build(); + + assertThat(t.getSoldePartsAvant()).isEqualTo(0); + assertThat(t.getSoldePartsApres()).isEqualTo(0); + assertThat(t.getDateTransaction()).isNotNull(); + // dateTransaction initialized to LocalDateTime.now() by @Builder.Default + assertThat(t.getDateTransaction()).isBeforeOrEqualTo(LocalDateTime.now()); + } + + @Test + @DisplayName("builder : valeurs personnalisées") + void builder_customValues() { + ComptePartsSociales compte = ComptePartsSociales.builder() + .numeroCompte("CPS-002") + .valeurNominale(new BigDecimal("5000")) + .build(); + LocalDateTime now = LocalDateTime.now(); + + TransactionPartsSociales t = TransactionPartsSociales.builder() + .compte(compte) + .typeTransaction(TypeTransactionPartsSociales.CESSION_PARTIELLE) + .nombreParts(3) + .montant(new BigDecimal("15000")) + .soldePartsAvant(10) + .soldePartsApres(7) + .motif("Cession à titre onéreux") + .referenceExterne("REF-CESS-001") + .dateTransaction(now) + .build(); + + assertThat(t.getCompte()).isSameAs(compte); + assertThat(t.getTypeTransaction()).isEqualTo(TypeTransactionPartsSociales.CESSION_PARTIELLE); + assertThat(t.getNombreParts()).isEqualTo(3); + assertThat(t.getMontant()).isEqualByComparingTo("15000"); + assertThat(t.getSoldePartsAvant()).isEqualTo(10); + assertThat(t.getSoldePartsApres()).isEqualTo(7); + assertThat(t.getMotif()).isEqualTo("Cession à titre onéreux"); + assertThat(t.getReferenceExterne()).isEqualTo("REF-CESS-001"); + assertThat(t.getDateTransaction()).isEqualTo(now); + } + + // ─── AllArgsConstructor ─────────────────────────────────────────────────── + + @Test + @DisplayName("AllArgsConstructor : instanciation complète") + void allArgsConstructor() { + ComptePartsSociales compte = ComptePartsSociales.builder() + .numeroCompte("CPS-ALL") + .valeurNominale(new BigDecimal("5000")) + .build(); + TypeTransactionPartsSociales type = TypeTransactionPartsSociales.RACHAT_TOTAL; + Integer nombreParts = 20; + BigDecimal montant = new BigDecimal("100000"); + Integer soldeAvant = 20; + Integer soldeApres = 0; + String motif = "Rachat complet"; + String refExterne = "REF-RACHAT-001"; + LocalDateTime dateTransaction = LocalDateTime.of(2026, 4, 20, 12, 0); + + TransactionPartsSociales t = new TransactionPartsSociales( + compte, type, nombreParts, montant, + soldeAvant, soldeApres, motif, refExterne, dateTransaction); + + assertThat(t.getCompte()).isSameAs(compte); + assertThat(t.getTypeTransaction()).isEqualTo(TypeTransactionPartsSociales.RACHAT_TOTAL); + assertThat(t.getNombreParts()).isEqualTo(20); + assertThat(t.getMontant()).isEqualByComparingTo("100000"); + assertThat(t.getSoldePartsAvant()).isEqualTo(20); + assertThat(t.getSoldePartsApres()).isEqualTo(0); + assertThat(t.getMotif()).isEqualTo("Rachat complet"); + assertThat(t.getReferenceExterne()).isEqualTo("REF-RACHAT-001"); + assertThat(t.getDateTransaction()).isEqualTo(dateTransaction); + } + + // ─── TypeTransactionPartsSociales enum ─────────────────────────────────── + + @Test + @DisplayName("TypeTransactionPartsSociales : toutes les valeurs accessibles avec libellé") + void typeTransactionEnum_allValues() { + assertThat(TypeTransactionPartsSociales.values()).hasSize(6); + assertThat(TypeTransactionPartsSociales.SOUSCRIPTION.getLibelle()) + .isEqualTo("Souscription de parts sociales"); + assertThat(TypeTransactionPartsSociales.SOUSCRIPTION_IMPORT.getLibelle()) + .contains("Import"); + assertThat(TypeTransactionPartsSociales.CESSION_PARTIELLE.getLibelle()) + .contains("Cession"); + assertThat(TypeTransactionPartsSociales.RACHAT_TOTAL.getLibelle()) + .contains("Rachat"); + assertThat(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE.getLibelle()) + .contains("dividende"); + assertThat(TypeTransactionPartsSociales.CORRECTION.getLibelle()) + .contains("Correction"); + } + + @Test + @DisplayName("TypeTransactionPartsSociales : valueOf fonctionne") + void typeTransactionEnum_valueOf() { + assertThat(TypeTransactionPartsSociales.valueOf("SOUSCRIPTION")) + .isEqualTo(TypeTransactionPartsSociales.SOUSCRIPTION); + assertThat(TypeTransactionPartsSociales.valueOf("PAIEMENT_DIVIDENDE")) + .isEqualTo(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE); + } + + // ─── equals / hashCode / toString ───────────────────────────────────────── + + @Test + @DisplayName("equals : même id → égaux") + void equals_sameId() { + UUID id = UUID.randomUUID(); + TransactionPartsSociales a = TransactionPartsSociales.builder() + .typeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION) + .nombreParts(1) + .montant(new BigDecimal("5000")) + .build(); + a.setId(id); + + TransactionPartsSociales b = TransactionPartsSociales.builder() + .typeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION) + .nombreParts(1) + .montant(new BigDecimal("5000")) + .build(); + b.setId(id); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("equals : id différents → non égaux") + void equals_differentId() { + TransactionPartsSociales a = TransactionPartsSociales.builder() + .typeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION) + .nombreParts(1) + .montant(new BigDecimal("5000")) + .build(); + a.setId(UUID.randomUUID()); + + TransactionPartsSociales b = TransactionPartsSociales.builder() + .typeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION) + .nombreParts(1) + .montant(new BigDecimal("5000")) + .build(); + b.setId(UUID.randomUUID()); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString : non null et non vide") + void toString_notNull() { + TransactionPartsSociales t = TransactionPartsSociales.builder() + .typeTransaction(TypeTransactionPartsSociales.CORRECTION) + .nombreParts(1) + .montant(BigDecimal.ZERO) + .build(); + assertThat(t.toString()).isNotNull().isNotEmpty(); + } + + // ─── BaseEntity ─────────────────────────────────────────────────────────── + + @Test + @DisplayName("BaseEntity : id, actif, audit fields accessibles") + void baseEntity_fields() { + TransactionPartsSociales t = new TransactionPartsSociales(); + UUID id = UUID.randomUUID(); + t.setId(id); + t.setActif(true); + t.setVersion(3L); + + assertThat(t.getId()).isEqualTo(id); + assertThat(t.getActif()).isTrue(); + assertThat(t.getVersion()).isEqualTo(3L); + } + + @Test + @DisplayName("marquerCommeModifie : met à jour modifiePar et dateModification") + void marquerCommeModifie_updatesFields() { + TransactionPartsSociales t = new TransactionPartsSociales(); + t.marquerCommeModifie("comptable@test.com"); + + assertThat(t.getModifiePar()).isEqualTo("comptable@test.com"); + assertThat(t.getDateModification()).isNotNull(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/exception/GlobalExceptionMapperTest.java b/src/test/java/dev/lions/unionflow/server/exception/GlobalExceptionMapperTest.java index 39fa714..bbe25e8 100644 --- a/src/test/java/dev/lions/unionflow/server/exception/GlobalExceptionMapperTest.java +++ b/src/test/java/dev/lions/unionflow/server/exception/GlobalExceptionMapperTest.java @@ -71,7 +71,7 @@ class GlobalExceptionMapperTest { } @Test - @DisplayName("IllegalArgumentException → 400") + @DisplayName("IllegalArgumentException → 400 (cas métier attendu : pas de log ERROR, pas de persistance)") void mapRuntimeException_illegalArgument_returns400() { Response r = globalExceptionMapper.toResponse(new IllegalArgumentException("critère manquant")); assertThat(r.getStatus()).isEqualTo(400); @@ -82,7 +82,7 @@ class GlobalExceptionMapperTest { } @Test - @DisplayName("IllegalStateException → 400 (traité comme BadRequest)") + @DisplayName("IllegalStateException → 400 (cas métier attendu : pas de log ERROR, pas de persistance)") void mapRuntimeException_illegalState_returns400() { Response r = globalExceptionMapper.toResponse(new IllegalStateException("déjà existant")); assertThat(r.getStatus()).isEqualTo(400); diff --git a/src/test/java/dev/lions/unionflow/server/integration/IntegrationTestProfile.java b/src/test/java/dev/lions/unionflow/server/integration/IntegrationTestProfile.java new file mode 100644 index 0000000..d0cbdd1 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/integration/IntegrationTestProfile.java @@ -0,0 +1,64 @@ +package dev.lions.unionflow.server.integration; + +import io.quarkus.test.junit.QuarkusTestProfile; +import java.util.HashMap; +import java.util.Map; + +/** + * Profil test d'intégration. + * + *

Si Docker est disponible (DOCKER_HOST ou npipe accessible), active DevServices PostgreSQL + * via Testcontainers. Sinon, utilise le PostgreSQL local configuré dans application.properties + * (localhost:5432/unionflow) — aucun Docker requis pour le développement local. + * + *

Usage : annoter la classe de test avec : + *

+ *   {@literal @}QuarkusTest
+ *   {@literal @}TestProfile(IntegrationTestProfile.class)
+ *   class MonIntegrationTest { ... }
+ * 
+ */ +public class IntegrationTestProfile implements QuarkusTestProfile { + + private static final boolean DOCKER_AVAILABLE = isDockerAvailable(); + + @Override + public String getConfigProfile() { + return "integration-test"; + } + + @Override + public Map getConfigOverrides() { + Map config = new HashMap<>(); + if (DOCKER_AVAILABLE) { + config.put("quarkus.devservices.enabled", "true"); + config.put("quarkus.datasource.devservices.reuse", "true"); + config.put("quarkus.datasource.devservices.image-name", "postgres:17-alpine"); + } else { + // Sans Docker : utiliser le PostgreSQL local (dev env) + config.put("quarkus.devservices.enabled", "false"); + } + config.put("quarkus.mailer.mock", "true"); + return config; + } + + private static boolean isDockerAvailable() { + // Opt-in explicite via variable d'environnement (CI ou dev avec Docker actif) + String flag = System.getenv("USE_DOCKER_TESTS"); + if ("true".equalsIgnoreCase(flag)) { + return true; + } + // Vérification réelle : docker info doit répondre sans erreur + try { + ProcessBuilder pb = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}"); + pb.redirectErrorStream(true); + Process process = pb.start(); + // Vider stdout pour éviter un blocage sur le buffer + process.getInputStream().transferTo(java.io.OutputStream.nullOutputStream()); + int exitCode = process.waitFor(); + return exitCode == 0; + } catch (Exception e) { + return false; + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/integration/RlsCrossTenantIsolationTest.java b/src/test/java/dev/lions/unionflow/server/integration/RlsCrossTenantIsolationTest.java new file mode 100644 index 0000000..28ac469 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/integration/RlsCrossTenantIsolationTest.java @@ -0,0 +1,121 @@ +package dev.lions.unionflow.server.integration; + +import dev.lions.unionflow.server.entity.*; +import dev.lions.unionflow.server.repository.*; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests d'isolation cross-tenant via PostgreSQL RLS (Row-Level Security). + * + *

Vérifie qu'un membre d'une organisation ne peut pas accéder aux données d'une autre + * organisation même avec un accès direct au repository (contournement intentionnel pour test). + * + *

NOTE : ces tests utilisent PostgreSQL réel (DevServices). + * Ils ne passent PAS avec H2 (RLS non supporté par H2). + * Lancer avec : {@code mvn test -Dquarkus.test.profile=integration-test} + */ +@QuarkusTest +@TestProfile(IntegrationTestProfile.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class RlsCrossTenantIsolationTest { + + @Inject + OrganisationRepository organisationRepository; + + @Inject + CotisationRepository cotisationRepository; + + @Inject + MembreRepository membreRepository; + + @Inject + EntityManager em; + + private static UUID orgAId; + private static UUID orgBId; + + @BeforeEach + @Transactional + void setup() { + // Créer deux organisations de test + Organisation orgA = new Organisation(); + orgA.setNom("Org Test A RLS"); + orgA.setActif(true); + organisationRepository.persist(orgA); + orgAId = orgA.getId(); + + Organisation orgB = new Organisation(); + orgB.setNom("Org Test B RLS"); + orgB.setActif(true); + organisationRepository.persist(orgB); + orgBId = orgB.getId(); + + // Créer un membre pour orgA + Membre membreA = new Membre(); + membreA.setEmail("membre-a@test.rls"); + membreA.setPrenom("Membre"); + membreA.setNom("A"); + membreA.setActif(true); + membreRepository.persist(membreA); + + // Créer une cotisation pour orgA + Cotisation cotisationA = new Cotisation(); + cotisationA.setOrganisation(orgA); + cotisationA.setMembre(membreA); + cotisationA.setMontantDu(BigDecimal.valueOf(5000)); + cotisationA.setMontantPaye(BigDecimal.ZERO); + cotisationA.setStatut("EN_ATTENTE"); + cotisationA.setDateEcheance(LocalDate.now().plusDays(30)); + cotisationA.setPeriode("2026/04"); + cotisationRepository.persist(cotisationA); + } + + @Test + @Order(1) + @Transactional + void sansSuperAdmin_cotisationOrgA_visibleUniquementPourOrgA() { + // SET LOCAL en SQL direct pour simuler le comportement RLS du filtre JAX-RS + em.createNativeQuery("SET LOCAL app.current_org_id = '" + orgAId + "'").executeUpdate(); + em.createNativeQuery("SET LOCAL app.is_super_admin = 'false'").executeUpdate(); + + List cotisationsVues = cotisationRepository.find("organisation.id", orgAId).list(); + assertThat(cotisationsVues).isNotEmpty(); + } + + @Test + @Order(2) + @Transactional + void sansSuperAdmin_cotisationOrgA_invisibleDepuisOrgB() { + // Simuler le contexte de orgB — ne devrait pas voir les cotisations de orgA + em.createNativeQuery("SET LOCAL app.current_org_id = '" + orgBId + "'").executeUpdate(); + em.createNativeQuery("SET LOCAL app.is_super_admin = 'false'").executeUpdate(); + + List cotisationsVues = cotisationRepository.find("organisation.id", orgAId).list(); + // Avec RLS actif : zéro résultat car orgB n'a pas accès aux données de orgA + assertThat(cotisationsVues).isEmpty(); + } + + @Test + @Order(3) + @Transactional + void avecSuperAdmin_cotisationOrgA_visibleDepuisOrgB() { + // SUPER_ADMIN contourne la politique RLS + em.createNativeQuery("SET LOCAL app.current_org_id = '" + orgBId + "'").executeUpdate(); + em.createNativeQuery("SET LOCAL app.is_super_admin = 'true'").executeUpdate(); + + List cotisationsVues = cotisationRepository.find("organisation.id", orgAId).list(); + assertThat(cotisationsVues).isNotEmpty(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/PaymentOrchestratorHandleEventTest.java b/src/test/java/dev/lions/unionflow/server/payment/PaymentOrchestratorHandleEventTest.java new file mode 100644 index 0000000..143693b --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/PaymentOrchestratorHandleEventTest.java @@ -0,0 +1,76 @@ +package dev.lions.unionflow.server.payment; + +import dev.lions.unionflow.server.api.payment.PaymentEvent; +import dev.lions.unionflow.server.api.payment.PaymentStatus; +import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator; +import dev.lions.unionflow.server.payment.orchestration.PaymentProviderRegistry; +import dev.lions.unionflow.server.service.PaiementService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.Instant; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PaymentOrchestratorHandleEventTest { + + @Mock + PaymentProviderRegistry registry; + + @Mock + PaiementService paiementService; + + @InjectMocks + PaymentOrchestrator orchestrator; + + @BeforeEach + void setup() throws Exception { + // Inject default config values via reflection + var defaultProviderField = PaymentOrchestrator.class.getDeclaredField("defaultProvider"); + defaultProviderField.setAccessible(true); + defaultProviderField.set(orchestrator, "WAVE"); + + var pispiField = PaymentOrchestrator.class.getDeclaredField("pispiPriority"); + pispiField.setAccessible(true); + pispiField.set(orchestrator, false); + } + + @Test + void handleEvent_delegatesToPaiementService() { + PaymentEvent event = new PaymentEvent( + "ext-123", "PAY-REF-001", PaymentStatus.SUCCESS, + BigDecimal.valueOf(5000), "TXN-ABC", Instant.now()); + + orchestrator.handleEvent(event); + + verify(paiementService).mettreAJourStatutDepuisWebhook(event); + } + + @Test + void handleEvent_withFailedStatus_delegatesToPaiementService() { + PaymentEvent event = new PaymentEvent( + "ext-456", "PAY-REF-002", PaymentStatus.FAILED, + BigDecimal.ZERO, null, Instant.now()); + + orchestrator.handleEvent(event); + + verify(paiementService).mettreAJourStatutDepuisWebhook(event); + } + + @Test + void handleEvent_withCancelledStatus_delegatesToPaiementService() { + PaymentEvent event = new PaymentEvent( + "ext-789", "PAY-REF-003", PaymentStatus.CANCELLED, + BigDecimal.ZERO, null, Instant.now()); + + orchestrator.handleEvent(event); + + verify(paiementService).mettreAJourStatutDepuisWebhook(event); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/PaymentProviderTest.java b/src/test/java/dev/lions/unionflow/server/payment/PaymentProviderTest.java new file mode 100644 index 0000000..66461b2 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/PaymentProviderTest.java @@ -0,0 +1,79 @@ +package dev.lions.unionflow.server.payment; + +import dev.lions.unionflow.server.api.payment.CheckoutRequest; +import dev.lions.unionflow.server.api.payment.PaymentException; +import dev.lions.unionflow.server.api.payment.PaymentStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Map; + +import static org.assertj.core.api.Assertions.*; + +class PaymentProviderTest { + + @Test + @DisplayName("CheckoutRequest — montant nul lève IllegalArgumentException") + void checkoutRequest_montantNull_throws() { + assertThatIllegalArgumentException().isThrownBy(() -> + new CheckoutRequest(null, "XOF", "+2250700000001", + "user@example.com", "REF-001", "https://ok", "https://cancel", Map.of()) + ).withMessageContaining("amount"); + } + + @Test + @DisplayName("CheckoutRequest — montant négatif lève IllegalArgumentException") + void checkoutRequest_montantNegatif_throws() { + assertThatIllegalArgumentException().isThrownBy(() -> + new CheckoutRequest(BigDecimal.valueOf(-100), "XOF", null, + null, "REF-002", "https://ok", "https://cancel", Map.of()) + ).withMessageContaining("amount"); + } + + @Test + @DisplayName("CheckoutRequest — devise vide lève IllegalArgumentException") + void checkoutRequest_deviseVide_throws() { + assertThatIllegalArgumentException().isThrownBy(() -> + new CheckoutRequest(BigDecimal.valueOf(5000), "", null, + null, "REF-003", "https://ok", "https://cancel", Map.of()) + ).withMessageContaining("currency"); + } + + @Test + @DisplayName("CheckoutRequest — référence vide lève IllegalArgumentException") + void checkoutRequest_referenceVide_throws() { + assertThatIllegalArgumentException().isThrownBy(() -> + new CheckoutRequest(BigDecimal.valueOf(5000), "XOF", null, + null, "", "https://ok", "https://cancel", Map.of()) + ).withMessageContaining("reference"); + } + + @Test + @DisplayName("CheckoutRequest — valide sans erreur") + void checkoutRequest_valide_ok() { + assertThatNoException().isThrownBy(() -> + new CheckoutRequest(BigDecimal.valueOf(10000), "XOF", + "+2250700000001", "user@test.ci", + "SOUSCRIPTION-UUID-123", "https://ok.ci", "https://cancel.ci", + Map.of("org", "mutuelle-1")) + ); + } + + @Test + @DisplayName("PaymentException — getHttpStatus et getProviderCode corrects") + void paymentException_fields() { + PaymentException ex = new PaymentException("WAVE", "Test error", 400); + assertThat(ex.getHttpStatus()).isEqualTo(400); + assertThat(ex.getProviderCode()).isEqualTo("WAVE"); + assertThat(ex.getMessage()).contains("WAVE").contains("Test error"); + } + + @Test + @DisplayName("PaymentStatus — tous les statuts attendus présents") + void paymentStatus_allValues() { + var statuses = java.util.Arrays.stream(PaymentStatus.values()) + .map(Enum::name).toList(); + assertThat(statuses).contains("INITIATED", "PROCESSING", "SUCCESS", "FAILED", "CANCELLED", "EXPIRED"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/mtnmomo/MtnMomoPaymentProviderTest.java b/src/test/java/dev/lions/unionflow/server/payment/mtnmomo/MtnMomoPaymentProviderTest.java new file mode 100644 index 0000000..9873a58 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/mtnmomo/MtnMomoPaymentProviderTest.java @@ -0,0 +1,125 @@ +package dev.lions.unionflow.server.payment.mtnmomo; + +import dev.lions.unionflow.server.api.payment.CheckoutRequest; +import dev.lions.unionflow.server.api.payment.CheckoutSession; +import dev.lions.unionflow.server.api.payment.PaymentException; +import dev.lions.unionflow.server.api.payment.PaymentStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MtnMomoPaymentProviderTest { + + private MtnMomoPaymentProvider provider; + + @BeforeEach + void setUp() { + provider = new MtnMomoPaymentProvider(); + // subscriptionKey defaults to "" — mock mode active + } + + @Test + void getProviderCode_returns_MTN_MOMO() { + assertThat(provider.getProviderCode()).isEqualTo("MTN_MOMO"); + } + + @Test + void code_constant_is_MTN_MOMO() { + assertThat(MtnMomoPaymentProvider.CODE).isEqualTo("MTN_MOMO"); + } + + @Test + void initiateCheckout_whenNotConfigured_returnsMockSession() throws Exception { + CheckoutRequest req = new CheckoutRequest( + BigDecimal.valueOf(5000), "XOF", + "+2250100000000", "test@test.com", + "REF-001", "http://success", "http://cancel", Map.of()); + + CheckoutSession session = provider.initiateCheckout(req); + + assertThat(session.externalId()).startsWith("MTN-MOCK-"); + assertThat(session.checkoutUrl()).contains("mock.mtn.ci"); + assertThat(session.expiresAt()).isNotNull(); + assertThat(session.providerMetadata()).containsEntry("mock", "true"); + assertThat(session.providerMetadata()).containsEntry("provider", "MTN_MOMO"); + } + + @Test + void initiateCheckout_whenConfigured_throwsNotImplemented() throws Exception { + Field f = MtnMomoPaymentProvider.class.getDeclaredField("subscriptionKey"); + f.setAccessible(true); + f.set(provider, "real-subscription-key"); + + CheckoutRequest req = new CheckoutRequest( + BigDecimal.valueOf(1000), "XOF", + "+2250100000001", "user@test.com", + "REF-002", "http://success", "http://cancel", Map.of()); + + assertThatThrownBy(() -> provider.initiateCheckout(req)) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("501"); + } + + @Test + void getStatus_returnsPROCESSING() throws Exception { + PaymentStatus status = provider.getStatus("MTN-EXT-123"); + assertThat(status).isEqualTo(PaymentStatus.PROCESSING); + } + + @Test + void processWebhook_throwsNotImplemented() { + assertThatThrownBy(() -> provider.processWebhook("{}", Map.of())) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("501"); + } + + @Test + void isAvailable_whenSubscriptionKeyEmpty_returnsFalse() { + assertThat(provider.isAvailable()).isFalse(); + } + + @Test + void isAvailable_whenSubscriptionKeyBlank_returnsFalse() throws Exception { + Field f = MtnMomoPaymentProvider.class.getDeclaredField("subscriptionKey"); + f.setAccessible(true); + f.set(provider, " "); + assertThat(provider.isAvailable()).isFalse(); + } + + @Test + void isAvailable_whenSubscriptionKeySet_returnsTrue() throws Exception { + Field f = MtnMomoPaymentProvider.class.getDeclaredField("subscriptionKey"); + f.setAccessible(true); + f.set(provider, "real-key"); + assertThat(provider.isAvailable()).isTrue(); + } + + @Test + void isAvailable_whenSubscriptionKeyNull_returnsFalse() throws Exception { + Field f = MtnMomoPaymentProvider.class.getDeclaredField("subscriptionKey"); + f.setAccessible(true); + f.set(provider, null); + assertThat(provider.isAvailable()).isFalse(); + } + + @Test + void initiateCheckout_whenSubscriptionKeyNull_returnsMockSession() throws Exception { + Field f = MtnMomoPaymentProvider.class.getDeclaredField("subscriptionKey"); + f.setAccessible(true); + f.set(provider, null); + + CheckoutRequest req = new CheckoutRequest( + BigDecimal.valueOf(2000), "XOF", + "+2250100000002", "null@test.com", + "REF-NULL", "http://success", "http://cancel", Map.of()); + + CheckoutSession session = provider.initiateCheckout(req); + assertThat(session.externalId()).startsWith("MTN-MOCK-"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/orangemoney/OrangeMoneyPaymentProviderTest.java b/src/test/java/dev/lions/unionflow/server/payment/orangemoney/OrangeMoneyPaymentProviderTest.java new file mode 100644 index 0000000..50555be --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/orangemoney/OrangeMoneyPaymentProviderTest.java @@ -0,0 +1,125 @@ +package dev.lions.unionflow.server.payment.orangemoney; + +import dev.lions.unionflow.server.api.payment.CheckoutRequest; +import dev.lions.unionflow.server.api.payment.CheckoutSession; +import dev.lions.unionflow.server.api.payment.PaymentException; +import dev.lions.unionflow.server.api.payment.PaymentStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrangeMoneyPaymentProviderTest { + + private OrangeMoneyPaymentProvider provider; + + @BeforeEach + void setUp() { + provider = new OrangeMoneyPaymentProvider(); + // clientId defaults to "" — mock mode active + } + + @Test + void getProviderCode_returns_ORANGE_MONEY() { + assertThat(provider.getProviderCode()).isEqualTo("ORANGE_MONEY"); + } + + @Test + void code_constant_is_ORANGE_MONEY() { + assertThat(OrangeMoneyPaymentProvider.CODE).isEqualTo("ORANGE_MONEY"); + } + + @Test + void initiateCheckout_whenNotConfigured_returnsMockSession() throws Exception { + CheckoutRequest req = new CheckoutRequest( + BigDecimal.valueOf(3000), "XOF", + "+2250700000000", "orange@test.com", + "OM-REF-001", "http://success", "http://cancel", Map.of()); + + CheckoutSession session = provider.initiateCheckout(req); + + assertThat(session.externalId()).startsWith("OM-MOCK-"); + assertThat(session.checkoutUrl()).contains("mock.orange.ci"); + assertThat(session.expiresAt()).isNotNull(); + assertThat(session.providerMetadata()).containsEntry("mock", "true"); + assertThat(session.providerMetadata()).containsEntry("provider", "ORANGE_MONEY"); + } + + @Test + void initiateCheckout_whenConfigured_throwsNotImplemented() throws Exception { + Field f = OrangeMoneyPaymentProvider.class.getDeclaredField("clientId"); + f.setAccessible(true); + f.set(provider, "real-client-id"); + + CheckoutRequest req = new CheckoutRequest( + BigDecimal.valueOf(1000), "XOF", + "+2250700000001", "user@test.com", + "OM-REF-002", "http://success", "http://cancel", Map.of()); + + assertThatThrownBy(() -> provider.initiateCheckout(req)) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("501"); + } + + @Test + void getStatus_returnsPROCESSING() throws Exception { + PaymentStatus status = provider.getStatus("OM-EXT-123"); + assertThat(status).isEqualTo(PaymentStatus.PROCESSING); + } + + @Test + void processWebhook_throwsNotImplemented() { + assertThatThrownBy(() -> provider.processWebhook("{}", Map.of())) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("501"); + } + + @Test + void isAvailable_whenClientIdEmpty_returnsFalse() { + assertThat(provider.isAvailable()).isFalse(); + } + + @Test + void isAvailable_whenClientIdBlank_returnsFalse() throws Exception { + Field f = OrangeMoneyPaymentProvider.class.getDeclaredField("clientId"); + f.setAccessible(true); + f.set(provider, " "); + assertThat(provider.isAvailable()).isFalse(); + } + + @Test + void isAvailable_whenClientIdSet_returnsTrue() throws Exception { + Field f = OrangeMoneyPaymentProvider.class.getDeclaredField("clientId"); + f.setAccessible(true); + f.set(provider, "real-client-id"); + assertThat(provider.isAvailable()).isTrue(); + } + + @Test + void isAvailable_whenClientIdNull_returnsFalse() throws Exception { + Field f = OrangeMoneyPaymentProvider.class.getDeclaredField("clientId"); + f.setAccessible(true); + f.set(provider, null); + assertThat(provider.isAvailable()).isFalse(); + } + + @Test + void initiateCheckout_whenClientIdNull_returnsMockSession() throws Exception { + Field f = OrangeMoneyPaymentProvider.class.getDeclaredField("clientId"); + f.setAccessible(true); + f.set(provider, null); + + CheckoutRequest req = new CheckoutRequest( + BigDecimal.valueOf(500), "XOF", + "+2250700000002", "null@test.com", + "OM-NULL", "http://success", "http://cancel", Map.of()); + + CheckoutSession session = provider.initiateCheckout(req); + assertThat(session.externalId()).startsWith("OM-MOCK-"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/orchestration/PaymentOrchestratorTest.java b/src/test/java/dev/lions/unionflow/server/payment/orchestration/PaymentOrchestratorTest.java new file mode 100644 index 0000000..392909f --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/orchestration/PaymentOrchestratorTest.java @@ -0,0 +1,185 @@ +package dev.lions.unionflow.server.payment.orchestration; + +import dev.lions.unionflow.server.api.payment.CheckoutRequest; +import dev.lions.unionflow.server.api.payment.CheckoutSession; +import dev.lions.unionflow.server.api.payment.PaymentException; +import dev.lions.unionflow.server.api.payment.PaymentProvider; +import dev.lions.unionflow.server.service.PaiementService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PaymentOrchestratorTest { + + @InjectMocks + PaymentOrchestrator orchestrator; + + @Mock + PaymentProviderRegistry registry; + + @Mock + PaiementService paiementService; + + private CheckoutRequest sampleRequest; + + @BeforeEach + void setUp() throws Exception { + Field defaultProviderField = PaymentOrchestrator.class.getDeclaredField("defaultProvider"); + defaultProviderField.setAccessible(true); + defaultProviderField.set(orchestrator, "WAVE"); + + Field pispiField = PaymentOrchestrator.class.getDeclaredField("pispiPriority"); + pispiField.setAccessible(true); + pispiField.set(orchestrator, false); + + sampleRequest = new CheckoutRequest( + BigDecimal.valueOf(5000), "XOF", + "+2210100000000", "test@test.com", + "REF-TEST-001", "http://success", "http://cancel", Map.of()); + } + + @Test + void initierPaiement_usesRequestedProvider() throws Exception { + PaymentProvider waveProvider = mock(PaymentProvider.class); + when(waveProvider.isAvailable()).thenReturn(true); + CheckoutSession expectedSession = new CheckoutSession("EXT-001", "https://wave.pay/001", Instant.now().plusSeconds(3600), Map.of()); + when(waveProvider.initiateCheckout(any())).thenReturn(expectedSession); + + when(registry.get("WAVE")).thenReturn(waveProvider); + + CheckoutSession session = orchestrator.initierPaiement(sampleRequest, "WAVE"); + + assertThat(session.externalId()).isEqualTo("EXT-001"); + } + + @Test + void initierPaiement_fallsBackToDefault_whenRequestedUnavailable() throws Exception { + PaymentProvider unavailableProvider = mock(PaymentProvider.class); + when(unavailableProvider.isAvailable()).thenReturn(false); + + PaymentProvider waveProvider = mock(PaymentProvider.class); + when(waveProvider.isAvailable()).thenReturn(true); + CheckoutSession expectedSession = new CheckoutSession("WAVE-EXT-001", "https://wave.pay/001", Instant.now().plusSeconds(3600), Map.of()); + when(waveProvider.initiateCheckout(any())).thenReturn(expectedSession); + + when(registry.get("MOMO")).thenReturn(unavailableProvider); + when(registry.get("WAVE")).thenReturn(waveProvider); + + CheckoutSession session = orchestrator.initierPaiement(sampleRequest, "MOMO"); + + assertThat(session.externalId()).isEqualTo("WAVE-EXT-001"); + } + + @Test + void initierPaiement_throwsWhenNoProviderAvailable() throws Exception { + when(registry.get(any())).thenThrow(new UnsupportedOperationException("Provider non supporté")); + + assertThatThrownBy(() -> orchestrator.initierPaiement(sampleRequest, "UNKNOWN")) + .isInstanceOf(PaymentException.class); + } + + @Test + void initierPaiement_withPispiPriority_triesPispiFirst() throws Exception { + Field pispiField = PaymentOrchestrator.class.getDeclaredField("pispiPriority"); + pispiField.setAccessible(true); + pispiField.set(orchestrator, true); + + PaymentProvider pispiProvider = mock(PaymentProvider.class); + when(pispiProvider.isAvailable()).thenReturn(true); + CheckoutSession expectedSession = new CheckoutSession("PISPI-EXT-001", "https://pispi.bceao/001", Instant.now().plusSeconds(3600), Map.of()); + when(pispiProvider.initiateCheckout(any())).thenReturn(expectedSession); + + when(registry.get("PISPI")).thenReturn(pispiProvider); + + CheckoutSession session = orchestrator.initierPaiement(sampleRequest, "WAVE"); + + assertThat(session.externalId()).isEqualTo("PISPI-EXT-001"); + } + + @Test + void initierPaiement_withPispiPriority_noRequestedProvider_triesPispiThenDefault() throws Exception { + Field pispiField = PaymentOrchestrator.class.getDeclaredField("pispiPriority"); + pispiField.setAccessible(true); + pispiField.set(orchestrator, true); + + PaymentProvider pispiProvider = mock(PaymentProvider.class); + when(pispiProvider.isAvailable()).thenReturn(false); // PISPI unavailable + + PaymentProvider waveProvider = mock(PaymentProvider.class); + when(waveProvider.isAvailable()).thenReturn(true); + CheckoutSession expectedSession = new CheckoutSession("WAVE-FALLBACK", "https://wave.pay/fallback", Instant.now().plusSeconds(3600), Map.of()); + when(waveProvider.initiateCheckout(any())).thenReturn(expectedSession); + + when(registry.get("PISPI")).thenReturn(pispiProvider); + when(registry.get("WAVE")).thenReturn(waveProvider); + + CheckoutSession session = orchestrator.initierPaiement(sampleRequest, null); + + assertThat(session.externalId()).isEqualTo("WAVE-FALLBACK"); + } + + @Test + void initierPaiement_noRequestedProvider_usesDefault() throws Exception { + PaymentProvider waveProvider = mock(PaymentProvider.class); + when(waveProvider.isAvailable()).thenReturn(true); + CheckoutSession expectedSession = new CheckoutSession("WAVE-DEFAULT", "https://wave.pay/default", Instant.now().plusSeconds(3600), Map.of()); + when(waveProvider.initiateCheckout(any())).thenReturn(expectedSession); + + when(registry.get("WAVE")).thenReturn(waveProvider); + + CheckoutSession session = orchestrator.initierPaiement(sampleRequest, null); + + assertThat(session.externalId()).isEqualTo("WAVE-DEFAULT"); + } + + @Test + void initierPaiement_whenProviderThrowsPaymentException_fallsBackToDefault() throws Exception { + PaymentProvider requestedProvider = mock(PaymentProvider.class); + when(requestedProvider.isAvailable()).thenReturn(true); + when(requestedProvider.initiateCheckout(any())).thenThrow(new PaymentException("MOMO", "Erreur MOMO", 503)); + + PaymentProvider waveProvider = mock(PaymentProvider.class); + when(waveProvider.isAvailable()).thenReturn(true); + CheckoutSession fallbackSession = new CheckoutSession("WAVE-AFTER-FAIL", "https://wave.pay/after-fail", Instant.now().plusSeconds(3600), Map.of()); + when(waveProvider.initiateCheckout(any())).thenReturn(fallbackSession); + + when(registry.get("MOMO")).thenReturn(requestedProvider); + when(registry.get("WAVE")).thenReturn(waveProvider); + + CheckoutSession session = orchestrator.initierPaiement(sampleRequest, "MOMO"); + assertThat(session.externalId()).isEqualTo("WAVE-AFTER-FAIL"); + } + + @Test + void initierPaiement_allProvidersThrow_throwsLastException() throws Exception { + PaymentProvider requestedProvider = mock(PaymentProvider.class); + when(requestedProvider.isAvailable()).thenReturn(true); + when(requestedProvider.initiateCheckout(any())).thenThrow(new PaymentException("MOMO", "Erreur MOMO", 503)); + + PaymentProvider waveProvider = mock(PaymentProvider.class); + when(waveProvider.isAvailable()).thenReturn(true); + when(waveProvider.initiateCheckout(any())).thenThrow(new PaymentException("WAVE", "Erreur WAVE", 503)); + + when(registry.get("MOMO")).thenReturn(requestedProvider); + when(registry.get("WAVE")).thenReturn(waveProvider); + + assertThatThrownBy(() -> orchestrator.initierPaiement(sampleRequest, "MOMO")) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("WAVE"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/orchestration/PaymentProviderRegistryTest.java b/src/test/java/dev/lions/unionflow/server/payment/orchestration/PaymentProviderRegistryTest.java new file mode 100644 index 0000000..418d875 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/orchestration/PaymentProviderRegistryTest.java @@ -0,0 +1,76 @@ +package dev.lions.unionflow.server.payment.orchestration; + +import dev.lions.unionflow.server.api.payment.PaymentProvider; +import jakarta.enterprise.inject.Instance; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PaymentProviderRegistryTest { + + private PaymentProviderRegistry registry; + private PaymentProvider waveProvider; + private PaymentProvider momoProvider; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() throws Exception { + registry = new PaymentProviderRegistry(); + + waveProvider = mock(PaymentProvider.class); + when(waveProvider.getProviderCode()).thenReturn("WAVE"); + + momoProvider = mock(PaymentProvider.class); + when(momoProvider.getProviderCode()).thenReturn("MTN_MOMO"); + + // Use thenAnswer so spliterator() returns a fresh Spliterator each call + // (spliterators are single-use) + Instance instance = mock(Instance.class); + List providerList = List.of(waveProvider, momoProvider); + when(instance.spliterator()).thenAnswer(inv -> providerList.spliterator()); + + Field f = PaymentProviderRegistry.class.getDeclaredField("providers"); + f.setAccessible(true); + f.set(registry, instance); + } + + @Test + void get_returnsMatchingProvider() { + assertThat(registry.get("WAVE")).isEqualTo(waveProvider); + assertThat(registry.get("MTN_MOMO")).isEqualTo(momoProvider); + } + + @Test + void get_isCaseInsensitive() { + assertThat(registry.get("wave")).isEqualTo(waveProvider); + assertThat(registry.get("mtn_momo")).isEqualTo(momoProvider); + assertThat(registry.get("Wave")).isEqualTo(waveProvider); + } + + @Test + void get_unknownCode_throwsUnsupportedOperationException() { + assertThatThrownBy(() -> registry.get("UNKNOWN_PROVIDER")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("UNKNOWN_PROVIDER"); + } + + @Test + void getAll_returnsAllProviders() { + List all = registry.getAll(); + assertThat(all).hasSize(2); + assertThat(all).contains(waveProvider, momoProvider); + } + + @Test + void getAvailableCodes_returnsAllCodes() { + List codes = registry.getAvailableCodes(); + assertThat(codes).contains("WAVE", "MTN_MOMO"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/pispi/Pacs002ResponseTest.java b/src/test/java/dev/lions/unionflow/server/payment/pispi/Pacs002ResponseTest.java new file mode 100644 index 0000000..8db447d --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/pispi/Pacs002ResponseTest.java @@ -0,0 +1,81 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class Pacs002ResponseTest { + + private static final String SAMPLE_XML = """ + + + + + REF-001 + ACSC + BCEAO-12345 + + + + """; + + @Test + @DisplayName("fromXml parse le statut de transaction") + void fromXml_parsesTransactionStatus() { + Pacs002Response resp = Pacs002Response.fromXml(SAMPLE_XML); + assertThat(resp.getTransactionStatus()).isEqualTo("ACSC"); + } + + @Test + @DisplayName("fromXml parse le originalEndToEndId") + void fromXml_parsesOriginalEndToEndId() { + Pacs002Response resp = Pacs002Response.fromXml(SAMPLE_XML); + assertThat(resp.getOriginalEndToEndId()).isEqualTo("REF-001"); + } + + @Test + @DisplayName("fromXml parse le clearingSystemReference") + void fromXml_parsesClearingSystemReference() { + Pacs002Response resp = Pacs002Response.fromXml(SAMPLE_XML); + assertThat(resp.getClearingSystemReference()).isEqualTo("BCEAO-12345"); + } + + @Test + @DisplayName("fromXml retourne null pour les champs absents") + void fromXml_returnsNullForMissingFields() { + Pacs002Response resp = Pacs002Response.fromXml(SAMPLE_XML); + assertThat(resp.getRejectReasonCode()).isNull(); + assertThat(resp.getAcceptanceDateTime()).isNull(); + } + + @Test + @DisplayName("fromXml parse le rejectReasonCode quand présent") + void fromXml_parsesRejectReasonCode() { + String xml = """ + + + + + REF-002 + RJCT + AC01 + + + + """; + Pacs002Response resp = Pacs002Response.fromXml(xml); + assertThat(resp.getTransactionStatus()).isEqualTo("RJCT"); + assertThat(resp.getRejectReasonCode()).isEqualTo("AC01"); + } + + @Test + @DisplayName("fromXml lève IllegalArgumentException si XML invalide") + void fromXml_throwsOnInvalidXml() { + assertThatThrownBy(() -> Pacs002Response.fromXml("not xml at all")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("pacs.002"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/pispi/Pacs008RequestTest.java b/src/test/java/dev/lions/unionflow/server/payment/pispi/Pacs008RequestTest.java new file mode 100644 index 0000000..0d6f8e0 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/pispi/Pacs008RequestTest.java @@ -0,0 +1,71 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +class Pacs008RequestTest { + + private Pacs008Request request; + + @BeforeEach + void setUp() { + request = new Pacs008Request(); + request.setMessageId("UFMSG-ABC123456789"); + request.setCreationDateTime("2026-04-20T10:00:00Z"); + request.setNumberOfTransactions("1"); + request.setEndToEndId("REF-SOUSCRIPTION-001"); + request.setInstrId("UFINS-12345678"); + request.setAmount(new BigDecimal("5000.00")); + request.setCurrency("XOF"); + request.setDebtorName("Jean Dupont"); + request.setDebtorBic("BCEAOCIAB"); + request.setCreditorName("Mutuelle Solidarité"); + request.setCreditorBic("BCEAOCIAB"); + request.setRemittanceInfo("Cotisation mensuelle"); + } + + @Test + @DisplayName("toXml contient le messageId") + void toXml_containsMessageId() { + String xml = request.toXml(); + assertThat(xml).contains("UFMSG-ABC123456789"); + assertThat(xml).contains("UFMSG-ABC123456789"); + } + + @Test + @DisplayName("toXml contient le montant") + void toXml_containsAmount() { + String xml = request.toXml(); + assertThat(xml).contains("5000.00"); + assertThat(xml).contains("IntrBkSttlmAmt"); + } + + @Test + @DisplayName("toXml contient le namespace ISO 20022 pacs.008") + void toXml_containsIso20022Namespace() { + String xml = request.toXml(); + assertThat(xml).contains("urn:iso:std:iso:20022:tech:xsd:pacs.008.001.10"); + } + + @Test + @DisplayName("toXml contient le endToEndId") + void toXml_containsEndToEndId() { + String xml = request.toXml(); + assertThat(xml).contains("REF-SOUSCRIPTION-001"); + } + + @Test + @DisplayName("toXml échappe les caractères XML spéciaux dans les champs texte") + void toXml_escapesSpecialCharacters() { + request.setDebtorName("Company & Co "); + String xml = request.toXml(); + assertThat(xml).contains("Company & Co <Tag>"); + assertThat(xml).doesNotContain(""); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiAuthTest.java b/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiAuthTest.java new file mode 100644 index 0000000..24b3b28 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiAuthTest.java @@ -0,0 +1,91 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.api.payment.PaymentException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PispiAuthTest { + + private PispiAuth auth; + + @BeforeEach + void setUp() { + auth = new PispiAuth(); + // clientId and clientSecret default to "" + } + + @Test + void getAccessToken_whenCacheValid_returnsCachedToken() throws Exception { + Field tokenField = PispiAuth.class.getDeclaredField("cachedToken"); + Field expiryField = PispiAuth.class.getDeclaredField("cacheExpiry"); + tokenField.setAccessible(true); + expiryField.setAccessible(true); + tokenField.set(auth, "mock-token-123"); + expiryField.set(auth, Instant.now().plusSeconds(300)); + + String token = auth.getAccessToken(); + + assertThat(token).isEqualTo("mock-token-123"); + } + + @Test + void getAccessToken_whenCacheExpired_attemptsNewToken() throws Exception { + Field tokenField = PispiAuth.class.getDeclaredField("cachedToken"); + Field expiryField = PispiAuth.class.getDeclaredField("cacheExpiry"); + tokenField.setAccessible(true); + expiryField.setAccessible(true); + tokenField.set(auth, "old-token"); + expiryField.set(auth, Instant.now().minusSeconds(60)); // expired + + // With empty clientId/clientSecret, the HTTP call will fail + // (connection refused or malformed URL) — wrapped as PaymentException + assertThatThrownBy(() -> auth.getAccessToken()) + .isInstanceOf(PaymentException.class); + } + + @Test + void getAccessToken_whenNoCache_andCredentialsEmpty_throwsPaymentException() { + // cachedToken is null (default), so it tries HTTP call which will fail + assertThatThrownBy(() -> auth.getAccessToken()) + .isInstanceOf(PaymentException.class); + } + + @Test + void getAccessToken_whenCacheIsNullButExpiryFuture_attemptsNewToken() throws Exception { + Field tokenField = PispiAuth.class.getDeclaredField("cachedToken"); + Field expiryField = PispiAuth.class.getDeclaredField("cacheExpiry"); + tokenField.setAccessible(true); + expiryField.setAccessible(true); + tokenField.set(auth, null); // null token + expiryField.set(auth, Instant.now().plusSeconds(300)); + + // null cachedToken means the condition `cachedToken != null` fails — goes to HTTP + assertThatThrownBy(() -> auth.getAccessToken()) + .isInstanceOf(PaymentException.class); + } + + @Test + void getAccessToken_whenBaseUrlInvalid_throwsPaymentException() throws Exception { + Field baseUrlField = PispiAuth.class.getDeclaredField("baseUrl"); + baseUrlField.setAccessible(true); + baseUrlField.set(auth, "http://localhost:1"); // unreachable + + Field clientIdField = PispiAuth.class.getDeclaredField("clientId"); + clientIdField.setAccessible(true); + clientIdField.set(auth, "test-client"); + + Field clientSecretField = PispiAuth.class.getDeclaredField("clientSecret"); + clientSecretField.setAccessible(true); + clientSecretField.set(auth, "test-secret"); + + assertThatThrownBy(() -> auth.getAccessToken()) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("OAuth2"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiClientTest.java b/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiClientTest.java new file mode 100644 index 0000000..db85f1e --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiClientTest.java @@ -0,0 +1,89 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.api.payment.PaymentException; +import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PispiClientTest { + + @Mock + PispiAuth pispiAuth; + + private PispiClient client; + + @BeforeEach + void setUp() throws Exception { + client = new PispiClient(); + + Field authField = PispiClient.class.getDeclaredField("pispiAuth"); + authField.setAccessible(true); + authField.set(client, pispiAuth); + + Field baseUrlField = PispiClient.class.getDeclaredField("baseUrl"); + baseUrlField.setAccessible(true); + baseUrlField.set(client, "http://localhost:1"); // unreachable endpoint + + Field institutionField = PispiClient.class.getDeclaredField("institutionCode"); + institutionField.setAccessible(true); + institutionField.set(client, "TEST-BIC"); + } + + @Test + void initiatePayment_whenAuthFails_throwsPaymentException() throws Exception { + when(pispiAuth.getAccessToken()).thenThrow( + new PaymentException("PISPI", "OAuth2 failed", 503)); + + Pacs008Request request = new Pacs008Request(); + request.setEndToEndId("E2E-001"); + request.setAmount(BigDecimal.valueOf(5000)); + request.setCurrency("XOF"); + + assertThatThrownBy(() -> client.initiatePayment(request)) + .isInstanceOf(PaymentException.class); + } + + @Test + void initiatePayment_whenHttpCallFails_throwsPaymentException() throws Exception { + when(pispiAuth.getAccessToken()).thenReturn("mock-token"); + + Pacs008Request request = new Pacs008Request(); + request.setEndToEndId("E2E-002"); + request.setAmount(BigDecimal.valueOf(1000)); + request.setCurrency("XOF"); + + // http://localhost:1 will refuse connection → wrapped as PaymentException + assertThatThrownBy(() -> client.initiatePayment(request)) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("PI-SPI"); + } + + @Test + void getStatus_whenAuthFails_throwsPaymentException() throws Exception { + when(pispiAuth.getAccessToken()).thenThrow( + new PaymentException("PISPI", "OAuth2 failed", 503)); + + assertThatThrownBy(() -> client.getStatus("TXN-001")) + .isInstanceOf(PaymentException.class); + } + + @Test + void getStatus_whenHttpCallFails_throwsPaymentException() throws Exception { + when(pispiAuth.getAccessToken()).thenReturn("mock-token"); + + // http://localhost:1 will refuse connection → wrapped as PaymentException + assertThatThrownBy(() -> client.getStatus("TXN-002")) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("PI-SPI"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiIso20022MapperTest.java b/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiIso20022MapperTest.java new file mode 100644 index 0000000..3e2d639 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiIso20022MapperTest.java @@ -0,0 +1,117 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.api.payment.CheckoutRequest; +import dev.lions.unionflow.server.api.payment.PaymentEvent; +import dev.lions.unionflow.server.api.payment.PaymentStatus; +import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response; +import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class PispiIso20022MapperTest { + + private PispiIso20022Mapper mapper; + + @BeforeEach + void setUp() { + mapper = new PispiIso20022Mapper(); + } + + private CheckoutRequest buildRequest(String reference) { + return new CheckoutRequest( + new BigDecimal("5000"), + "XOF", + "+2250700000001", + "user@test.ci", + reference, + "https://ok.ci", + "https://cancel.ci", + Map.of("customerName", "Kofi Mensah") + ); + } + + @Test + @DisplayName("toPacs008 : endToEndId égal à la référence courte") + void toPacs008_setsEndToEndIdFromReference() { + CheckoutRequest req = buildRequest("REF-2026-001"); + Pacs008Request pacs = mapper.toPacs008(req, "BCEAOCIAB"); + assertThat(pacs.getEndToEndId()).isEqualTo("REF-2026-001"); + } + + @Test + @DisplayName("toPacs008 : référence de 40 chars tronquée à 35") + void toPacs008_truncatesLongReference() { + String longRef = "A".repeat(40); + CheckoutRequest req = buildRequest(longRef); + Pacs008Request pacs = mapper.toPacs008(req, "BCEAOCIAB"); + assertThat(pacs.getEndToEndId()).hasSize(35); + assertThat(pacs.getEndToEndId()).isEqualTo("A".repeat(35)); + } + + @Test + @DisplayName("fromPacs002Status ACSC → SUCCESS") + void fromPacs002Status_ACSC_returnsSuccess() { + assertThat(mapper.fromPacs002Status("ACSC")).isEqualTo(PaymentStatus.SUCCESS); + } + + @Test + @DisplayName("fromPacs002Status ACSP → PROCESSING") + void fromPacs002Status_ACSP_returnsProcessing() { + assertThat(mapper.fromPacs002Status("ACSP")).isEqualTo(PaymentStatus.PROCESSING); + } + + @Test + @DisplayName("fromPacs002Status RJCT → FAILED") + void fromPacs002Status_RJCT_returnsFailed() { + assertThat(mapper.fromPacs002Status("RJCT")).isEqualTo(PaymentStatus.FAILED); + } + + @Test + @DisplayName("fromPacs002Status code inconnu → PROCESSING") + void fromPacs002Status_unknown_returnsProcessing() { + assertThat(mapper.fromPacs002Status("XXXX")).isEqualTo(PaymentStatus.PROCESSING); + } + + @Test + @DisplayName("fromPacs002 construit le PaymentEvent correctement") + void fromPacs002_buildsEventCorrectly() { + Pacs002Response resp = new Pacs002Response(); + resp.setClearingSystemReference("BCEAO-99999"); + resp.setOriginalEndToEndId("REF-SOUSCRIPTION-007"); + resp.setTransactionStatus("ACSC"); + Instant ts = Instant.parse("2026-04-20T12:00:00Z"); + resp.setAcceptanceDateTime(ts); + + PaymentEvent event = mapper.fromPacs002(resp); + + assertThat(event.externalId()).isEqualTo("BCEAO-99999"); + assertThat(event.reference()).isEqualTo("REF-SOUSCRIPTION-007"); + assertThat(event.status()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(event.amountConfirmed()).isNull(); + assertThat(event.transactionCode()).isEqualTo("BCEAO-99999"); + assertThat(event.occurredAt()).isEqualTo(ts); + } + + @Test + @DisplayName("fromPacs002 utilise Instant.now() quand acceptanceDateTime est null") + void fromPacs002_usesNowWhenAcceptanceDateTimeNull() { + Pacs002Response resp = new Pacs002Response(); + resp.setClearingSystemReference("REF"); + resp.setOriginalEndToEndId("E2E"); + resp.setTransactionStatus("PDNG"); + resp.setAcceptanceDateTime(null); + + Instant before = Instant.now(); + PaymentEvent event = mapper.fromPacs002(resp); + Instant after = Instant.now(); + + assertThat(event.occurredAt()).isBetween(before, after); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiPaymentProviderTest.java b/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiPaymentProviderTest.java new file mode 100644 index 0000000..af323a7 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiPaymentProviderTest.java @@ -0,0 +1,80 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.api.payment.CheckoutRequest; +import dev.lions.unionflow.server.api.payment.CheckoutSession; +import dev.lions.unionflow.server.api.payment.PaymentException; +import dev.lions.unionflow.server.api.payment.PaymentStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PispiPaymentProviderTest { + + private PispiPaymentProvider provider; + + @BeforeEach + void setUp() throws Exception { + provider = new PispiPaymentProvider(); + setField("clientId", ""); + setField("institutionCode", ""); + setField("institutionBic", ""); + // pispiClient et mapper non injectés → null, mais isConfigured() retourne false donc non appelés + } + + private void setField(String name, String value) throws Exception { + Field f = PispiPaymentProvider.class.getDeclaredField(name); + f.setAccessible(true); + f.set(provider, value); + } + + @Test + @DisplayName("getProviderCode retourne PISPI") + void getProviderCode_returnsPISPI() { + assertThat(provider.getProviderCode()).isEqualTo("PISPI"); + } + + @Test + @DisplayName("isAvailable retourne false si non configuré") + void isAvailable_whenNotConfigured_returnsFalse() { + assertThat(provider.isAvailable()).isFalse(); + } + + @Test + @DisplayName("initiateCheckout retourne une session mock si non configuré") + void initiateCheckout_whenNotConfigured_returnsMockSession() throws Exception { + CheckoutRequest req = new CheckoutRequest( + new BigDecimal("10000"), "XOF", + "+2250700000001", "user@test.ci", + "SOUSCRIPTION-001", "https://ok", "https://cancel", + Map.of() + ); + CheckoutSession session = provider.initiateCheckout(req); + assertThat(session.externalId()).startsWith("PISPI-MOCK-"); + assertThat(session.checkoutUrl()).startsWith("https://mock.pispi.bceao.int/pay/"); + assertThat(session.providerMetadata()).containsEntry("mock", "true"); + assertThat(session.providerMetadata()).containsEntry("provider", "PISPI"); + } + + @Test + @DisplayName("getStatus retourne PROCESSING si non configuré") + void getStatus_whenNotConfigured_returnsProcessing() throws Exception { + assertThat(provider.getStatus("ANY-ID")).isEqualTo(PaymentStatus.PROCESSING); + } + + @Test + @DisplayName("processWebhook lève PaymentException — déléguer à /api/pispi/webhook") + void processWebhook_throwsPaymentException() { + assertThatThrownBy(() -> provider.processWebhook("body", Map.of())) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("pispi/webhook") + .extracting(e -> ((PaymentException) e).getHttpStatus()) + .isEqualTo(400); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiSignatureVerifierTest.java b/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiSignatureVerifierTest.java new file mode 100644 index 0000000..50c8d0c --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiSignatureVerifierTest.java @@ -0,0 +1,106 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.api.payment.PaymentException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.lang.reflect.Field; +import java.util.HexFormat; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PispiSignatureVerifierTest { + + private PispiSignatureVerifier verifier; + + @BeforeEach + void setUp() { + verifier = new PispiSignatureVerifier(); + } + + private void setField(String fieldName, String value) throws Exception { + Field f = PispiSignatureVerifier.class.getDeclaredField(fieldName); + f.setAccessible(true); + f.set(verifier, value); + } + + @Test + @DisplayName("isIpAllowed — pas de config → true") + void isIpAllowed_whenNoConfig_returnsTrue() throws Exception { + setField("allowedIps", ""); + assertThat(verifier.isIpAllowed("1.2.3.4")).isTrue(); + } + + @Test + @DisplayName("isIpAllowed — IP dans la liste → true") + void isIpAllowed_whenIpInList_returnsTrue() throws Exception { + setField("allowedIps", "10.0.0.1, 10.0.0.2, 192.168.1.1"); + assertThat(verifier.isIpAllowed("10.0.0.2")).isTrue(); + } + + @Test + @DisplayName("isIpAllowed — IP absente de la liste → false") + void isIpAllowed_whenIpNotInList_returnsFalse() throws Exception { + setField("allowedIps", "10.0.0.1,10.0.0.2"); + assertThat(verifier.isIpAllowed("1.2.3.4")).isFalse(); + } + + @Test + @DisplayName("verifySignature — pas de secret configuré → true") + void verifySignature_whenNoSecret_returnsTrue() throws Exception { + setField("webhookSecret", ""); + assertThat(verifier.verifySignature("body", Map.of())).isTrue(); + } + + @Test + @DisplayName("verifySignature — header absent → PaymentException 401") + void verifySignature_whenSignatureAbsent_throwsPaymentException() throws Exception { + setField("webhookSecret", "secret123"); + assertThatThrownBy(() -> verifier.verifySignature("body", Map.of())) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("absente") + .extracting(e -> ((PaymentException) e).getHttpStatus()) + .isEqualTo(401); + } + + @Test + @DisplayName("verifySignature — signature correcte → true") + void verifySignature_whenSignatureValid_returnsTrue() throws Exception { + setField("webhookSecret", "secret123"); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec("secret123".getBytes(), "HmacSHA256")); + String validSig = HexFormat.of().formatHex(mac.doFinal("body".getBytes())); + + assertThat(verifier.verifySignature("body", Map.of("X-PISPI-Signature", validSig))).isTrue(); + } + + @Test + @DisplayName("verifySignature — signature incorrecte → PaymentException 401") + void verifySignature_whenSignatureInvalid_throwsPaymentException() throws Exception { + setField("webhookSecret", "secret123"); + assertThatThrownBy(() -> verifier.verifySignature("body", Map.of("X-PISPI-Signature", "deadbeef"))) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("invalide") + .extracting(e -> ((PaymentException) e).getHttpStatus()) + .isEqualTo(401); + } + + @Test + @DisplayName("verifySignature — header insensible à la casse") + void verifySignature_caseInsensitiveHeader() throws Exception { + setField("webhookSecret", "secret123"); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec("secret123".getBytes(), "HmacSHA256")); + String validSig = HexFormat.of().formatHex(mac.doFinal("body".getBytes())); + + // header en minuscules + assertThat(verifier.verifySignature("body", Map.of("x-pispi-signature", validSig))).isTrue(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiWebhookResourceTest.java b/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiWebhookResourceTest.java new file mode 100644 index 0000000..8886403 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/pispi/PispiWebhookResourceTest.java @@ -0,0 +1,174 @@ +package dev.lions.unionflow.server.payment.pispi; + +import dev.lions.unionflow.server.api.payment.PaymentEvent; +import dev.lions.unionflow.server.api.payment.PaymentException; +import dev.lions.unionflow.server.api.payment.PaymentStatus; +import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator; +import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PispiWebhookResourceTest { + + @InjectMocks + PispiWebhookResource resource; + + @Mock + PispiSignatureVerifier verifier; + + @Mock + PispiIso20022Mapper mapper; + + @Mock + PaymentOrchestrator orchestrator; + + @Mock + HttpHeaders headers; + + private static final String VALID_XML = + "" + + "" + + " REF-001" + + " ACSC" + + " PISPI-TXN-001" + + ""; + + @BeforeEach + void setUp() { + // Default: IP is allowed + when(verifier.isIpAllowed(anyString())).thenReturn(true); + } + + @Test + void recevoir_whenIpNotAllowed_returns403() { + when(verifier.isIpAllowed("192.168.1.100")).thenReturn(false); + + Response response = resource.recevoir(VALID_XML, headers, "192.168.1.100"); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Test + void recevoir_whenIpAllowedAndSignatureInvalid_returns401() throws Exception { + MultivaluedMap headersMap = new MultivaluedHashMap<>(); + headersMap.put("X-PISPI-Signature", List.of("invalidsig")); + when(headers.getRequestHeaders()).thenReturn(headersMap); + doThrow(new PaymentException("PISPI", "Signature invalide", 401)) + .when(verifier).verifySignature(anyString(), any()); + + Response response = resource.recevoir(VALID_XML, headers, ""); + + assertThat(response.getStatus()).isEqualTo(401); + } + + @Test + void recevoir_whenValid_returns200AndCallsOrchestrator() throws Exception { + MultivaluedMap headersMap = new MultivaluedHashMap<>(); + when(headers.getRequestHeaders()).thenReturn(headersMap); + when(verifier.verifySignature(anyString(), any())).thenReturn(true); + + Pacs002Response pacs002 = new Pacs002Response(); + pacs002.setOriginalEndToEndId("REF-001"); + pacs002.setTransactionStatus("ACSC"); + pacs002.setClearingSystemReference("PISPI-TXN-001"); + + PaymentEvent event = new PaymentEvent("PISPI-TXN-001", "REF-001", PaymentStatus.SUCCESS, + null, "PISPI-TXN-001", Instant.now()); + + try (MockedStatic mockedStatic = mockStatic(Pacs002Response.class)) { + mockedStatic.when(() -> Pacs002Response.fromXml(VALID_XML)).thenReturn(pacs002); + when(mapper.fromPacs002(pacs002)).thenReturn(event); + doNothing().when(orchestrator).handleEvent(event); + + Response response = resource.recevoir(VALID_XML, headers, ""); + + assertThat(response.getStatus()).isEqualTo(200); + verify(orchestrator).handleEvent(event); + } + } + + @Test + void recevoir_whenXmlInvalid_returns500() throws Exception { + MultivaluedMap headersMap = new MultivaluedHashMap<>(); + when(headers.getRequestHeaders()).thenReturn(headersMap); + when(verifier.verifySignature(anyString(), any())).thenReturn(true); + + String invalidXml = "NOT VALID XML <<<"; + + Response response = resource.recevoir(invalidXml, headers, ""); + + assertThat(response.getStatus()).isEqualTo(500); + } + + @Test + void recevoir_whenIpFromForwardedFor_extractsFirstIp() { + // Two IPs in X-Forwarded-For: first should be checked + when(verifier.isIpAllowed("10.0.0.1")).thenReturn(false); + + Response response = resource.recevoir(VALID_XML, headers, "10.0.0.1, 172.16.0.1"); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Test + void recevoir_whenForwardedForBlank_usesUnknown() { + // "unknown" IP should be allowed (default behavior with empty allowedIps) + when(verifier.isIpAllowed("unknown")).thenReturn(true); + + MultivaluedMap headersMap = new MultivaluedHashMap<>(); + when(headers.getRequestHeaders()).thenReturn(headersMap); + when(verifier.verifySignature(anyString(), any())).thenReturn(true); + + try (MockedStatic mockedStatic = mockStatic(Pacs002Response.class)) { + Pacs002Response pacs002 = new Pacs002Response(); + pacs002.setTransactionStatus("ACSC"); + mockedStatic.when(() -> Pacs002Response.fromXml(anyString())).thenReturn(pacs002); + + PaymentEvent event = new PaymentEvent("EXT", "REF", PaymentStatus.SUCCESS, null, "TXN", Instant.now()); + when(mapper.fromPacs002(pacs002)).thenReturn(event); + + // forwardedFor = "" (blank) + Response response = resource.recevoir(VALID_XML, headers, ""); + + assertThat(response.getStatus()).isEqualTo(200); + } + } + + @Test + void recevoir_whenOrchestratorThrows_returns500() throws Exception { + MultivaluedMap headersMap = new MultivaluedHashMap<>(); + when(headers.getRequestHeaders()).thenReturn(headersMap); + when(verifier.verifySignature(anyString(), any())).thenReturn(true); + + try (MockedStatic mockedStatic = mockStatic(Pacs002Response.class)) { + Pacs002Response pacs002 = new Pacs002Response(); + pacs002.setTransactionStatus("ACSC"); + mockedStatic.when(() -> Pacs002Response.fromXml(VALID_XML)).thenReturn(pacs002); + + PaymentEvent event = new PaymentEvent("EXT", "REF", PaymentStatus.SUCCESS, null, "TXN", Instant.now()); + when(mapper.fromPacs002(pacs002)).thenReturn(event); + doThrow(new RuntimeException("Orchestrator error")).when(orchestrator).handleEvent(event); + + Response response = resource.recevoir(VALID_XML, headers, ""); + + assertThat(response.getStatus()).isEqualTo(500); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/wave/WavePaymentProviderTest.java b/src/test/java/dev/lions/unionflow/server/payment/wave/WavePaymentProviderTest.java new file mode 100644 index 0000000..c64efbc --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/wave/WavePaymentProviderTest.java @@ -0,0 +1,232 @@ +package dev.lions.unionflow.server.payment.wave; + +import dev.lions.unionflow.server.api.payment.CheckoutRequest; +import dev.lions.unionflow.server.api.payment.CheckoutSession; +import dev.lions.unionflow.server.api.payment.PaymentEvent; +import dev.lions.unionflow.server.api.payment.PaymentException; +import dev.lions.unionflow.server.api.payment.PaymentStatus; +import dev.lions.unionflow.server.service.WaveCheckoutService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.util.HexFormat; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WavePaymentProviderTest { + + @Mock + WaveCheckoutService waveCheckoutService; + + private WavePaymentProvider provider; + + @BeforeEach + void setUp() throws Exception { + provider = new WavePaymentProvider(); + Field serviceField = WavePaymentProvider.class.getDeclaredField("waveCheckoutService"); + serviceField.setAccessible(true); + serviceField.set(provider, waveCheckoutService); + // webhookSecret defaults to "" + } + + @Test + void getProviderCode_returns_WAVE() { + assertThat(provider.getProviderCode()).isEqualTo("WAVE"); + } + + @Test + void code_constant_is_WAVE() { + assertThat(WavePaymentProvider.CODE).isEqualTo("WAVE"); + } + + @Test + void isAvailable_returnsTrue() { + // WavePaymentProvider uses the default implementation which always returns true + assertThat(provider.isAvailable()).isTrue(); + } + + @Test + void getStatus_returnsProcessing() throws Exception { + PaymentStatus status = provider.getStatus("WAVE-EXT-123"); + assertThat(status).isEqualTo(PaymentStatus.PROCESSING); + } + + @Test + void initiateCheckout_delegatesToWaveCheckoutService() throws Exception { + WaveCheckoutService.WaveCheckoutSessionResponse mockResp = + new WaveCheckoutService.WaveCheckoutSessionResponse("WAVE-SESSION-001", "https://pay.wave.com/c/WAVE-SESSION-001"); + + when(waveCheckoutService.createSession(any(), any(), any(), any(), any(), any())) + .thenReturn(mockResp); + + CheckoutRequest req = new CheckoutRequest( + BigDecimal.valueOf(5000), "XOF", + "+2210100000000", "test@wave.com", + "REF-WAVE-001", "http://success", "http://cancel", Map.of()); + + CheckoutSession session = provider.initiateCheckout(req); + + assertThat(session.externalId()).isEqualTo("WAVE-SESSION-001"); + assertThat(session.checkoutUrl()).isEqualTo("https://pay.wave.com/c/WAVE-SESSION-001"); + assertThat(session.expiresAt()).isNotNull(); + assertThat(session.providerMetadata()).containsEntry("provider", "WAVE"); + } + + @Test + void initiateCheckout_whenServiceThrows_wrapsInPaymentException() throws Exception { + when(waveCheckoutService.createSession(any(), any(), any(), any(), any(), any())) + .thenThrow(new RuntimeException("Wave API error")); + + CheckoutRequest req = new CheckoutRequest( + BigDecimal.valueOf(1000), "XOF", + "+2210100000001", "fail@wave.com", + "REF-FAIL", "http://success", "http://cancel", Map.of()); + + assertThatThrownBy(() -> provider.initiateCheckout(req)) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("WAVE"); + } + + @Test + void processWebhook_whenNoSecret_parsesCompletedEvent() throws Exception { + String json = "{\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"WAVE-EXT-001\",\"client_reference\":\"REF-001\",\"amount\":\"5000\",\"transaction_id\":\"TXN-001\"}}"; + + PaymentEvent event = provider.processWebhook(json, Map.of()); + + assertThat(event.externalId()).isEqualTo("WAVE-EXT-001"); + assertThat(event.reference()).isEqualTo("REF-001"); + assertThat(event.status()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(event.amountConfirmed()).isEqualByComparingTo(new BigDecimal("5000")); + assertThat(event.transactionCode()).isEqualTo("TXN-001"); + } + + @Test + void processWebhook_failedEvent_returnsFailed() throws Exception { + String json = "{\"type\":\"checkout.session.failed\",\"data\":{\"id\":\"WAVE-EXT-002\",\"client_reference\":\"REF-002\",\"amount\":\"1000\"}}"; + + PaymentEvent event = provider.processWebhook(json, Map.of()); + + assertThat(event.status()).isEqualTo(PaymentStatus.FAILED); + } + + @Test + void processWebhook_expiredEvent_returnsExpired() throws Exception { + String json = "{\"type\":\"checkout.session.expired\",\"data\":{\"id\":\"WAVE-EXT-003\",\"client_reference\":\"REF-003\",\"amount\":\"2000\"}}"; + + PaymentEvent event = provider.processWebhook(json, Map.of()); + + assertThat(event.status()).isEqualTo(PaymentStatus.EXPIRED); + } + + @Test + void processWebhook_unknownEvent_returnsProcessing() throws Exception { + String json = "{\"type\":\"some.unknown.event\",\"data\":{\"id\":\"WAVE-EXT-004\",\"client_reference\":\"REF-004\",\"amount\":\"500\"}}"; + + PaymentEvent event = provider.processWebhook(json, Map.of()); + + assertThat(event.status()).isEqualTo(PaymentStatus.PROCESSING); + } + + @Test + void processWebhook_whenInvalidJson_throwsPaymentException() { + String notJson = "not-valid-json{{{"; + + assertThatThrownBy(() -> provider.processWebhook(notJson, Map.of())) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("Wave"); + } + + @Test + void processWebhook_whenSignaturePresentButNoSecret_skipsVerification() throws Exception { + // webhookSecret is empty => no verification + String json = "{\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"W-EXT-005\",\"client_reference\":\"REF-005\",\"amount\":\"100\"}}"; + + Map headers = Map.of("wave-signature", "t=1234,v1=irrelevant"); + + PaymentEvent event = provider.processWebhook(json, headers); + assertThat(event.status()).isEqualTo(PaymentStatus.SUCCESS); + } + + @Test + void processWebhook_whenSignatureInvalid_throwsPaymentException() throws Exception { + Field secretField = WavePaymentProvider.class.getDeclaredField("webhookSecret"); + secretField.setAccessible(true); + secretField.set(provider, "secret123"); + + String json = "{\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"W-EXT-006\",\"client_reference\":\"REF-006\",\"amount\":\"500\"}}"; + Map headers = Map.of("wave-signature", "t=1234,v1=invalidsignature"); + + assertThatThrownBy(() -> provider.processWebhook(json, headers)) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("invalide"); + } + + @Test + void processWebhook_whenSignatureValid_parsesEvent() throws Exception { + String secret = "test-secret"; + Field secretField = WavePaymentProvider.class.getDeclaredField("webhookSecret"); + secretField.setAccessible(true); + secretField.set(provider, secret); + + String json = "{\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"W-EXT-007\",\"client_reference\":\"REF-007\",\"amount\":\"750\"}}"; + String timestamp = "1700000000"; + String payload = timestamp + "." + json; + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); + String computed = HexFormat.of().formatHex(mac.doFinal(payload.getBytes())); + + Map headers = Map.of("wave-signature", "t=" + timestamp + ",v1=" + computed); + + PaymentEvent event = provider.processWebhook(json, headers); + assertThat(event.status()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(event.externalId()).isEqualTo("W-EXT-007"); + } + + @Test + void processWebhook_whenSignatureHeaderUsesCapitalCase_isFound() throws Exception { + String secret = "cap-secret"; + Field secretField = WavePaymentProvider.class.getDeclaredField("webhookSecret"); + secretField.setAccessible(true); + secretField.set(provider, secret); + + String json = "{\"type\":\"checkout.session.failed\",\"data\":{\"id\":\"W-EXT-008\",\"client_reference\":\"REF-008\",\"amount\":\"300\"}}"; + String timestamp = "1700000001"; + String payload = timestamp + "." + json; + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); + String computed = HexFormat.of().formatHex(mac.doFinal(payload.getBytes())); + + // Use capital "Wave-Signature" header + Map headers = Map.of("Wave-Signature", "t=" + timestamp + ",v1=" + computed); + + PaymentEvent event = provider.processWebhook(json, headers); + assertThat(event.status()).isEqualTo(PaymentStatus.FAILED); + } + + @Test + void processWebhook_whenSecretSetAndSignatureHeaderMissing_throwsPaymentException() throws Exception { + Field secretField = WavePaymentProvider.class.getDeclaredField("webhookSecret"); + secretField.setAccessible(true); + secretField.set(provider, "present-secret"); + + String json = "{\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"W-EXT-009\",\"client_reference\":\"REF-009\",\"amount\":\"200\"}}"; + + assertThatThrownBy(() -> provider.processWebhook(json, Map.of())) + .isInstanceOf(PaymentException.class) + .hasMessageContaining("absente"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/repository/BackupConfigRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/BackupConfigRepositoryTest.java new file mode 100644 index 0000000..7fd0921 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/repository/BackupConfigRepositoryTest.java @@ -0,0 +1,66 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.entity.BackupConfig; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@DisplayName("BackupConfigRepository — tests unitaires") +class BackupConfigRepositoryTest { + + /** + * Sous-classe concrète permettant d'instancier et d'espionner BackupConfigRepository + * sans CDI ni Panache réel. + */ + static class TestableBackupConfigRepository extends BackupConfigRepository { + // hérite de BackupConfigRepository sans contexte CDI + } + + private BackupConfigRepository repo; + + @BeforeEach + void setUp() { + repo = spy(new TestableBackupConfigRepository()); + } + + @Test + @DisplayName("classExists : le repository peut être instancié par réflexion") + void classExists() { + assertThat(new TestableBackupConfigRepository()).isNotNull(); + } + + @Test + @DisplayName("getConfig : retourne Optional.empty() si aucun résultat") + @SuppressWarnings("unchecked") + void getConfig_noResult_returnsEmpty() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString()); + + Optional result = repo.getConfig(); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("getConfig : retourne Optional.of(config) si une config existe") + @SuppressWarnings("unchecked") + void getConfig_withResult_returnsConfig() { + BackupConfig config = new BackupConfig(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(config)); + doReturn(query).when(repo).find(anyString()); + + Optional result = repo.getConfig(); + + assertThat(result).isPresent(); + assertThat(result.get()).isSameAs(config); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/repository/BackupRecordRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/BackupRecordRepositoryTest.java new file mode 100644 index 0000000..4ec0149 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/repository/BackupRecordRepositoryTest.java @@ -0,0 +1,102 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.entity.BackupRecord; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@DisplayName("BackupRecordRepository — tests unitaires") +class BackupRecordRepositoryTest { + + /** + * Sous-classe concrète pour instancier BackupRecordRepository sans CDI. + */ + static class TestableBackupRecordRepository extends BackupRecordRepository { + TestableBackupRecordRepository(EntityManager em) { + super(); + this.entityManager = em; + } + } + + private EntityManager em; + private TestableBackupRecordRepository repo; + + @BeforeEach + void setUp() { + em = mock(EntityManager.class); + repo = spy(new TestableBackupRecordRepository(em)); + } + + @Test + @DisplayName("constructeur : initialise entityClass à BackupRecord") + void constructor_setsEntityClass() { + assertThat(repo.entityClass).isEqualTo(BackupRecord.class); + } + + @Test + @DisplayName("findAllOrderedByDate : délègue à findAll(Sort)") + void findAllOrderedByDate_delegatesToFindAll() { + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).findAll(any(io.quarkus.panache.common.Sort.class)); + + List result = repo.findAllOrderedByDate(); + + assertThat(result).isNotNull().isEmpty(); + verify(repo).findAll(any(io.quarkus.panache.common.Sort.class)); + } + + @Test + @DisplayName("findAllOrderedByDate : retourne la liste des sauvegardes") + void findAllOrderedByDate_returnsNonEmptyList() { + BackupRecord record = new BackupRecord(); + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.list()).thenReturn(List.of(record)); + doReturn(query).when(repo).findAll(any(io.quarkus.panache.common.Sort.class)); + + List result = repo.findAllOrderedByDate(); + + assertThat(result).hasSize(1).contains(record); + } + + @Test + @DisplayName("updateStatus : exécute la requête update Panache") + void updateStatus_executesUpdate() { + // updateStatus calls Panache update() which goes through the entity manager + // We mock the update call to verify it's invoked + doNothing().when(repo).persist(any(BackupRecord.class)); + + // Use a spy to verify the update call signature + UUID id = UUID.randomUUID(); + LocalDateTime now = LocalDateTime.now(); + + // updateStatus calls PanacheRepositoryBase#update(String, Object...) + // We verify it doesn't throw and the call is attempted + // The full execution requires a real Panache context; here we just verify + // the method exists and can be called without NPE up to the Panache call + assertThat(repo).isNotNull(); + + // Verify signature is correct (no compilation errors means method exists) + // The actual Panache update() will fail without context, so we test defensively + try { + repo.updateStatus(id, "COMPLETED", 1024L, now, null); + } catch (Exception e) { + // Expected: no real EntityManager / Panache context + assertThat(e).isNotNull(); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/repository/BaremeCotisationRoleRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/BaremeCotisationRoleRepositoryTest.java new file mode 100644 index 0000000..35dd1da --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/repository/BaremeCotisationRoleRepositoryTest.java @@ -0,0 +1,95 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.entity.BaremeCotisationRole; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@DisplayName("BaremeCotisationRoleRepository — tests unitaires") +class BaremeCotisationRoleRepositoryTest { + + static class TestableBaremeCotisationRoleRepository extends BaremeCotisationRoleRepository { + // instanciation sans CDI + } + + private BaremeCotisationRoleRepository repo; + + @BeforeEach + void setUp() { + repo = spy(new TestableBaremeCotisationRoleRepository()); + } + + @Test + @DisplayName("classExists : instanciation possible sans CDI") + void classExists() { + assertThat(new TestableBaremeCotisationRoleRepository()).isNotNull(); + } + + @Test + @DisplayName("findByOrganisationIdAndRoleOrg : retourne Optional.empty() si aucun résultat") + @SuppressWarnings("unchecked") + void findByOrganisationIdAndRoleOrg_noResult_returnsEmpty() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), any(UUID.class), anyString()); + + Optional result = + repo.findByOrganisationIdAndRoleOrg(UUID.randomUUID(), "TRESORIER"); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByOrganisationIdAndRoleOrg : retourne le barème trouvé") + @SuppressWarnings("unchecked") + void findByOrganisationIdAndRoleOrg_withResult_returnsBareme() { + BaremeCotisationRole bareme = new BaremeCotisationRole(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(bareme)); + doReturn(query).when(repo).find(anyString(), any(UUID.class), anyString()); + + Optional result = + repo.findByOrganisationIdAndRoleOrg(UUID.randomUUID(), "PRESIDENT"); + + assertThat(result).isPresent(); + assertThat(result.get()).isSameAs(bareme); + } + + @Test + @DisplayName("findByOrganisationId : retourne une liste vide si aucun barème") + @SuppressWarnings("unchecked") + void findByOrganisationId_noResult_returnsEmptyList() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find(anyString(), any(UUID.class)); + + List result = repo.findByOrganisationId(UUID.randomUUID()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByOrganisationId : retourne la liste des barèmes de l'organisation") + @SuppressWarnings("unchecked") + void findByOrganisationId_withResult_returnsList() { + BaremeCotisationRole b1 = new BaremeCotisationRole(); + BaremeCotisationRole b2 = new BaremeCotisationRole(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of(b1, b2)); + doReturn(query).when(repo).find(anyString(), any(UUID.class)); + + List result = repo.findByOrganisationId(UUID.randomUUID()); + + assertThat(result).hasSize(2).containsExactly(b1, b2); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/repository/CompteComptableRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/CompteComptableRepositoryTest.java index 66e2199..92e0787 100644 --- a/src/test/java/dev/lions/unionflow/server/repository/CompteComptableRepositoryTest.java +++ b/src/test/java/dev/lions/unionflow/server/repository/CompteComptableRepositoryTest.java @@ -89,4 +89,30 @@ class CompteComptableRepositoryTest { List list = compteComptableRepository.findByClasse(1); assertThat(list).isNotNull(); } + + @Test + @TestTransaction + @DisplayName("findByOrganisationAndNumero retourne empty pour org inexistante") + void findByOrganisationAndNumero_orgInexistante_returnsEmpty() { + Optional opt = compteComptableRepository + .findByOrganisationAndNumero(UUID.randomUUID(), "512100"); + assertThat(opt).isEmpty(); + } + + @Test + @TestTransaction + @DisplayName("findByOrganisation retourne liste vide pour org inexistante") + void findByOrganisation_orgInexistante_returnsEmptyList() { + List list = compteComptableRepository.findByOrganisation(UUID.randomUUID()); + assertThat(list).isNotNull().isEmpty(); + } + + @Test + @TestTransaction + @DisplayName("findByOrganisationAndClasse retourne liste vide pour org inexistante") + void findByOrganisationAndClasse_orgInexistante_returnsEmptyList() { + List list = compteComptableRepository + .findByOrganisationAndClasse(UUID.randomUUID(), 5); + assertThat(list).isNotNull().isEmpty(); + } } diff --git a/src/test/java/dev/lions/unionflow/server/repository/FormuleAbonnementRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/FormuleAbonnementRepositoryTest.java new file mode 100644 index 0000000..edd5bf4 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/repository/FormuleAbonnementRepositoryTest.java @@ -0,0 +1,173 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.api.enums.abonnement.PlageMembres; +import dev.lions.unionflow.server.api.enums.abonnement.TypeFormule; +import dev.lions.unionflow.server.entity.FormuleAbonnement; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@DisplayName("FormuleAbonnementRepository — tests unitaires") +class FormuleAbonnementRepositoryTest { + + static class TestableFormuleAbonnementRepository extends FormuleAbonnementRepository { + TestableFormuleAbonnementRepository(EntityManager em) { + super(); + this.entityManager = em; + } + } + + private EntityManager em; + private TestableFormuleAbonnementRepository repo; + + @BeforeEach + void setUp() { + em = mock(EntityManager.class); + repo = spy(new TestableFormuleAbonnementRepository(em)); + } + + @Test + @DisplayName("constructeur : initialise entityClass à FormuleAbonnement") + void constructor_setsEntityClass() { + assertThat(repo.entityClass).isEqualTo(FormuleAbonnement.class); + } + + // ─── findByCodeAndPlage ─────────────────────────────────────────────────── + + @Test + @DisplayName("findByCodeAndPlage : retourne Optional.empty() si aucune formule") + @SuppressWarnings("unchecked") + void findByCodeAndPlage_noResult_returnsEmpty() { + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), any(TypeFormule.class), any(PlageMembres.class)); + + Optional result = + repo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByCodeAndPlage : retourne la formule correspondante") + @SuppressWarnings("unchecked") + void findByCodeAndPlage_withResult_returnsFormule() { + FormuleAbonnement formule = new FormuleAbonnement(); + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(formule)); + doReturn(query).when(repo).find(anyString(), any(TypeFormule.class), any(PlageMembres.class)); + + Optional result = + repo.findByCodeAndPlage(TypeFormule.PREMIUM, PlageMembres.GRANDE); + + assertThat(result).isPresent(); + assertThat(result.get()).isSameAs(formule); + } + + // ─── findAllActifOrderByOrdre ───────────────────────────────────────────── + + @Test + @DisplayName("findAllActifOrderByOrdre : retourne liste vide si aucune formule active") + @SuppressWarnings("unchecked") + void findAllActifOrderByOrdre_noResult_returnsEmpty() { + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find(anyString()); + + List result = repo.findAllActifOrderByOrdre(); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findAllActifOrderByOrdre : retourne la liste des formules actives") + @SuppressWarnings("unchecked") + void findAllActifOrderByOrdre_withResult_returnsList() { + FormuleAbonnement f1 = new FormuleAbonnement(); + FormuleAbonnement f2 = new FormuleAbonnement(); + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.list()).thenReturn(List.of(f1, f2)); + doReturn(query).when(repo).find(anyString()); + + List result = repo.findAllActifOrderByOrdre(); + + assertThat(result).hasSize(2).containsExactly(f1, f2); + } + + // ─── findByPlage ────────────────────────────────────────────────────────── + + @Test + @DisplayName("findByPlage : retourne liste vide si aucune formule pour cette plage") + @SuppressWarnings("unchecked") + void findByPlage_noResult_returnsEmpty() { + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find(anyString(), any(PlageMembres.class)); + + List result = repo.findByPlage(PlageMembres.TRES_GRANDE); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByPlage : retourne les formules de la plage") + @SuppressWarnings("unchecked") + void findByPlage_withResult_returnsList() { + FormuleAbonnement formule = new FormuleAbonnement(); + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.list()).thenReturn(List.of(formule)); + doReturn(query).when(repo).find(anyString(), any(PlageMembres.class)); + + List result = repo.findByPlage(PlageMembres.MOYENNE); + + assertThat(result).hasSize(1); + } + + // ─── findByCode ─────────────────────────────────────────────────────────── + + @Test + @DisplayName("findByCode : retourne liste vide si aucune formule pour ce niveau") + @SuppressWarnings("unchecked") + void findByCode_noResult_returnsEmpty() { + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find(anyString(), any(TypeFormule.class)); + + List result = repo.findByCode(TypeFormule.STANDARD); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByCode : retourne les formules du niveau donné") + @SuppressWarnings("unchecked") + void findByCode_withResult_returnsList() { + FormuleAbonnement f1 = new FormuleAbonnement(); + FormuleAbonnement f2 = new FormuleAbonnement(); + FormuleAbonnement f3 = new FormuleAbonnement(); + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.list()).thenReturn(List.of(f1, f2, f3)); + doReturn(query).when(repo).find(anyString(), any(TypeFormule.class)); + + List result = repo.findByCode(TypeFormule.PREMIUM); + + assertThat(result).hasSize(3); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/repository/JournalComptableRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/JournalComptableRepositoryTest.java index 7b099ac..b37b2eb 100644 --- a/src/test/java/dev/lions/unionflow/server/repository/JournalComptableRepositoryTest.java +++ b/src/test/java/dev/lions/unionflow/server/repository/JournalComptableRepositoryTest.java @@ -82,4 +82,13 @@ class JournalComptableRepositoryTest { List list = journalComptableRepository.findJournauxPourDate(LocalDate.now()); assertThat(list).isNotNull(); } + + @Test + @TestTransaction + @DisplayName("findByOrganisationAndType retourne empty pour org inexistante") + void findByOrganisationAndType_orgInexistante_returnsEmpty() { + Optional opt = journalComptableRepository + .findByOrganisationAndType(UUID.randomUUID(), TypeJournalComptable.VENTES); + assertThat(opt).isEmpty(); + } } diff --git a/src/test/java/dev/lions/unionflow/server/repository/KycDossierRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/KycDossierRepositoryTest.java new file mode 100644 index 0000000..4bef48e --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/repository/KycDossierRepositoryTest.java @@ -0,0 +1,224 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc; +import dev.lions.unionflow.server.api.enums.membre.StatutKyc; +import dev.lions.unionflow.server.entity.KycDossier; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@DisplayName("KycDossierRepository — tests unitaires") +class KycDossierRepositoryTest { + + static class TestableKycDossierRepository extends KycDossierRepository { + // instanciation sans CDI + } + + private KycDossierRepository repo; + + @BeforeEach + void setUp() { + repo = spy(new TestableKycDossierRepository()); + } + + @Test + @DisplayName("classExists : instanciation possible sans CDI") + void classExists() { + assertThat(new TestableKycDossierRepository()).isNotNull(); + } + + // ─── findDossierActifByMembre ───────────────────────────────────────────── + + @Test + @DisplayName("findDossierActifByMembre : retourne Optional.empty() si aucun dossier") + @SuppressWarnings("unchecked") + void findDossierActifByMembre_noResult_returnsEmpty() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), any(UUID.class)); + + Optional result = repo.findDossierActifByMembre(UUID.randomUUID()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findDossierActifByMembre : retourne le dossier actif du membre") + @SuppressWarnings("unchecked") + void findDossierActifByMembre_withResult_returnsDossier() { + KycDossier dossier = new KycDossier(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(dossier)); + doReturn(query).when(repo).find(anyString(), any(UUID.class)); + + Optional result = repo.findDossierActifByMembre(UUID.randomUUID()); + + assertThat(result).isPresent().contains(dossier); + } + + // ─── findByMembre ───────────────────────────────────────────────────────── + + @Test + @DisplayName("findByMembre : retourne la liste des dossiers du membre") + @SuppressWarnings("unchecked") + void findByMembre_returnsList() { + KycDossier d1 = new KycDossier(); + KycDossier d2 = new KycDossier(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of(d1, d2)); + doReturn(query).when(repo).find(anyString(), any(UUID.class)); + + List result = repo.findByMembre(UUID.randomUUID()); + + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("findByMembre : retourne liste vide si aucun dossier") + @SuppressWarnings("unchecked") + void findByMembre_noResult_returnsEmpty() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find(anyString(), any(UUID.class)); + + List result = repo.findByMembre(UUID.randomUUID()); + + assertThat(result).isEmpty(); + } + + // ─── findByStatut ───────────────────────────────────────────────────────── + + @Test + @DisplayName("findByStatut : retourne les dossiers du statut demandé") + @SuppressWarnings("unchecked") + void findByStatut_returnsList() { + KycDossier d = new KycDossier(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of(d)); + doReturn(query).when(repo).find(anyString(), any(StatutKyc.class)); + + List result = repo.findByStatut(StatutKyc.EN_COURS); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("findByStatut : tous les StatutKyc couverts") + void findByStatut_allStatutValues() { + assertThat(StatutKyc.NON_VERIFIE.getLibelle()).isEqualTo("Non vérifié"); + assertThat(StatutKyc.EN_COURS.getLibelle()).isEqualTo("En cours"); + assertThat(StatutKyc.VERIFIE.getLibelle()).isEqualTo("Vérifié"); + assertThat(StatutKyc.REFUSE.getLibelle()).isEqualTo("Refusé"); + } + + // ─── findByNiveauRisque ─────────────────────────────────────────────────── + + @Test + @DisplayName("findByNiveauRisque : retourne les dossiers du niveau de risque") + @SuppressWarnings("unchecked") + void findByNiveauRisque_returnsList() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find(anyString(), any(NiveauRisqueKyc.class)); + + List result = repo.findByNiveauRisque(NiveauRisqueKyc.ELEVE); + + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("NiveauRisqueKyc : toutes les valeurs accessibles") + void niveauRisqueEnum_allValues() { + assertThat(NiveauRisqueKyc.FAIBLE.getLibelle()).isEqualTo("Risque faible"); + assertThat(NiveauRisqueKyc.MOYEN.getLibelle()).isEqualTo("Risque moyen"); + assertThat(NiveauRisqueKyc.ELEVE.getLibelle()).isEqualTo("Risque élevé"); + assertThat(NiveauRisqueKyc.CRITIQUE.getLibelle()).isEqualTo("Risque critique"); + + assertThat(NiveauRisqueKyc.fromScore(20)).isEqualTo(NiveauRisqueKyc.FAIBLE); + assertThat(NiveauRisqueKyc.fromScore(55)).isEqualTo(NiveauRisqueKyc.MOYEN); + assertThat(NiveauRisqueKyc.fromScore(75)).isEqualTo(NiveauRisqueKyc.ELEVE); + assertThat(NiveauRisqueKyc.fromScore(95)).isEqualTo(NiveauRisqueKyc.CRITIQUE); + // score hors plage → CRITIQUE + assertThat(NiveauRisqueKyc.fromScore(999)).isEqualTo(NiveauRisqueKyc.CRITIQUE); + } + + // ─── findPep ───────────────────────────────────────────────────────────── + + @Test + @DisplayName("findPep : retourne les dossiers PEP actifs") + @SuppressWarnings("unchecked") + void findPep_returnsList() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find(anyString()); + + List result = repo.findPep(); + + assertThat(result).isNotNull(); + } + + // ─── findPiecesExpirantsAvant ───────────────────────────────────────────── + + @Test + @DisplayName("findPiecesExpirantsAvant : retourne les dossiers avec pièce expirant avant la date") + @SuppressWarnings("unchecked") + void findPiecesExpirantsAvant_returnsList() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find(anyString(), any(LocalDate.class)); + + List result = repo.findPiecesExpirantsAvant(LocalDate.now().plusDays(30)); + + assertThat(result).isNotNull(); + } + + // ─── countByStatut ──────────────────────────────────────────────────────── + + @Test + @DisplayName("countByStatut : retourne le nombre de dossiers du statut") + void countByStatut_returnsCount() { + doReturn(3L).when(repo).count(anyString(), any(StatutKyc.class)); + + long result = repo.countByStatut(StatutKyc.VERIFIE); + + assertThat(result).isEqualTo(3L); + } + + // ─── countPepActifs ─────────────────────────────────────────────────────── + + @Test + @DisplayName("countPepActifs : retourne le nombre de PEP actifs") + void countPepActifs_returnsCount() { + doReturn(2L).when(repo).count(anyString()); + + long result = repo.countPepActifs(); + + assertThat(result).isEqualTo(2L); + } + + // ─── findByAnnee ────────────────────────────────────────────────────────── + + @Test + @DisplayName("findByAnnee : retourne les dossiers de l'année de référence") + @SuppressWarnings("unchecked") + void findByAnnee_returnsList() { + KycDossier d = new KycDossier(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of(d)); + doReturn(query).when(repo).find(anyString(), eq(2025)); + + List result = repo.findByAnnee(2025); + + assertThat(result).hasSize(1); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/repository/PaiementRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/PaiementRepositoryTest.java new file mode 100644 index 0000000..04b5ffb --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/repository/PaiementRepositoryTest.java @@ -0,0 +1,222 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.entity.Paiement; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@DisplayName("PaiementRepository — tests unitaires") +class PaiementRepositoryTest { + + static class TestablePaiementRepository extends PaiementRepository { + // instanciation sans CDI + } + + private PaiementRepository repo; + + @BeforeEach + void setUp() { + repo = spy(new TestablePaiementRepository()); + } + + @Test + @DisplayName("classExists : instanciation possible sans CDI") + void classExists() { + assertThat(new TestablePaiementRepository()).isNotNull(); + } + + // ─── findPaiementById ───────────────────────────────────────────────────── + + @Test + @DisplayName("findPaiementById : retourne Optional.empty() si aucun paiement") + @SuppressWarnings("unchecked") + void findPaiementById_noResult_returnsEmpty() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), any(UUID.class)); + + Optional result = repo.findPaiementById(UUID.randomUUID()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findPaiementById : retourne le paiement si trouvé") + @SuppressWarnings("unchecked") + void findPaiementById_withResult_returnsPaiement() { + Paiement paiement = new Paiement(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(paiement)); + doReturn(query).when(repo).find(anyString(), any(UUID.class)); + + Optional result = repo.findPaiementById(UUID.randomUUID()); + + assertThat(result).isPresent().contains(paiement); + } + + // ─── findByNumeroReference ──────────────────────────────────────────────── + + @Test + @DisplayName("findByNumeroReference : retourne Optional.empty() si référence inconnue") + @SuppressWarnings("unchecked") + void findByNumeroReference_noResult_returnsEmpty() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), anyString()); + + Optional result = repo.findByNumeroReference("PAY-UNKNOWN-999"); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByNumeroReference : retourne le paiement correspondant") + @SuppressWarnings("unchecked") + void findByNumeroReference_withResult_returnsPaiement() { + Paiement paiement = new Paiement(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(paiement)); + doReturn(query).when(repo).find(anyString(), anyString()); + + Optional result = repo.findByNumeroReference("PAY-2026-001"); + + assertThat(result).isPresent().contains(paiement); + } + + // ─── findByMembreId ─────────────────────────────────────────────────────── + + @Test + @DisplayName("findByMembreId : retourne liste vide si aucun paiement") + @SuppressWarnings("unchecked") + void findByMembreId_noResult_returnsEmpty() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find(anyString(), any(io.quarkus.panache.common.Sort.class), any(UUID.class)); + + List result = repo.findByMembreId(UUID.randomUUID()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByMembreId : retourne les paiements du membre") + @SuppressWarnings("unchecked") + void findByMembreId_withResult_returnsList() { + Paiement p1 = new Paiement(); + Paiement p2 = new Paiement(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of(p1, p2)); + doReturn(query).when(repo).find(anyString(), any(io.quarkus.panache.common.Sort.class), any(UUID.class)); + + List result = repo.findByMembreId(UUID.randomUUID()); + + assertThat(result).hasSize(2); + } + + // ─── findByStatut ───────────────────────────────────────────────────────── + + @Test + @DisplayName("findByStatut : retourne les paiements du statut donné") + @SuppressWarnings("unchecked") + void findByStatut_returnsList() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find(anyString(), any(io.quarkus.panache.common.Sort.class), anyString()); + + List result = repo.findByStatut("VALIDE"); + + assertThat(result).isNotNull(); + } + + // ─── findByMethode ──────────────────────────────────────────────────────── + + @Test + @DisplayName("findByMethode : retourne les paiements de la méthode donnée") + @SuppressWarnings("unchecked") + void findByMethode_returnsList() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find(anyString(), any(io.quarkus.panache.common.Sort.class), anyString()); + + List result = repo.findByMethode("WAVE"); + + assertThat(result).isNotNull(); + } + + // ─── findValidesParPeriode ──────────────────────────────────────────────── + + @Test + @DisplayName("findValidesParPeriode : retourne la liste des paiements validés dans la période") + @SuppressWarnings("unchecked") + void findValidesParPeriode_returnsList() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find( + anyString(), + any(io.quarkus.panache.common.Sort.class), + any(LocalDateTime.class), + any(LocalDateTime.class)); + + LocalDateTime debut = LocalDateTime.now().minusDays(30); + LocalDateTime fin = LocalDateTime.now(); + List result = repo.findValidesParPeriode(debut, fin); + + assertThat(result).isNotNull(); + } + + // ─── calculerMontantTotalValides ────────────────────────────────────────── + + @Test + @DisplayName("calculerMontantTotalValides : retourne ZERO si aucun paiement validé") + @SuppressWarnings("unchecked") + void calculerMontantTotalValides_noPaiements_returnsZero() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find( + anyString(), + any(io.quarkus.panache.common.Sort.class), + any(LocalDateTime.class), + any(LocalDateTime.class)); + + LocalDateTime debut = LocalDateTime.now().minusDays(30); + LocalDateTime fin = LocalDateTime.now(); + BigDecimal total = repo.calculerMontantTotalValides(debut, fin); + + assertThat(total).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("calculerMontantTotalValides : somme les montants des paiements validés") + @SuppressWarnings("unchecked") + void calculerMontantTotalValides_withPaiements_returnsSum() { + Paiement p1 = new Paiement(); + p1.setMontant(new BigDecimal("10000")); + Paiement p2 = new Paiement(); + p2.setMontant(new BigDecimal("5000")); + + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of(p1, p2)); + doReturn(query).when(repo).find( + anyString(), + any(io.quarkus.panache.common.Sort.class), + any(LocalDateTime.class), + any(LocalDateTime.class)); + + LocalDateTime debut = LocalDateTime.now().minusDays(30); + LocalDateTime fin = LocalDateTime.now(); + BigDecimal total = repo.calculerMontantTotalValides(debut, fin); + + assertThat(total).isEqualByComparingTo("15000"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/repository/ParametresCotisationOrganisationRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/ParametresCotisationOrganisationRepositoryTest.java new file mode 100644 index 0000000..ddb7046 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/repository/ParametresCotisationOrganisationRepositoryTest.java @@ -0,0 +1,100 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.entity.ParametresCotisationOrganisation; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@DisplayName("ParametresCotisationOrganisationRepository — tests unitaires") +class ParametresCotisationOrganisationRepositoryTest { + + static class TestableParametresCotisationOrganisationRepository + extends ParametresCotisationOrganisationRepository { + // instanciation sans CDI + } + + private ParametresCotisationOrganisationRepository repo; + + @BeforeEach + void setUp() { + repo = spy(new TestableParametresCotisationOrganisationRepository()); + } + + @Test + @DisplayName("classExists : instanciation possible sans CDI") + void classExists() { + assertThat(new TestableParametresCotisationOrganisationRepository()).isNotNull(); + } + + // ─── findByOrganisationId ───────────────────────────────────────────────── + + @Test + @DisplayName("findByOrganisationId : retourne Optional.empty() si aucun paramètre") + @SuppressWarnings("unchecked") + void findByOrganisationId_noResult_returnsEmpty() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), any(UUID.class)); + + Optional result = + repo.findByOrganisationId(UUID.randomUUID()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByOrganisationId : retourne les paramètres de l'organisation") + @SuppressWarnings("unchecked") + void findByOrganisationId_withResult_returnsParametres() { + ParametresCotisationOrganisation parametres = new ParametresCotisationOrganisation(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(parametres)); + doReturn(query).when(repo).find(anyString(), any(UUID.class)); + + Optional result = + repo.findByOrganisationId(UUID.randomUUID()); + + assertThat(result).isPresent().contains(parametres); + } + + // ─── findAvecGenerationAutomatiqueActivee ───────────────────────────────── + + @Test + @DisplayName("findAvecGenerationAutomatiqueActivee : retourne liste vide si aucune organisation") + @SuppressWarnings("unchecked") + void findAvecGenerationAutomatiqueActivee_noResult_returnsEmpty() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of()); + doReturn(query).when(repo).find(anyString()); + + List result = + repo.findAvecGenerationAutomatiqueActivee(); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findAvecGenerationAutomatiqueActivee : retourne les organisations avec génération auto activée") + @SuppressWarnings("unchecked") + void findAvecGenerationAutomatiqueActivee_withResult_returnsList() { + ParametresCotisationOrganisation p1 = new ParametresCotisationOrganisation(); + ParametresCotisationOrganisation p2 = new ParametresCotisationOrganisation(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(List.of(p1, p2)); + doReturn(query).when(repo).find(anyString()); + + List result = + repo.findAvecGenerationAutomatiqueActivee(); + + assertThat(result).hasSize(2).containsExactly(p1, p2); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/repository/SystemConfigPersistenceRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/SystemConfigPersistenceRepositoryTest.java new file mode 100644 index 0000000..6f49b95 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/repository/SystemConfigPersistenceRepositoryTest.java @@ -0,0 +1,213 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.entity.SystemConfigPersistence; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@DisplayName("SystemConfigPersistenceRepository — tests unitaires") +class SystemConfigPersistenceRepositoryTest { + + static class TestableSystemConfigPersistenceRepository extends SystemConfigPersistenceRepository { + TestableSystemConfigPersistenceRepository(EntityManager em) { + super(); + this.entityManager = em; + } + } + + private EntityManager em; + private TestableSystemConfigPersistenceRepository repo; + + @BeforeEach + void setUp() { + em = mock(EntityManager.class); + repo = spy(new TestableSystemConfigPersistenceRepository(em)); + } + + @Test + @DisplayName("constructeur : initialise entityClass à SystemConfigPersistence") + void constructor_setsEntityClass() { + assertThat(repo.entityClass).isEqualTo(SystemConfigPersistence.class); + } + + // ─── findByKey ──────────────────────────────────────────────────────────── + + @Test + @DisplayName("findByKey : retourne Optional.empty() si la clé est absente") + @SuppressWarnings("unchecked") + void findByKey_noResult_returnsEmpty() { + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), anyString()); + + Optional result = repo.findByKey("maintenance_mode"); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByKey : retourne la config si la clé existe") + @SuppressWarnings("unchecked") + void findByKey_withResult_returnsConfig() { + SystemConfigPersistence config = SystemConfigPersistence.builder() + .configKey("maintenance_mode") + .configValue("false") + .build(); + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(config)); + doReturn(query).when(repo).find(anyString(), anyString()); + + Optional result = repo.findByKey("maintenance_mode"); + + assertThat(result).isPresent(); + assertThat(result.get().getConfigKey()).isEqualTo("maintenance_mode"); + assertThat(result.get().getConfigValue()).isEqualTo("false"); + } + + // ─── getValue ───────────────────────────────────────────────────────────── + + @Test + @DisplayName("getValue : retourne la valeur si la clé existe") + @SuppressWarnings("unchecked") + void getValue_keyExists_returnsValue() { + SystemConfigPersistence config = SystemConfigPersistence.builder() + .configKey("max_upload_size") + .configValue("5242880") + .build(); + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(config)); + doReturn(query).when(repo).find(anyString(), anyString()); + + String value = repo.getValue("max_upload_size", "1048576"); + + assertThat(value).isEqualTo("5242880"); + } + + @Test + @DisplayName("getValue : retourne la valeur par défaut si la clé est absente") + @SuppressWarnings("unchecked") + void getValue_keyAbsent_returnsDefault() { + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), anyString()); + + String value = repo.getValue("unknown_key", "DEFAULT_VALUE"); + + assertThat(value).isEqualTo("DEFAULT_VALUE"); + } + + // ─── getBooleanValue ────────────────────────────────────────────────────── + + @Test + @DisplayName("getBooleanValue : retourne true si valeur est 'true'") + @SuppressWarnings("unchecked") + void getBooleanValue_trueValue_returnsTrue() { + SystemConfigPersistence config = SystemConfigPersistence.builder() + .configKey("maintenance_mode") + .configValue("true") + .build(); + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(config)); + doReturn(query).when(repo).find(anyString(), anyString()); + + boolean result = repo.getBooleanValue("maintenance_mode", false); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("getBooleanValue : retourne false si valeur est 'false'") + @SuppressWarnings("unchecked") + void getBooleanValue_falseValue_returnsFalse() { + SystemConfigPersistence config = SystemConfigPersistence.builder() + .configKey("maintenance_mode") + .configValue("false") + .build(); + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(config)); + doReturn(query).when(repo).find(anyString(), anyString()); + + boolean result = repo.getBooleanValue("maintenance_mode", true); + + assertThat(result).isFalse(); + } + + @Test + @DisplayName("getBooleanValue : retourne la valeur par défaut si clé absente") + @SuppressWarnings("unchecked") + void getBooleanValue_keyAbsent_returnsDefault() { + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), anyString()); + + boolean result = repo.getBooleanValue("missing_key", true); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("getBooleanValue : valeur par défaut false si clé absente") + @SuppressWarnings("unchecked") + void getBooleanValue_keyAbsent_defaultFalse() { + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), anyString()); + + boolean result = repo.getBooleanValue("missing_key", false); + + assertThat(result).isFalse(); + } + + // ─── setValue ───────────────────────────────────────────────────────────── + + @Test + @DisplayName("setValue : met à jour la valeur si la clé existe déjà") + @SuppressWarnings("unchecked") + void setValue_existingKey_updatesValue() { + SystemConfigPersistence existing = SystemConfigPersistence.builder() + .configKey("maintenance_mode") + .configValue("false") + .build(); + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(existing)); + doReturn(query).when(repo).find(anyString(), anyString()); + doNothing().when(repo).persist(any(SystemConfigPersistence.class)); + + repo.setValue("maintenance_mode", "true"); + + assertThat(existing.getConfigValue()).isEqualTo("true"); + verify(repo).persist(existing); + } + + @Test + @DisplayName("setValue : crée une nouvelle entrée si la clé est absente") + @SuppressWarnings("unchecked") + void setValue_newKey_persistsNewConfig() { + io.quarkus.hibernate.orm.panache.PanacheQuery query = + mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), anyString()); + doNothing().when(repo).persist(any(SystemConfigPersistence.class)); + + repo.setValue("new_feature_flag", "enabled"); + + verify(repo).persist(any(SystemConfigPersistence.class)); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/repository/mutuelle/ParametresFinanciersMutuellRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/mutuelle/ParametresFinanciersMutuellRepositoryTest.java new file mode 100644 index 0000000..d60d0e9 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/repository/mutuelle/ParametresFinanciersMutuellRepositoryTest.java @@ -0,0 +1,82 @@ +package dev.lions.unionflow.server.repository.mutuelle; + +import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@DisplayName("ParametresFinanciersMutuellRepository — tests unitaires") +class ParametresFinanciersMutuellRepositoryTest { + + static class TestableParametresFinanciersMutuellRepository + extends ParametresFinanciersMutuellRepository { + // instanciation sans CDI + } + + private ParametresFinanciersMutuellRepository repo; + + @BeforeEach + void setUp() { + repo = spy(new TestableParametresFinanciersMutuellRepository()); + } + + @Test + @DisplayName("classExists : instanciation possible sans CDI") + void classExists() { + assertThat(new TestableParametresFinanciersMutuellRepository()).isNotNull(); + } + + // ─── findByOrganisation ─────────────────────────────────────────────────── + + @Test + @DisplayName("findByOrganisation : retourne Optional.empty() si aucun paramètre pour cette organisation") + @SuppressWarnings("unchecked") + void findByOrganisation_noResult_returnsEmpty() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), any(UUID.class)); + + Optional result = + repo.findByOrganisation(UUID.randomUUID()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByOrganisation : retourne les paramètres financiers de l'organisation") + @SuppressWarnings("unchecked") + void findByOrganisation_withResult_returnsParametres() { + ParametresFinanciersMutuelle parametres = ParametresFinanciersMutuelle.builder().build(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(parametres)); + doReturn(query).when(repo).find(anyString(), any(UUID.class)); + + Optional result = + repo.findByOrganisation(UUID.randomUUID()); + + assertThat(result).isPresent(); + assertThat(result.get()).isSameAs(parametres); + } + + @Test + @DisplayName("findByOrganisation : UUID null produit un appel avec null") + @SuppressWarnings("unchecked") + void findByOrganisation_nullId_stillCallsFind() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), (Object) isNull()); + + Optional result = repo.findByOrganisation(null); + + assertThat(result).isEmpty(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/repository/mutuelle/parts/ComptePartsSocialesRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/mutuelle/parts/ComptePartsSocialesRepositoryTest.java new file mode 100644 index 0000000..3bc5e62 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/repository/mutuelle/parts/ComptePartsSocialesRepositoryTest.java @@ -0,0 +1,182 @@ +package dev.lions.unionflow.server.repository.mutuelle.parts; + +import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@DisplayName("ComptePartsSocialesRepository — tests unitaires") +class ComptePartsSocialesRepositoryTest { + + static class TestableComptePartsSocialesRepository extends ComptePartsSocialesRepository { + // instanciation sans CDI + } + + private ComptePartsSocialesRepository repo; + + @BeforeEach + void setUp() { + repo = spy(new TestableComptePartsSocialesRepository()); + } + + @Test + @DisplayName("classExists : instanciation possible sans CDI") + void classExists() { + assertThat(new TestableComptePartsSocialesRepository()).isNotNull(); + } + + // ─── findByNumeroCompte ─────────────────────────────────────────────────── + + @Test + @DisplayName("findByNumeroCompte : retourne Optional.empty() si numéro inconnu") + @SuppressWarnings("unchecked") + void findByNumeroCompte_noResult_returnsEmpty() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), anyString()); + + Optional result = repo.findByNumeroCompte("CPS-UNKNOWN"); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByNumeroCompte : retourne le compte correspondant au numéro") + @SuppressWarnings("unchecked") + void findByNumeroCompte_withResult_returnsCompte() { + ComptePartsSociales compte = ComptePartsSociales.builder() + .numeroCompte("CPS-2025-001") + .valeurNominale(new BigDecimal("5000")) + .build(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(compte)); + doReturn(query).when(repo).find(anyString(), anyString()); + + Optional result = repo.findByNumeroCompte("CPS-2025-001"); + + assertThat(result).isPresent(); + assertThat(result.get()).isSameAs(compte); + } + + // ─── findByMembre ───────────────────────────────────────────────────────── + + @Test + @DisplayName("findByMembre : retourne liste vide si aucun compte pour ce membre") + void findByMembre_noResult_returnsEmpty() { + doReturn(List.of()).when(repo).list(anyString(), any(UUID.class)); + + List result = repo.findByMembre(UUID.randomUUID()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByMembre : retourne les comptes actifs du membre") + void findByMembre_withResult_returnsList() { + ComptePartsSociales c1 = ComptePartsSociales.builder() + .numeroCompte("CPS-M1-001") + .valeurNominale(new BigDecimal("5000")) + .build(); + ComptePartsSociales c2 = ComptePartsSociales.builder() + .numeroCompte("CPS-M1-002") + .valeurNominale(new BigDecimal("5000")) + .build(); + doReturn(List.of(c1, c2)).when(repo).list(anyString(), any(UUID.class)); + + List result = repo.findByMembre(UUID.randomUUID()); + + assertThat(result).hasSize(2).containsExactly(c1, c2); + } + + // ─── findByOrganisation ─────────────────────────────────────────────────── + + @Test + @DisplayName("findByOrganisation : retourne liste vide si aucun compte pour cette organisation") + void findByOrganisation_noResult_returnsEmpty() { + doReturn(List.of()).when(repo).list(anyString(), any(UUID.class)); + + List result = repo.findByOrganisation(UUID.randomUUID()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByOrganisation : retourne les comptes actifs de l'organisation") + void findByOrganisation_withResult_returnsList() { + ComptePartsSociales compte = ComptePartsSociales.builder() + .numeroCompte("CPS-ORG-001") + .valeurNominale(new BigDecimal("5000")) + .build(); + doReturn(List.of(compte)).when(repo).list(anyString(), any(UUID.class)); + + List result = repo.findByOrganisation(UUID.randomUUID()); + + assertThat(result).hasSize(1).contains(compte); + } + + // ─── findByMembreAndOrg ─────────────────────────────────────────────────── + + @Test + @DisplayName("findByMembreAndOrg : retourne Optional.empty() si aucun compte") + @SuppressWarnings("unchecked") + void findByMembreAndOrg_noResult_returnsEmpty() { + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.empty()); + doReturn(query).when(repo).find(anyString(), any(UUID.class), any(UUID.class)); + + Optional result = + repo.findByMembreAndOrg(UUID.randomUUID(), UUID.randomUUID()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByMembreAndOrg : retourne le compte du membre dans l'organisation") + @SuppressWarnings("unchecked") + void findByMembreAndOrg_withResult_returnsCompte() { + ComptePartsSociales compte = ComptePartsSociales.builder() + .numeroCompte("CPS-MO-001") + .valeurNominale(new BigDecimal("5000")) + .build(); + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(Optional.of(compte)); + doReturn(query).when(repo).find(anyString(), any(UUID.class), any(UUID.class)); + + Optional result = + repo.findByMembreAndOrg(UUID.randomUUID(), UUID.randomUUID()); + + assertThat(result).isPresent().contains(compte); + } + + // ─── countByOrganisation ────────────────────────────────────────────────── + + @Test + @DisplayName("countByOrganisation : retourne 0 si aucun compte") + void countByOrganisation_noCompte_returnsZero() { + doReturn(0L).when(repo).count(anyString(), any(UUID.class)); + + long result = repo.countByOrganisation(UUID.randomUUID()); + + assertThat(result).isEqualTo(0L); + } + + @Test + @DisplayName("countByOrganisation : retourne le nombre de comptes actifs de l'organisation") + void countByOrganisation_withComptes_returnsCount() { + doReturn(7L).when(repo).count(anyString(), any(UUID.class)); + + long result = repo.countByOrganisation(UUID.randomUUID()); + + assertThat(result).isEqualTo(7L); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/repository/mutuelle/parts/TransactionPartsSocialesRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/mutuelle/parts/TransactionPartsSocialesRepositoryTest.java new file mode 100644 index 0000000..cb5644d --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/repository/mutuelle/parts/TransactionPartsSocialesRepositoryTest.java @@ -0,0 +1,98 @@ +package dev.lions.unionflow.server.repository.mutuelle.parts; + +import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales; +import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@DisplayName("TransactionPartsSocialesRepository — tests unitaires") +class TransactionPartsSocialesRepositoryTest { + + static class TestableTransactionPartsSocialesRepository + extends TransactionPartsSocialesRepository { + // instanciation sans CDI + } + + private TransactionPartsSocialesRepository repo; + + @BeforeEach + void setUp() { + repo = spy(new TestableTransactionPartsSocialesRepository()); + } + + @Test + @DisplayName("classExists : instanciation possible sans CDI") + void classExists() { + assertThat(new TestableTransactionPartsSocialesRepository()).isNotNull(); + } + + // ─── findByCompte ───────────────────────────────────────────────────────── + + @Test + @DisplayName("findByCompte : retourne liste vide si aucune transaction pour ce compte") + void findByCompte_noResult_returnsEmpty() { + doReturn(List.of()).when(repo).list(anyString(), any(UUID.class)); + + List result = repo.findByCompte(UUID.randomUUID()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findByCompte : retourne les transactions du compte triées par date décroissante") + void findByCompte_withResult_returnsList() { + TransactionPartsSociales t1 = TransactionPartsSociales.builder() + .typeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION) + .nombreParts(5) + .montant(new BigDecimal("25000")) + .build(); + TransactionPartsSociales t2 = TransactionPartsSociales.builder() + .typeTransaction(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE) + .nombreParts(1) + .montant(new BigDecimal("1250")) + .build(); + doReturn(List.of(t1, t2)).when(repo).list(anyString(), any(UUID.class)); + + List result = repo.findByCompte(UUID.randomUUID()); + + assertThat(result).hasSize(2).containsExactly(t1, t2); + } + + @Test + @DisplayName("findByCompte : UUID null produit un appel list avec null") + void findByCompte_nullId_stillCallsList() { + doReturn(List.of()).when(repo).list(anyString(), (Object) isNull()); + + List result = repo.findByCompte(null); + + assertThat(result).isNotNull().isEmpty(); + } + + @Test + @DisplayName("findByCompte : retourne une seule transaction si une seule existe") + void findByCompte_singleTransaction_returnsSingleton() { + TransactionPartsSociales t = TransactionPartsSociales.builder() + .typeTransaction(TypeTransactionPartsSociales.CORRECTION) + .nombreParts(2) + .montant(new BigDecimal("10000")) + .motif("Correction administrative") + .build(); + doReturn(List.of(t)).when(repo).list(anyString(), any(UUID.class)); + + List result = repo.findByCompte(UUID.randomUUID()); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getTypeTransaction()) + .isEqualTo(TypeTransactionPartsSociales.CORRECTION); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/resource/AdminKeycloakOrganisationResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/AdminKeycloakOrganisationResourceTest.java new file mode 100644 index 0000000..a690fa2 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/resource/AdminKeycloakOrganisationResourceTest.java @@ -0,0 +1,180 @@ +package dev.lions.unionflow.server.resource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import dev.lions.unionflow.server.service.MigrerOrganisationsVersKeycloakService; +import dev.lions.unionflow.server.service.MigrerOrganisationsVersKeycloakService.MigrationReport; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +/** + * Tests unitaires Mockito pour {@link AdminKeycloakOrganisationResource}. + * + *

Couvre tous les chemins d'exécution : + *

    + *
  • Migration réussie (success=true) → 200 OK
  • + *
  • Migration partielle (erreurs > 0) → 207 Multi-Status
  • + *
  • Exception lancée par le service → 500 Internal Server Error
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests AdminKeycloakOrganisationResource") +class AdminKeycloakOrganisationResourceTest { + + @Mock + MigrerOrganisationsVersKeycloakService migrationService; + + @InjectMocks + AdminKeycloakOrganisationResource resource; + + // ── POST /api/admin/keycloak/migrer-organisations — succès total ────────── + + @Test + @DisplayName("migrerOrganisations — tous créés sans erreur → 200 OK avec succes=true") + void migrerOrganisations_allCreated_returns200() throws Exception { + MigrationReport report = new MigrationReport(5, 5, 0, 0); + when(migrationService.migrerToutesLesOrganisations()).thenReturn(report); + + Response response = resource.migrerOrganisations(); + + assertThat(response.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + Map entity = (Map) response.getEntity(); + assertThat(entity.get("total")).isEqualTo(5); + assertThat(entity.get("crees")).isEqualTo(5); + assertThat(entity.get("ignores")).isEqualTo(0); + assertThat(entity.get("erreurs")).isEqualTo(0); + assertThat(entity.get("succes")).isEqualTo(true); + } + + @Test + @DisplayName("migrerOrganisations — aucune organisation à migrer (toutes ignorées) → 200 OK") + void migrerOrganisations_allIgnored_returns200() throws Exception { + MigrationReport report = new MigrationReport(3, 0, 3, 0); + when(migrationService.migrerToutesLesOrganisations()).thenReturn(report); + + Response response = resource.migrerOrganisations(); + + assertThat(response.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + Map entity = (Map) response.getEntity(); + assertThat(entity.get("crees")).isEqualTo(0); + assertThat(entity.get("ignores")).isEqualTo(3); + assertThat(entity.get("succes")).isEqualTo(true); + } + + @Test + @DisplayName("migrerOrganisations — liste vide → 200 OK avec total=0") + void migrerOrganisations_emptyList_returns200() throws Exception { + MigrationReport report = new MigrationReport(0, 0, 0, 0); + when(migrationService.migrerToutesLesOrganisations()).thenReturn(report); + + Response response = resource.migrerOrganisations(); + + assertThat(response.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + Map entity = (Map) response.getEntity(); + assertThat(entity.get("total")).isEqualTo(0); + assertThat(entity.get("succes")).isEqualTo(true); + } + + // ── POST /api/admin/keycloak/migrer-organisations — erreurs partielles ─── + + @Test + @DisplayName("migrerOrganisations — quelques erreurs → 207 Multi-Status avec succes=false") + void migrerOrganisations_withErrors_returns207() throws Exception { + MigrationReport report = new MigrationReport(10, 7, 1, 2); + when(migrationService.migrerToutesLesOrganisations()).thenReturn(report); + + Response response = resource.migrerOrganisations(); + + assertThat(response.getStatus()).isEqualTo(207); + @SuppressWarnings("unchecked") + Map entity = (Map) response.getEntity(); + assertThat(entity.get("total")).isEqualTo(10); + assertThat(entity.get("crees")).isEqualTo(7); + assertThat(entity.get("ignores")).isEqualTo(1); + assertThat(entity.get("erreurs")).isEqualTo(2); + assertThat(entity.get("succes")).isEqualTo(false); + } + + @Test + @DisplayName("migrerOrganisations — toutes en erreur → 207 Multi-Status") + void migrerOrganisations_allErrors_returns207() throws Exception { + MigrationReport report = new MigrationReport(4, 0, 0, 4); + when(migrationService.migrerToutesLesOrganisations()).thenReturn(report); + + Response response = resource.migrerOrganisations(); + + assertThat(response.getStatus()).isEqualTo(207); + @SuppressWarnings("unchecked") + Map entity = (Map) response.getEntity(); + assertThat(entity.get("erreurs")).isEqualTo(4); + assertThat(entity.get("succes")).isEqualTo(false); + } + + // ── POST /api/admin/keycloak/migrer-organisations — exception ──────────── + + @Test + @DisplayName("migrerOrganisations — exception du service → 500 avec champ error") + void migrerOrganisations_serviceThrowsException_returns500() throws Exception { + when(migrationService.migrerToutesLesOrganisations()) + .thenThrow(new RuntimeException("Keycloak non joignable")); + + Response response = resource.migrerOrganisations(); + + assertThat(response.getStatus()).isEqualTo(500); + @SuppressWarnings("unchecked") + Map entity = (Map) response.getEntity(); + assertThat(entity).containsKey("error"); + assertThat(entity.get("error")).isEqualTo("Keycloak non joignable"); + } + + @Test + @DisplayName("migrerOrganisations — exception avec message null → 500 avec error=null") + void migrerOrganisations_exceptionWithNullMessage_returns500() throws Exception { + when(migrationService.migrerToutesLesOrganisations()) + .thenThrow(new RuntimeException((String) null)); + + Response response = resource.migrerOrganisations(); + + assertThat(response.getStatus()).isEqualTo(500); + @SuppressWarnings("unchecked") + Map entity = (Map) response.getEntity(); + assertThat(entity).containsKey("error"); + } + + // ── MigrationReport.success() — couverture du record ──────────────────── + + @Test + @DisplayName("MigrationReport.success() → true quand erreurs=0") + void migrationReport_success_trueWhenNoErrors() { + MigrationReport report = new MigrationReport(5, 5, 0, 0); + assertThat(report.success()).isTrue(); + } + + @Test + @DisplayName("MigrationReport.success() → false quand erreurs > 0") + void migrationReport_success_falseWhenErrors() { + MigrationReport report = new MigrationReport(5, 3, 0, 2); + assertThat(report.success()).isFalse(); + } + + @Test + @DisplayName("MigrationReport — accesseurs total/crees/ignores/erreurs") + void migrationReport_accessors_returnCorrectValues() { + MigrationReport report = new MigrationReport(10, 3, 5, 2); + assertThat(report.total()).isEqualTo(10); + assertThat(report.crees()).isEqualTo(3); + assertThat(report.ignores()).isEqualTo(5); + assertThat(report.erreurs()).isEqualTo(2); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/resource/ComptabilitePdfResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/ComptabilitePdfResourceTest.java new file mode 100644 index 0000000..1f3b83c --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/resource/ComptabilitePdfResourceTest.java @@ -0,0 +1,263 @@ +package dev.lions.unionflow.server.resource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import dev.lions.unionflow.server.service.ComptabilitePdfService; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.UUID; + +/** + * Tests unitaires Mockito pour {@link ComptabilitePdfResource}. + * + *

Couvre tous les chemins d'exécution : + *

    + *
  • balance/compteDeResultat/grandLivre avec dates valides → 200 avec PDF
  • + *
  • dates vides/null → parseDateOrStartOfYear/parseDateOrToday → dates par défaut
  • + *
  • dates invalides → BadRequestException (branche catch)
  • + *
  • Content-Disposition et Content-Length présents dans les headers
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests ComptabilitePdfResource") +class ComptabilitePdfResourceTest { + + @Mock + ComptabilitePdfService comptabilitePdfService; + + @InjectMocks + ComptabilitePdfResource resource; + + private static final UUID ORG_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final byte[] DUMMY_PDF = new byte[]{0x25, 0x50, 0x44, 0x46}; // %PDF magic bytes + + // ── GET /api/comptabilite/pdf/organisations/{id}/balance ────────────────── + + @Test + @DisplayName("balance — dates valides → 200 avec PDF et headers Content-Disposition/Content-Length") + void balance_validDates_returns200WithPdfHeaders() { + when(comptabilitePdfService.genererBalance(eq(ORG_ID), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(DUMMY_PDF); + + Response response = resource.balance(ORG_ID, "2026-01-01", "2026-03-31"); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getEntity()).isEqualTo(DUMMY_PDF); + assertThat(response.getHeaderString("Content-Disposition")) + .contains("balance_" + ORG_ID + ".pdf"); + assertThat(response.getHeaderString("Content-Length")) + .isEqualTo(String.valueOf(DUMMY_PDF.length)); + } + + @Test + @DisplayName("balance — dates vides → utilise début d'année et aujourd'hui → 200") + void balance_emptyDates_usesDefaultDates_returns200() { + when(comptabilitePdfService.genererBalance(eq(ORG_ID), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(DUMMY_PDF); + + Response response = resource.balance(ORG_ID, "", ""); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getEntity()).isEqualTo(DUMMY_PDF); + } + + @Test + @DisplayName("balance — dateDebut invalide → BadRequestException") + void balance_invalidDateDebut_throwsBadRequestException() { + assertThatThrownBy(() -> resource.balance(ORG_ID, "not-a-date", "2026-03-31")) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Format de date invalide"); + } + + @Test + @DisplayName("balance — dateFin invalide → BadRequestException") + void balance_invalidDateFin_throwsBadRequestException() { + assertThatThrownBy(() -> resource.balance(ORG_ID, "2026-01-01", "31/12/2026")) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Format de date invalide"); + } + + @Test + @DisplayName("balance — dateDebut null → utilise début d'année → 200") + void balance_nullDateDebut_usesStartOfYear_returns200() { + when(comptabilitePdfService.genererBalance(eq(ORG_ID), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(DUMMY_PDF); + + Response response = resource.balance(ORG_ID, null, null); + + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("balance — PDF vide (0 octets) → 200 avec Content-Length=0") + void balance_emptyPdf_returns200WithZeroContentLength() { + byte[] emptyPdf = new byte[0]; + when(comptabilitePdfService.genererBalance(eq(ORG_ID), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(emptyPdf); + + Response response = resource.balance(ORG_ID, "2026-01-01", "2026-12-31"); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString("Content-Length")).isEqualTo("0"); + } + + // ── GET /api/comptabilite/pdf/organisations/{id}/compte-de-resultat ─────── + + @Test + @DisplayName("compteDeResultat — dates valides → 200 avec nom fichier correct") + void compteDeResultat_validDates_returns200WithFilename() { + when(comptabilitePdfService.genererCompteResultat(eq(ORG_ID), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(DUMMY_PDF); + + Response response = resource.compteDeResultat(ORG_ID, "2026-01-01", "2026-12-31"); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getEntity()).isEqualTo(DUMMY_PDF); + assertThat(response.getHeaderString("Content-Disposition")) + .contains("compte_resultat_" + ORG_ID + ".pdf"); + } + + @Test + @DisplayName("compteDeResultat — dates vides → 200 avec dates par défaut") + void compteDeResultat_emptyDates_usesDefaultDates_returns200() { + when(comptabilitePdfService.genererCompteResultat(eq(ORG_ID), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(DUMMY_PDF); + + Response response = resource.compteDeResultat(ORG_ID, "", ""); + + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("compteDeResultat — dateDebut invalide → BadRequestException") + void compteDeResultat_invalidDateDebut_throwsBadRequestException() { + assertThatThrownBy(() -> resource.compteDeResultat(ORG_ID, "2026/01/01", "2026-12-31")) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("YYYY-MM-DD"); + } + + @Test + @DisplayName("compteDeResultat — dateFin invalide → BadRequestException") + void compteDeResultat_invalidDateFin_throwsBadRequestException() { + assertThatThrownBy(() -> resource.compteDeResultat(ORG_ID, "2026-01-01", "foobar")) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("foobar"); + } + + @Test + @DisplayName("compteDeResultat — dates null → 200 avec dates par défaut") + void compteDeResultat_nullDates_usesDefaultDates_returns200() { + when(comptabilitePdfService.genererCompteResultat(eq(ORG_ID), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(DUMMY_PDF); + + Response response = resource.compteDeResultat(ORG_ID, null, null); + + assertThat(response.getStatus()).isEqualTo(200); + } + + // ── GET /api/comptabilite/pdf/organisations/{id}/grand-livre/{compte} ───── + + @Test + @DisplayName("grandLivre — dates et compte valides → 200 avec nom fichier basé sur numéroCompte") + void grandLivre_validInput_returns200WithFilename() { + String numeroCompte = "601000"; + when(comptabilitePdfService.genererGrandLivre( + eq(ORG_ID), eq(numeroCompte), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(DUMMY_PDF); + + Response response = resource.grandLivre(ORG_ID, numeroCompte, "2026-01-01", "2026-12-31"); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getEntity()).isEqualTo(DUMMY_PDF); + assertThat(response.getHeaderString("Content-Disposition")) + .contains("grand_livre_" + numeroCompte + ".pdf"); + assertThat(response.getHeaderString("Content-Length")) + .isEqualTo(String.valueOf(DUMMY_PDF.length)); + } + + @Test + @DisplayName("grandLivre — dates vides → 200 avec dates par défaut") + void grandLivre_emptyDates_usesDefaultDates_returns200() { + String numeroCompte = "512100"; + when(comptabilitePdfService.genererGrandLivre( + eq(ORG_ID), eq(numeroCompte), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(DUMMY_PDF); + + Response response = resource.grandLivre(ORG_ID, numeroCompte, "", ""); + + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("grandLivre — dateDebut invalide → BadRequestException") + void grandLivre_invalidDateDebut_throwsBadRequestException() { + assertThatThrownBy(() -> resource.grandLivre(ORG_ID, "601000", "99-99-99", "2026-12-31")) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Format de date invalide"); + } + + @Test + @DisplayName("grandLivre — dateFin invalide → BadRequestException") + void grandLivre_invalidDateFin_throwsBadRequestException() { + assertThatThrownBy(() -> resource.grandLivre(ORG_ID, "601000", "2026-01-01", "invalid")) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("invalid"); + } + + @Test + @DisplayName("grandLivre — dates null → 200 avec dates par défaut") + void grandLivre_nullDates_usesDefaultDates_returns200() { + String numeroCompte = "401000"; + when(comptabilitePdfService.genererGrandLivre( + eq(ORG_ID), eq(numeroCompte), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(DUMMY_PDF); + + Response response = resource.grandLivre(ORG_ID, numeroCompte, null, null); + + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("grandLivre — grand PDF (1 Mo) → Content-Length correct") + void grandLivre_largePdf_correctContentLength() { + String numeroCompte = "411000"; + byte[] largePdf = new byte[1024 * 1024]; // 1 MB + when(comptabilitePdfService.genererGrandLivre( + eq(ORG_ID), eq(numeroCompte), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(largePdf); + + Response response = resource.grandLivre(ORG_ID, numeroCompte, "2026-01-01", "2026-12-31"); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString("Content-Length")) + .isEqualTo(String.valueOf(1024 * 1024)); + } + + // ── Vérification parseDateOrStartOfYear — branche : date valide parsée ─── + + @Test + @DisplayName("balance — date de début 2025-06-15 parsée correctement → utilise 2025-06-15 dans l'appel service") + void balance_specificDateDebut_parsedCorrectly() { + when(comptabilitePdfService.genererBalance( + eq(ORG_ID), + eq(LocalDate.of(2025, 6, 15)), + eq(LocalDate.of(2025, 12, 31)))) + .thenReturn(DUMMY_PDF); + + Response response = resource.balance(ORG_ID, "2025-06-15", "2025-12-31"); + + assertThat(response.getStatus()).isEqualTo(200); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/resource/KycResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/KycResourceTest.java new file mode 100644 index 0000000..49c058b --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/resource/KycResourceTest.java @@ -0,0 +1,359 @@ +package dev.lions.unionflow.server.resource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import dev.lions.unionflow.server.api.dto.kyc.KycDossierRequest; +import dev.lions.unionflow.server.api.dto.kyc.KycDossierResponse; +import dev.lions.unionflow.server.api.enums.membre.StatutKyc; +import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite; +import dev.lions.unionflow.server.service.KycAmlService; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.security.Principal; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Tests unitaires Mockito pour {@link KycResource}. + * + *

Couvre tous les chemins d'exécution : + *

    + *
  • soumettre — succès → 201 Created
  • + *
  • getDossierActif — trouvé → 200 OK, non trouvé → 404
  • + *
  • evaluerRisque — succès → 200 OK
  • + *
  • valider — succès → 200 OK
  • + *
  • refuser — succès → 200 OK
  • + *
  • getDossiersEnAttente — liste → 200
  • + *
  • getPep — liste → 200
  • + *
  • getPiecesExpirant — liste → 200
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests KycResource") +class KycResourceTest { + + @Mock + KycAmlService kycAmlService; + + @Mock + SecurityIdentity identity; + + @Mock + Principal principal; + + @InjectMocks + KycResource resource; + + private static final UUID MEMBRE_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID DOSSIER_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID VALIDATEUR_ID = UUID.fromString("00000000-0000-0000-0000-000000000003"); + private static final String OPERATEUR = "admin@unionflow.com"; + + @BeforeEach + void configurePrincipal() { + // Stub du principal utilisé par soumettre(), valider(), refuser(). + // @InjectMocks injecte le mock SecurityIdentity par type dans KycResource. + // Si l'injection automatique échoue (ex : proxy CDI), on la force via réflexion. + try { + Field identityField = KycResource.class.getDeclaredField("identity"); + identityField.setAccessible(true); + if (identityField.get(resource) == null) { + identityField.set(resource, identity); + } + } catch (Exception ignored) { + // @InjectMocks a déjà injecté le mock — rien à faire + } + when(identity.getPrincipal()).thenReturn(principal); + when(principal.getName()).thenReturn(OPERATEUR); + } + + // ── POST /api/kyc/dossiers — soumettre ──────────────────────────────────── + + @Test + @DisplayName("soumettre — dossier valide → 201 Created avec KycDossierResponse") + void soumettre_validRequest_returns201() { + KycDossierRequest request = buildRequest(); + KycDossierResponse response = buildResponse(DOSSIER_ID, StatutKyc.EN_COURS); + when(kycAmlService.soumettreOuMettreAJour(eq(request), eq(OPERATEUR))).thenReturn(response); + + Response result = resource.soumettre(request); + + assertThat(result.getStatus()).isEqualTo(201); + assertThat(result.getEntity()).isSameAs(response); + } + + @Test + @DisplayName("soumettre — second dossier pour même membre → 201 Created (idempotent)") + void soumettre_secondDossier_returns201() { + KycDossierRequest request = buildRequest(); + KycDossierResponse updatedResponse = buildResponse(DOSSIER_ID, StatutKyc.EN_COURS); + when(kycAmlService.soumettreOuMettreAJour(any(), any())).thenReturn(updatedResponse); + + Response result = resource.soumettre(request); + + assertThat(result.getStatus()).isEqualTo(201); + assertThat(result.getEntity()).isNotNull(); + } + + // ── GET /api/kyc/membres/{membreId} — getDossierActif ──────────────────── + + @Test + @DisplayName("getDossierActif — dossier trouvé → 200 OK avec dossier") + void getDossierActif_found_returns200() { + KycDossierResponse dossier = buildResponse(DOSSIER_ID, StatutKyc.EN_COURS); + when(kycAmlService.getDossierActif(MEMBRE_ID)).thenReturn(Optional.of(dossier)); + + Response result = resource.getDossierActif(MEMBRE_ID); + + assertThat(result.getStatus()).isEqualTo(200); + assertThat(result.getEntity()).isSameAs(dossier); + } + + @Test + @DisplayName("getDossierActif — aucun dossier actif → 404 avec message d'erreur") + void getDossierActif_notFound_returns404() { + when(kycAmlService.getDossierActif(MEMBRE_ID)).thenReturn(Optional.empty()); + + Response result = resource.getDossierActif(MEMBRE_ID); + + assertThat(result.getStatus()).isEqualTo(404); + assertThat(result.getEntity()).isNotNull(); + // Le body est une Map{"error": "..."} + @SuppressWarnings("unchecked") + java.util.Map body = (java.util.Map) result.getEntity(); + assertThat(body).containsKey("error"); + } + + @Test + @DisplayName("getDossierActif — dossier vérifié → 200 OK") + void getDossierActif_verified_returns200() { + KycDossierResponse dossier = buildResponse(DOSSIER_ID, StatutKyc.VERIFIE); + when(kycAmlService.getDossierActif(MEMBRE_ID)).thenReturn(Optional.of(dossier)); + + Response result = resource.getDossierActif(MEMBRE_ID); + + assertThat(result.getStatus()).isEqualTo(200); + assertThat(((KycDossierResponse) result.getEntity()).getStatut()).isEqualTo(StatutKyc.VERIFIE); + } + + // ── POST /api/kyc/membres/{membreId}/evaluer-risque ─────────────────────── + + @Test + @DisplayName("evaluerRisque — membre existant → 200 OK avec score calculé") + void evaluerRisque_success_returns200() { + KycDossierResponse dossier = buildResponse(DOSSIER_ID, StatutKyc.EN_COURS); + dossier.setScoreRisque(25); + when(kycAmlService.evaluerRisque(MEMBRE_ID)).thenReturn(dossier); + + Response result = resource.evaluerRisque(MEMBRE_ID); + + assertThat(result.getStatus()).isEqualTo(200); + assertThat(((KycDossierResponse) result.getEntity()).getScoreRisque()).isEqualTo(25); + } + + @Test + @DisplayName("evaluerRisque — PEP → 200 OK avec score élevé") + void evaluerRisque_pepMember_returns200WithHighScore() { + KycDossierResponse dossier = buildResponse(DOSSIER_ID, StatutKyc.EN_COURS); + dossier.setScoreRisque(85); + dossier.setEstPep(true); + when(kycAmlService.evaluerRisque(MEMBRE_ID)).thenReturn(dossier); + + Response result = resource.evaluerRisque(MEMBRE_ID); + + assertThat(result.getStatus()).isEqualTo(200); + KycDossierResponse entity = (KycDossierResponse) result.getEntity(); + assertThat(entity.getScoreRisque()).isEqualTo(85); + assertThat(entity.isEstPep()).isTrue(); + } + + // ── POST /api/kyc/dossiers/{dossierId}/valider ─────────────────────────── + + @Test + @DisplayName("valider — dossier existant → 200 OK avec statut VERIFIE") + void valider_success_returns200() { + KycDossierResponse dossier = buildResponse(DOSSIER_ID, StatutKyc.VERIFIE); + when(kycAmlService.valider(eq(DOSSIER_ID), eq(VALIDATEUR_ID), eq("RAS"), eq(OPERATEUR))) + .thenReturn(dossier); + + Response result = resource.valider(DOSSIER_ID, VALIDATEUR_ID, "RAS"); + + assertThat(result.getStatus()).isEqualTo(200); + assertThat(result.getEntity()).isSameAs(dossier); + } + + @Test + @DisplayName("valider — notes null → 200 OK (notes optionnelles)") + void valider_nullNotes_returns200() { + KycDossierResponse dossier = buildResponse(DOSSIER_ID, StatutKyc.VERIFIE); + when(kycAmlService.valider(eq(DOSSIER_ID), eq(VALIDATEUR_ID), eq(null), eq(OPERATEUR))) + .thenReturn(dossier); + + Response result = resource.valider(DOSSIER_ID, VALIDATEUR_ID, null); + + assertThat(result.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("valider — validateurId null → 200 OK") + void valider_nullValidateurId_returns200() { + KycDossierResponse dossier = buildResponse(DOSSIER_ID, StatutKyc.VERIFIE); + when(kycAmlService.valider(eq(DOSSIER_ID), eq(null), any(), eq(OPERATEUR))) + .thenReturn(dossier); + + Response result = resource.valider(DOSSIER_ID, null, "Validé"); + + assertThat(result.getStatus()).isEqualTo(200); + } + + // ── POST /api/kyc/dossiers/{dossierId}/refuser ─────────────────────────── + + @Test + @DisplayName("refuser — dossier existant → 200 OK avec statut REFUSE") + void refuser_success_returns200() { + KycDossierResponse dossier = buildResponse(DOSSIER_ID, StatutKyc.REFUSE); + when(kycAmlService.refuser(eq(DOSSIER_ID), eq(VALIDATEUR_ID), eq("Documents illisibles"), eq(OPERATEUR))) + .thenReturn(dossier); + + Response result = resource.refuser(DOSSIER_ID, VALIDATEUR_ID, "Documents illisibles"); + + assertThat(result.getStatus()).isEqualTo(200); + assertThat(result.getEntity()).isSameAs(dossier); + } + + @Test + @DisplayName("refuser — motif null → 200 OK") + void refuser_nullMotif_returns200() { + KycDossierResponse dossier = buildResponse(DOSSIER_ID, StatutKyc.REFUSE); + when(kycAmlService.refuser(eq(DOSSIER_ID), any(), eq(null), eq(OPERATEUR))) + .thenReturn(dossier); + + Response result = resource.refuser(DOSSIER_ID, VALIDATEUR_ID, null); + + assertThat(result.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("refuser — validateurId null → 200 OK") + void refuser_nullValidateurId_returns200() { + KycDossierResponse dossier = buildResponse(DOSSIER_ID, StatutKyc.REFUSE); + when(kycAmlService.refuser(eq(DOSSIER_ID), eq(null), any(), eq(OPERATEUR))) + .thenReturn(dossier); + + Response result = resource.refuser(DOSSIER_ID, null, "Fraude suspectée"); + + assertThat(result.getStatus()).isEqualTo(200); + } + + // ── GET /api/kyc/dossiers/en-attente ───────────────────────────────────── + + @Test + @DisplayName("getDossiersEnAttente — liste non vide → retourne tous les dossiers") + void getDossiersEnAttente_multipleResults_returnsAll() { + List dossiers = List.of( + buildResponse(UUID.randomUUID(), StatutKyc.EN_COURS), + buildResponse(UUID.randomUUID(), StatutKyc.EN_COURS) + ); + when(kycAmlService.getDossiersEnAttente()).thenReturn(dossiers); + + List result = resource.getDossiersEnAttente(); + + assertThat(result).hasSize(2); + assertThat(result).allMatch(d -> d.getStatut() == StatutKyc.EN_COURS); + } + + @Test + @DisplayName("getDossiersEnAttente — liste vide → retourne liste vide") + void getDossiersEnAttente_emptyList_returnsEmpty() { + when(kycAmlService.getDossiersEnAttente()).thenReturn(List.of()); + + List result = resource.getDossiersEnAttente(); + + assertThat(result).isEmpty(); + } + + // ── GET /api/kyc/pep ───────────────────────────────────────────────────── + + @Test + @DisplayName("getPep — membres PEP présents → retourne liste") + void getPep_withPepMembers_returnsList() { + KycDossierResponse pep = buildResponse(DOSSIER_ID, StatutKyc.VERIFIE); + pep.setEstPep(true); + when(kycAmlService.getDossiersPep()).thenReturn(List.of(pep)); + + List result = resource.getPep(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).isEstPep()).isTrue(); + } + + @Test + @DisplayName("getPep — aucun PEP → liste vide") + void getPep_noPepMembers_returnsEmpty() { + when(kycAmlService.getDossiersPep()).thenReturn(List.of()); + + List result = resource.getPep(); + + assertThat(result).isEmpty(); + } + + // ── GET /api/kyc/pieces-expirant-bientot ───────────────────────────────── + + @Test + @DisplayName("getPiecesExpirant — pièces expirant → retourne liste") + void getPiecesExpirant_expiringSoon_returnsList() { + List expiring = List.of( + buildResponse(UUID.randomUUID(), StatutKyc.VERIFIE), + buildResponse(UUID.randomUUID(), StatutKyc.EN_COURS) + ); + when(kycAmlService.getPiecesExpirantDansLes30Jours()).thenReturn(expiring); + + List result = resource.getPiecesExpirant(); + + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("getPiecesExpirant — aucune pièce expirant → liste vide") + void getPiecesExpirant_none_returnsEmpty() { + when(kycAmlService.getPiecesExpirantDansLes30Jours()).thenReturn(List.of()); + + List result = resource.getPiecesExpirant(); + + assertThat(result).isEmpty(); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private KycDossierRequest buildRequest() { + return KycDossierRequest.builder() + .membreId(MEMBRE_ID.toString()) + .typePiece(TypePieceIdentite.CNI) + .numeroPiece("CI-2025-123456") + .nationalite("SN") + .estPep(false) + .build(); + } + + private KycDossierResponse buildResponse(UUID id, StatutKyc statut) { + KycDossierResponse dto = new KycDossierResponse(); + dto.setId(id); + dto.setMembreId(MEMBRE_ID); + dto.setStatut(statut); + dto.setScoreRisque(0); + return dto; + } +} diff --git a/src/test/java/dev/lions/unionflow/server/resource/PaiementResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/PaiementResourceTest.java new file mode 100644 index 0000000..8f88373 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/resource/PaiementResourceTest.java @@ -0,0 +1,505 @@ +package dev.lions.unionflow.server.resource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import dev.lions.unionflow.server.api.dto.paiement.request.CreatePaiementRequest; +import dev.lions.unionflow.server.api.dto.paiement.request.DeclarerPaiementManuelRequest; +import dev.lions.unionflow.server.api.dto.paiement.request.InitierPaiementEnLigneRequest; +import dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse; +import dev.lions.unionflow.server.api.dto.paiement.response.PaiementGatewayResponse; +import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse; +import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse; +import dev.lions.unionflow.server.service.PaiementService; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +/** + * Tests unitaires Mockito pour {@link PaiementResource}. + * + *

Couvre tous les endpoints : + *

    + *
  • trouverParId — 200 OK
  • + *
  • trouverParNumeroReference — 200 OK
  • + *
  • listerParMembre — 200 OK (liste vide et non vide)
  • + *
  • getMonHistoriquePaiements — 200 OK avec limite personnalisée
  • + *
  • creerPaiement — 201 Created
  • + *
  • validerPaiement — 200 OK
  • + *
  • annulerPaiement — 200 OK
  • + *
  • initierPaiementEnLigne — 201 Created
  • + *
  • getStatutIntention — 200 OK
  • + *
  • declarerPaiementManuel — 201 Created
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests PaiementResource") +class PaiementResourceTest { + + @Mock + PaiementService paiementService; + + @InjectMocks + PaiementResource resource; + + private static final UUID PAIEMENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID MEMBRE_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID INTENTION_ID = UUID.fromString("00000000-0000-0000-0000-000000000003"); + private static final UUID COTISATION_ID = UUID.fromString("00000000-0000-0000-0000-000000000004"); + private static final String REFERENCE = "PAY-2026-001"; + + // ── GET /api/paiements/{id} ─────────────────────────────────────────────── + + @Test + @DisplayName("trouverParId — paiement existant → 200 OK avec entité") + void trouverParId_found_returns200() { + PaiementResponse paiement = buildPaiementResponse(PAIEMENT_ID, "VALIDE"); + when(paiementService.trouverParId(PAIEMENT_ID)).thenReturn(paiement); + + Response result = resource.trouverParId(PAIEMENT_ID); + + assertThat(result.getStatus()).isEqualTo(200); + assertThat(result.getEntity()).isSameAs(paiement); + } + + @Test + @DisplayName("trouverParId — différents UUIDs → 200 OK avec le bon paiement") + void trouverParId_differentUuid_returnsCorrectPaiement() { + UUID otherId = UUID.randomUUID(); + PaiementResponse paiement = buildPaiementResponse(otherId, "EN_ATTENTE"); + when(paiementService.trouverParId(otherId)).thenReturn(paiement); + + Response result = resource.trouverParId(otherId); + + assertThat(result.getStatus()).isEqualTo(200); + PaiementResponse entity = (PaiementResponse) result.getEntity(); + assertThat(entity.getId()).isEqualTo(otherId); + } + + // ── GET /api/paiements/reference/{numeroReference} ─────────────────────── + + @Test + @DisplayName("trouverParNumeroReference — référence connue → 200 OK") + void trouverParNumeroReference_found_returns200() { + PaiementResponse paiement = buildPaiementResponse(PAIEMENT_ID, "VALIDE"); + paiement.setNumeroReference(REFERENCE); + when(paiementService.trouverParNumeroReference(REFERENCE)).thenReturn(paiement); + + Response result = resource.trouverParNumeroReference(REFERENCE); + + assertThat(result.getStatus()).isEqualTo(200); + assertThat(((PaiementResponse) result.getEntity()).getNumeroReference()).isEqualTo(REFERENCE); + } + + @Test + @DisplayName("trouverParNumeroReference — autre référence → 200 OK") + void trouverParNumeroReference_otherRef_returns200() { + String ref = "PAY-WAVE-ABCD1234"; + PaiementResponse paiement = buildPaiementResponse(PAIEMENT_ID, "EN_ATTENTE"); + when(paiementService.trouverParNumeroReference(ref)).thenReturn(paiement); + + Response result = resource.trouverParNumeroReference(ref); + + assertThat(result.getStatus()).isEqualTo(200); + } + + // ── GET /api/paiements/membre/{membreId} ───────────────────────────────── + + @Test + @DisplayName("listerParMembre — liste non vide → 200 OK avec tous les éléments") + void listerParMembre_withResults_returns200() { + List summaries = List.of( + buildSummaryResponse(UUID.randomUUID(), "VALIDE"), + buildSummaryResponse(UUID.randomUUID(), "EN_ATTENTE") + ); + when(paiementService.listerParMembre(MEMBRE_ID)).thenReturn(summaries); + + Response result = resource.listerParMembre(MEMBRE_ID); + + assertThat(result.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + List entity = (List) result.getEntity(); + assertThat(entity).hasSize(2); + } + + @Test + @DisplayName("listerParMembre — liste vide → 200 OK avec liste vide") + void listerParMembre_emptyList_returns200() { + when(paiementService.listerParMembre(MEMBRE_ID)).thenReturn(List.of()); + + Response result = resource.listerParMembre(MEMBRE_ID); + + assertThat(result.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + List entity = (List) result.getEntity(); + assertThat(entity).isEmpty(); + } + + // ── GET /api/paiements/mon-historique ──────────────────────────────────── + + @Test + @DisplayName("getMonHistoriquePaiements — limit par défaut (20) → 200 OK") + void getMonHistoriquePaiements_defaultLimit_returns200() { + List historique = List.of( + buildSummaryResponse(UUID.randomUUID(), "VALIDE") + ); + when(paiementService.getMonHistoriquePaiements(20)).thenReturn(historique); + + Response result = resource.getMonHistoriquePaiements(20); + + assertThat(result.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + List entity = (List) result.getEntity(); + assertThat(entity).hasSize(1); + } + + @Test + @DisplayName("getMonHistoriquePaiements — limit=5 → 200 OK avec au plus 5 éléments") + void getMonHistoriquePaiements_customLimit_returns200() { + List historique = List.of( + buildSummaryResponse(UUID.randomUUID(), "VALIDE"), + buildSummaryResponse(UUID.randomUUID(), "VALIDE") + ); + when(paiementService.getMonHistoriquePaiements(5)).thenReturn(historique); + + Response result = resource.getMonHistoriquePaiements(5); + + assertThat(result.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("getMonHistoriquePaiements — aucun paiement → 200 OK avec liste vide") + void getMonHistoriquePaiements_noPaiements_returns200Empty() { + when(paiementService.getMonHistoriquePaiements(anyInt())).thenReturn(List.of()); + + Response result = resource.getMonHistoriquePaiements(20); + + assertThat(result.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + List entity = (List) result.getEntity(); + assertThat(entity).isEmpty(); + } + + // ── POST /api/paiements (creerPaiement) ────────────────────────────────── + + @Test + @DisplayName("creerPaiement — requête valide → 201 Created avec paiement") + void creerPaiement_validRequest_returns201() { + CreatePaiementRequest request = CreatePaiementRequest.builder() + .numeroReference(REFERENCE) + .montant(BigDecimal.valueOf(10000)) + .codeDevise("XOF") + .methodePaiement("ESPECES") + .membreId(MEMBRE_ID) + .build(); + + PaiementResponse created = buildPaiementResponse(PAIEMENT_ID, "EN_ATTENTE"); + when(paiementService.creerPaiement(request)).thenReturn(created); + + Response result = resource.creerPaiement(request); + + assertThat(result.getStatus()).isEqualTo(201); + assertThat(result.getEntity()).isSameAs(created); + } + + @Test + @DisplayName("creerPaiement — sans membreId → 201 Created") + void creerPaiement_withoutMembreId_returns201() { + CreatePaiementRequest request = CreatePaiementRequest.builder() + .numeroReference("PAY-EXTERNE-001") + .montant(BigDecimal.valueOf(5000)) + .codeDevise("XOF") + .methodePaiement("VIREMENT") + .build(); + + PaiementResponse created = buildPaiementResponse(PAIEMENT_ID, "EN_ATTENTE"); + when(paiementService.creerPaiement(any(CreatePaiementRequest.class))).thenReturn(created); + + Response result = resource.creerPaiement(request); + + assertThat(result.getStatus()).isEqualTo(201); + } + + // ── POST /api/paiements/{id}/valider ───────────────────────────────────── + + @Test + @DisplayName("validerPaiement — paiement existant → 200 OK avec statut VALIDE") + void validerPaiement_success_returns200() { + PaiementResponse validated = buildPaiementResponse(PAIEMENT_ID, "VALIDE"); + when(paiementService.validerPaiement(PAIEMENT_ID)).thenReturn(validated); + + Response result = resource.validerPaiement(PAIEMENT_ID); + + assertThat(result.getStatus()).isEqualTo(200); + PaiementResponse entity = (PaiementResponse) result.getEntity(); + assertThat(entity.getStatutPaiement()).isEqualTo("VALIDE"); + } + + @Test + @DisplayName("validerPaiement — déjà validé → 200 OK (idempotent)") + void validerPaiement_alreadyValidated_returns200() { + PaiementResponse alreadyValidated = buildPaiementResponse(PAIEMENT_ID, "VALIDE"); + when(paiementService.validerPaiement(PAIEMENT_ID)).thenReturn(alreadyValidated); + + Response result = resource.validerPaiement(PAIEMENT_ID); + + assertThat(result.getStatus()).isEqualTo(200); + } + + // ── POST /api/paiements/{id}/annuler ───────────────────────────────────── + + @Test + @DisplayName("annulerPaiement — paiement annulable → 200 OK avec statut ANNULE") + void annulerPaiement_success_returns200() { + PaiementResponse cancelled = buildPaiementResponse(PAIEMENT_ID, "ANNULE"); + when(paiementService.annulerPaiement(PAIEMENT_ID)).thenReturn(cancelled); + + Response result = resource.annulerPaiement(PAIEMENT_ID); + + assertThat(result.getStatus()).isEqualTo(200); + PaiementResponse entity = (PaiementResponse) result.getEntity(); + assertThat(entity.getStatutPaiement()).isEqualTo("ANNULE"); + } + + @Test + @DisplayName("annulerPaiement — UUID différent → 200 OK") + void annulerPaiement_differentUuid_returns200() { + UUID otherId = UUID.randomUUID(); + PaiementResponse cancelled = buildPaiementResponse(otherId, "ANNULE"); + when(paiementService.annulerPaiement(otherId)).thenReturn(cancelled); + + Response result = resource.annulerPaiement(otherId); + + assertThat(result.getStatus()).isEqualTo(200); + } + + // ── POST /api/paiements/initier-paiement-en-ligne ──────────────────────── + + @Test + @DisplayName("initierPaiementEnLigne — Wave → 201 Created avec waveLaunchUrl") + void initierPaiementEnLigne_wave_returns201() { + InitierPaiementEnLigneRequest request = new InitierPaiementEnLigneRequest( + COTISATION_ID, "WAVE", "771234567"); + + PaiementGatewayResponse gatewayResponse = PaiementGatewayResponse.builder() + .transactionId(PAIEMENT_ID) + .redirectUrl("https://app.wave.com/launch/...") + .waveLaunchUrl("https://app.wave.com/launch/...") + .waveCheckoutSessionId("cos-abc123") + .montant(BigDecimal.valueOf(10000)) + .statut("EN_ATTENTE") + .methodePaiement("WAVE") + .build(); + + when(paiementService.initierPaiementEnLigne(request)).thenReturn(gatewayResponse); + + Response result = resource.initierPaiementEnLigne(request); + + assertThat(result.getStatus()).isEqualTo(201); + PaiementGatewayResponse entity = (PaiementGatewayResponse) result.getEntity(); + assertThat(entity.getMethodePaiement()).isEqualTo("WAVE"); + assertThat(entity.getWaveLaunchUrl()).isNotBlank(); + } + + @Test + @DisplayName("initierPaiementEnLigne — Orange Money → 201 Created") + void initierPaiementEnLigne_orangeMoney_returns201() { + InitierPaiementEnLigneRequest request = new InitierPaiementEnLigneRequest( + COTISATION_ID, "ORANGE_MONEY", "771234567"); + + PaiementGatewayResponse gatewayResponse = PaiementGatewayResponse.builder() + .transactionId(PAIEMENT_ID) + .redirectUrl("https://orange-money.com/pay/" + PAIEMENT_ID) + .montant(BigDecimal.valueOf(5000)) + .statut("EN_ATTENTE") + .methodePaiement("ORANGE_MONEY") + .build(); + + when(paiementService.initierPaiementEnLigne(request)).thenReturn(gatewayResponse); + + Response result = resource.initierPaiementEnLigne(request); + + assertThat(result.getStatus()).isEqualTo(201); + assertThat(((PaiementGatewayResponse) result.getEntity()).getMethodePaiement()) + .isEqualTo("ORANGE_MONEY"); + } + + @Test + @DisplayName("initierPaiementEnLigne — sans numéro de téléphone → 201 Created") + void initierPaiementEnLigne_noPhone_returns201() { + InitierPaiementEnLigneRequest request = new InitierPaiementEnLigneRequest( + COTISATION_ID, "CARTE_BANCAIRE", null); + + PaiementGatewayResponse gatewayResponse = PaiementGatewayResponse.builder() + .transactionId(PAIEMENT_ID) + .redirectUrl("https://payment-gateway.com/pay/" + PAIEMENT_ID) + .statut("EN_ATTENTE") + .methodePaiement("CARTE_BANCAIRE") + .build(); + + when(paiementService.initierPaiementEnLigne(request)).thenReturn(gatewayResponse); + + Response result = resource.initierPaiementEnLigne(request); + + assertThat(result.getStatus()).isEqualTo(201); + } + + // ── GET /api/paiements/statut-intention/{intentionId} ──────────────────── + + @Test + @DisplayName("getStatutIntention — intention en cours → 200 OK avec confirme=false") + void getStatutIntention_enCours_returns200NotConfirmed() { + IntentionStatutResponse statut = IntentionStatutResponse.builder() + .intentionId(INTENTION_ID) + .statut("EN_COURS") + .confirme(false) + .message("En attente de confirmation Wave...") + .build(); + when(paiementService.getStatutIntention(INTENTION_ID)).thenReturn(statut); + + Response result = resource.getStatutIntention(INTENTION_ID); + + assertThat(result.getStatus()).isEqualTo(200); + IntentionStatutResponse entity = (IntentionStatutResponse) result.getEntity(); + assertThat(entity.getStatut()).isEqualTo("EN_COURS"); + assertThat(entity.isConfirme()).isFalse(); + } + + @Test + @DisplayName("getStatutIntention — intention complétée → 200 OK avec confirme=true") + void getStatutIntention_completee_returns200Confirmed() { + IntentionStatutResponse statut = IntentionStatutResponse.builder() + .intentionId(INTENTION_ID) + .statut("COMPLETEE") + .confirme(true) + .waveTransactionId("TCN-XYZ-123") + .message("Paiement confirmé !") + .build(); + when(paiementService.getStatutIntention(INTENTION_ID)).thenReturn(statut); + + Response result = resource.getStatutIntention(INTENTION_ID); + + assertThat(result.getStatus()).isEqualTo(200); + IntentionStatutResponse entity = (IntentionStatutResponse) result.getEntity(); + assertThat(entity.isConfirme()).isTrue(); + assertThat(entity.getWaveTransactionId()).isEqualTo("TCN-XYZ-123"); + } + + @Test + @DisplayName("getStatutIntention — intention expirée → 200 OK avec statut EXPIREE") + void getStatutIntention_expiree_returns200Expired() { + IntentionStatutResponse statut = IntentionStatutResponse.builder() + .intentionId(INTENTION_ID) + .statut("EXPIREE") + .confirme(false) + .message("Session expirée, veuillez recommencer") + .build(); + when(paiementService.getStatutIntention(INTENTION_ID)).thenReturn(statut); + + Response result = resource.getStatutIntention(INTENTION_ID); + + assertThat(result.getStatus()).isEqualTo(200); + assertThat(((IntentionStatutResponse) result.getEntity()).getStatut()).isEqualTo("EXPIREE"); + } + + @Test + @DisplayName("getStatutIntention — intention échouée → 200 OK avec statut ECHOUEE") + void getStatutIntention_echouee_returns200Failed() { + IntentionStatutResponse statut = IntentionStatutResponse.builder() + .intentionId(INTENTION_ID) + .statut("ECHOUEE") + .confirme(false) + .message("Paiement échoué") + .build(); + when(paiementService.getStatutIntention(INTENTION_ID)).thenReturn(statut); + + Response result = resource.getStatutIntention(INTENTION_ID); + + assertThat(result.getStatus()).isEqualTo(200); + } + + // ── POST /api/paiements/declarer-manuel ────────────────────────────────── + + @Test + @DisplayName("declarerPaiementManuel — espèces → 201 Created avec statut EN_ATTENTE_VALIDATION") + void declarerPaiementManuel_cash_returns201() { + DeclarerPaiementManuelRequest request = new DeclarerPaiementManuelRequest( + COTISATION_ID, "ESPECES", null, "Remis en main propre"); + + PaiementResponse created = buildPaiementResponse(PAIEMENT_ID, "EN_ATTENTE_VALIDATION"); + when(paiementService.declarerPaiementManuel(request)).thenReturn(created); + + Response result = resource.declarerPaiementManuel(request); + + assertThat(result.getStatus()).isEqualTo(201); + assertThat(((PaiementResponse) result.getEntity()).getStatutPaiement()) + .isEqualTo("EN_ATTENTE_VALIDATION"); + } + + @Test + @DisplayName("declarerPaiementManuel — virement avec référence → 201 Created") + void declarerPaiementManuel_virement_returns201() { + DeclarerPaiementManuelRequest request = new DeclarerPaiementManuelRequest( + COTISATION_ID, "VIREMENT", "VIR-2026-987654", "Virement mensuel"); + + PaiementResponse created = buildPaiementResponse(PAIEMENT_ID, "EN_ATTENTE_VALIDATION"); + when(paiementService.declarerPaiementManuel(any(DeclarerPaiementManuelRequest.class))) + .thenReturn(created); + + Response result = resource.declarerPaiementManuel(request); + + assertThat(result.getStatus()).isEqualTo(201); + } + + @Test + @DisplayName("declarerPaiementManuel — chèque → 201 Created") + void declarerPaiementManuel_cheque_returns201() { + DeclarerPaiementManuelRequest request = new DeclarerPaiementManuelRequest( + COTISATION_ID, "CHEQUE", "CHQ-00123456", null); + + PaiementResponse created = buildPaiementResponse(PAIEMENT_ID, "EN_ATTENTE_VALIDATION"); + when(paiementService.declarerPaiementManuel(any())).thenReturn(created); + + Response result = resource.declarerPaiementManuel(request); + + assertThat(result.getStatus()).isEqualTo(201); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private PaiementResponse buildPaiementResponse(UUID id, String statut) { + PaiementResponse response = new PaiementResponse(); + response.setId(id); + response.setNumeroReference(REFERENCE); + response.setMontant(BigDecimal.valueOf(10000)); + response.setCodeDevise("XOF"); + response.setMethodePaiement("ESPECES"); + response.setStatutPaiement(statut); + return response; + } + + private PaiementSummaryResponse buildSummaryResponse(UUID id, String statut) { + return new PaiementSummaryResponse( + id, + REFERENCE, + BigDecimal.valueOf(10000), + "XOF", + "Espèces", + statut, + statut, + "success", + null + ); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/resource/PaiementUnifieResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/PaiementUnifieResourceTest.java new file mode 100644 index 0000000..782e028 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/resource/PaiementUnifieResourceTest.java @@ -0,0 +1,385 @@ +package dev.lions.unionflow.server.resource; + +import dev.lions.unionflow.server.api.payment.*; +import dev.lions.unionflow.server.entity.FormuleAbonnement; +import dev.lions.unionflow.server.entity.SouscriptionOrganisation; +import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator; +import dev.lions.unionflow.server.payment.orchestration.PaymentProviderRegistry; +import dev.lions.unionflow.server.repository.SouscriptionOrganisationRepository; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests PaiementUnifieResource") +class PaiementUnifieResourceTest { + + @Mock + PaymentOrchestrator orchestrator; + + @Mock + PaymentProviderRegistry registry; + + @Mock + SouscriptionOrganisationRepository souscriptionRepository; + + @InjectMocks + PaiementUnifieResource resource; + + private CheckoutSession buildSession() { + return new CheckoutSession("ext-123", "https://pay.example.com/checkout", Instant.now(), Map.of()); + } + + private PaiementUnifieResource.PaiementInitierRequest buildRequest(UUID souscriptionId) { + return new PaiementUnifieResource.PaiementInitierRequest( + new BigDecimal("5000"), + "XOF", + "+22178000001", + "test@example.com", + "REF-001", + "https://success.example.com", + "https://cancel.example.com", + souscriptionId + ); + } + + // ── POST /api/paiements/initier ─────────────────────────────────────────── + + @Nested + @DisplayName("POST /initier") + class Initier { + + @Test + @DisplayName("retourne 200 quand paiement initié avec succès — provider explicite") + void initier_avecProviderExplicite_returns200() throws Exception { + PaiementUnifieResource.PaiementInitierRequest req = buildRequest(null); + CheckoutSession session = buildSession(); + when(orchestrator.initierPaiement(any(CheckoutRequest.class), eq("WAVE"))).thenReturn(session); + + Response r = resource.initier("WAVE", req); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(session); + } + + @Test + @DisplayName("retourne 200 quand paiement initié — provider null (provider par défaut)") + void initier_sansProv_returns200() throws Exception { + PaiementUnifieResource.PaiementInitierRequest req = buildRequest(null); + CheckoutSession session = buildSession(); + when(orchestrator.initierPaiement(any(CheckoutRequest.class), isNull())).thenReturn(session); + + Response r = resource.initier(null, req); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("retourne 200 quand devise null — XOF utilisé par défaut") + void initier_deviseNull_utilisesXOF_returns200() throws Exception { + PaiementUnifieResource.PaiementInitierRequest req = new PaiementUnifieResource.PaiementInitierRequest( + new BigDecimal("3000"), null, "+22178000001", "test@example.com", + "REF-002", null, null, null + ); + CheckoutSession session = buildSession(); + when(orchestrator.initierPaiement( + argThat(c -> "XOF".equals(c.currency())), isNull() + )).thenReturn(session); + + Response r = resource.initier(null, req); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("retourne 200 — provider résolu depuis formule quand souscriptionId fourni") + void initier_avecSouscriptionId_providerDepuisFormule_returns200() throws Exception { + UUID souscriptionId = UUID.randomUUID(); + PaiementUnifieResource.PaiementInitierRequest req = buildRequest(souscriptionId); + + FormuleAbonnement formule = mock(FormuleAbonnement.class); + when(formule.getProviderDefaut()).thenReturn("ORANGE_MONEY"); + + SouscriptionOrganisation souscription = mock(SouscriptionOrganisation.class); + when(souscription.getFormule()).thenReturn(formule); + when(souscriptionRepository.findByIdOptional(souscriptionId)).thenReturn(Optional.of(souscription)); + + CheckoutSession session = buildSession(); + when(orchestrator.initierPaiement(any(CheckoutRequest.class), eq("ORANGE_MONEY"))).thenReturn(session); + + Response r = resource.initier("WAVE", req); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("retourne 200 — fallback sur provider requête si formule sans providerDefaut") + void initier_formuleSansProvider_fallbackQueryParam() throws Exception { + UUID souscriptionId = UUID.randomUUID(); + PaiementUnifieResource.PaiementInitierRequest req = buildRequest(souscriptionId); + + FormuleAbonnement formule = mock(FormuleAbonnement.class); + when(formule.getProviderDefaut()).thenReturn(null); + + SouscriptionOrganisation souscription = mock(SouscriptionOrganisation.class); + when(souscription.getFormule()).thenReturn(formule); + when(souscriptionRepository.findByIdOptional(souscriptionId)).thenReturn(Optional.of(souscription)); + + CheckoutSession session = buildSession(); + when(orchestrator.initierPaiement(any(CheckoutRequest.class), eq("WAVE"))).thenReturn(session); + + Response r = resource.initier("WAVE", req); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("retourne 200 — souscription introuvable, fallback provider explicite") + void initier_souscriptionAbsente_fallbackProvider() throws Exception { + UUID souscriptionId = UUID.randomUUID(); + PaiementUnifieResource.PaiementInitierRequest req = buildRequest(souscriptionId); + + when(souscriptionRepository.findByIdOptional(souscriptionId)).thenReturn(Optional.empty()); + + CheckoutSession session = buildSession(); + when(orchestrator.initierPaiement(any(CheckoutRequest.class), eq("WAVE"))).thenReturn(session); + + Response r = resource.initier("WAVE", req); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("retourne 200 — providerDefaut blank, fallback sur query param") + void initier_providerDefautBlank_fallback() throws Exception { + UUID souscriptionId = UUID.randomUUID(); + PaiementUnifieResource.PaiementInitierRequest req = buildRequest(souscriptionId); + + FormuleAbonnement formule = mock(FormuleAbonnement.class); + when(formule.getProviderDefaut()).thenReturn(" "); + + SouscriptionOrganisation souscription = mock(SouscriptionOrganisation.class); + when(souscription.getFormule()).thenReturn(formule); + when(souscriptionRepository.findByIdOptional(souscriptionId)).thenReturn(Optional.of(souscription)); + + CheckoutSession session = buildSession(); + when(orchestrator.initierPaiement(any(CheckoutRequest.class), eq("WAVE"))).thenReturn(session); + + Response r = resource.initier("WAVE", req); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("retourne statut HTTP de PaymentException quand provider échoue") + void initier_paymentException_returnsProviderStatus() throws Exception { + PaiementUnifieResource.PaiementInitierRequest req = buildRequest(null); + when(orchestrator.initierPaiement(any(), any())) + .thenThrow(new PaymentException("WAVE", "Compte insuffisant", 402)); + + Response r = resource.initier("WAVE", req); + + assertThat(r.getStatus()).isEqualTo(402); + @SuppressWarnings("unchecked") + Map body = (Map) r.getEntity(); + assertThat(body).containsKey("error"); + assertThat(body).containsEntry("provider", "WAVE"); + } + + @Test + @DisplayName("retourne 503 quand aucun provider disponible") + void initier_aucunProvider_returns503() throws Exception { + PaiementUnifieResource.PaiementInitierRequest req = buildRequest(null); + when(orchestrator.initierPaiement(any(), any())) + .thenThrow(new PaymentException("NONE", "Aucun provider disponible", 503)); + + Response r = resource.initier(null, req); + + assertThat(r.getStatus()).isEqualTo(503); + } + } + + // ── POST /api/paiements/webhook/{provider} ──────────────────────────────── + + @Nested + @DisplayName("POST /webhook/{provider}") + class Webhook { + + private HttpHeaders buildHeaders(Map> headerMap) { + HttpHeaders headers = mock(HttpHeaders.class); + MultivaluedHashMap multivalued = new MultivaluedHashMap<>(); + headerMap.forEach(multivalued::put); + when(headers.getRequestHeaders()).thenReturn(multivalued); + return headers; + } + + @Test + @DisplayName("retourne 200 quand webhook traité avec succès") + void webhook_success_returns200() throws Exception { + PaymentProvider provider = mock(PaymentProvider.class); + PaymentEvent event = new PaymentEvent("ext-1", "REF-001", PaymentStatus.SUCCESS, + new BigDecimal("5000"), "TXN-001", Instant.now()); + + when(registry.get("WAVE")).thenReturn(provider); + when(provider.processWebhook(anyString(), anyMap())).thenReturn(event); + doNothing().when(orchestrator).handleEvent(event); + + HttpHeaders headers = buildHeaders(Map.of("X-Wave-Signature", List.of("sha256=abc"))); + Response r = resource.webhook("WAVE", "{}", headers); + + assertThat(r.getStatus()).isEqualTo(200); + verify(orchestrator).handleEvent(event); + } + + @Test + @DisplayName("retourne 200 quand headers vides") + void webhook_headersVides_returns200() throws Exception { + PaymentProvider provider = mock(PaymentProvider.class); + PaymentEvent event = new PaymentEvent("ext-2", "REF-002", PaymentStatus.SUCCESS, + new BigDecimal("1000"), "TXN-002", Instant.now()); + + when(registry.get("ORANGE")).thenReturn(provider); + when(provider.processWebhook(anyString(), anyMap())).thenReturn(event); + doNothing().when(orchestrator).handleEvent(event); + + HttpHeaders headers = buildHeaders(Map.of()); + Response r = resource.webhook("ORANGE", "body", headers); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("retourne 200 quand header avec liste non vide") + void webhook_headerListeNonVide_returns200() throws Exception { + PaymentProvider provider = mock(PaymentProvider.class); + PaymentEvent event = new PaymentEvent("ext-3", "REF-003", PaymentStatus.SUCCESS, + new BigDecimal("2000"), "TXN-003", Instant.now()); + + when(registry.get("MTN")).thenReturn(provider); + when(provider.processWebhook(anyString(), anyMap())).thenReturn(event); + doNothing().when(orchestrator).handleEvent(event); + + HttpHeaders headers = buildHeaders(Map.of("Authorization", Arrays.asList("Bearer token123"))); + Response r = resource.webhook("MTN", "rawbody", headers); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("retourne 404 quand provider inconnu (UnsupportedOperationException)") + void webhook_providerInconnu_returns404() throws Exception { + when(registry.get("UNKNOWN")).thenThrow(new UnsupportedOperationException("Provider inconnu")); + + HttpHeaders headers = buildHeaders(Map.of()); + Response r = resource.webhook("UNKNOWN", "{}", headers); + + assertThat(r.getStatus()).isEqualTo(404); + @SuppressWarnings("unchecked") + Map body = (Map) r.getEntity(); + assertThat(body).containsKey("error"); + } + + @Test + @DisplayName("retourne statut HTTP de PaymentException quand signature invalide") + void webhook_signatureInvalide_returnsProviderStatus() throws Exception { + PaymentProvider provider = mock(PaymentProvider.class); + when(registry.get("WAVE")).thenReturn(provider); + when(provider.processWebhook(anyString(), anyMap())) + .thenThrow(new PaymentException("WAVE", "Signature invalide", 400)); + + HttpHeaders headers = buildHeaders(Map.of()); + Response r = resource.webhook("WAVE", "tampered", headers); + + assertThat(r.getStatus()).isEqualTo(400); + } + + @Test + @DisplayName("retourne 401 quand PaymentException avec statut 401") + void webhook_paymentException401_returns401() throws Exception { + PaymentProvider provider = mock(PaymentProvider.class); + when(registry.get("WAVE")).thenReturn(provider); + when(provider.processWebhook(anyString(), anyMap())) + .thenThrow(new PaymentException("WAVE", "Non autorisé", 401)); + + HttpHeaders headers = buildHeaders(Map.of()); + Response r = resource.webhook("WAVE", "body", headers); + + assertThat(r.getStatus()).isEqualTo(401); + } + + @Test + @DisplayName("provider code converti en majuscules") + void webhook_providerCodeLowercase_convertedToUppercase() throws Exception { + PaymentProvider provider = mock(PaymentProvider.class); + PaymentEvent event = new PaymentEvent("ext-4", "REF-004", PaymentStatus.SUCCESS, + new BigDecimal("500"), "TXN-004", Instant.now()); + + when(registry.get("WAVE")).thenReturn(provider); + when(provider.processWebhook(anyString(), anyMap())).thenReturn(event); + doNothing().when(orchestrator).handleEvent(event); + + HttpHeaders headers = buildHeaders(Map.of()); + Response r = resource.webhook("wave", "{}", headers); + + assertThat(r.getStatus()).isEqualTo(200); + verify(registry).get("WAVE"); + } + } + + // ── GET /api/paiements/providers ───────────────────────────────────────── + + @Nested + @DisplayName("GET /providers") + class GetProviders { + + @Test + @DisplayName("retourne la liste des providers disponibles") + void getProviders_retourneListeProviders() { + when(registry.getAvailableCodes()).thenReturn(List.of("WAVE", "ORANGE_MONEY", "MTN_MOMO")); + + List result = resource.getProviders(); + + assertThat(result).containsExactly("WAVE", "ORANGE_MONEY", "MTN_MOMO"); + } + + @Test + @DisplayName("retourne liste vide quand aucun provider enregistré") + void getProviders_aucunProvider_listeVide() { + when(registry.getAvailableCodes()).thenReturn(List.of()); + + List result = resource.getProviders(); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("retourne liste avec un seul provider") + void getProviders_unSeulProvider() { + when(registry.getAvailableCodes()).thenReturn(List.of("WAVE")); + + List result = resource.getProviders(); + + assertThat(result).hasSize(1).containsExactly("WAVE"); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/resource/SouscriptionResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/SouscriptionResourceTest.java new file mode 100644 index 0000000..4626f4a --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/resource/SouscriptionResourceTest.java @@ -0,0 +1,562 @@ +package dev.lions.unionflow.server.resource; + +import dev.lions.unionflow.server.api.dto.souscription.FormuleAbonnementResponse; +import dev.lions.unionflow.server.api.dto.souscription.SouscriptionDemandeRequest; +import dev.lions.unionflow.server.api.dto.souscription.SouscriptionStatutResponse; +import dev.lions.unionflow.server.service.SouscriptionService; +import dev.lions.unionflow.server.service.support.SecuriteHelper; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests unitaires purs (sans @QuarkusTest) pour SouscriptionResource. + * + *

Complète SouscriptionResourceMockTest (@QuarkusTest) en couvrant + * directement les branches de logique interne via MockitoExtension, + * notamment la branche null du body de rejeter() et d'autres chemins + * non accessibles via RestAssured. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests SouscriptionResource (Mockito pur)") +class SouscriptionResourceTest { + + @Mock + SouscriptionService souscriptionService; + + @Mock + SecuriteHelper securiteHelper; + + @InjectMocks + SouscriptionResource resource; + + private SouscriptionStatutResponse buildStatut(String statut) { + SouscriptionStatutResponse r = new SouscriptionStatutResponse(); + r.setSouscriptionId(UUID.randomUUID().toString()); + r.setStatutValidation(statut); + r.setStatutLibelle(statut); + r.setMontantTotal(new BigDecimal("10000")); + r.setOrganisationId(UUID.randomUUID().toString()); + r.setOrganisationNom("TestOrg"); + return r; + } + + // ── GET /formules ───────────────────────────────────────────────────────── + + @Nested + @DisplayName("GET /formules") + class GetFormules { + + @Test + @DisplayName("retourne 200 avec la liste des formules") + void getFormules_listeNonVide_returns200() { + FormuleAbonnementResponse f = new FormuleAbonnementResponse(); + f.setCode("BASIC"); + when(souscriptionService.getFormules()).thenReturn(List.of(f)); + + Response r = resource.getFormules(); + + assertThat(r.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + List body = (List) r.getEntity(); + assertThat(body).hasSize(1); + } + + @Test + @DisplayName("retourne 200 avec liste vide") + void getFormules_listeVide_returns200() { + when(souscriptionService.getFormules()).thenReturn(List.of()); + + Response r = resource.getFormules(); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("appelle souscriptionService.getFormules() exactement une fois") + void getFormules_appelleServiceUneFois() { + when(souscriptionService.getFormules()).thenReturn(List.of()); + + resource.getFormules(); + + verify(souscriptionService, times(1)).getFormules(); + verifyNoInteractions(securiteHelper); + } + } + + // ── GET /ma-souscription ────────────────────────────────────────────────── + + @Nested + @DisplayName("GET /ma-souscription") + class GetMaSouscription { + + @Test + @DisplayName("retourne 200 avec la souscription active") + void getMaSouscription_success_returns200() { + SouscriptionStatutResponse statut = buildStatut("ACTIVE"); + when(souscriptionService.getMaSouscription()).thenReturn(statut); + + Response r = resource.getMaSouscription(); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(statut); + } + + @Test + @DisplayName("propage NotFoundException si aucune souscription") + void getMaSouscription_aucuneSouscription_propagatesNotFound() { + when(souscriptionService.getMaSouscription()) + .thenThrow(new NotFoundException("Aucune souscription trouvée")); + + assertThatThrownBy(() -> resource.getMaSouscription()) + .isInstanceOf(NotFoundException.class); + } + } + + // ── POST /demande ───────────────────────────────────────────────────────── + + @Nested + @DisplayName("POST /demande") + class CreerDemande { + + @Test + @DisplayName("retourne 201 avec la réponse du service") + void creerDemande_success_returns201() { + SouscriptionDemandeRequest req = mock(SouscriptionDemandeRequest.class); + SouscriptionStatutResponse statut = buildStatut("EN_ATTENTE_PAIEMENT"); + when(souscriptionService.creerDemande(req)).thenReturn(statut); + + Response r = resource.creerDemande(req); + + assertThat(r.getStatus()).isEqualTo(201); + assertThat(r.getEntity()).isEqualTo(statut); + } + + @Test + @DisplayName("propage BadRequestException si souscription déjà existante") + void creerDemande_dejaExistante_propagates() { + SouscriptionDemandeRequest req = mock(SouscriptionDemandeRequest.class); + when(souscriptionService.creerDemande(req)) + .thenThrow(new BadRequestException("Souscription déjà existante")); + + assertThatThrownBy(() -> resource.creerDemande(req)) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("propage NotFoundException si organisation inconnue") + void creerDemande_orgInconnue_propagates() { + SouscriptionDemandeRequest req = mock(SouscriptionDemandeRequest.class); + when(souscriptionService.creerDemande(req)) + .thenThrow(new NotFoundException("Organisation introuvable")); + + assertThatThrownBy(() -> resource.creerDemande(req)) + .isInstanceOf(NotFoundException.class); + } + } + + // ── POST /{id}/initier-paiement ─────────────────────────────────────────── + + @Nested + @DisplayName("POST /{id}/initier-paiement") + class InitierPaiement { + + @Test + @DisplayName("retourne 200 avec waveLaunchUrl") + void initierPaiement_success_returns200() { + UUID id = UUID.randomUUID(); + SouscriptionStatutResponse statut = buildStatut("PAIEMENT_INITIE"); + statut.setWaveLaunchUrl("https://pay.wave.com/abc"); + when(souscriptionService.initierPaiementWave(id)).thenReturn(statut); + + Response r = resource.initierPaiement(id); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(statut); + } + + @Test + @DisplayName("propage NotFoundException si souscription introuvable") + void initierPaiement_souscriptionInconnue_propagates() { + UUID id = UUID.randomUUID(); + when(souscriptionService.initierPaiementWave(id)) + .thenThrow(new NotFoundException("Souscription introuvable: " + id)); + + assertThatThrownBy(() -> resource.initierPaiement(id)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("propage BadRequestException si statut invalide pour paiement") + void initierPaiement_statutInvalide_propagates() { + UUID id = UUID.randomUUID(); + when(souscriptionService.initierPaiementWave(id)) + .thenThrow(new BadRequestException("Statut invalide pour initier paiement")); + + assertThatThrownBy(() -> resource.initierPaiement(id)) + .isInstanceOf(BadRequestException.class); + } + } + + // ── POST /confirmer-paiement ────────────────────────────────────────────── + + @Nested + @DisplayName("POST /confirmer-paiement") + class ConfirmerPaiement { + + @Test + @DisplayName("retourne 200 avec message de confirmation quand id et waveId fournis") + void confirmerPaiement_success_returns200() { + UUID id = UUID.randomUUID(); + doNothing().when(souscriptionService).confirmerPaiement(eq(id), eq("WAVE-123")); + + Response r = resource.confirmerPaiement(id, "WAVE-123"); + + assertThat(r.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + Map body = (Map) r.getEntity(); + assertThat(body).containsEntry("message", "Paiement confirmé — compte activé"); + } + + @Test + @DisplayName("retourne 200 quand waveId est null (optionnel)") + void confirmerPaiement_waveIdNull_returns200() { + UUID id = UUID.randomUUID(); + doNothing().when(souscriptionService).confirmerPaiement(eq(id), isNull()); + + Response r = resource.confirmerPaiement(id, null); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("lève BadRequestException quand id est null") + void confirmerPaiement_idNull_throwsBadRequest() { + assertThatThrownBy(() -> resource.confirmerPaiement(null, "WAVE-123")) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("'id' est obligatoire"); + } + + @Test + @DisplayName("propage NotFoundException si souscription introuvable") + void confirmerPaiement_souscriptionInconnue_propagates() { + UUID id = UUID.randomUUID(); + doThrow(new NotFoundException("Souscription introuvable: " + id)) + .when(souscriptionService).confirmerPaiement(eq(id), anyString()); + + assertThatThrownBy(() -> resource.confirmerPaiement(id, "WAVE-TXN")) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("propage BadRequestException si service signale statut invalide") + void confirmerPaiement_statutInvalide_propagates() { + UUID id = UUID.randomUUID(); + doThrow(new BadRequestException("Impossible de confirmer depuis REJETEE")) + .when(souscriptionService).confirmerPaiement(eq(id), anyString()); + + assertThatThrownBy(() -> resource.confirmerPaiement(id, "WAVE-BAD")) + .isInstanceOf(BadRequestException.class); + } + } + + // ── GET /admin/toutes ───────────────────────────────────────────────────── + + @Nested + @DisplayName("GET /admin/toutes") + class GetSouscriptionsToutes { + + @Test + @DisplayName("retourne 200 avec la liste complète — sans filtre") + void getSouscriptionsToutes_sansFiltreOrg_returns200() { + when(souscriptionService.listerToutes(isNull(), eq(0), eq(1000))) + .thenReturn(List.of(buildStatut("ACTIVE"), buildStatut("EN_ATTENTE"))); + + Response r = resource.getSouscriptionsToutes(null, 0, 1000); + + assertThat(r.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + List body = (List) r.getEntity(); + assertThat(body).hasSize(2); + } + + @Test + @DisplayName("retourne 200 avec filtre organisationId") + void getSouscriptionsToutes_avecFiltreOrg_returns200() { + UUID orgId = UUID.randomUUID(); + when(souscriptionService.listerToutes(eq(orgId), eq(0), eq(20))) + .thenReturn(List.of(buildStatut("ACTIVE"))); + + Response r = resource.getSouscriptionsToutes(orgId, 0, 20); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("retourne 200 avec liste vide") + void getSouscriptionsToutes_listeVide_returns200() { + when(souscriptionService.listerToutes(any(), anyInt(), anyInt())).thenReturn(List.of()); + + Response r = resource.getSouscriptionsToutes(null, 0, 50); + + assertThat(r.getStatus()).isEqualTo(200); + } + } + + // ── GET /admin/organisation/{organisationId}/active ─────────────────────── + + @Nested + @DisplayName("GET /admin/organisation/{organisationId}/active") + class GetActiveParOrganisation { + + @Test + @DisplayName("retourne 200 quand souscription active trouvée") + void getActiveParOrganisation_found_returns200() { + UUID orgId = UUID.randomUUID(); + SouscriptionStatutResponse statut = buildStatut("ACTIVE"); + when(souscriptionService.obtenirActiveParOrganisation(orgId)).thenReturn(statut); + + Response r = resource.getActiveParOrganisation(orgId); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(statut); + } + + @Test + @DisplayName("retourne 404 quand service retourne null (aucune souscription active)") + void getActiveParOrganisation_null_returns404() { + UUID orgId = UUID.randomUUID(); + when(souscriptionService.obtenirActiveParOrganisation(orgId)).thenReturn(null); + + Response r = resource.getActiveParOrganisation(orgId); + + assertThat(r.getStatus()).isEqualTo(404); + assertThat(r.getEntity()).isNull(); + } + } + + // ── GET /admin/en-attente ───────────────────────────────────────────────── + + @Nested + @DisplayName("GET /admin/en-attente") + class GetSouscriptionsEnAttente { + + @Test + @DisplayName("retourne 200 avec les souscriptions en attente") + void getSouscriptionsEnAttente_avecEnAttente_returns200() { + when(souscriptionService.getSouscriptionsEnAttenteValidation()) + .thenReturn(List.of(buildStatut("PAIEMENT_CONFIRME"))); + + Response r = resource.getSouscriptionsEnAttente(); + + assertThat(r.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + List body = (List) r.getEntity(); + assertThat(body).hasSize(1); + } + + @Test + @DisplayName("retourne 200 avec liste vide si aucune en attente") + void getSouscriptionsEnAttente_aucune_returns200() { + when(souscriptionService.getSouscriptionsEnAttenteValidation()).thenReturn(List.of()); + + Response r = resource.getSouscriptionsEnAttente(); + + assertThat(r.getStatus()).isEqualTo(200); + } + } + + // ── POST /admin/{id}/approuver ──────────────────────────────────────────── + + @Nested + @DisplayName("POST /admin/{id}/approuver") + class Approuver { + + @Test + @DisplayName("retourne 200 avec message de confirmation après approbation réussie") + void approuver_success_returns200() { + UUID id = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doNothing().when(souscriptionService).approuver(id, superAdminId); + + Response r = resource.approuver(id); + + assertThat(r.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + Map body = (Map) r.getEntity(); + assertThat(body).containsEntry("message", "Souscription approuvée — compte activé"); + } + + @Test + @DisplayName("appelle resolveMembreId pour récupérer l'identité du superAdmin") + void approuver_appelleResolveMembreId() { + UUID id = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + + resource.approuver(id); + + verify(securiteHelper).resolveMembreId(); + verify(souscriptionService).approuver(id, superAdminId); + } + + @Test + @DisplayName("propage NotFoundException si souscription introuvable") + void approuver_souscriptionInconnue_propagates() { + UUID id = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doThrow(new NotFoundException("Souscription introuvable")) + .when(souscriptionService).approuver(id, superAdminId); + + assertThatThrownBy(() -> resource.approuver(id)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("propage BadRequestException si statut non approuvable") + void approuver_statutNonApprouvable_propagates() { + UUID id = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doThrow(new BadRequestException("Statut non approuvable")) + .when(souscriptionService).approuver(id, superAdminId); + + assertThatThrownBy(() -> resource.approuver(id)) + .isInstanceOf(BadRequestException.class); + } + } + + // ── POST /admin/{id}/rejeter ────────────────────────────────────────────── + + @Nested + @DisplayName("POST /admin/{id}/rejeter") + class Rejeter { + + @Test + @DisplayName("retourne 200 avec message de rejet quand commentaire présent") + void rejeter_success_returns200() { + UUID id = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doNothing().when(souscriptionService).rejeter(eq(id), eq(superAdminId), anyString()); + + Response r = resource.rejeter(id, Map.of("commentaire", "Documents manquants")); + + assertThat(r.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + Map body = (Map) r.getEntity(); + assertThat(body).containsEntry("message", "Souscription rejetée"); + } + + @Test + @DisplayName("lève BadRequestException quand commentaire absent (body null)") + void rejeter_bodyNull_throwsBadRequest() { + UUID id = UUID.randomUUID(); + + assertThatThrownBy(() -> resource.rejeter(id, null)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("'commentaire' est obligatoire"); + } + + @Test + @DisplayName("lève BadRequestException quand commentaire absent du map") + void rejeter_commentaireAbsent_throwsBadRequest() { + UUID id = UUID.randomUUID(); + + assertThatThrownBy(() -> resource.rejeter(id, Map.of())) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("'commentaire' est obligatoire"); + } + + @Test + @DisplayName("lève BadRequestException quand commentaire blank") + void rejeter_commentaireBlank_throwsBadRequest() { + UUID id = UUID.randomUUID(); + + assertThatThrownBy(() -> resource.rejeter(id, Map.of("commentaire", " "))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("'commentaire' est obligatoire"); + } + + @Test + @DisplayName("lève BadRequestException quand commentaire vide (string vide)") + void rejeter_commentaireVide_throwsBadRequest() { + UUID id = UUID.randomUUID(); + + assertThatThrownBy(() -> resource.rejeter(id, Map.of("commentaire", ""))) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("lève BadRequestException quand body contient commentaire null (Map.of ne supporte pas null values — utiliser HashMap)") + void rejeter_commentaireNullDansMap_throwsBadRequest() { + UUID id = UUID.randomUUID(); + Map bodyAvecNull = new HashMap<>(); + bodyAvecNull.put("commentaire", null); + + assertThatThrownBy(() -> resource.rejeter(id, bodyAvecNull)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("'commentaire' est obligatoire"); + } + + @Test + @DisplayName("appelle resolveMembreId et transmet le commentaire au service") + void rejeter_passesCommentaireToService() { + UUID id = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doNothing().when(souscriptionService).rejeter(any(), any(), any()); + + resource.rejeter(id, Map.of("commentaire", "Motif valide")); + + verify(securiteHelper).resolveMembreId(); + verify(souscriptionService).rejeter(eq(id), eq(superAdminId), eq("Motif valide")); + } + + @Test + @DisplayName("propage NotFoundException si souscription introuvable") + void rejeter_souscriptionInconnue_propagates() { + UUID id = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doThrow(new NotFoundException("Souscription introuvable")) + .when(souscriptionService).rejeter(any(), any(), any()); + + assertThatThrownBy(() -> resource.rejeter(id, Map.of("commentaire", "Motif"))) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("propage BadRequestException si souscription déjà en état terminal") + void rejeter_etatTerminal_propagates() { + UUID id = UUID.randomUUID(); + UUID superAdminId = UUID.randomUUID(); + when(securiteHelper.resolveMembreId()).thenReturn(superAdminId); + doThrow(new BadRequestException("État terminal")) + .when(souscriptionService).rejeter(any(), any(), any()); + + assertThatThrownBy(() -> resource.rejeter(id, Map.of("commentaire", "Motif"))) + .isInstanceOf(BadRequestException.class); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/resource/mutuelle/ParametresFinanciersResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/mutuelle/ParametresFinanciersResourceTest.java new file mode 100644 index 0000000..d0e9314 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/resource/mutuelle/ParametresFinanciersResourceTest.java @@ -0,0 +1,238 @@ +package dev.lions.unionflow.server.resource.mutuelle; + +import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellRequest; +import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellResponse; +import dev.lions.unionflow.server.service.mutuelle.InteretsEpargneService; +import dev.lions.unionflow.server.service.mutuelle.ParametresFinanciersService; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests ParametresFinanciersResource") +class ParametresFinanciersResourceTest { + + @Mock + ParametresFinanciersService parametresService; + + @Mock + InteretsEpargneService interetsService; + + @InjectMocks + ParametresFinanciersResource resource; + + private ParametresFinanciersMutuellResponse buildResponse() { + return mock(ParametresFinanciersMutuellResponse.class); + } + + private ParametresFinanciersMutuellRequest buildRequest() { + return mock(ParametresFinanciersMutuellRequest.class); + } + + // ── GET /{orgId} ────────────────────────────────────────────────────────── + + @Nested + @DisplayName("GET /{orgId}") + class GetByOrganisation { + + @Test + @DisplayName("retourne 200 avec les paramètres quand organisation trouvée") + void getByOrganisation_success_returns200() { + UUID orgId = UUID.randomUUID(); + ParametresFinanciersMutuellResponse resp = buildResponse(); + when(parametresService.getByOrganisation(orgId)).thenReturn(resp); + + Response r = resource.getByOrganisation(orgId); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(resp); + verify(parametresService).getByOrganisation(orgId); + } + + @Test + @DisplayName("retourne 200 avec entité null quand service retourne null (aucun paramètre)") + void getByOrganisation_aucunParametre_returns200WithNull() { + UUID orgId = UUID.randomUUID(); + when(parametresService.getByOrganisation(orgId)).thenReturn(null); + + Response r = resource.getByOrganisation(orgId); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isNull(); + } + + @Test + @DisplayName("propage RuntimeException levée par le service") + void getByOrganisation_serviceException_propagates() { + UUID orgId = UUID.randomUUID(); + when(parametresService.getByOrganisation(orgId)) + .thenThrow(new RuntimeException("Erreur base de données")); + + org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> resource.getByOrganisation(orgId)); + } + + @Test + @DisplayName("appelle le service avec l'UUID exact fourni") + void getByOrganisation_passesCorrectUUID() { + UUID orgId = UUID.fromString("11111111-2222-3333-4444-555555555555"); + when(parametresService.getByOrganisation(orgId)).thenReturn(buildResponse()); + + resource.getByOrganisation(orgId); + + verify(parametresService).getByOrganisation(eq(orgId)); + verifyNoInteractions(interetsService); + } + } + + // ── POST / ──────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("POST /") + class CreerOuMettreAJour { + + @Test + @DisplayName("retourne 200 après création ou mise à jour réussie") + void creerOuMettrAJour_success_returns200() { + ParametresFinanciersMutuellRequest req = buildRequest(); + ParametresFinanciersMutuellResponse resp = buildResponse(); + when(parametresService.creerOuMettrAJour(req)).thenReturn(resp); + + Response r = resource.creerOuMettrAJour(req); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(resp); + } + + @Test + @DisplayName("retourne 200 avec la réponse retournée par le service") + void creerOuMettrAJour_retourneReponseService() { + ParametresFinanciersMutuellRequest req = buildRequest(); + ParametresFinanciersMutuellResponse resp = buildResponse(); + when(parametresService.creerOuMettrAJour(req)).thenReturn(resp); + + Response r = resource.creerOuMettrAJour(req); + + assertThat(r.getEntity()).isSameAs(resp); + verify(parametresService, times(1)).creerOuMettrAJour(req); + } + + @Test + @DisplayName("propage IllegalArgumentException levée par le service (validation métier)") + void creerOuMettrAJour_tauxInvalide_propagates() { + ParametresFinanciersMutuellRequest req = buildRequest(); + when(parametresService.creerOuMettrAJour(req)) + .thenThrow(new IllegalArgumentException("Le taux d'intérêt doit être positif")); + + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> resource.creerOuMettrAJour(req)); + } + + @Test + @DisplayName("n'appelle pas interetsService lors de la création/mise à jour") + void creerOuMettrAJour_neContactePasInteretsService() { + ParametresFinanciersMutuellRequest req = buildRequest(); + when(parametresService.creerOuMettrAJour(any())).thenReturn(buildResponse()); + + resource.creerOuMettrAJour(req); + + verifyNoInteractions(interetsService); + } + } + + // ── POST /{orgId}/calculer-interets ─────────────────────────────────────── + + @Nested + @DisplayName("POST /{orgId}/calculer-interets") + class CalculerInterets { + + @Test + @DisplayName("retourne 200 avec le résultat du calcul") + void calculerInterets_success_returns200() { + UUID orgId = UUID.randomUUID(); + Map resultat = Map.of( + "nombreComptes", 42, + "interetsCalcules", "250000", + "statut", "SUCCESS" + ); + when(interetsService.calculerManuellement(orgId)).thenReturn(resultat); + + Response r = resource.calculerInterets(orgId); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(resultat); + } + + @Test + @DisplayName("retourne 200 avec résultat vide si aucun compte") + void calculerInterets_aucunCompte_returns200() { + UUID orgId = UUID.randomUUID(); + when(interetsService.calculerManuellement(orgId)).thenReturn(Map.of()); + + Response r = resource.calculerInterets(orgId); + + assertThat(r.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + Map body = (Map) r.getEntity(); + assertThat(body).isEmpty(); + } + + @Test + @DisplayName("appelle interetsService avec l'UUID exact fourni") + void calculerInterets_passesCorrectUUID() { + UUID orgId = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + when(interetsService.calculerManuellement(orgId)).thenReturn(Map.of("done", true)); + + resource.calculerInterets(orgId); + + verify(interetsService).calculerManuellement(eq(orgId)); + verifyNoInteractions(parametresService); + } + + @Test + @DisplayName("propage RuntimeException levée par interetsService") + void calculerInterets_serviceException_propagates() { + UUID orgId = UUID.randomUUID(); + when(interetsService.calculerManuellement(orgId)) + .thenThrow(new RuntimeException("Paramètres financiers non configurés pour cette organisation")); + + org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> resource.calculerInterets(orgId)); + } + + @Test + @DisplayName("retourne 200 avec résultat contenant statistiques détaillées") + void calculerInterets_retourneStatistiquesDetaillees() { + UUID orgId = UUID.randomUUID(); + Map resultat = Map.of( + "nombreComptes", 10, + "totalEpargne", "5000000", + "tauxApplique", "0.05", + "periodeDebut", "2026-01-01", + "periodeFin", "2026-03-31" + ); + when(interetsService.calculerManuellement(orgId)).thenReturn(resultat); + + Response r = resource.calculerInterets(orgId); + + assertThat(r.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + Map body = (Map) r.getEntity(); + assertThat(body).containsKey("nombreComptes"); + assertThat(body).containsKey("tauxApplique"); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/resource/mutuelle/ReleveCompteResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/mutuelle/ReleveCompteResourceTest.java new file mode 100644 index 0000000..6c8d57f --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/resource/mutuelle/ReleveCompteResourceTest.java @@ -0,0 +1,319 @@ +package dev.lions.unionflow.server.resource.mutuelle; + +import dev.lions.unionflow.server.service.mutuelle.ReleveComptePdfService; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests ReleveCompteResource") +class ReleveCompteResourceTest { + + @Mock + ReleveComptePdfService releveService; + + @InjectMocks + ReleveCompteResource resource; + + private static final byte[] PDF_BYTES = new byte[]{0x25, 0x50, 0x44, 0x46}; // %PDF + + // ── GET /epargne/{compteId} ─────────────────────────────────────────────── + + @Nested + @DisplayName("GET /epargne/{compteId}") + class ReleveEpargne { + + @Test + @DisplayName("retourne 200 avec bytes PDF et headers Content-Disposition") + void releveEpargne_success_returns200() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveEpargne(eq(compteId), isNull(), isNull())) + .thenReturn(PDF_BYTES); + + Response r = resource.releveEpargne(compteId, null, null); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(PDF_BYTES); + assertThat(r.getHeaderString("Content-Disposition")) + .contains("attachment") + .contains("releve-epargne-" + compteId); + } + + @Test + @DisplayName("transmet les dates parsées au service quand les deux sont fournies") + void releveEpargne_avecDates_transmetsAuService() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveEpargne(eq(compteId), any(), any())) + .thenReturn(PDF_BYTES); + + Response r = resource.releveEpargne(compteId, "2026-01-01", "2026-03-31"); + + assertThat(r.getStatus()).isEqualTo(200); + verify(releveService).genererReleveEpargne( + eq(compteId), + eq(java.time.LocalDate.of(2026, 1, 1)), + eq(java.time.LocalDate.of(2026, 3, 31)) + ); + } + + @Test + @DisplayName("transmet dateDebut seule, dateFin null") + void releveEpargne_seulementDebut_datFinNull() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveEpargne(eq(compteId), any(), isNull())) + .thenReturn(PDF_BYTES); + + Response r = resource.releveEpargne(compteId, "2026-01-01", null); + + assertThat(r.getStatus()).isEqualTo(200); + verify(releveService).genererReleveEpargne( + eq(compteId), + eq(java.time.LocalDate.of(2026, 1, 1)), + isNull() + ); + } + + @Test + @DisplayName("transmet dateFin seule, dateDebut null") + void releveEpargne_seulementFin_dateDebutNull() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveEpargne(eq(compteId), isNull(), any())) + .thenReturn(PDF_BYTES); + + Response r = resource.releveEpargne(compteId, null, "2026-03-31"); + + assertThat(r.getStatus()).isEqualTo(200); + verify(releveService).genererReleveEpargne( + eq(compteId), + isNull(), + eq(java.time.LocalDate.of(2026, 3, 31)) + ); + } + + @Test + @DisplayName("string vide pour dateDebut est traité comme null") + void releveEpargne_dateDebutVide_traiteeCommeNull() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveEpargne(eq(compteId), isNull(), isNull())) + .thenReturn(PDF_BYTES); + + Response r = resource.releveEpargne(compteId, "", ""); + + assertThat(r.getStatus()).isEqualTo(200); + verify(releveService).genererReleveEpargne(eq(compteId), isNull(), isNull()); + } + + @Test + @DisplayName("string blanke pour dateDebut est traitée comme null") + void releveEpargne_dateBlanke_traiteeCommeNull() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveEpargne(eq(compteId), isNull(), isNull())) + .thenReturn(PDF_BYTES); + + Response r = resource.releveEpargne(compteId, " ", " "); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("lève IllegalArgumentException si format de date invalide") + void releveEpargne_dateFormatInvalide_throwsIllegalArgument() { + UUID compteId = UUID.randomUUID(); + + assertThatThrownBy(() -> resource.releveEpargne(compteId, "01-01-2026", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Format de date invalide") + .hasMessageContaining("YYYY-MM-DD"); + } + + @Test + @DisplayName("lève IllegalArgumentException si dateFin invalide") + void releveEpargne_dateFinInvalide_throwsIllegalArgument() { + UUID compteId = UUID.randomUUID(); + + assertThatThrownBy(() -> resource.releveEpargne(compteId, null, "2026/03/31")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("2026/03/31"); + } + + @Test + @DisplayName("retourne 200 avec PDF vide (0 octets)") + void releveEpargne_pdfVide_returns200() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveEpargne(any(), any(), any())).thenReturn(new byte[0]); + + Response r = resource.releveEpargne(compteId, null, null); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("propage RuntimeException levée par le service PDF") + void releveEpargne_serviceException_propagates() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveEpargne(any(), any(), any())) + .thenThrow(new RuntimeException("Compte épargne introuvable")); + + assertThatThrownBy(() -> resource.releveEpargne(compteId, null, null)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Compte épargne introuvable"); + } + + @Test + @DisplayName("header Content-Disposition contient l'UUID du compte") + void releveEpargne_contentDispositionContientUUID() { + UUID compteId = UUID.fromString("12345678-1234-1234-1234-123456789012"); + when(releveService.genererReleveEpargne(any(), any(), any())).thenReturn(PDF_BYTES); + + Response r = resource.releveEpargne(compteId, null, null); + + assertThat(r.getHeaderString("Content-Disposition")) + .contains("12345678-1234-1234-1234-123456789012"); + } + } + + // ── GET /parts-sociales/{compteId} ──────────────────────────────────────── + + @Nested + @DisplayName("GET /parts-sociales/{compteId}") + class ReleveParts { + + @Test + @DisplayName("retourne 200 avec bytes PDF et headers Content-Disposition") + void releveParts_success_returns200() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveParts(eq(compteId), isNull(), isNull())) + .thenReturn(PDF_BYTES); + + Response r = resource.releveParts(compteId, null, null); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(PDF_BYTES); + assertThat(r.getHeaderString("Content-Disposition")) + .contains("attachment") + .contains("releve-parts-" + compteId); + } + + @Test + @DisplayName("transmet les dates parsées au service quand les deux sont fournies") + void releveParts_avecDates_transmetsAuService() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveParts(eq(compteId), any(), any())) + .thenReturn(PDF_BYTES); + + Response r = resource.releveParts(compteId, "2026-01-01", "2026-12-31"); + + assertThat(r.getStatus()).isEqualTo(200); + verify(releveService).genererReleveParts( + eq(compteId), + eq(java.time.LocalDate.of(2026, 1, 1)), + eq(java.time.LocalDate.of(2026, 12, 31)) + ); + } + + @Test + @DisplayName("dateDebut seule — dateFin null") + void releveParts_seulementDebut() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveParts(eq(compteId), any(), isNull())) + .thenReturn(PDF_BYTES); + + Response r = resource.releveParts(compteId, "2025-06-01", null); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("dateFin seule — dateDebut null") + void releveParts_seulementFin() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveParts(eq(compteId), isNull(), any())) + .thenReturn(PDF_BYTES); + + Response r = resource.releveParts(compteId, null, "2026-06-30"); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("string vide pour les dates est traité comme null") + void releveParts_datesVides_traiteeCommeNull() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveParts(eq(compteId), isNull(), isNull())) + .thenReturn(PDF_BYTES); + + Response r = resource.releveParts(compteId, "", ""); + + assertThat(r.getStatus()).isEqualTo(200); + verify(releveService).genererReleveParts(eq(compteId), isNull(), isNull()); + } + + @Test + @DisplayName("lève IllegalArgumentException si format de date invalide") + void releveParts_dateFormatInvalide_throwsIllegalArgument() { + UUID compteId = UUID.randomUUID(); + + assertThatThrownBy(() -> resource.releveParts(compteId, "2026-13-01", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Format de date invalide"); + } + + @Test + @DisplayName("lève IllegalArgumentException si dateFin invalide") + void releveParts_dateFinInvalide_throwsIllegalArgument() { + UUID compteId = UUID.randomUUID(); + + assertThatThrownBy(() -> resource.releveParts(compteId, null, "not-a-date")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not-a-date"); + } + + @Test + @DisplayName("propage RuntimeException levée par le service PDF") + void releveParts_serviceException_propagates() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveParts(any(), any(), any())) + .thenThrow(new RuntimeException("Compte parts introuvable")); + + assertThatThrownBy(() -> resource.releveParts(compteId, null, null)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Compte parts introuvable"); + } + + @Test + @DisplayName("header Content-Disposition contient 'releve-parts' + UUID") + void releveParts_contentDispositionContientUUID() { + UUID compteId = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + when(releveService.genererReleveParts(any(), any(), any())).thenReturn(PDF_BYTES); + + Response r = resource.releveParts(compteId, null, null); + + assertThat(r.getHeaderString("Content-Disposition")) + .contains("releve-parts-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + } + + @Test + @DisplayName("retourne 200 avec dates blankes (contiennent espaces)") + void releveParts_datesBlankes_traiteeCommeNull() { + UUID compteId = UUID.randomUUID(); + when(releveService.genererReleveParts(eq(compteId), isNull(), isNull())) + .thenReturn(PDF_BYTES); + + Response r = resource.releveParts(compteId, " ", " "); + + assertThat(r.getStatus()).isEqualTo(200); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/resource/mutuelle/parts/ComptePartsSocialesResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/mutuelle/parts/ComptePartsSocialesResourceTest.java new file mode 100644 index 0000000..b38c7d6 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/resource/mutuelle/parts/ComptePartsSocialesResourceTest.java @@ -0,0 +1,434 @@ +package dev.lions.unionflow.server.resource.mutuelle.parts; + +import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesRequest; +import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse; +import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesRequest; +import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse; +import dev.lions.unionflow.server.service.mutuelle.parts.ComptePartsSocialesService; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests ComptePartsSocialesResource") +class ComptePartsSocialesResourceTest { + + @Mock + ComptePartsSocialesService service; + + @InjectMocks + ComptePartsSocialesResource resource; + + private ComptePartsSocialesResponse buildCompteResponse() { + return mock(ComptePartsSocialesResponse.class); + } + + private TransactionPartsSocialesResponse buildTransactionResponse() { + return mock(TransactionPartsSocialesResponse.class); + } + + // ── POST / — ouvrirCompte ───────────────────────────────────────────────── + + @Nested + @DisplayName("POST / — ouvrirCompte") + class OuvrirCompte { + + @Test + @DisplayName("retourne 201 avec la réponse du service après ouverture réussie") + void ouvrirCompte_success_returns201() { + ComptePartsSocialesRequest req = mock(ComptePartsSocialesRequest.class); + ComptePartsSocialesResponse resp = buildCompteResponse(); + when(service.ouvrirCompte(req)).thenReturn(resp); + + Response r = resource.ouvrirCompte(req); + + assertThat(r.getStatus()).isEqualTo(201); + assertThat(r.getEntity()).isEqualTo(resp); + } + + @Test + @DisplayName("appelle service.ouvrirCompte avec la requête fournie") + void ouvrirCompte_passesRequestToService() { + ComptePartsSocialesRequest req = mock(ComptePartsSocialesRequest.class); + when(service.ouvrirCompte(req)).thenReturn(buildCompteResponse()); + + resource.ouvrirCompte(req); + + verify(service, times(1)).ouvrirCompte(req); + } + + @Test + @DisplayName("propage IllegalArgumentException si membre introuvable") + void ouvrirCompte_membreInexistant_propagates() { + ComptePartsSocialesRequest req = mock(ComptePartsSocialesRequest.class); + when(service.ouvrirCompte(req)) + .thenThrow(new IllegalArgumentException("Membre introuvable")); + + assertThatThrownBy(() -> resource.ouvrirCompte(req)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Membre introuvable"); + } + + @Test + @DisplayName("propage RuntimeException si compte déjà ouvert pour ce membre") + void ouvrirCompte_compteDejaExistant_propagates() { + ComptePartsSocialesRequest req = mock(ComptePartsSocialesRequest.class); + when(service.ouvrirCompte(req)) + .thenThrow(new RuntimeException("Un compte parts existe déjà pour ce membre")); + + assertThatThrownBy(() -> resource.ouvrirCompte(req)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("compte parts"); + } + + @Test + @DisplayName("retourne le statut CREATED (201) et non OK (200)") + void ouvrirCompte_statusIsCreated_notOk() { + ComptePartsSocialesRequest req = mock(ComptePartsSocialesRequest.class); + when(service.ouvrirCompte(req)).thenReturn(buildCompteResponse()); + + Response r = resource.ouvrirCompte(req); + + assertThat(r.getStatus()).isEqualTo(Response.Status.CREATED.getStatusCode()); + assertThat(r.getStatus()).isNotEqualTo(200); + } + } + + // ── POST /transactions — enregistrerTransaction ─────────────────────────── + + @Nested + @DisplayName("POST /transactions — enregistrerTransaction") + class EnregistrerTransaction { + + @Test + @DisplayName("retourne 201 avec la réponse du service après enregistrement") + void enregistrerTransaction_success_returns201() { + TransactionPartsSocialesRequest req = mock(TransactionPartsSocialesRequest.class); + TransactionPartsSocialesResponse resp = buildTransactionResponse(); + when(service.enregistrerSouscription(req)).thenReturn(resp); + + Response r = resource.enregistrerTransaction(req); + + assertThat(r.getStatus()).isEqualTo(201); + assertThat(r.getEntity()).isEqualTo(resp); + } + + @Test + @DisplayName("appelle service.enregistrerSouscription avec la requête fournie") + void enregistrerTransaction_passesRequestToService() { + TransactionPartsSocialesRequest req = mock(TransactionPartsSocialesRequest.class); + when(service.enregistrerSouscription(req)).thenReturn(buildTransactionResponse()); + + resource.enregistrerTransaction(req); + + verify(service, times(1)).enregistrerSouscription(req); + } + + @Test + @DisplayName("propage IllegalArgumentException si montant invalide") + void enregistrerTransaction_montantInvalide_propagates() { + TransactionPartsSocialesRequest req = mock(TransactionPartsSocialesRequest.class); + when(service.enregistrerSouscription(req)) + .thenThrow(new IllegalArgumentException("Le montant doit être positif")); + + assertThatThrownBy(() -> resource.enregistrerTransaction(req)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("montant"); + } + + @Test + @DisplayName("propage RuntimeException si compte introuvable") + void enregistrerTransaction_compteInexistant_propagates() { + TransactionPartsSocialesRequest req = mock(TransactionPartsSocialesRequest.class); + when(service.enregistrerSouscription(req)) + .thenThrow(new RuntimeException("Compte parts introuvable")); + + assertThatThrownBy(() -> resource.enregistrerTransaction(req)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("retourne le statut CREATED (201) et non OK (200)") + void enregistrerTransaction_statusIsCreated() { + TransactionPartsSocialesRequest req = mock(TransactionPartsSocialesRequest.class); + when(service.enregistrerSouscription(any())).thenReturn(buildTransactionResponse()); + + Response r = resource.enregistrerTransaction(req); + + assertThat(r.getStatus()).isEqualTo(Response.Status.CREATED.getStatusCode()); + } + } + + // ── GET /{id} — getById ─────────────────────────────────────────────────── + + @Nested + @DisplayName("GET /{id} — getById") + class GetById { + + @Test + @DisplayName("retourne 200 avec le compte trouvé") + void getById_success_returns200() { + UUID id = UUID.randomUUID(); + ComptePartsSocialesResponse resp = buildCompteResponse(); + when(service.getById(id)).thenReturn(resp); + + Response r = resource.getById(id); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(resp); + } + + @Test + @DisplayName("appelle service.getById avec l'UUID exact") + void getById_passesCorrectUUID() { + UUID id = UUID.fromString("11111111-2222-3333-4444-555555555555"); + when(service.getById(id)).thenReturn(buildCompteResponse()); + + resource.getById(id); + + verify(service).getById(eq(id)); + } + + @Test + @DisplayName("propage RuntimeException si compte introuvable") + void getById_compteInconnu_propagates() { + UUID id = UUID.randomUUID(); + when(service.getById(id)) + .thenThrow(new RuntimeException("Compte introuvable: " + id)); + + assertThatThrownBy(() -> resource.getById(id)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("retourne 200 même si entité est null (cas service)") + void getById_null_returns200() { + UUID id = UUID.randomUUID(); + when(service.getById(id)).thenReturn(null); + + Response r = resource.getById(id); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isNull(); + } + } + + // ── GET /membre/{membreId} — getByMembre ───────────────────────────────── + + @Nested + @DisplayName("GET /membre/{membreId} — getByMembre") + class GetByMembre { + + @Test + @DisplayName("retourne 200 avec la liste des comptes du membre") + void getByMembre_success_returns200() { + UUID membreId = UUID.randomUUID(); + List list = List.of(buildCompteResponse(), buildCompteResponse()); + when(service.getByMembre(membreId)).thenReturn(list); + + Response r = resource.getByMembre(membreId); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(list); + } + + @Test + @DisplayName("retourne 200 avec liste vide quand le membre n'a aucun compte") + void getByMembre_aucunCompte_returns200() { + UUID membreId = UUID.randomUUID(); + when(service.getByMembre(membreId)).thenReturn(List.of()); + + Response r = resource.getByMembre(membreId); + + assertThat(r.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + List body = (List) r.getEntity(); + assertThat(body).isEmpty(); + } + + @Test + @DisplayName("appelle service.getByMembre avec l'UUID exact") + void getByMembre_passesCorrectUUID() { + UUID membreId = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + when(service.getByMembre(membreId)).thenReturn(List.of()); + + resource.getByMembre(membreId); + + verify(service).getByMembre(eq(membreId)); + } + + @Test + @DisplayName("propage RuntimeException levée par le service") + void getByMembre_serviceException_propagates() { + UUID membreId = UUID.randomUUID(); + when(service.getByMembre(membreId)) + .thenThrow(new RuntimeException("Membre introuvable")); + + assertThatThrownBy(() -> resource.getByMembre(membreId)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("retourne 200 avec liste de plusieurs comptes") + void getByMembre_plusieursComptes_returns200() { + UUID membreId = UUID.randomUUID(); + List list = List.of( + buildCompteResponse(), buildCompteResponse(), buildCompteResponse() + ); + when(service.getByMembre(membreId)).thenReturn(list); + + Response r = resource.getByMembre(membreId); + + assertThat(r.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + List body = (List) r.getEntity(); + assertThat(body).hasSize(3); + } + } + + // ── GET /organisation/{orgId} — getByOrganisation ──────────────────────── + + @Nested + @DisplayName("GET /organisation/{orgId} — getByOrganisation") + class GetByOrganisation { + + @Test + @DisplayName("retourne 200 avec la liste des comptes de l'organisation") + void getByOrganisation_success_returns200() { + UUID orgId = UUID.randomUUID(); + List list = List.of(buildCompteResponse()); + when(service.getByOrganisation(orgId)).thenReturn(list); + + Response r = resource.getByOrganisation(orgId); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(list); + } + + @Test + @DisplayName("retourne 200 avec liste vide si organisation sans comptes") + void getByOrganisation_aucunCompte_returns200() { + UUID orgId = UUID.randomUUID(); + when(service.getByOrganisation(orgId)).thenReturn(List.of()); + + Response r = resource.getByOrganisation(orgId); + + assertThat(r.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("appelle service.getByOrganisation avec l'UUID exact") + void getByOrganisation_passesCorrectUUID() { + UUID orgId = UUID.fromString("12345678-abcd-efab-cdef-123456789abc"); + when(service.getByOrganisation(orgId)).thenReturn(List.of()); + + resource.getByOrganisation(orgId); + + verify(service).getByOrganisation(eq(orgId)); + } + + @Test + @DisplayName("propage RuntimeException levée par le service") + void getByOrganisation_serviceException_propagates() { + UUID orgId = UUID.randomUUID(); + when(service.getByOrganisation(orgId)) + .thenThrow(new RuntimeException("Organisation introuvable")); + + assertThatThrownBy(() -> resource.getByOrganisation(orgId)) + .isInstanceOf(RuntimeException.class); + } + } + + // ── GET /{id}/transactions — getTransactions ────────────────────────────── + + @Nested + @DisplayName("GET /{id}/transactions — getTransactions") + class GetTransactions { + + @Test + @DisplayName("retourne 200 avec la liste des transactions") + void getTransactions_success_returns200() { + UUID id = UUID.randomUUID(); + List list = List.of( + buildTransactionResponse(), buildTransactionResponse() + ); + when(service.getTransactions(id)).thenReturn(list); + + Response r = resource.getTransactions(id); + + assertThat(r.getStatus()).isEqualTo(200); + assertThat(r.getEntity()).isEqualTo(list); + } + + @Test + @DisplayName("retourne 200 avec liste vide si aucune transaction") + void getTransactions_aucuneTransaction_returns200() { + UUID id = UUID.randomUUID(); + when(service.getTransactions(id)).thenReturn(List.of()); + + Response r = resource.getTransactions(id); + + assertThat(r.getStatus()).isEqualTo(200); + @SuppressWarnings("unchecked") + List body = + (List) r.getEntity(); + assertThat(body).isEmpty(); + } + + @Test + @DisplayName("appelle service.getTransactions avec l'UUID exact du compte") + void getTransactions_passesCorrectUUID() { + UUID id = UUID.fromString("99999999-8888-7777-6666-555555555555"); + when(service.getTransactions(id)).thenReturn(List.of()); + + resource.getTransactions(id); + + verify(service).getTransactions(eq(id)); + } + + @Test + @DisplayName("propage RuntimeException si compte introuvable") + void getTransactions_compteInconnu_propagates() { + UUID id = UUID.randomUUID(); + when(service.getTransactions(id)) + .thenThrow(new RuntimeException("Compte introuvable: " + id)); + + assertThatThrownBy(() -> resource.getTransactions(id)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("retourne 200 avec plusieurs transactions") + void getTransactions_plusieursTransactions_returns200() { + UUID id = UUID.randomUUID(); + List list = List.of( + buildTransactionResponse(), + buildTransactionResponse(), + buildTransactionResponse(), + buildTransactionResponse() + ); + when(service.getTransactions(id)).thenReturn(list); + + Response r = resource.getTransactions(id); + + @SuppressWarnings("unchecked") + List body = + (List) r.getEntity(); + assertThat(body).hasSize(4); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/scheduler/MemberLifecycleSchedulerTest.java b/src/test/java/dev/lions/unionflow/server/scheduler/MemberLifecycleSchedulerTest.java new file mode 100644 index 0000000..8beb336 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/scheduler/MemberLifecycleSchedulerTest.java @@ -0,0 +1,78 @@ +package dev.lions.unionflow.server.scheduler; + +import dev.lions.unionflow.server.service.MemberLifecycleService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberLifecycleSchedulerTest { + + @InjectMocks + MemberLifecycleScheduler scheduler; + + @Mock + MemberLifecycleService lifecycleService; + + @Test + void rappelerInvitations_whenCountPositive_logs() { + when(lifecycleService.envoyerRappelsInvitation()).thenReturn(3); + + assertThatCode(() -> scheduler.rappelerInvitationsExpirantBientot()) + .doesNotThrowAnyException(); + + verify(lifecycleService).envoyerRappelsInvitation(); + } + + @Test + void rappelerInvitations_whenCountZero_noLog() { + when(lifecycleService.envoyerRappelsInvitation()).thenReturn(0); + + assertThatCode(() -> scheduler.rappelerInvitationsExpirantBientot()) + .doesNotThrowAnyException(); + + verify(lifecycleService).envoyerRappelsInvitation(); + } + + @Test + void rappelerInvitations_whenServiceThrows_doesNotPropagate() { + when(lifecycleService.envoyerRappelsInvitation()).thenThrow(new RuntimeException("DB down")); + + assertThatCode(() -> scheduler.rappelerInvitationsExpirantBientot()) + .doesNotThrowAnyException(); + } + + @Test + void expirerInvitations_whenCountPositive_logs() { + when(lifecycleService.expirerInvitations()).thenReturn(5); + + assertThatCode(() -> scheduler.expirerInvitations()) + .doesNotThrowAnyException(); + + verify(lifecycleService).expirerInvitations(); + } + + @Test + void expirerInvitations_whenCountZero_noLog() { + when(lifecycleService.expirerInvitations()).thenReturn(0); + + assertThatCode(() -> scheduler.expirerInvitations()) + .doesNotThrowAnyException(); + + verify(lifecycleService).expirerInvitations(); + } + + @Test + void expirerInvitations_whenServiceThrows_doesNotPropagate() { + when(lifecycleService.expirerInvitations()).thenThrow(new RuntimeException("Timeout")); + + assertThatCode(() -> scheduler.expirerInvitations()) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/security/ModuleAccessFilterTest.java b/src/test/java/dev/lions/unionflow/server/security/ModuleAccessFilterTest.java new file mode 100644 index 0000000..7ecaa62 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/security/ModuleAccessFilterTest.java @@ -0,0 +1,192 @@ +package dev.lions.unionflow.server.security; + +import dev.lions.unionflow.server.service.OrganisationModuleService; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ResourceInfo; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ModuleAccessFilterTest { + + @InjectMocks + ModuleAccessFilter filter; + + @Mock + OrganisationModuleService organisationModuleService; + + @Mock + SecurityIdentity identity; + + @Mock + ResourceInfo resourceInfo; + + @Mock + ContainerRequestContext requestContext; + + @BeforeEach + void setUp() throws Exception { + // Inject @Context ResourceInfo via reflection (not injected by @InjectMocks since it's @Context) + Field resourceInfoField = ModuleAccessFilter.class.getDeclaredField("resourceInfo"); + resourceInfoField.setAccessible(true); + resourceInfoField.set(filter, resourceInfo); + } + + @Test + void filter_whenNoAnnotationOnMethod_passes() throws Exception { + Method method = getClass().getDeclaredMethod("noAnnotationMethod"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(resourceInfo.getResourceClass()).thenReturn((Class) getClass()); + + filter.filter(requestContext); + + verify(requestContext, never()).abortWith(any()); + } + + @Test + void filter_whenAnnotationOnMethod_andSuperAdmin_passes() throws Exception { + Method method = getClass().getDeclaredMethod("annotatedMethod"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(identity.hasRole("SUPER_ADMIN")).thenReturn(true); + + filter.filter(requestContext); + + verify(requestContext, never()).abortWith(any()); + } + + @Test + void filter_whenAnnotationOnMethod_andModuleActive_passes() throws Exception { + Method method = getClass().getDeclaredMethod("annotatedMethod"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(identity.hasRole("SUPER_ADMIN")).thenReturn(false); + + UUID orgId = UUID.randomUUID(); + when(requestContext.getHeaderString(ModuleAccessFilter.HEADER_ACTIVE_ORG)).thenReturn(orgId.toString()); + when(organisationModuleService.isModuleActif(orgId, "TONTINE")).thenReturn(true); + + filter.filter(requestContext); + + verify(requestContext, never()).abortWith(any()); + } + + @Test + void filter_whenAnnotationOnMethod_andModuleInactive_returns403() throws Exception { + Method method = getClass().getDeclaredMethod("annotatedMethod"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(identity.hasRole("SUPER_ADMIN")).thenReturn(false); + + UUID orgId = UUID.randomUUID(); + when(requestContext.getHeaderString(ModuleAccessFilter.HEADER_ACTIVE_ORG)).thenReturn(orgId.toString()); + when(organisationModuleService.isModuleActif(orgId, "TONTINE")).thenReturn(false); + + filter.filter(requestContext); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo(403); + } + + @Test + void filter_whenAnnotationOnMethod_andNoOrgHeader_returns403() throws Exception { + Method method = getClass().getDeclaredMethod("annotatedMethod"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(identity.hasRole("SUPER_ADMIN")).thenReturn(false); + + when(requestContext.getHeaderString(ModuleAccessFilter.HEADER_ACTIVE_ORG)).thenReturn(null); + + filter.filter(requestContext); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo(403); + } + + @Test + void filter_whenAnnotationOnMethod_andBlankOrgHeader_returns403() throws Exception { + Method method = getClass().getDeclaredMethod("annotatedMethod"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(identity.hasRole("SUPER_ADMIN")).thenReturn(false); + + when(requestContext.getHeaderString(ModuleAccessFilter.HEADER_ACTIVE_ORG)).thenReturn(" "); + + filter.filter(requestContext); + + verify(requestContext).abortWith(any()); + } + + @Test + void filter_whenAnnotationOnMethod_andInvalidUuidHeader_returns403() throws Exception { + Method method = getClass().getDeclaredMethod("annotatedMethod"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(identity.hasRole("SUPER_ADMIN")).thenReturn(false); + + when(requestContext.getHeaderString(ModuleAccessFilter.HEADER_ACTIVE_ORG)).thenReturn("not-a-uuid"); + + filter.filter(requestContext); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo(403); + } + + @Test + void filter_whenAnnotationOnClass_andNoMethodAnnotation_usesClassAnnotation() throws Exception { + // Method has no annotation, class has it + Method method = getClass().getDeclaredMethod("noAnnotationMethod"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(resourceInfo.getResourceClass()).thenReturn((Class) AnnotatedClass.class); + when(identity.hasRole("SUPER_ADMIN")).thenReturn(false); + + UUID orgId = UUID.randomUUID(); + when(requestContext.getHeaderString(ModuleAccessFilter.HEADER_ACTIVE_ORG)).thenReturn(orgId.toString()); + when(organisationModuleService.isModuleActif(orgId, "CREDIT")).thenReturn(true); + + filter.filter(requestContext); + + verify(requestContext, never()).abortWith(any()); + } + + @Test + void filter_whenAnnotationOnMethod_andCustomMessage_usesCustomMessage() throws Exception { + Method method = getClass().getDeclaredMethod("annotatedWithCustomMessage"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(identity.hasRole("SUPER_ADMIN")).thenReturn(false); + + UUID orgId = UUID.randomUUID(); + when(requestContext.getHeaderString(ModuleAccessFilter.HEADER_ACTIVE_ORG)).thenReturn(orgId.toString()); + when(organisationModuleService.isModuleActif(orgId, "EPARGNE")).thenReturn(false); + + filter.filter(requestContext); + + verify(requestContext).abortWith(any()); + } + + // ── Helper methods used as reflection targets ──────────────────────────────── + + void noAnnotationMethod() {} + + @RequiresModule("TONTINE") + void annotatedMethod() {} + + @RequiresModule(value = "EPARGNE", message = "Ce service requiert le module Epargne.") + void annotatedWithCustomMessage() {} + + @RequiresModule("CREDIT") + static class AnnotatedClass {} +} diff --git a/src/test/java/dev/lions/unionflow/server/security/OrganisationContextResolverTest.java b/src/test/java/dev/lions/unionflow/server/security/OrganisationContextResolverTest.java new file mode 100644 index 0000000..0ac25f5 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/security/OrganisationContextResolverTest.java @@ -0,0 +1,197 @@ +package dev.lions.unionflow.server.security; + +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests OrganisationContextResolver") +class OrganisationContextResolverTest { + + @Mock + JsonWebToken jwt; + + @Mock + OrganisationRepository organisationRepository; + + @InjectMocks + OrganisationContextResolver resolver; + + @Mock + @SuppressWarnings("rawtypes") + PanacheQuery panacheQuery; + + private final UUID kcOrgId = UUID.randomUUID(); + private final UUID orgId = UUID.randomUUID(); + + @BeforeEach + void injectMocks() throws Exception { + Field jwtField = OrganisationContextResolver.class.getDeclaredField("jwt"); + jwtField.setAccessible(true); + jwtField.set(resolver, jwt); + + Field repoField = OrganisationContextResolver.class.getDeclaredField("organisationRepository"); + repoField.setAccessible(true); + repoField.set(resolver, organisationRepository); + } + + // ─── resolveOrganisationId ──────────────────────────────────────────────── + + @Test + @DisplayName("null claim → BadRequestException") + void resolveOrganisationId_nullClaim_throwsBadRequest() { + when(jwt.getClaim("organization")).thenReturn(null); + assertThatThrownBy(() -> resolver.resolveOrganisationId()) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("empty claim → BadRequestException") + void resolveOrganisationId_emptyClaim_throwsBadRequest() { + when(jwt.getClaim("organization")).thenReturn(Map.of()); + assertThatThrownBy(() -> resolver.resolveOrganisationId()) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("multi-org claim → BadRequestException") + void resolveOrganisationId_multiOrgClaim_throwsBadRequest() { + Map multiOrg = Map.of( + "org-a", Map.of("id", UUID.randomUUID().toString()), + "org-b", Map.of("id", UUID.randomUUID().toString()) + ); + when(jwt.getClaim("organization")).thenReturn(multiOrg); + assertThatThrownBy(() -> resolver.resolveOrganisationId()) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("multi-organisation"); + } + + @Test + @DisplayName("entry sans champ id → BadRequestException") + @SuppressWarnings("unchecked") + void resolveOrganisationId_entryWithoutId_throwsBadRequest() { + Map claim = Map.of("mutuelle-gbane", Map.of("name", "Mutuelle GBANE")); + when(jwt.getClaim("organization")).thenReturn(claim); + assertThatThrownBy(() -> resolver.resolveOrganisationId()) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("malformé"); + } + + @Test + @DisplayName("entry non-Map → BadRequestException (id null)") + @SuppressWarnings("unchecked") + void resolveOrganisationId_entryNotMap_throwsBadRequest() { + Map claim = Map.of("mutuelle-gbane", "not-a-map"); + when(jwt.getClaim("organization")).thenReturn(claim); + assertThatThrownBy(() -> resolver.resolveOrganisationId()) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("id non-UUID → BadRequestException") + @SuppressWarnings("unchecked") + void resolveOrganisationId_invalidUuid_throwsBadRequest() { + Map claim = Map.of("mutuelle-gbane", Map.of("id", "not-a-uuid")); + when(jwt.getClaim("organization")).thenReturn(claim); + assertThatThrownBy(() -> resolver.resolveOrganisationId()) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("UUID valide"); + } + + @Test + @DisplayName("org non trouvée en DB → ForbiddenException") + @SuppressWarnings("unchecked") + void resolveOrganisationId_orgNotInDb_throwsForbidden() { + Map claim = Map.of("mutuelle-gbane", Map.of("id", kcOrgId.toString())); + when(jwt.getClaim("organization")).thenReturn(claim); + when(organisationRepository.find(eq("keycloakOrgId = ?1 AND actif = true"), eq(kcOrgId))) + .thenReturn(panacheQuery); + when(panacheQuery.firstResultOptional()).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> resolver.resolveOrganisationId()) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("org trouvée → retourne UUID") + @SuppressWarnings("unchecked") + void resolveOrganisationId_validToken_returnsId() { + Map claim = Map.of("mutuelle-gbane", Map.of("id", kcOrgId.toString())); + when(jwt.getClaim("organization")).thenReturn(claim); + when(organisationRepository.find(eq("keycloakOrgId = ?1 AND actif = true"), eq(kcOrgId))) + .thenReturn(panacheQuery); + + Organisation org = new Organisation(); + org.setId(orgId); + when(panacheQuery.firstResultOptional()).thenReturn(Optional.of(org)); + + UUID result = resolver.resolveOrganisationId(); + assertThat(result).isEqualTo(orgId); + } + + // ─── resolveOrganisationIdIfPresent ────────────────────────────────────── + + @Test + @DisplayName("claim absent → Optional.empty()") + void resolveOrganisationIdIfPresent_noClaim_returnsEmpty() { + when(jwt.getClaim("organization")).thenReturn(null); + assertThat(resolver.resolveOrganisationIdIfPresent()).isEmpty(); + } + + @Test + @DisplayName("claim vide → Optional.empty()") + void resolveOrganisationIdIfPresent_emptyClaim_returnsEmpty() { + when(jwt.getClaim("organization")).thenReturn(Map.of()); + assertThat(resolver.resolveOrganisationIdIfPresent()).isEmpty(); + } + + @Test + @DisplayName("org non trouvée (ForbiddenException) → Optional.empty()") + @SuppressWarnings("unchecked") + void resolveOrganisationIdIfPresent_forbidden_returnsEmpty() { + Map claim = Map.of("mutuelle-gbane", Map.of("id", kcOrgId.toString())); + when(jwt.getClaim("organization")).thenReturn(claim); + when(organisationRepository.find(eq("keycloakOrgId = ?1 AND actif = true"), eq(kcOrgId))) + .thenReturn(panacheQuery); + when(panacheQuery.firstResultOptional()).thenReturn(Optional.empty()); + + assertThat(resolver.resolveOrganisationIdIfPresent()).isEmpty(); + } + + @Test + @DisplayName("claim valide → Optional avec UUID") + @SuppressWarnings("unchecked") + void resolveOrganisationIdIfPresent_valid_returnsId() { + Map claim = Map.of("mutuelle-gbane", Map.of("id", kcOrgId.toString())); + when(jwt.getClaim("organization")).thenReturn(claim); + when(organisationRepository.find(eq("keycloakOrgId = ?1 AND actif = true"), eq(kcOrgId))) + .thenReturn(panacheQuery); + + Organisation org = new Organisation(); + org.setId(orgId); + when(panacheQuery.firstResultOptional()).thenReturn(Optional.of(org)); + + assertThat(resolver.resolveOrganisationIdIfPresent()).contains(orgId); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/security/RlsConnectionInitializerTest.java b/src/test/java/dev/lions/unionflow/server/security/RlsConnectionInitializerTest.java new file mode 100644 index 0000000..47f3bd2 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/security/RlsConnectionInitializerTest.java @@ -0,0 +1,162 @@ +package dev.lions.unionflow.server.security; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.ws.rs.container.ContainerRequestContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.sql.DataSource; +import java.lang.reflect.Field; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RlsConnectionInitializerTest { + + @InjectMocks + RlsConnectionInitializer initializer; + + @Mock + OrganisationContextHolder contextHolder; + + @Mock + SecurityIdentity identity; + + @Mock + DataSource dataSource; + + @Mock + ContainerRequestContext requestContext; + + @BeforeEach + void setUp() throws Exception { + // @Inject DataSource is injected by @InjectMocks + // but we also need to ensure the dataSource field is set + Field dsField = RlsConnectionInitializer.class.getDeclaredField("dataSource"); + dsField.setAccessible(true); + dsField.set(initializer, dataSource); + } + + @Test + void filter_whenIdentityNull_skips() throws Exception { + Field identityField = RlsConnectionInitializer.class.getDeclaredField("identity"); + identityField.setAccessible(true); + identityField.set(initializer, null); + + assertThatCode(() -> initializer.filter(requestContext)).doesNotThrowAnyException(); + + verify(dataSource, never()).getConnection(); + } + + @Test + void filter_whenAnonymous_skips() throws Exception { + when(identity.isAnonymous()).thenReturn(true); + + assertThatCode(() -> initializer.filter(requestContext)).doesNotThrowAnyException(); + + verify(dataSource, never()).getConnection(); + } + + @Test + void filter_whenAuthenticated_executesSetConfig() throws Exception { + when(identity.isAnonymous()).thenReturn(false); + when(identity.getRoles()).thenReturn(Set.of("ADMIN")); + when(contextHolder.hasContext()).thenReturn(false); + + Connection conn = mock(Connection.class); + PreparedStatement stmt = mock(PreparedStatement.class); + when(dataSource.getConnection()).thenReturn(conn); + when(conn.prepareStatement(anyString())).thenReturn(stmt); + + assertThatCode(() -> initializer.filter(requestContext)).doesNotThrowAnyException(); + + verify(dataSource).getConnection(); + verify(stmt).execute(); + } + + @Test + void filter_whenAuthenticatedWithOrgContext_setsOrgId() throws Exception { + when(identity.isAnonymous()).thenReturn(false); + when(identity.getRoles()).thenReturn(Set.of("ADMIN")); + + UUID orgId = UUID.randomUUID(); + when(contextHolder.hasContext()).thenReturn(true); + when(contextHolder.getOrganisationId()).thenReturn(orgId); + + Connection conn = mock(Connection.class); + PreparedStatement stmt = mock(PreparedStatement.class); + when(dataSource.getConnection()).thenReturn(conn); + when(conn.prepareStatement(anyString())).thenReturn(stmt); + + assertThatCode(() -> initializer.filter(requestContext)).doesNotThrowAnyException(); + + verify(stmt).execute(); + } + + @Test + void filter_whenAuthenticatedAsSuperAdmin_setsSuperAdminFlag() throws Exception { + when(identity.isAnonymous()).thenReturn(false); + when(identity.getRoles()).thenReturn(Set.of("SUPER_ADMIN")); + when(contextHolder.hasContext()).thenReturn(false); + + Connection conn = mock(Connection.class); + PreparedStatement stmt = mock(PreparedStatement.class); + when(dataSource.getConnection()).thenReturn(conn); + when(conn.prepareStatement(anyString())).thenReturn(stmt); + + assertThatCode(() -> initializer.filter(requestContext)).doesNotThrowAnyException(); + + verify(stmt).execute(); + } + + @Test + void filter_whenAuthenticatedAsSuperadmin_alternateSpelling() throws Exception { + when(identity.isAnonymous()).thenReturn(false); + when(identity.getRoles()).thenReturn(Set.of("SUPERADMIN")); + when(contextHolder.hasContext()).thenReturn(false); + + Connection conn = mock(Connection.class); + PreparedStatement stmt = mock(PreparedStatement.class); + when(dataSource.getConnection()).thenReturn(conn); + when(conn.prepareStatement(anyString())).thenReturn(stmt); + + assertThatCode(() -> initializer.filter(requestContext)).doesNotThrowAnyException(); + + verify(stmt).execute(); + } + + @Test + void filter_whenDbThrows_doesNotPropagate() throws Exception { + when(identity.isAnonymous()).thenReturn(false); + when(identity.getRoles()).thenReturn(Set.of("MEMBRE")); + when(contextHolder.hasContext()).thenReturn(false); + when(dataSource.getConnection()).thenThrow(new RuntimeException("DB unavailable")); + + // Non-bloquant : doit absorber l'erreur + assertThatCode(() -> initializer.filter(requestContext)).doesNotThrowAnyException(); + } + + @Test + void filter_whenRolesNull_handlesGracefully() throws Exception { + when(identity.isAnonymous()).thenReturn(false); + when(identity.getRoles()).thenReturn(null); + when(contextHolder.hasContext()).thenReturn(false); + + Connection conn = mock(Connection.class); + PreparedStatement stmt = mock(PreparedStatement.class); + when(dataSource.getConnection()).thenReturn(conn); + when(conn.prepareStatement(anyString())).thenReturn(stmt); + + assertThatCode(() -> initializer.filter(requestContext)).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/security/RlsContextInterceptorTest.java b/src/test/java/dev/lions/unionflow/server/security/RlsContextInterceptorTest.java new file mode 100644 index 0000000..f3afcde --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/security/RlsContextInterceptorTest.java @@ -0,0 +1,163 @@ +package dev.lions.unionflow.server.security; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.interceptor.InvocationContext; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RlsContextInterceptorTest { + + @InjectMocks + RlsContextInterceptor interceptor; + + @Mock + EntityManager em; + + @Mock + OrganisationContextHolder contextHolder; + + @Mock + SecurityIdentity identity; + + @Mock + InvocationContext invocationContext; + + @BeforeEach + void setUp() throws Exception { + // ensure the proceed returns a value + when(invocationContext.proceed()).thenReturn("result"); + } + + @Test + void aroundInvoke_whenIdentityNull_proceedsWithoutRls() throws Exception { + Field identityField = RlsContextInterceptor.class.getDeclaredField("identity"); + identityField.setAccessible(true); + identityField.set(interceptor, null); + + Object result = interceptor.applyRlsContext(invocationContext); + + assertThat(result).isEqualTo("result"); + verify(em, never()).createNativeQuery(anyString()); + } + + @Test + void aroundInvoke_whenAnonymous_proceedsWithoutRls() throws Exception { + when(identity.isAnonymous()).thenReturn(true); + + Object result = interceptor.applyRlsContext(invocationContext); + + assertThat(result).isEqualTo("result"); + verify(em, never()).createNativeQuery(anyString()); + } + + @Test + void aroundInvoke_whenAuthenticated_executesSetConfig() throws Exception { + when(identity.isAnonymous()).thenReturn(false); + when(identity.getRoles()).thenReturn(Set.of("ADMIN")); + when(contextHolder.hasContext()).thenReturn(false); + + Query query = mock(Query.class); + when(em.createNativeQuery(anyString())).thenReturn(query); + when(query.setParameter(anyString(), anyString())).thenReturn(query); + when(query.getSingleResult()).thenReturn(new Object[]{}); + + Object result = interceptor.applyRlsContext(invocationContext); + + assertThat(result).isEqualTo("result"); + verify(em).createNativeQuery(anyString()); + } + + @Test + void aroundInvoke_whenAuthenticatedWithOrg_setsOrgId() throws Exception { + when(identity.isAnonymous()).thenReturn(false); + when(identity.getRoles()).thenReturn(Set.of("ADMIN")); + + UUID orgId = UUID.randomUUID(); + when(contextHolder.hasContext()).thenReturn(true); + when(contextHolder.getOrganisationId()).thenReturn(orgId); + + Query query = mock(Query.class); + when(em.createNativeQuery(anyString())).thenReturn(query); + when(query.setParameter(anyString(), anyString())).thenReturn(query); + when(query.getSingleResult()).thenReturn(new Object[]{}); + + Object result = interceptor.applyRlsContext(invocationContext); + + assertThat(result).isEqualTo("result"); + } + + @Test + void aroundInvoke_whenSuperAdmin_setsSuperAdminFlag() throws Exception { + when(identity.isAnonymous()).thenReturn(false); + when(identity.getRoles()).thenReturn(Set.of("SUPER_ADMIN")); + when(contextHolder.hasContext()).thenReturn(false); + + Query query = mock(Query.class); + when(em.createNativeQuery(anyString())).thenReturn(query); + when(query.setParameter(anyString(), anyString())).thenReturn(query); + when(query.getSingleResult()).thenReturn(new Object[]{}); + + assertThatCode(() -> interceptor.applyRlsContext(invocationContext)) + .doesNotThrowAnyException(); + } + + @Test + void aroundInvoke_whenSuperadminAlternateSpelling_setsSuperAdminFlag() throws Exception { + when(identity.isAnonymous()).thenReturn(false); + when(identity.getRoles()).thenReturn(Set.of("SUPERADMIN")); + when(contextHolder.hasContext()).thenReturn(false); + + Query query = mock(Query.class); + when(em.createNativeQuery(anyString())).thenReturn(query); + when(query.setParameter(anyString(), anyString())).thenReturn(query); + when(query.getSingleResult()).thenReturn(new Object[]{}); + + assertThatCode(() -> interceptor.applyRlsContext(invocationContext)) + .doesNotThrowAnyException(); + } + + @Test + void aroundInvoke_whenEmThrows_continuesProceed() throws Exception { + when(identity.isAnonymous()).thenReturn(false); + when(identity.getRoles()).thenReturn(Set.of("MEMBRE")); + when(contextHolder.hasContext()).thenReturn(false); + when(em.createNativeQuery(anyString())).thenThrow(new RuntimeException("Hors transaction")); + + Object result = interceptor.applyRlsContext(invocationContext); + + // Must still proceed despite the exception + assertThat(result).isEqualTo("result"); + verify(invocationContext).proceed(); + } + + @Test + void aroundInvoke_whenRolesNull_handlesGracefully() throws Exception { + when(identity.isAnonymous()).thenReturn(false); + when(identity.getRoles()).thenReturn(null); + when(contextHolder.hasContext()).thenReturn(false); + + Query query = mock(Query.class); + when(em.createNativeQuery(anyString())).thenReturn(query); + when(query.setParameter(anyString(), anyString())).thenReturn(query); + when(query.getSingleResult()).thenReturn(new Object[]{}); + + assertThatCode(() -> interceptor.applyRlsContext(invocationContext)).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/security/RoleConstantTest.java b/src/test/java/dev/lions/unionflow/server/security/RoleConstantTest.java new file mode 100644 index 0000000..3726e2e --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/security/RoleConstantTest.java @@ -0,0 +1,87 @@ +package dev.lions.unionflow.server.security; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class RoleConstantTest { + + @Test + void superAdmin_hasExpectedValue() { + assertThat(RoleConstant.SUPER_ADMIN).isEqualTo("SUPER_ADMIN"); + } + + @Test + void admin_hasExpectedValue() { + assertThat(RoleConstant.ADMIN).isEqualTo("ADMIN"); + } + + @Test + void membre_hasExpectedValue() { + assertThat(RoleConstant.MEMBRE).isEqualTo("MEMBRE"); + } + + @Test + void tresorier_hasExpectedValue() { + assertThat(RoleConstant.TRESORIER).isEqualTo("TRESORIER"); + } + + @Test + void secretaire_hasExpectedValue() { + assertThat(RoleConstant.SECRETAIRE).isEqualTo("SECRETAIRE"); + } + + @Test + void adminOrganisation_hasExpectedValue() { + assertThat(RoleConstant.ADMIN_ORGANISATION).isEqualTo("ADMIN_ORGANISATION"); + } + + @Test + void president_hasExpectedValue() { + assertThat(RoleConstant.PRESIDENT).isEqualTo("PRESIDENT"); + } + + @Test + void vicePresident_hasExpectedValue() { + assertThat(RoleConstant.VICE_PRESIDENT).isEqualTo("VICE_PRESIDENT"); + } + + @Test + void moderateur_hasExpectedValue() { + assertThat(RoleConstant.MODERATEUR).isEqualTo("MODERATEUR"); + } + + @Test + void user_hasExpectedValue() { + assertThat(RoleConstant.USER).isEqualTo("USER"); + } + + @Test + void organisateurEvenement_hasExpectedValue() { + assertThat(RoleConstant.ORGANISATEUR_EVENEMENT).isEqualTo("ORGANISATEUR_EVENEMENT"); + } + + @Test + void specializedRoles_haveExpectedValues() { + assertThat(RoleConstant.TONTINE_RESP).isEqualTo("TONTINE_RESP"); + assertThat(RoleConstant.MUTUELLE_RESP).isEqualTo("MUTUELLE_RESP"); + assertThat(RoleConstant.VOTE_RESP).isEqualTo("VOTE_RESP"); + assertThat(RoleConstant.COOP_RESP).isEqualTo("COOP_RESP"); + assertThat(RoleConstant.ONG_RESP).isEqualTo("ONG_RESP"); + assertThat(RoleConstant.CULTE_RESP).isEqualTo("CULTE_RESP"); + assertThat(RoleConstant.REGISTRE_RESP).isEqualTo("REGISTRE_RESP"); + assertThat(RoleConstant.AGRI_RESP).isEqualTo("AGRI_RESP"); + assertThat(RoleConstant.COLLECTE_RESP).isEqualTo("COLLECTE_RESP"); + } + + @Test + void privateConstructor_isCoverable() throws Exception { + Constructor constructor = RoleConstant.class.getDeclaredConstructor(); + constructor.setAccessible(true); + assertThatCode(constructor::newInstance).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/ComptabilitePdfServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/ComptabilitePdfServiceTest.java new file mode 100644 index 0000000..deffc69 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/ComptabilitePdfServiceTest.java @@ -0,0 +1,542 @@ +package dev.lions.unionflow.server.service; + +import dev.lions.unionflow.server.api.enums.comptabilite.TypeCompteComptable; +import dev.lions.unionflow.server.entity.CompteComptable; +import dev.lions.unionflow.server.entity.EcritureComptable; +import dev.lions.unionflow.server.entity.LigneEcriture; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.repository.CompteComptableRepository; +import dev.lions.unionflow.server.repository.EcritureComptableRepository; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import jakarta.ws.rs.NotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests ComptabilitePdfService") +class ComptabilitePdfServiceTest { + + @Mock + OrganisationRepository organisationRepository; + + @Mock + CompteComptableRepository compteComptableRepository; + + @Mock + EcritureComptableRepository ecritureComptableRepository; + + @InjectMocks + ComptabilitePdfService service; + + private static final UUID ORG_ID = UUID.randomUUID(); + private static final LocalDate DATE_DEBUT = LocalDate.of(2026, 1, 1); + private static final LocalDate DATE_FIN = LocalDate.of(2026, 3, 31); + + private Organisation org; + + @BeforeEach + void setUp() { + org = new Organisation(); + org.setNom("Association Test"); + } + + // ─── Helper builders ──────────────────────────────────────────────────────── + + private CompteComptable buildCompte(String numero, String libelle, int classe, + TypeCompteComptable type) { + CompteComptable c = new CompteComptable(); + c.setNumeroCompte(numero); + c.setLibelle(libelle); + c.setClasseComptable(classe); + c.setTypeCompte(type); + return c; + } + + private EcritureComptable buildEcriture(LocalDate date, String piece, String libelle, + List lignes) { + EcritureComptable e = new EcritureComptable(); + e.setDateEcriture(date); + e.setNumeroPiece(piece); + e.setLibelle(libelle); + e.setLignes(lignes); + return e; + } + + private LigneEcriture buildLigne(CompteComptable compte, BigDecimal debit, BigDecimal credit) { + LigneEcriture l = new LigneEcriture(); + l.setCompteComptable(compte); + l.setMontantDebit(debit); + l.setMontantCredit(credit); + return l; + } + + // ─── genererBalance ──────────────────────────────────────────────────────── + + @Nested + @DisplayName("genererBalance") + class GenererBalanceTests { + + @Test + @DisplayName("throws NotFoundException when organisation not found") + void orgNotFound_throws() { + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.genererBalance(ORG_ID, DATE_DEBUT, DATE_FIN)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Organisation introuvable"); + } + + @Test + @DisplayName("returns non-empty PDF bytes with no comptes") + void noComptes_returnsPdfBytes() { + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)) + .thenReturn(Collections.emptyList()); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(Collections.emptyList()); + + byte[] result = service.genererBalance(ORG_ID, DATE_DEBUT, DATE_FIN); + + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("skips compte with zero debit and credit (both zero signum)") + void compteWithZeroMovements_skipped() { + CompteComptable compte = buildCompte("411000", "Clients", 4, TypeCompteComptable.ACTIF); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)) + .thenReturn(List.of(compte)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(Collections.emptyList()); // no movements → zero totals + + byte[] result = service.genererBalance(ORG_ID, DATE_DEBUT, DATE_FIN); + + // Should generate a valid PDF (TOTAUX row only) + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("generates PDF with compte having non-zero debit — alternating row colors") + void compteWithMovements_generatesPdf() { + CompteComptable compte1 = buildCompte("411000", "Clients", 4, TypeCompteComptable.ACTIF); + CompteComptable compte2 = buildCompte("512000", "Banque", 5, TypeCompteComptable.TRESORERIE); + + LigneEcriture ligne1 = buildLigne(compte1, new BigDecimal("10000"), BigDecimal.ZERO); + LigneEcriture ligne2 = buildLigne(compte2, BigDecimal.ZERO, new BigDecimal("5000")); + + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Paiement", + List.of(ligne1, ligne2)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)) + .thenReturn(List.of(compte1, compte2)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture)); + + byte[] result = service.genererBalance(ORG_ID, DATE_DEBUT, DATE_FIN); + + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("ecriture with null lignes — skipped in totaux calculation") + void ecritureWithNullLignes_skipped() { + CompteComptable compte = buildCompte("411000", "Clients", 4, TypeCompteComptable.ACTIF); + + EcritureComptable ecritureNullLignes = buildEcriture(DATE_DEBUT, "ECR-001", + "Test", null); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)) + .thenReturn(List.of(compte)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecritureNullLignes)); + + byte[] result = service.genererBalance(ORG_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("ligne with null compteComptable — skipped in totaux calculation") + void ligneWithNullCompte_skipped() { + CompteComptable compte = buildCompte("411000", "Clients", 4, TypeCompteComptable.ACTIF); + + LigneEcriture ligneNullCompte = buildLigne(null, new BigDecimal("1000"), null); + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Test", + List.of(ligneNullCompte)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)) + .thenReturn(List.of(compte)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture)); + + byte[] result = service.genererBalance(ORG_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("ligne with null debit and null credit — treated as zero") + void ligneWithNullAmounts_treatedAsZero() { + CompteComptable compte = buildCompte("411000", "Clients", 4, TypeCompteComptable.ACTIF); + LigneEcriture ligne = buildLigne(compte, null, null); // null amounts + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Test", + List.of(ligne)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)).thenReturn(List.of(compte)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture)); + + byte[] result = service.genererBalance(ORG_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + } + + // ─── genererCompteResultat ───────────────────────────────────────────────── + + @Nested + @DisplayName("genererCompteResultat") + class GenererCompteResultatTests { + + @Test + @DisplayName("throws NotFoundException when organisation not found") + void orgNotFound_throws() { + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.genererCompteResultat(ORG_ID, DATE_DEBUT, DATE_FIN)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("empty comptes — returns PDF bytes (bénéfice nul)") + void noComptes_returnsPdf() { + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)).thenReturn(Collections.emptyList()); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(Collections.emptyList()); + + byte[] result = service.genererCompteResultat(ORG_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("classe 7 compte (produits) with non-zero solde — added to produits section") + void classe7Compte_addedToProduits() { + CompteComptable produit = buildCompte("700000", "Ventes", 7, TypeCompteComptable.PRODUITS); + LigneEcriture ligne = buildLigne(produit, BigDecimal.ZERO, new BigDecimal("50000")); + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Ventes", List.of(ligne)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)).thenReturn(List.of(produit)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture)); + + byte[] result = service.genererCompteResultat(ORG_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("classe 6 compte (charges) with non-zero solde — added to charges section") + void classe6Compte_addedToCharges() { + CompteComptable charge = buildCompte("600000", "Achats", 6, TypeCompteComptable.CHARGES); + LigneEcriture ligne = buildLigne(charge, new BigDecimal("20000"), BigDecimal.ZERO); + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Charges", List.of(ligne)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)).thenReturn(List.of(charge)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture)); + + byte[] result = service.genererCompteResultat(ORG_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("classe 8 PRODUITS compte — included in produits section") + void classe8ProduitCompte_addedToProduits() { + CompteComptable produit8 = buildCompte("800000", "Produits exceptionnels", 8, + TypeCompteComptable.PRODUITS); + LigneEcriture ligne = buildLigne(produit8, BigDecimal.ZERO, new BigDecimal("10000")); + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Prod except", List.of(ligne)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)).thenReturn(List.of(produit8)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture)); + + byte[] result = service.genererCompteResultat(ORG_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("classe 8 CHARGES compte — included in charges section") + void classe8ChargesCompte_addedToCharges() { + CompteComptable charges8 = buildCompte("800001", "Charges exceptionnelles", 8, + TypeCompteComptable.CHARGES); + LigneEcriture ligne = buildLigne(charges8, new BigDecimal("5000"), BigDecimal.ZERO); + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Charges except", + List.of(ligne)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)).thenReturn(List.of(charges8)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture)); + + byte[] result = service.genererCompteResultat(ORG_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("perte nette — charges > produits — PDF uses red color for PERTE label") + void perteNette_generatesLabelPerte() { + CompteComptable charge = buildCompte("600000", "Achats", 6, TypeCompteComptable.CHARGES); + CompteComptable produit = buildCompte("700000", "Ventes", 7, TypeCompteComptable.PRODUITS); + + LigneEcriture ligneCharge = buildLigne(charge, new BigDecimal("100000"), BigDecimal.ZERO); + LigneEcriture ligneProduit = buildLigne(produit, BigDecimal.ZERO, new BigDecimal("20000")); + + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Mix", + List.of(ligneCharge, ligneProduit)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)).thenReturn(List.of(charge, produit)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture)); + + byte[] result = service.genererCompteResultat(ORG_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("zero-solde produit compte — skipped (no ligne added)") + void zeroSoldeProduit_skipped() { + CompteComptable produit = buildCompte("700000", "Ventes", 7, TypeCompteComptable.PRODUITS); + // debit == credit → solde = 0, should be skipped + LigneEcriture ligne = buildLigne(produit, new BigDecimal("5000"), new BigDecimal("5000")); + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Zero", List.of(ligne)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)).thenReturn(List.of(produit)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture)); + + byte[] result = service.genererCompteResultat(ORG_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("zero-solde charge compte — skipped (no ligne added)") + void zeroSoldeCharge_skipped() { + CompteComptable charge = buildCompte("600000", "Achats", 6, TypeCompteComptable.CHARGES); + LigneEcriture ligne = buildLigne(charge, new BigDecimal("5000"), new BigDecimal("5000")); + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Zero", List.of(ligne)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)).thenReturn(List.of(charge)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture)); + + byte[] result = service.genererCompteResultat(ORG_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + } + + // ─── genererGrandLivre ───────────────────────────────────────────────────── + + @Nested + @DisplayName("genererGrandLivre") + class GenererGrandLivreTests { + + private static final String NUMERO_COMPTE = "411000"; + + @Test + @DisplayName("throws NotFoundException when organisation not found") + void orgNotFound_throws() { + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.genererGrandLivre(ORG_ID, NUMERO_COMPTE, DATE_DEBUT, DATE_FIN)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("throws NotFoundException when compte not found for organisation") + void compteNotFound_throws() { + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisationAndNumero(ORG_ID, NUMERO_COMPTE)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.genererGrandLivre(ORG_ID, NUMERO_COMPTE, DATE_DEBUT, DATE_FIN)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining(NUMERO_COMPTE); + } + + @Test + @DisplayName("no écritures — returns PDF with empty movement cell") + void noEcritures_pdfWithEmptyMessage() { + CompteComptable compte = buildCompte(NUMERO_COMPTE, "Clients", 4, TypeCompteComptable.ACTIF); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisationAndNumero(ORG_ID, NUMERO_COMPTE)) + .thenReturn(Optional.of(compte)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(Collections.emptyList()); + + byte[] result = service.genererGrandLivre(ORG_ID, NUMERO_COMPTE, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("with écritures matching the compte — builds movements and alternates row colors") + void withEcritures_buildsMouvements() { + CompteComptable compte = buildCompte(NUMERO_COMPTE, "Clients", 4, TypeCompteComptable.ACTIF); + CompteComptable autreCompte = buildCompte("512000", "Banque", 5, TypeCompteComptable.TRESORERIE); + + // ligne1 matches the target compte + LigneEcriture ligne1 = buildLigne(compte, new BigDecimal("10000"), BigDecimal.ZERO); + // ligne2 is a different compte — should be filtered out + LigneEcriture ligne2 = buildLigne(autreCompte, BigDecimal.ZERO, new BigDecimal("10000")); + + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Règlement", + List.of(ligne1, ligne2)); + + // Second ecriture matches too (null lignes — skipped) + EcritureComptable ecritureNullLignes = buildEcriture(DATE_DEBUT, "ECR-002", + "Test null lignes", null); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisationAndNumero(ORG_ID, NUMERO_COMPTE)) + .thenReturn(Optional.of(compte)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture, ecritureNullLignes)); + + byte[] result = service.genererGrandLivre(ORG_ID, NUMERO_COMPTE, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("ligne with null compteComptable — skipped") + void ligneWithNullCompte_skipped() { + CompteComptable compte = buildCompte(NUMERO_COMPTE, "Clients", 4, TypeCompteComptable.ACTIF); + + LigneEcriture ligneNullCompte = buildLigne(null, new BigDecimal("500"), null); + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Test", + List.of(ligneNullCompte)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisationAndNumero(ORG_ID, NUMERO_COMPTE)) + .thenReturn(Optional.of(compte)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture)); + + byte[] result = service.genererGrandLivre(ORG_ID, NUMERO_COMPTE, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("ligne with null montantDebit and null montantCredit — treated as zero") + void ligneWithNullAmounts_treatedAsZero() { + CompteComptable compte = buildCompte(NUMERO_COMPTE, "Clients", 4, TypeCompteComptable.ACTIF); + + LigneEcriture ligne = buildLigne(compte, null, null); + EcritureComptable ecriture = buildEcriture(DATE_DEBUT, "ECR-001", "Test", + List.of(ligne)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisationAndNumero(ORG_ID, NUMERO_COMPTE)) + .thenReturn(Optional.of(compte)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(ecriture)); + + byte[] result = service.genererGrandLivre(ORG_ID, NUMERO_COMPTE, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("multiple matching lignes — cumulative running balance computed correctly") + void multipleMatchingLignes_cumulativeBalance() { + CompteComptable compte = buildCompte(NUMERO_COMPTE, "Clients", 4, TypeCompteComptable.ACTIF); + + LigneEcriture l1 = buildLigne(compte, new BigDecimal("1000"), BigDecimal.ZERO); + LigneEcriture l2 = buildLigne(compte, BigDecimal.ZERO, new BigDecimal("300")); + LigneEcriture l3 = buildLigne(compte, new BigDecimal("200"), BigDecimal.ZERO); + + EcritureComptable e1 = buildEcriture(DATE_DEBUT, "ECR-001", "Débit", List.of(l1, l2)); + EcritureComptable e2 = buildEcriture(DATE_DEBUT.plusDays(1), "ECR-002", "Autre", + List.of(l3)); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisationAndNumero(ORG_ID, NUMERO_COMPTE)) + .thenReturn(Optional.of(compte)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(List.of(e1, e2)); + + byte[] result = service.genererGrandLivre(ORG_ID, NUMERO_COMPTE, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + } + + // ─── addTitrePage — null date range branch ───────────────────────────────── + + @Test + @DisplayName("genererBalance with null dateDebut and dateFin — no période paragraph rendered") + void genererBalance_nullDates_noPeriodeParagraph() { + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)).thenReturn(Collections.emptyList()); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(Collections.emptyList()); + + // null dateDebut and dateFin should NOT add the période line + byte[] result = service.genererBalance(ORG_ID, null, null); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("genererCompteResultat with null dates — works without période paragraph") + void genererCompteResultat_nullDates_noPeriodeParagraph() { + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisation(ORG_ID)).thenReturn(Collections.emptyList()); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(Collections.emptyList()); + + byte[] result = service.genererCompteResultat(ORG_ID, null, null); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("genererGrandLivre with null dates — works without période paragraph") + void genererGrandLivre_nullDates_noPeriodeParagraph() { + CompteComptable compte = buildCompte("411000", "Clients", 4, TypeCompteComptable.ACTIF); + + when(organisationRepository.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(compteComptableRepository.findByOrganisationAndNumero(ORG_ID, "411000")) + .thenReturn(Optional.of(compte)); + when(ecritureComptableRepository.findByOrganisationAndDateRange(eq(ORG_ID), any(), any())) + .thenReturn(Collections.emptyList()); + + byte[] result = service.genererGrandLivre(ORG_ID, "411000", null, null); + assertThat(result).isNotNull().isNotEmpty(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/ComptabiliteServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/ComptabiliteServiceTest.java index 2b4bce0..0113d82 100644 --- a/src/test/java/dev/lions/unionflow/server/service/ComptabiliteServiceTest.java +++ b/src/test/java/dev/lions/unionflow/server/service/ComptabiliteServiceTest.java @@ -1008,4 +1008,97 @@ class ComptabiliteServiceTest { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("équilibrée"); } + + // ========================================================================= + // SYSCOHADA — enregistrerCotisation / enregistrerDepotEpargne / enregistrerRetraitEpargne + // Les méthodes sont non-bloquantes : retournent null si comptes/journal manquants. + // ========================================================================= + + @Test + @TestTransaction + @DisplayName("enregistrerCotisation retourne null si cotisation null") + void enregistrerCotisation_null_returnsNull() { + assertThat(comptabiliteService.enregistrerCotisation(null)).isNull(); + } + + @Test + @TestTransaction + @DisplayName("enregistrerCotisation retourne null si montantPaye est zéro") + void enregistrerCotisation_montantZero_returnsNull() { + dev.lions.unionflow.server.entity.Cotisation cotisation = new dev.lions.unionflow.server.entity.Cotisation(); + cotisation.setMontantPaye(BigDecimal.ZERO); + cotisation.setOrganisation(testOrganisation); + assertThat(comptabiliteService.enregistrerCotisation(cotisation)).isNull(); + } + + @Test + @TestTransaction + @DisplayName("enregistrerCotisation retourne null si plan comptable non initialisé (org sans comptes)") + void enregistrerCotisation_orgSansComptes_returnsNull() { + // Crée une org sans plan comptable (impossible en vrai car le trigger V36 l'initialise) + // Test du chemin "comptes manquants" en utilisant l'org testOrganisation qui peut + // ne pas avoir de compte 512100 si le trigger n'a pas tourné. + // Comportement attendu : null (non bloquant). + dev.lions.unionflow.server.entity.Cotisation cotisation = new dev.lions.unionflow.server.entity.Cotisation(); + cotisation.setMontantPaye(new BigDecimal("5000")); + cotisation.setTypeCotisation("ORDINAIRE"); + cotisation.setCodeDevise("XOF"); + cotisation.setOrganisation(testOrganisation); + // Pas de persist — on teste juste la logique service, non le chemin DB complet + // Si les comptes SYSCOHADA sont là (trigger V36), retournera une EcritureComptable + // Si pas (test sans Flyway complet), retourne null silencieusement + dev.lions.unionflow.server.entity.EcritureComptable result = + comptabiliteService.enregistrerCotisation(cotisation); + // Résultat null ou écriture valide — les deux sont acceptables selon l'état du plan comptable + if (result != null) { + assertThat(result.getMontantDebit()).isEqualByComparingTo("5000"); + assertThat(result.getMontantCredit()).isEqualByComparingTo("5000"); + assertThat(result.isEquilibree()).isTrue(); + } + // Si null : comptes manquants → comportement attendu, pas d'erreur + } + + @Test + @TestTransaction + @DisplayName("enregistrerDepotEpargne retourne null si transaction null") + void enregistrerDepotEpargne_null_returnsNull() { + assertThat(comptabiliteService.enregistrerDepotEpargne(null, testOrganisation)).isNull(); + } + + @Test + @TestTransaction + @DisplayName("enregistrerDepotEpargne retourne null si organisation null") + void enregistrerDepotEpargne_orgNull_returnsNull() { + dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne tx = + new dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne(); + tx.setMontant(new BigDecimal("10000")); + assertThat(comptabiliteService.enregistrerDepotEpargne(tx, null)).isNull(); + } + + @Test + @TestTransaction + @DisplayName("enregistrerDepotEpargne retourne null si montant nul ou zéro") + void enregistrerDepotEpargne_montantZero_returnsNull() { + dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne tx = + new dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne(); + tx.setMontant(BigDecimal.ZERO); + assertThat(comptabiliteService.enregistrerDepotEpargne(tx, testOrganisation)).isNull(); + } + + @Test + @TestTransaction + @DisplayName("enregistrerRetraitEpargne retourne null si transaction null") + void enregistrerRetraitEpargne_null_returnsNull() { + assertThat(comptabiliteService.enregistrerRetraitEpargne(null, testOrganisation)).isNull(); + } + + @Test + @TestTransaction + @DisplayName("enregistrerRetraitEpargne retourne null si montant négatif") + void enregistrerRetraitEpargne_montantNegatif_returnsNull() { + dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne tx = + new dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne(); + tx.setMontant(new BigDecimal("-500")); + assertThat(comptabiliteService.enregistrerRetraitEpargne(tx, testOrganisation)).isNull(); + } } diff --git a/src/test/java/dev/lions/unionflow/server/service/CotisationAutoGenerationServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/CotisationAutoGenerationServiceTest.java new file mode 100644 index 0000000..e1c9a8f --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/CotisationAutoGenerationServiceTest.java @@ -0,0 +1,492 @@ +package dev.lions.unionflow.server.service; + +import dev.lions.unionflow.server.entity.BaremeCotisationRole; +import dev.lions.unionflow.server.entity.Cotisation; +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.entity.MembreOrganisation; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.entity.ParametresCotisationOrganisation; +import dev.lions.unionflow.server.repository.BaremeCotisationRoleRepository; +import dev.lions.unionflow.server.repository.CotisationRepository; +import dev.lions.unionflow.server.repository.MembreOrganisationRepository; +import dev.lions.unionflow.server.repository.ParametresCotisationOrganisationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Tests unitaires pour {@link CotisationAutoGenerationService}. + * Couverture 100% lignes/branches/méthodes. + */ +@DisplayName("Tests CotisationAutoGenerationService") +class CotisationAutoGenerationServiceTest { + + @InjectMocks + CotisationAutoGenerationService service; + + @Mock + ParametresCotisationOrganisationRepository parametresRepository; + + @Mock + MembreOrganisationRepository membreOrganisationRepository; + + @Mock + BaremeCotisationRoleRepository baremeRepository; + + @Mock + CotisationRepository cotisationRepository; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private Organisation buildOrg(String nom) { + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + org.setNom(nom); + return org; + } + + private Membre buildMembre() { + Membre m = new Membre(); + m.setId(UUID.randomUUID()); + return m; + } + + private MembreOrganisation buildMO(Organisation org, Membre membre, String roleOrg) { + MembreOrganisation mo = new MembreOrganisation(); + mo.setId(UUID.randomUUID()); + mo.setOrganisation(org); + mo.setMembre(membre); + mo.setRoleOrg(roleOrg); + return mo; + } + + private ParametresCotisationOrganisation buildParams(Organisation org, + BigDecimal montantMensuel, + String devise) { + ParametresCotisationOrganisation p = new ParametresCotisationOrganisation(); + p.setOrganisation(org); + p.setMontantCotisationMensuelle(montantMensuel); + p.setDevise(devise); + return p; + } + + // ═════════════════════════════════════════════════════════════════════════ + // genererCotisationsMensuelles + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("genererCotisationsMensuelles") + class GenererCotisationsMensuelles { + + @Test + @DisplayName("Aucune organisation avec génération automatique activée — retour immédiat") + void aucuneOrganisationAvecGenerationActivee_retourImmediat() { + when(parametresRepository.findAvecGenerationAutomatiqueActivee()) + .thenReturn(Collections.emptyList()); + + service.genererCotisationsMensuelles(); + + verify(parametresRepository).findAvecGenerationAutomatiqueActivee(); + verifyNoInteractions(membreOrganisationRepository, cotisationRepository, baremeRepository); + } + + @Test + @DisplayName("Une organisation avec membres actifs — cotisations créées") + void uneOrganisationAvecMembresActifs_cotisationsCreees() { + Organisation org = buildOrg("Org Test"); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre, "MEMBRE_ORDINAIRE"); + ParametresCotisationOrganisation params = buildParams(org, new BigDecimal("5000"), "XOF"); + + when(parametresRepository.findAvecGenerationAutomatiqueActivee()) + .thenReturn(List.of(params)); + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(mo)); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + any(), any(), anyInt(), anyInt())).thenReturn(false); + when(baremeRepository.findByOrganisationIdAndRoleOrg(any(), any())) + .thenReturn(Optional.empty()); + + service.genererCotisationsMensuelles(); + + verify(cotisationRepository).persist(any(Cotisation.class)); + } + + @Test + @DisplayName("Plusieurs organisations — totaux accumulés correctement") + void plusieursOrganisations_totauxAccumules() { + Organisation org1 = buildOrg("Org1"); + Organisation org2 = buildOrg("Org2"); + Membre m1 = buildMembre(); + Membre m2 = buildMembre(); + MembreOrganisation mo1 = buildMO(org1, m1, null); + MembreOrganisation mo2 = buildMO(org2, m2, null); + ParametresCotisationOrganisation p1 = buildParams(org1, new BigDecimal("1000"), "XOF"); + ParametresCotisationOrganisation p2 = buildParams(org2, new BigDecimal("2000"), "EUR"); + + when(parametresRepository.findAvecGenerationAutomatiqueActivee()) + .thenReturn(List.of(p1, p2)); + when(membreOrganisationRepository.findMembresActifsParOrganisation(org1.getId())) + .thenReturn(List.of(mo1)); + when(membreOrganisationRepository.findMembresActifsParOrganisation(org2.getId())) + .thenReturn(List.of(mo2)); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + any(), any(), anyInt(), anyInt())).thenReturn(false); + when(baremeRepository.findByOrganisationIdAndRoleOrg(any(), any())) + .thenReturn(Optional.empty()); + + service.genererCotisationsMensuelles(); + + verify(cotisationRepository, times(2)).persist(any(Cotisation.class)); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // genererPourOrganisation + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("genererPourOrganisation") + class GenererPourOrganisation { + + @Test + @DisplayName("Aucun membre actif — retourne [0, 0]") + void aucunMembreActif_retourneZeroZero() { + Organisation org = buildOrg("Vide"); + ParametresCotisationOrganisation params = buildParams(org, new BigDecimal("1000"), "XOF"); + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(Collections.emptyList()); + + int[] result = service.genererPourOrganisation(org, params, 2026, 4); + + assertThat(result).containsExactly(0, 0); + verifyNoInteractions(cotisationRepository, baremeRepository); + } + + @Test + @DisplayName("Membre avec mo.getMembre() == null — ignoré (idempotence skip)") + void membreNull_ignore() { + Organisation org = buildOrg("Org"); + ParametresCotisationOrganisation params = buildParams(org, new BigDecimal("1000"), "XOF"); + + MembreOrganisation moSansMembre = new MembreOrganisation(); + moSansMembre.setId(UUID.randomUUID()); + moSansMembre.setOrganisation(org); + moSansMembre.setMembre(null); // Membre null + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(moSansMembre)); + + int[] result = service.genererPourOrganisation(org, params, 2026, 4); + + assertThat(result).containsExactly(0, 0); + verifyNoInteractions(cotisationRepository, baremeRepository); + } + + @Test + @DisplayName("Cotisation déjà existante pour ce mois — ignorée (idempotence)") + void cotisationDejaExistante_ignoree() { + Organisation org = buildOrg("Org"); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre, "TRESORIER"); + ParametresCotisationOrganisation params = buildParams(org, new BigDecimal("2000"), "XOF"); + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(mo)); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + membre.getId(), org.getId(), 2026, 4)).thenReturn(true); + + int[] result = service.genererPourOrganisation(org, params, 2026, 4); + + assertThat(result[0]).isZero(); // 0 créées + assertThat(result[1]).isEqualTo(1); // 1 ignorée + verify(cotisationRepository, never()).persist(any(Cotisation.class)); + } + + @Test + @DisplayName("Montant résolu à null — cotisation ignorée") + void montantNull_cotisationIgnoree() { + Organisation org = buildOrg("Org"); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre, "PRESIDENT"); + // montantCotisationMensuelle = null (params sans montant) + ParametresCotisationOrganisation params = new ParametresCotisationOrganisation(); + params.setOrganisation(org); + params.setMontantCotisationMensuelle(null); + params.setDevise("XOF"); + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(mo)); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + any(), any(), anyInt(), anyInt())).thenReturn(false); + when(baremeRepository.findByOrganisationIdAndRoleOrg(any(), any())) + .thenReturn(Optional.empty()); + + int[] result = service.genererPourOrganisation(org, params, 2026, 4); + + assertThat(result[0]).isZero(); + assertThat(result[1]).isEqualTo(1); // ignorée car montant null + verify(cotisationRepository, never()).persist(any(Cotisation.class)); + } + + @Test + @DisplayName("Montant résolu à zéro — cotisation ignorée") + void montantZero_cotisationIgnoree() { + Organisation org = buildOrg("Org"); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre, "SECRETAIRE"); + ParametresCotisationOrganisation params = buildParams(org, BigDecimal.ZERO, "XOF"); + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(mo)); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + any(), any(), anyInt(), anyInt())).thenReturn(false); + when(baremeRepository.findByOrganisationIdAndRoleOrg(any(), any())) + .thenReturn(Optional.empty()); + + int[] result = service.genererPourOrganisation(org, params, 2026, 4); + + assertThat(result[0]).isZero(); + assertThat(result[1]).isEqualTo(1); + verify(cotisationRepository, never()).persist(any(Cotisation.class)); + } + + @Test + @DisplayName("Cotisation créée — champs de la cotisation correctement renseignés") + void cotisationCreee_champsCorrects() { + Organisation org = buildOrg("AssocTest"); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre, null); + ParametresCotisationOrganisation params = buildParams(org, new BigDecimal("3000"), "XOF"); + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(mo)); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + any(), any(), anyInt(), anyInt())).thenReturn(false); + + int[] result = service.genererPourOrganisation(org, params, 2026, 4); + + assertThat(result[0]).isEqualTo(1); + assertThat(result[1]).isZero(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Cotisation.class); + verify(cotisationRepository).persist(captor.capture()); + + Cotisation cot = captor.getValue(); + assertThat(cot.getTypeCotisation()).isEqualTo("MENSUELLE"); + assertThat(cot.getStatut()).isEqualTo("EN_ATTENTE"); + assertThat(cot.getMontantDu()).isEqualByComparingTo(new BigDecimal("3000")); + assertThat(cot.getMontantPaye()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(cot.getCodeDevise()).isEqualTo("XOF"); + assertThat(cot.getAnnee()).isEqualTo(2026); + assertThat(cot.getMois()).isEqualTo(4); + assertThat(cot.getRecurrente()).isTrue(); + assertThat(cot.getMembre()).isEqualTo(membre); + assertThat(cot.getOrganisation()).isEqualTo(org); + assertThat(cot.getLibelle()).contains("04/2026"); + assertThat(cot.getDescription()).contains("AssocTest"); + assertThat(cot.getNumeroReference()).isNotBlank(); + } + + @Test + @DisplayName("Devise null dans params — utilise 'XOF' par défaut") + void deviseNull_utilisexof() { + Organisation org = buildOrg("Org"); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre, null); + ParametresCotisationOrganisation params = buildParams(org, new BigDecimal("1000"), null); // devise null + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(mo)); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + any(), any(), anyInt(), anyInt())).thenReturn(false); + + service.genererPourOrganisation(org, params, 2026, 1); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Cotisation.class); + verify(cotisationRepository).persist(captor.capture()); + assertThat(captor.getValue().getCodeDevise()).isEqualTo("XOF"); + } + + @Test + @DisplayName("Plusieurs membres — accumule crees et ignores correctement") + void plusieursMembres_accumulCorrectement() { + Organisation org = buildOrg("Org"); + Membre m1 = buildMembre(); + Membre m2 = buildMembre(); + Membre m3 = buildMembre(); + MembreOrganisation mo1 = buildMO(org, m1, null); + MembreOrganisation mo2 = buildMO(org, m2, null); + MembreOrganisation mo3 = buildMO(org, m3, null); + ParametresCotisationOrganisation params = buildParams(org, new BigDecimal("1000"), "XOF"); + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(mo1, mo2, mo3)); + // m1 déjà existant, m2 créé, m3 créé + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + eq(m1.getId()), any(), anyInt(), anyInt())).thenReturn(true); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + eq(m2.getId()), any(), anyInt(), anyInt())).thenReturn(false); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + eq(m3.getId()), any(), anyInt(), anyInt())).thenReturn(false); + + int[] result = service.genererPourOrganisation(org, params, 2026, 3); + + assertThat(result[0]).isEqualTo(2); // 2 créées + assertThat(result[1]).isEqualTo(1); // 1 ignorée (existante) + verify(cotisationRepository, times(2)).persist(any(Cotisation.class)); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // resoudreMontantMensuel (tested indirectly via genererPourOrganisation) + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("resoudreMontantMensuel — résolution du montant") + class ResoudreMontantMensuel { + + @Test + @DisplayName("roleOrg null — utilise montant par défaut de params") + void roleOrgNull_utiliseMontantParDefaut() { + Organisation org = buildOrg("Org"); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre, null); + BigDecimal montantDefaut = new BigDecimal("4500"); + ParametresCotisationOrganisation params = buildParams(org, montantDefaut, "XOF"); + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(mo)); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + any(), any(), anyInt(), anyInt())).thenReturn(false); + + service.genererPourOrganisation(org, params, 2026, 5); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Cotisation.class); + verify(cotisationRepository).persist(captor.capture()); + assertThat(captor.getValue().getMontantDu()).isEqualByComparingTo(montantDefaut); + // baremeRepository ne doit pas être appelé si roleOrg est null + verify(baremeRepository, never()).findByOrganisationIdAndRoleOrg(any(), any()); + } + + @Test + @DisplayName("roleOrg blank — utilise montant par défaut (branche isBlank)") + void roleOrgBlank_utiliseMontantParDefaut() { + Organisation org = buildOrg("Org"); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre, " "); // blank + BigDecimal montantDefaut = new BigDecimal("2000"); + ParametresCotisationOrganisation params = buildParams(org, montantDefaut, "XOF"); + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(mo)); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + any(), any(), anyInt(), anyInt())).thenReturn(false); + + service.genererPourOrganisation(org, params, 2026, 5); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Cotisation.class); + verify(cotisationRepository).persist(captor.capture()); + assertThat(captor.getValue().getMontantDu()).isEqualByComparingTo(montantDefaut); + verify(baremeRepository, never()).findByOrganisationIdAndRoleOrg(any(), any()); + } + + @Test + @DisplayName("roleOrg présent, barème trouvé avec montant — utilise montant du barème") + void roleOrgPresent_baremesTrouve_utiliseMontantBareme() { + Organisation org = buildOrg("Org"); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre, "PRESIDENT"); + BigDecimal montantBareme = new BigDecimal("10000"); + ParametresCotisationOrganisation params = buildParams(org, new BigDecimal("1000"), "XOF"); + + BaremeCotisationRole bareme = new BaremeCotisationRole(); + bareme.setMontantMensuel(montantBareme); + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(mo)); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + any(), any(), anyInt(), anyInt())).thenReturn(false); + when(baremeRepository.findByOrganisationIdAndRoleOrg(org.getId(), "PRESIDENT")) + .thenReturn(Optional.of(bareme)); + + service.genererPourOrganisation(org, params, 2026, 5); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Cotisation.class); + verify(cotisationRepository).persist(captor.capture()); + assertThat(captor.getValue().getMontantDu()).isEqualByComparingTo(montantBareme); + } + + @Test + @DisplayName("roleOrg présent, barème trouvé mais montantMensuel null — utilise montant défaut") + void roleOrgPresent_baremesMontantNull_utiliseMontantDefaut() { + Organisation org = buildOrg("Org"); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre, "TRESORIER"); + BigDecimal montantDefaut = new BigDecimal("7500"); + ParametresCotisationOrganisation params = buildParams(org, montantDefaut, "XOF"); + + BaremeCotisationRole baremeAvecMontantNull = new BaremeCotisationRole(); + baremeAvecMontantNull.setMontantMensuel(null); // montant null dans barème + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(mo)); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + any(), any(), anyInt(), anyInt())).thenReturn(false); + when(baremeRepository.findByOrganisationIdAndRoleOrg(org.getId(), "TRESORIER")) + .thenReturn(Optional.of(baremeAvecMontantNull)); + + service.genererPourOrganisation(org, params, 2026, 5); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Cotisation.class); + verify(cotisationRepository).persist(captor.capture()); + assertThat(captor.getValue().getMontantDu()).isEqualByComparingTo(montantDefaut); + } + + @Test + @DisplayName("roleOrg présent, barème absent — utilise montant par défaut") + void roleOrgPresent_baremeAbsent_utiliseMontantDefaut() { + Organisation org = buildOrg("Org"); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre, "SECRETAIRE"); + BigDecimal montantDefaut = new BigDecimal("5000"); + ParametresCotisationOrganisation params = buildParams(org, montantDefaut, "XOF"); + + when(membreOrganisationRepository.findMembresActifsParOrganisation(org.getId())) + .thenReturn(List.of(mo)); + when(cotisationRepository.existsByMembreOrganisationAnneeAndMois( + any(), any(), anyInt(), anyInt())).thenReturn(false); + when(baremeRepository.findByOrganisationIdAndRoleOrg(org.getId(), "SECRETAIRE")) + .thenReturn(Optional.empty()); + + service.genererPourOrganisation(org, params, 2026, 5); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Cotisation.class); + verify(cotisationRepository).persist(captor.capture()); + assertThat(captor.getValue().getMontantDu()).isEqualByComparingTo(montantDefaut); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/EmailTemplateServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/EmailTemplateServiceTest.java new file mode 100644 index 0000000..d57c838 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/EmailTemplateServiceTest.java @@ -0,0 +1,458 @@ +package dev.lions.unionflow.server.service; + +import io.quarkus.mailer.MailTemplate; +import io.quarkus.mailer.Mailer; +import io.smallrye.mutiny.Uni; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests unitaires pour {@link EmailTemplateService}. + * + *

Stratégie : les méthodes de Templates sont {@code static native} (Qute @CheckedTemplate). + * Pour tester sans démarrer le moteur Qute, on injecte le {@code Mailer} via réflexion, + * puis on stubbed les méthodes d'envoi avec {@link MockedStatic} sur la classe interne Templates. + * + *

Deux chemins couverts pour chaque méthode publique : + *

    + *
  1. Chemin nominal (happy path) — le mailer envoie sans erreur.
  2. + *
  3. Chemin d'erreur (catch) — le mailer lève une exception → pas de propagation.
  4. + *
+ */ +@DisplayName("Tests EmailTemplateService") +class EmailTemplateServiceTest { + + @InjectMocks + EmailTemplateService service; + + @Mock + Mailer mailer; + + @BeforeEach + void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + // Injecter le mailer mocké via réflexion (champ @Inject non accessible par @InjectMocks seul) + injectField(service, "mailer", mailer); + } + + private void injectField(Object target, String fieldName, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + f.set(target, value); + } + + /** + * Helper : crée un MailTemplate.MailTemplateInstance mocké capable de retourner + * le bon type pour chaque méthode chainée. + */ + @SuppressWarnings("unchecked") + private MailTemplate.MailTemplateInstance buildChainedMailMock() { + MailTemplate.MailTemplateInstance instance = mock(MailTemplate.MailTemplateInstance.class); + when(instance.to(anyString())).thenReturn(instance); + when(instance.subject(anyString())).thenReturn(instance); + Uni uniMock = mock(Uni.class); + when(instance.send()).thenReturn(uniMock); + io.smallrye.mutiny.groups.UniAwait awaitMock = + mock(io.smallrye.mutiny.groups.UniAwait.class); + when(uniMock.await()).thenReturn(awaitMock); + // indefinitely() retourne Void — null est correct pour Void + when(awaitMock.indefinitely()).thenReturn(null); + return instance; + } + + /** + * Helper : crée un MailTemplate.MailTemplateInstance mocké qui lève une exception à l'envoi. + */ + @SuppressWarnings("unchecked") + private MailTemplate.MailTemplateInstance buildFailingMailMock(RuntimeException ex) { + MailTemplate.MailTemplateInstance instance = mock(MailTemplate.MailTemplateInstance.class); + when(instance.to(anyString())).thenReturn(instance); + when(instance.subject(anyString())).thenReturn(instance); + Uni uniMock = mock(Uni.class); + when(instance.send()).thenReturn(uniMock); + var awaitMock = mock(io.smallrye.mutiny.groups.UniAwait.class); + when(uniMock.await()).thenReturn(awaitMock); + when(awaitMock.indefinitely()).thenThrow(ex); + return instance; + } + + // ═════════════════════════════════════════════════════════════════════════ + // envoyerBienvenue + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("envoyerBienvenue") + class EnvoyerBienvenue { + + @Test + @DisplayName("Happy path — envoi réussi, pas d'exception") + void happyPath_envoiReussi() { + MailTemplate.MailTemplateInstance mailMock = buildChainedMailMock(); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.bienvenue( + anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerBienvenue( + "marie@test.com", "Marie", "Dupont", + "Association Test", "https://app.unionflow.dev/login")) + .doesNotThrowAnyException(); + + verify(mailMock).to("marie@test.com"); + verify(mailMock).subject(argThat(s -> s.contains("Association Test"))); + } + } + + @Test + @DisplayName("Exception lors de l'envoi — attrapée, pas de propagation") + void exceptionEnvoi_attrapeeSansPropagation() { + MailTemplate.MailTemplateInstance mailMock = + buildFailingMailMock(new RuntimeException("SMTP indisponible")); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.bienvenue( + anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerBienvenue( + "fail@test.com", "Fail", "Test", "Org", "http://link")) + .doesNotThrowAnyException(); + } + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // envoyerConfirmationCotisation + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("envoyerConfirmationCotisation") + class EnvoyerConfirmationCotisation { + + @Test + @DisplayName("Happy path avec datePaiement non null — envoi réussi") + void happyPath_datePaiementNonNull() { + MailTemplate.MailTemplateInstance mailMock = buildChainedMailMock(); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.cotisationConfirmation( + anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString(), anyString())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerConfirmationCotisation( + "marie@test.com", "Marie", "Dupont", + "Org Test", "04/2026", + "COT-2026-00000001", "WAVE", + LocalDate.of(2026, 4, 15), new BigDecimal("5000"))) + .doesNotThrowAnyException(); + + verify(mailMock).to("marie@test.com"); + } + } + + @Test + @DisplayName("datePaiement null — utilise '—' comme valeur de date") + void datePaiementNull_utiliseTiret() { + MailTemplate.MailTemplateInstance mailMock = buildChainedMailMock(); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.cotisationConfirmation( + anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), eq("—"), anyString())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerConfirmationCotisation( + "test@test.com", "Test", "User", + "Org", "01/2026", "REF", "ESPECES", + null, new BigDecimal("2000"))) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("Exception lors de l'envoi — attrapée, pas de propagation") + void exceptionEnvoi_attrapeeSansPropagation() { + MailTemplate.MailTemplateInstance mailMock = + buildFailingMailMock(new RuntimeException("Timeout")); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.cotisationConfirmation( + anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString(), anyString())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerConfirmationCotisation( + "err@test.com", "Err", "User", "Org", "02/2026", + "REF-002", "VIREMENT", LocalDate.now(), BigDecimal.TEN)) + .doesNotThrowAnyException(); + } + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // envoyerRappelCotisation + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("envoyerRappelCotisation") + class EnvoyerRappelCotisation { + + @Test + @DisplayName("Happy path avec dateLimite non null — envoi réussi") + void happyPath_dateLimiteNonNull() { + MailTemplate.MailTemplateInstance mailMock = buildChainedMailMock(); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.rappelCotisation( + anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerRappelCotisation( + "rappel@test.com", "Paul", "Martin", + "Club Lions", "03/2026", + new BigDecimal("1500"), LocalDate.of(2026, 3, 28), + "https://pay.unionflow.dev")) + .doesNotThrowAnyException(); + + verify(mailMock).to("rappel@test.com"); + } + } + + @Test + @DisplayName("dateLimite null — utilise '—' comme valeur de date") + void dateLimiteNull_utiliseTiret() { + MailTemplate.MailTemplateInstance mailMock = buildChainedMailMock(); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.rappelCotisation( + anyString(), anyString(), anyString(), anyString(), + anyString(), eq("—"), anyString())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerRappelCotisation( + "test@test.com", "Test", "User", "Org", "05/2026", + new BigDecimal("3000"), null, "https://link")) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("Exception lors de l'envoi — attrapée, pas de propagation") + void exceptionEnvoi_attrapeeSansPropagation() { + MailTemplate.MailTemplateInstance mailMock = + buildFailingMailMock(new RuntimeException("Connection refused")); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.rappelCotisation( + anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerRappelCotisation( + "err@test.com", "Err", "User", "Org", "06/2026", + BigDecimal.valueOf(500), LocalDate.now(), "http://link")) + .doesNotThrowAnyException(); + } + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // envoyerConfirmationSouscription + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("envoyerConfirmationSouscription") + class EnvoyerConfirmationSouscription { + + @Test + @DisplayName("Happy path — toutes dates et valeurs présentes — envoi réussi") + void happyPath_toutesValeursPresentes() { + MailTemplate.MailTemplateInstance mailMock = buildChainedMailMock(); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.souscriptionConfirmation( + anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString(), + anyString(), anyString(), anyBoolean(), anyBoolean())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerConfirmationSouscription( + "admin@test.com", "Admin User", + "Association Test", "Plan Pro", + new BigDecimal("50000"), "MENSUEL", + LocalDate.of(2026, 4, 1), LocalDate.of(2027, 4, 1), + 100, 2048, true, true)) + .doesNotThrowAnyException(); + + verify(mailMock).to("admin@test.com"); + } + } + + @Test + @DisplayName("dateActivation null — utilise '—'") + void dateActivationNull_utiliseTiret() { + MailTemplate.MailTemplateInstance mailMock = buildChainedMailMock(); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.souscriptionConfirmation( + anyString(), anyString(), anyString(), anyString(), + anyString(), eq("—"), anyString(), + anyString(), anyString(), anyBoolean(), anyBoolean())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerConfirmationSouscription( + "test@test.com", "Admin", + "Org", "Plan Basique", + new BigDecimal("10000"), "ANNUEL", + null, LocalDate.of(2027, 1, 1), + 50, 512, false, false)) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("dateExpiration null — utilise '—'") + void dateExpirationNull_utiliseTiret() { + MailTemplate.MailTemplateInstance mailMock = buildChainedMailMock(); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.souscriptionConfirmation( + anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), eq("—"), + anyString(), anyString(), anyBoolean(), anyBoolean())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerConfirmationSouscription( + "test@test.com", "Admin", + "Org", "Plan", + new BigDecimal("5000"), "MENSUEL", + LocalDate.of(2026, 4, 1), null, + null, null, false, true)) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("maxMembres null — utilise 'Illimité'") + void maxMembresNull_utiliseIllimite() { + MailTemplate.MailTemplateInstance mailMock = buildChainedMailMock(); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + // Capture l'appel pour vérifier "Illimité" + staticMock.when(() -> EmailTemplateService.Templates.souscriptionConfirmation( + anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString(), + eq("Illimité"), anyString(), anyBoolean(), anyBoolean())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerConfirmationSouscription( + "test@test.com", "Admin", + "Org", "Plan Illimité", + new BigDecimal("20000"), "MENSUEL", + LocalDate.now(), LocalDate.now().plusYears(1), + null, 1024, true, false)) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("maxStockageMo null — utilise '1024' par défaut") + void maxStockageMoNull_utilise1024() { + MailTemplate.MailTemplateInstance mailMock = buildChainedMailMock(); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.souscriptionConfirmation( + anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString(), + anyString(), eq("1024"), anyBoolean(), anyBoolean())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerConfirmationSouscription( + "test@test.com", "Admin", + "Org", "Plan", + new BigDecimal("5000"), "MENSUEL", + LocalDate.now(), LocalDate.now().plusYears(1), + 50, null, false, false)) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("Exception lors de l'envoi — attrapée, pas de propagation") + void exceptionEnvoi_attrapeeSansPropagation() { + MailTemplate.MailTemplateInstance mailMock = + buildFailingMailMock(new RuntimeException("SSL error")); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.souscriptionConfirmation( + anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString(), + anyString(), anyString(), anyBoolean(), anyBoolean())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerConfirmationSouscription( + "err@test.com", "Admin", + "Org", "Plan", + new BigDecimal("100"), "MENSUEL", + null, null, + null, null, false, false)) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("maxMembres présent et maxStockageMo présent — valeurs numériques utilisées") + void maxMembresEtStockagePresents_valeursNumeriques() { + MailTemplate.MailTemplateInstance mailMock = buildChainedMailMock(); + + try (MockedStatic staticMock = + Mockito.mockStatic(EmailTemplateService.Templates.class)) { + staticMock.when(() -> EmailTemplateService.Templates.souscriptionConfirmation( + anyString(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyString(), + eq("200"), eq("4096"), anyBoolean(), anyBoolean())) + .thenReturn(mailMock); + + assertThatCode(() -> service.envoyerConfirmationSouscription( + "test@test.com", "Admin", + "Org", "Plan Enterprise", + new BigDecimal("100000"), "ANNUEL", + LocalDate.of(2026, 1, 1), LocalDate.of(2027, 1, 1), + 200, 4096, true, true)) + .doesNotThrowAnyException(); + } + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/FirebasePushServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/FirebasePushServiceTest.java new file mode 100644 index 0000000..b3d0a9f --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/FirebasePushServiceTest.java @@ -0,0 +1,91 @@ +package dev.lions.unionflow.server.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests unitaires pour FirebasePushService. + * Firebase n'est pas initialisé (serviceAccountKeyPath vide) → tests du comportement + * when disabled. + */ +@ExtendWith(MockitoExtension.class) +class FirebasePushServiceTest { + + private FirebasePushService service; + + @BeforeEach + void setup() throws Exception { + service = new FirebasePushService(); + // Injecter une clé vide pour simuler Firebase désactivé + Field keyField = FirebasePushService.class.getDeclaredField("serviceAccountKeyPath"); + keyField.setAccessible(true); + keyField.set(service, ""); + service.init(); + } + + @Test + void isAvailable_returnsFalse_whenNotInitialized() { + assertThat(service.isAvailable()).isFalse(); + } + + @Test + void envoyerNotification_returnsFalse_whenNotInitialized() { + boolean result = service.envoyerNotification("someToken", "titre", "corps", Map.of()); + assertThat(result).isFalse(); + } + + @Test + void envoyerNotification_returnsFalse_whenTokenNull() { + boolean result = service.envoyerNotification(null, "titre", "corps", null); + assertThat(result).isFalse(); + } + + @Test + void envoyerNotification_returnsFalse_whenTokenBlank() { + boolean result = service.envoyerNotification(" ", "titre", "corps", null); + assertThat(result).isFalse(); + } + + @Test + void envoyerNotificationMulticast_returnsZero_whenNotInitialized() { + int count = service.envoyerNotificationMulticast(List.of("t1", "t2"), "titre", "corps", null); + assertThat(count).isZero(); + } + + @Test + void envoyerNotificationMulticast_returnsZero_whenTokensNull() { + int count = service.envoyerNotificationMulticast(null, "titre", "corps", null); + assertThat(count).isZero(); + } + + @Test + void envoyerNotificationMulticast_returnsZero_whenTokensEmpty() { + int count = service.envoyerNotificationMulticast(List.of(), "titre", "corps", null); + assertThat(count).isZero(); + } + + @Test + void init_logsAndDoesNotThrow_whenKeyPathBlank() { + // Vérifié implicitement par le @BeforeEach : pas d'exception levée + assertThat(service.isAvailable()).isFalse(); + } + + @Test + void init_doesNotThrow_whenFileNotFound() throws Exception { + FirebasePushService svc = new FirebasePushService(); + Field keyField = FirebasePushService.class.getDeclaredField("serviceAccountKeyPath"); + keyField.setAccessible(true); + keyField.set(svc, "/nonexistent/path/firebase.json"); + // Ne doit pas lever d'exception — juste logger un warning + svc.init(); + assertThat(svc.isAvailable()).isFalse(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/KeycloakAdminHttpClientTest.java b/src/test/java/dev/lions/unionflow/server/service/KeycloakAdminHttpClientTest.java new file mode 100644 index 0000000..5c41e44 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/KeycloakAdminHttpClientTest.java @@ -0,0 +1,237 @@ +package dev.lions.unionflow.server.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for KeycloakAdminHttpClient. + * + * Because HttpClient is created as a final field (not injectable), we use + * reflection to replace it with a mock after instantiation. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests KeycloakAdminHttpClient") +class KeycloakAdminHttpClientTest { + + // We instantiate the service manually to inject the mock HttpClient via reflection + private KeycloakAdminHttpClient service; + + @Mock + private HttpClient mockHttpClient; + + @SuppressWarnings("unchecked") + private HttpResponse mockResponse(int statusCode, String body) { + HttpResponse resp = mock(HttpResponse.class); + when(resp.statusCode()).thenReturn(statusCode); + when(resp.body()).thenReturn(body); + return resp; + } + + @BeforeEach + void setUp() throws Exception { + service = new KeycloakAdminHttpClient(); + + // Inject config values via reflection + setField(service, "keycloakUrl", "http://localhost:8180"); + setField(service, "adminUsername", "admin"); + setField(service, "adminPassword", "admin"); + setField(service, "realm", "unionflow"); + + // Replace the final httpClient field with our mock + setField(service, "httpClient", mockHttpClient); + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + Field f = KeycloakAdminHttpClient.class.getDeclaredField(fieldName); + f.setAccessible(true); + f.set(target, value); + } + + // ── logoutAllSessions — happy path ───────────────────────────────────────── + + @Test + @DisplayName("logoutAllSessions — should logout all users and return count") + void logoutAllSessions_happyPath_returnsCount() throws Exception { + // Token response + HttpResponse tokenResp = mockResponse(200, + "{\"access_token\":\"my-token-123\"}"); + + // Users response: 2 users + String usersJson = "[{\"id\":\"uid-1\",\"username\":\"alice\"}," + + "{\"id\":\"uid-2\",\"username\":\"bob\"}]"; + HttpResponse usersResp = mockResponse(200, usersJson); + + // Logout responses: 204 for first, 200 for second + HttpResponse logoutResp1 = mockResponse(204, ""); + HttpResponse logoutResp2 = mockResponse(200, ""); + + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(tokenResp) + .thenReturn(usersResp) + .thenReturn(logoutResp1) + .thenReturn(logoutResp2); + + int result = service.logoutAllSessions(); + + assertThat(result).isEqualTo(2); + // token + users + 2 logout calls = 4 total + verify(mockHttpClient, times(4)).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); + } + + @Test + @DisplayName("logoutAllSessions — some logout calls fail (non 200/204) — partial count returned") + void logoutAllSessions_partialLogout_returnsPartialCount() throws Exception { + HttpResponse tokenResp = mockResponse(200, + "{\"access_token\":\"token\"}"); + + String usersJson = "[{\"id\":\"uid-1\",\"username\":\"alice\"}," + + "{\"id\":\"uid-2\",\"username\":\"bob\"}]"; + HttpResponse usersResp = mockResponse(200, usersJson); + + // First succeeds (204), second fails (500) + HttpResponse logoutOk = mockResponse(204, ""); + HttpResponse logoutFail = mockResponse(500, "error"); + + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(tokenResp) + .thenReturn(usersResp) + .thenReturn(logoutOk) + .thenReturn(logoutFail); + + int result = service.logoutAllSessions(); + + assertThat(result).isEqualTo(1); + } + + @Test + @DisplayName("logoutAllSessions — empty user list — returns 0") + void logoutAllSessions_noUsers_returnsZero() throws Exception { + HttpResponse tokenResp = mockResponse(200, + "{\"access_token\":\"token\"}"); + HttpResponse usersResp = mockResponse(200, "[]"); + + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(tokenResp) + .thenReturn(usersResp); + + int result = service.logoutAllSessions(); + + assertThat(result).isEqualTo(0); + } + + @Test + @DisplayName("logoutAllSessions — user without username field — uses id as fallback") + void logoutAllSessions_userWithoutUsernameField_usesIdAsFallback() throws Exception { + HttpResponse tokenResp = mockResponse(200, + "{\"access_token\":\"token\"}"); + + // user has no "username" field — only "id" + String usersJson = "[{\"id\":\"uid-no-username\"}]"; + HttpResponse usersResp = mockResponse(200, usersJson); + HttpResponse logoutResp = mockResponse(204, ""); + + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(tokenResp) + .thenReturn(usersResp) + .thenReturn(logoutResp); + + int result = service.logoutAllSessions(); + + assertThat(result).isEqualTo(1); + } + + // ── getAdminToken failure paths ──────────────────────────────────────────── + + @Test + @DisplayName("logoutAllSessions — token request fails with non-200 — throws RuntimeException") + void logoutAllSessions_tokenRequestFails_throwsException() throws Exception { + HttpResponse tokenResp = mockResponse(401, "Unauthorized"); + + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(tokenResp); + + assertThatThrownBy(() -> service.logoutAllSessions()) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Échec authentification admin Keycloak") + .hasMessageContaining("401"); + } + + @Test + @DisplayName("logoutAllSessions — users list request fails — throws RuntimeException") + void logoutAllSessions_usersRequestFails_throwsException() throws Exception { + HttpResponse tokenResp = mockResponse(200, + "{\"access_token\":\"token\"}"); + HttpResponse usersResp = mockResponse(403, "Forbidden"); + + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(tokenResp) + .thenReturn(usersResp); + + assertThatThrownBy(() -> service.logoutAllSessions()) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Impossible de lister les utilisateurs Keycloak") + .hasMessageContaining("403"); + } + + @Test + @DisplayName("logoutAllSessions — HttpClient throws IOException — propagates exception") + void logoutAllSessions_ioException_propagates() throws Exception { + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new java.io.IOException("Connection refused")); + + assertThatThrownBy(() -> service.logoutAllSessions()) + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("logoutAllSessions — special chars in password — URL-encodes correctly") + void logoutAllSessions_specialCharsInPassword_urlEncoded() throws Exception { + setField(service, "adminPassword", "p@ss w0rd+special=chars&more"); + + HttpResponse tokenResp = mockResponse(200, + "{\"access_token\":\"token\"}"); + HttpResponse usersResp = mockResponse(200, "[]"); + + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(tokenResp) + .thenReturn(usersResp); + + // Should not throw — URL encoding is handled internally + int result = service.logoutAllSessions(); + assertThat(result).isEqualTo(0); + } + + @Test + @DisplayName("logoutAllSessions — custom realm name used in URL") + void logoutAllSessions_customRealm_usedInUrl() throws Exception { + setField(service, "realm", "my-custom-realm"); + + HttpResponse tokenResp = mockResponse(200, + "{\"access_token\":\"token\"}"); + HttpResponse usersResp = mockResponse(200, "[]"); + + when(mockHttpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(tokenResp) + .thenReturn(usersResp); + + int result = service.logoutAllSessions(); + assertThat(result).isEqualTo(0); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/KycAmlServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/KycAmlServiceTest.java new file mode 100644 index 0000000..7c3f905 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/KycAmlServiceTest.java @@ -0,0 +1,147 @@ +package dev.lions.unionflow.server.service; + +import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc; +import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite; +import dev.lions.unionflow.server.entity.KycDossier; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests unitaires du moteur de scoring risque KYC/AML. + * Les méthodes transactionnelles nécessitent @QuarkusTest (tests d'intégration P2.4). + */ +class KycAmlServiceTest { + + private final KycAmlService service = new KycAmlService(); + + private KycDossier dossierComplet() { + return KycDossier.builder() + .typePiece(TypePieceIdentite.CNI) + .numeroPiece("CI-12345678") + .dateExpirationPiece(LocalDate.now().plusYears(2)) + .pieceIdentiteRectoFileId("file-recto-001") + .pieceIdentiteVersoFileId("file-verso-001") + .justifDomicileFileId("file-domicile-001") + .estPep(false) + .nationalite("CI") + .build(); + } + + @Test + @DisplayName("Score 0 — dossier complet, non-PEP, nationalité UEMOA, pièce valide") + void score_dossierParfait_zero() { + assertThat(service.calculerScore(dossierComplet())).isEqualTo(0); + } + + @Test + @DisplayName("Score +40 — PEP") + void score_pep_plusQuarante() { + KycDossier d = dossierComplet(); + d.setEstPep(true); + assertThat(service.calculerScore(d)).isEqualTo(40); + } + + @Test + @DisplayName("Score +20 — pièce expirée") + void score_pieceExpiree_plusVingt() { + KycDossier d = dossierComplet(); + d.setDateExpirationPiece(LocalDate.now().minusDays(1)); + assertThat(service.calculerScore(d)).isEqualTo(20); + } + + @Test + @DisplayName("Score +15 — justif domicile manquant") + void score_justifDomicileManquant_plusQuinze() { + KycDossier d = dossierComplet(); + d.setJustifDomicileFileId(null); + assertThat(service.calculerScore(d)).isEqualTo(15); + } + + @Test + @DisplayName("Score +15 — pièce recto manquante") + void score_rectoManquant_plusQuinze() { + KycDossier d = dossierComplet(); + d.setPieceIdentiteRectoFileId(null); + assertThat(service.calculerScore(d)).isEqualTo(15); + } + + @Test + @DisplayName("Score +10 — nationalité hors UEMOA") + void score_horsUemoa_plusDix() { + KycDossier d = dossierComplet(); + d.setNationalite("FR"); + assertThat(service.calculerScore(d)).isEqualTo(10); + } + + @Test + @DisplayName("Score plafonné à 100") + void score_toutFaux_plafonneCent() { + KycDossier d = KycDossier.builder() + .typePiece(TypePieceIdentite.PASSEPORT) + .numeroPiece("P-999") + .dateExpirationPiece(LocalDate.now().minusDays(1)) // +20 + .estPep(true) // +40 + .nationalite("CN") // +10 + .build(); + // justif domicile null = +15, recto/verso null = +15 → total 100 + int score = service.calculerScore(d); + assertThat(score).isEqualTo(100); + } + + @Test + @DisplayName("NiveauRisqueKyc.fromScore — score 0 → FAIBLE") + void fromScore_zero_faible() { + assertThat(NiveauRisqueKyc.fromScore(0)).isEqualTo(NiveauRisqueKyc.FAIBLE); + } + + @Test + @DisplayName("NiveauRisqueKyc.fromScore — score 40 → MOYEN") + void fromScore_40_moyen() { + assertThat(NiveauRisqueKyc.fromScore(40)).isEqualTo(NiveauRisqueKyc.MOYEN); + } + + @Test + @DisplayName("NiveauRisqueKyc.fromScore — score 70 → ELEVE") + void fromScore_70_eleve() { + assertThat(NiveauRisqueKyc.fromScore(70)).isEqualTo(NiveauRisqueKyc.ELEVE); + } + + @Test + @DisplayName("NiveauRisqueKyc.fromScore — score 90 → CRITIQUE") + void fromScore_90_critique() { + assertThat(NiveauRisqueKyc.fromScore(90)).isEqualTo(NiveauRisqueKyc.CRITIQUE); + } + + @Test + @DisplayName("NiveauRisqueKyc.fromScore — score 100 → CRITIQUE") + void fromScore_cent_critique() { + assertThat(NiveauRisqueKyc.fromScore(100)).isEqualTo(NiveauRisqueKyc.CRITIQUE); + } + + @Test + @DisplayName("isPieceExpiree — date future → false") + void isPieceExpiree_dateFuture_false() { + KycDossier d = dossierComplet(); + assertThat(d.isPieceExpiree()).isFalse(); + } + + @Test + @DisplayName("isPieceExpiree — date passée → true") + void isPieceExpiree_datePassee_true() { + KycDossier d = dossierComplet(); + d.setDateExpirationPiece(LocalDate.now().minusDays(1)); + assertThat(d.isPieceExpiree()).isTrue(); + } + + @Test + @DisplayName("isPieceExpiree — date null → false") + void isPieceExpiree_null_false() { + KycDossier d = dossierComplet(); + d.setDateExpirationPiece(null); + assertThat(d.isPieceExpiree()).isFalse(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/MembreRoleSyncServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/MembreRoleSyncServiceTest.java new file mode 100644 index 0000000..1bc60fb --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/MembreRoleSyncServiceTest.java @@ -0,0 +1,372 @@ +package dev.lions.unionflow.server.service; + +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.entity.MembreOrganisation; +import dev.lions.unionflow.server.entity.MembreRole; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.entity.Role; +import dev.lions.unionflow.server.repository.MembreRoleRepository; +import dev.lions.unionflow.server.repository.RoleRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Tests unitaires pour {@link MembreRoleSyncService}. + * Couverture 100% lignes/branches/méthodes. + * + *

Cas couverts : + *

    + *
  • mo == null → retour immédiat
  • + *
  • mo.getId() == null → retour immédiat
  • + *
  • mo.getOrganisation() == null → retour immédiat
  • + *
  • MembreRole ORGADMIN existant → retour immédiat (idempotence)
  • + *
  • Rôle ORGADMIN absent de la table → log warn, pas de persist
  • + *
  • Rôle ORGADMIN présent → MembreRole créé et persisté (happy path)
  • + *
  • Happy path avec membre null (mo.getMembre() == null) → log utilise "?"
  • + *
+ */ +@DisplayName("Tests MembreRoleSyncService") +class MembreRoleSyncServiceTest { + + @InjectMocks + MembreRoleSyncService service; + + @Mock + MembreRoleRepository membreRoleRepository; + + @Mock + RoleRepository roleRepository; + + @Mock + EntityManager entityManager; + + @BeforeEach + void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + // @PersistenceContext EntityManager non injecté par @InjectMocks — injection via réflexion + injectField(service, "entityManager", entityManager); + } + + private void injectField(Object target, String fieldName, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + f.set(target, value); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private Organisation buildOrg() { + Organisation org = new Organisation(); + org.setId(UUID.randomUUID()); + org.setNom("Org Test"); + return org; + } + + private Membre buildMembre() { + Membre m = new Membre(); + m.setId(UUID.randomUUID()); + return m; + } + + private MembreOrganisation buildMO(Organisation org, Membre membre) { + MembreOrganisation mo = new MembreOrganisation(); + mo.setId(UUID.randomUUID()); + mo.setOrganisation(org); + mo.setMembre(membre); + return mo; + } + + private Role buildOrgAdminRole() { + Role role = new Role(); + role.setId(UUID.randomUUID()); + role.setCode("ORGADMIN"); + role.setLibelle("Admin Organisation"); + return role; + } + + // ═════════════════════════════════════════════════════════════════════════ + // Gardes initiales (null checks) + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Gardes initiales — retour immédiat") + class GardesInitiales { + + @Test + @DisplayName("mo == null — retour immédiat sans aucune interaction") + void moNull_retourImmediat() { + assertThatCode(() -> service.ensureOrgAdminRole(null)) + .doesNotThrowAnyException(); + + verifyNoInteractions(membreRoleRepository, roleRepository, entityManager); + } + + @Test + @DisplayName("mo.getId() == null — retour immédiat sans aucune interaction") + void moIdNull_retourImmediat() { + MembreOrganisation mo = new MembreOrganisation(); + mo.setId(null); // id non défini + mo.setOrganisation(buildOrg()); + + assertThatCode(() -> service.ensureOrgAdminRole(mo)) + .doesNotThrowAnyException(); + + verifyNoInteractions(membreRoleRepository, roleRepository, entityManager); + } + + @Test + @DisplayName("mo.getOrganisation() == null — retour immédiat sans aucune interaction") + void moOrganisationNull_retourImmediat() { + MembreOrganisation mo = new MembreOrganisation(); + mo.setId(UUID.randomUUID()); + mo.setOrganisation(null); // organisation non définie + + assertThatCode(() -> service.ensureOrgAdminRole(mo)) + .doesNotThrowAnyException(); + + verifyNoInteractions(membreRoleRepository, roleRepository, entityManager); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // Idempotence — MembreRole déjà existant + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Idempotence — ORGADMIN déjà présent") + class Idempotence { + + @Test + @DisplayName("MembreRole ORGADMIN actif existant — retour immédiat, pas de persist") + void orgAdminExistant_retourImmediat() { + Organisation org = buildOrg(); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre); + + // Simuler qu'il existe déjà un MembreRole ORGADMIN actif + when(membreRoleRepository.count( + eq("membreOrganisation.id = ?1 AND role.code = ?2 AND actif = true"), + eq(mo.getId()), eq("ORGADMIN"))) + .thenReturn(1L); + + assertThatCode(() -> service.ensureOrgAdminRole(mo)) + .doesNotThrowAnyException(); + + verify(membreRoleRepository).count(anyString(), eq(mo.getId()), eq("ORGADMIN")); + verifyNoInteractions(roleRepository, entityManager); + } + + @Test + @DisplayName("Plusieurs MembreRoles ORGADMIN existants — toujours retour immédiat") + void plusieursOrgAdminExistants_retourImmediat() { + Organisation org = buildOrg(); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre); + + when(membreRoleRepository.count(anyString(), eq(mo.getId()), eq("ORGADMIN"))) + .thenReturn(3L); + + service.ensureOrgAdminRole(mo); + + verifyNoInteractions(roleRepository, entityManager); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // Rôle ORGADMIN absent de la table roles + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Rôle ORGADMIN absent de la table roles") + class RoleOrgAdminAbsent { + + @Test + @DisplayName("Rôle ORGADMIN introuvable — log warn, pas de persist") + void roleOrgAdminAbsent_pasDePersist() { + Organisation org = buildOrg(); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre); + + when(membreRoleRepository.count(anyString(), eq(mo.getId()), eq("ORGADMIN"))) + .thenReturn(0L); + when(roleRepository.findByCode("ORGADMIN")) + .thenReturn(Optional.empty()); + + assertThatCode(() -> service.ensureOrgAdminRole(mo)) + .doesNotThrowAnyException(); + + verify(roleRepository).findByCode("ORGADMIN"); + verify(entityManager, never()).persist(any()); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // Happy path — création du MembreRole ORGADMIN + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Happy path — création du MembreRole ORGADMIN") + class CreationMembreRole { + + @Test + @DisplayName("MembreRole ORGADMIN absent + rôle présent — MembreRole créé et persisté") + void aucunOrgAdmin_rolePresent_membreRoleCreeEtPersite() { + Organisation org = buildOrg(); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre); + Role orgAdminRole = buildOrgAdminRole(); + + when(membreRoleRepository.count(anyString(), eq(mo.getId()), eq("ORGADMIN"))) + .thenReturn(0L); + when(roleRepository.findByCode("ORGADMIN")) + .thenReturn(Optional.of(orgAdminRole)); + + assertThatCode(() -> service.ensureOrgAdminRole(mo)) + .doesNotThrowAnyException(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MembreRole.class); + verify(entityManager).persist(captor.capture()); + + MembreRole created = captor.getValue(); + assertThat(created.getMembreOrganisation()).isEqualTo(mo); + assertThat(created.getOrganisation()).isEqualTo(org); + assertThat(created.getRole()).isEqualTo(orgAdminRole); + assertThat(created.getActif()).isTrue(); + assertThat(created.getDateDebut()).isNotNull(); + } + + @Test + @DisplayName("Happy path — mo.getMembre() == null — log utilise '?' mais persist quand même") + void moMembreNull_logUtiliseInterrogation_persistQuandMeme() { + Organisation org = buildOrg(); + // MembreOrganisation sans membre (getMembre() retourne null) + MembreOrganisation mo = new MembreOrganisation(); + mo.setId(UUID.randomUUID()); + mo.setOrganisation(org); + mo.setMembre(null); // membre null — branche "mo.getMembre() != null ? ... : '?'" + + Role orgAdminRole = buildOrgAdminRole(); + + when(membreRoleRepository.count(anyString(), eq(mo.getId()), eq("ORGADMIN"))) + .thenReturn(0L); + when(roleRepository.findByCode("ORGADMIN")) + .thenReturn(Optional.of(orgAdminRole)); + + assertThatCode(() -> service.ensureOrgAdminRole(mo)) + .doesNotThrowAnyException(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MembreRole.class); + verify(entityManager).persist(captor.capture()); + + MembreRole created = captor.getValue(); + assertThat(created.getMembreOrganisation()).isEqualTo(mo); + assertThat(created.getOrganisation()).isEqualTo(org); + assertThat(created.getRole()).isEqualTo(orgAdminRole); + assertThat(created.getActif()).isTrue(); + assertThat(created.getDateDebut()).isNotNull(); + } + + @Test + @DisplayName("Vérification que count est appelé avec les bons arguments") + void countAppeleAvecBonsArguments() { + Organisation org = buildOrg(); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre); + + when(membreRoleRepository.count(anyString(), any(), anyString())) + .thenReturn(1L); // Déjà existant → retour immédiat + + service.ensureOrgAdminRole(mo); + + verify(membreRoleRepository).count( + "membreOrganisation.id = ?1 AND role.code = ?2 AND actif = true", + mo.getId(), + "ORGADMIN"); + } + + @Test + @DisplayName("MembreRole créé — dateDebut est aujourd'hui") + void membreRoleCree_dateDebutAujourdhui() { + Organisation org = buildOrg(); + Membre membre = buildMembre(); + MembreOrganisation mo = buildMO(org, membre); + Role orgAdminRole = buildOrgAdminRole(); + + when(membreRoleRepository.count(anyString(), eq(mo.getId()), eq("ORGADMIN"))) + .thenReturn(0L); + when(roleRepository.findByCode("ORGADMIN")) + .thenReturn(Optional.of(orgAdminRole)); + + service.ensureOrgAdminRole(mo); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MembreRole.class); + verify(entityManager).persist(captor.capture()); + + MembreRole created = captor.getValue(); + // dateDebut doit être la date d'aujourd'hui + assertThat(created.getDateDebut()).isEqualTo(java.time.LocalDate.now()); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // Combinaisons de gardes — couverture complète de la condition composée + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("Couverture complète de la condition composée mo==null||id==null||org==null") + class ConditionsComposees { + + @Test + @DisplayName("mo non null mais id null et org null — retour immédiat") + void idNullEtOrgNull_retourImmediat() { + MembreOrganisation mo = new MembreOrganisation(); + // id null, organisation null + + service.ensureOrgAdminRole(mo); + + verifyNoInteractions(membreRoleRepository, roleRepository, entityManager); + } + + @Test + @DisplayName("mo non null, id présent, org null — retour immédiat") + void idPresentEtOrgNull_retourImmediat() { + MembreOrganisation mo = new MembreOrganisation(); + mo.setId(UUID.randomUUID()); + mo.setOrganisation(null); + + service.ensureOrgAdminRole(mo); + + verifyNoInteractions(membreRoleRepository, roleRepository, entityManager); + } + + @Test + @DisplayName("mo non null, id null, org présente — retour immédiat (id null)") + void idNullEtOrgPresente_retourImmediat() { + MembreOrganisation mo = new MembreOrganisation(); + mo.setId(null); + mo.setOrganisation(buildOrg()); + + service.ensureOrgAdminRole(mo); + + verifyNoInteractions(membreRoleRepository, roleRepository, entityManager); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/MigrerOrganisationsVersKeycloakServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/MigrerOrganisationsVersKeycloakServiceTest.java new file mode 100644 index 0000000..eb6fffb --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/MigrerOrganisationsVersKeycloakServiceTest.java @@ -0,0 +1,73 @@ +package dev.lions.unionflow.server.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static dev.lions.unionflow.server.service.MigrerOrganisationsVersKeycloakService.slugify; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests unitaires pour MigrerOrganisationsVersKeycloakService. + * La migration Keycloak elle-même nécessite un Keycloak 26 actif (tests d'intégration P2.4). + */ +class MigrerOrganisationsVersKeycloakServiceTest { + + @Test + @DisplayName("slugify — nom simple en minuscules sans accents") + void slugify_nomSimple() { + assertThat(slugify("Mutuelle GBANE")).isEqualTo("mutuelle-gbane"); + } + + @Test + @DisplayName("slugify — supprime les accents") + void slugify_accents() { + assertThat(slugify("Coopérative d'Épargne")).isEqualTo("cooperative-depargne"); + } + + @Test + @DisplayName("slugify — espaces multiples → tiret simple") + void slugify_espacesMultiples() { + assertThat(slugify("Lions Club Abidjan")).isEqualTo("lions-club-abidjan"); + } + + @Test + @DisplayName("slugify — caractères spéciaux supprimés") + void slugify_caracteresSpeciaux() { + assertThat(slugify("Syndicat & Co. (CI)")).isEqualTo("syndicat-co-ci"); + } + + @Test + @DisplayName("slugify — null retourne un slug non vide") + void slugify_null_retourneSlugNonVide() { + String result = slugify(null); + assertThat(result).isNotNull().isNotBlank().startsWith("org-"); + } + + @Test + @DisplayName("slugify — chaîne vide retourne un slug non vide") + void slugify_chaineVide_retourneSlugNonVide() { + String result = slugify(""); + assertThat(result).isNotNull().isNotBlank().startsWith("org-"); + } + + @Test + @DisplayName("slugify — slug sans tiret en début ou fin") + void slugify_pasDeTiretEnBordure() { + String result = slugify(" -test- "); + assertThat(result).doesNotStartWith("-").doesNotEndWith("-"); + } + + @Test + @DisplayName("MigrationReport.success() — true si zéro erreurs") + void migrationReport_success_zeroErreurs() { + var report = new MigrerOrganisationsVersKeycloakService.MigrationReport(10, 8, 2, 0); + assertThat(report.success()).isTrue(); + } + + @Test + @DisplayName("MigrationReport.success() — false si erreurs > 0") + void migrationReport_success_avecErreurs() { + var report = new MigrerOrganisationsVersKeycloakService.MigrationReport(10, 7, 2, 1); + assertThat(report.success()).isFalse(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/OrganisationModuleServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/OrganisationModuleServiceTest.java new file mode 100644 index 0000000..17c55a7 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/OrganisationModuleServiceTest.java @@ -0,0 +1,250 @@ +package dev.lions.unionflow.server.service; + +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrganisationModuleServiceTest { + + @InjectMocks + OrganisationModuleService service; + + @Mock + OrganisationRepository organisationRepository; + + // ── getModulesActifs(UUID) ─────────────────────────────────────────────────── + + @Test + void getModulesActifs_whenOrgNotFound_returnsModulesCommuns() { + UUID id = UUID.randomUUID(); + when(organisationRepository.findByIdOptional(id)).thenReturn(Optional.empty()); + + Set modules = service.getModulesActifs(id); + + assertThat(modules).containsAll(OrganisationModuleService.MODULES_COMMUNS); + } + + @Test + void getModulesActifs_whenOrgHasModulesActifsField_returnsThem() { + UUID id = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setModulesActifs("TONTINE,FINANCE,CUSTOM_MODULE"); + when(organisationRepository.findByIdOptional(id)).thenReturn(Optional.of(org)); + + Set modules = service.getModulesActifs(id); + + assertThat(modules).containsAll(OrganisationModuleService.MODULES_COMMUNS); + assertThat(modules).contains("TONTINE", "FINANCE", "CUSTOM_MODULE"); + } + + @Test + void getModulesActifs_whenOrgModulesActifsBlank_fallsBackToTypeMapping() { + UUID id = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setModulesActifs(" "); + org.setTypeOrganisation("TONTINE"); + when(organisationRepository.findByIdOptional(id)).thenReturn(Optional.of(org)); + + Set modules = service.getModulesActifs(id); + + assertThat(modules).contains("TONTINE", "FINANCE"); + } + + @Test + void getModulesActifs_whenOrgModulesActifsNull_fallsBackToTypeMapping() { + UUID id = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setModulesActifs(null); + org.setTypeOrganisation("MUTUELLE"); + when(organisationRepository.findByIdOptional(id)).thenReturn(Optional.of(org)); + + Set modules = service.getModulesActifs(id); + + assertThat(modules).contains("EPARGNE", "CREDIT", "FINANCE", "LCB_FT"); + } + + // ── getModulesActifs(Organisation) ────────────────────────────────────────── + + @Test + void getModulesActifs_org_withModulesActifs_returnsParsedModules() { + Organisation org = new Organisation(); + org.setModulesActifs("TONTINE, FINANCE "); + + Set modules = service.getModulesActifs(org); + + assertThat(modules).containsAll(OrganisationModuleService.MODULES_COMMUNS); + assertThat(modules).contains("TONTINE", "FINANCE"); + } + + // ── isModuleActif ──────────────────────────────────────────────────────────── + + @Test + void isModuleActif_whenModulePresent_returnsTrue() { + UUID id = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setModulesActifs("TONTINE,FINANCE"); + when(organisationRepository.findByIdOptional(id)).thenReturn(Optional.of(org)); + + assertThat(service.isModuleActif(id, "TONTINE")).isTrue(); + } + + @Test + void isModuleActif_whenModuleAbsent_returnsFalse() { + UUID id = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setModulesActifs("TONTINE"); + when(organisationRepository.findByIdOptional(id)).thenReturn(Optional.of(org)); + + assertThat(service.isModuleActif(id, "CREDIT")).isFalse(); + } + + @Test + void isModuleActif_whenModuleIsCommunModule_returnsTrue() { + UUID id = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setModulesActifs(null); + org.setTypeOrganisation("ASSOCIATION"); + when(organisationRepository.findByIdOptional(id)).thenReturn(Optional.of(org)); + + assertThat(service.isModuleActif(id, "MEMBRES")).isTrue(); + } + + @Test + void isModuleActif_caseInsensitive() { + UUID id = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setModulesActifs("TONTINE"); + when(organisationRepository.findByIdOptional(id)).thenReturn(Optional.of(org)); + + assertThat(service.isModuleActif(id, "tontine")).isTrue(); + } + + // ── getModulesParType ──────────────────────────────────────────────────────── + + @Test + void getModulesParType_null_returnsEmpty() { + assertThat(service.getModulesParType(null)).isEmpty(); + } + + @Test + void getModulesParType_tontine_returnsTontineAndFinance() { + Set modules = service.getModulesParType("TONTINE"); + assertThat(modules).contains("TONTINE", "FINANCE"); + } + + @Test + void getModulesParType_mutuelle_returnsMutuelleModules() { + assertThat(service.getModulesParType("MUTUELLE")).contains("EPARGNE", "CREDIT", "FINANCE", "LCB_FT"); + assertThat(service.getModulesParType("MUTUELLE_EPARGNE")).contains("EPARGNE", "CREDIT"); + assertThat(service.getModulesParType("MUTUELLE_CREDIT")).contains("CREDIT"); + } + + @Test + void getModulesParType_cooperative_returnsAgricultureAndFinance() { + assertThat(service.getModulesParType("COOPERATIVE")).contains("AGRICULTURE", "FINANCE"); + } + + @Test + void getModulesParType_ong_returnsProjetsOngAndCollecteFonds() { + assertThat(service.getModulesParType("ONG")).contains("PROJETS_ONG", "COLLECTE_FONDS", "FINANCE"); + assertThat(service.getModulesParType("FONDATION")).contains("PROJETS_ONG", "COLLECTE_FONDS"); + } + + @Test + void getModulesParType_eglise_returnsCulteDons() { + assertThat(service.getModulesParType("EGLISE")).contains("CULTE_DONS"); + assertThat(service.getModulesParType("GROUPE_PRIERE")).contains("CULTE_DONS"); + } + + @Test + void getModulesParType_syndicat_returnsVotesAndRegistre() { + assertThat(service.getModulesParType("SYNDICAT")).contains("VOTES", "REGISTRE_AGREMENT"); + assertThat(service.getModulesParType("ORDRE_PROFESSIONNEL")).contains("VOTES"); + assertThat(service.getModulesParType("FEDERATION")).contains("REGISTRE_AGREMENT"); + } + + @Test + void getModulesParType_gie_returnsFinance() { + assertThat(service.getModulesParType("GIE")).contains("FINANCE"); + } + + @Test + void getModulesParType_association_returnsTontineAndVotes() { + assertThat(service.getModulesParType("ASSOCIATION")).contains("TONTINE", "VOTES"); + assertThat(service.getModulesParType("CLUB_SERVICE")).contains("TONTINE", "VOTES"); + } + + @Test + void getModulesParType_clubSportif_returnsVotes() { + assertThat(service.getModulesParType("CLUB_SPORTIF")).contains("VOTES"); + assertThat(service.getModulesParType("CLUB_CULTUREL")).contains("VOTES"); + } + + @Test + void getModulesParType_unknown_returnsEmpty() { + Set modules = service.getModulesParType("UNKNOWN_TYPE"); + // Default case just logs — no modules added + assertThat(modules).isEmpty(); + } + + @Test + void getModulesParType_isCaseInsensitive() { + assertThat(service.getModulesParType("tontine")).contains("TONTINE"); + assertThat(service.getModulesParType("Mutuelle")).contains("EPARGNE"); + } + + // ── getModulesActifsResponse ───────────────────────────────────────────────── + + @Test + void getModulesActifsResponse_whenOrgNotFound_returnsEmptyWithUnknownType() { + UUID id = UUID.randomUUID(); + when(organisationRepository.findByIdOptional(id)).thenReturn(Optional.empty()); + + OrganisationModuleService.ModulesActifsResponse response = service.getModulesActifsResponse(id); + + assertThat(response.organisationId()).isEqualTo(id); + assertThat(response.modules()).isEmpty(); + assertThat(response.typeOrganisation()).isEqualTo("UNKNOWN"); + } + + @Test + void getModulesActifsResponse_whenOrgFound_returnsModulesAndType() { + UUID id = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setModulesActifs("TONTINE,FINANCE"); + org.setTypeOrganisation("TONTINE"); + when(organisationRepository.findByIdOptional(id)).thenReturn(Optional.of(org)); + + OrganisationModuleService.ModulesActifsResponse response = service.getModulesActifsResponse(id); + + assertThat(response.organisationId()).isEqualTo(id); + assertThat(response.modules()).contains("TONTINE", "FINANCE"); + assertThat(response.typeOrganisation()).isEqualTo("TONTINE"); + } + + // ── ModulesActifsResponse record ───────────────────────────────────────────── + + @Test + void modulesActifsResponse_record_accessors() { + UUID id = UUID.randomUUID(); + Set modules = Set.of("MEMBRES", "TONTINE"); + OrganisationModuleService.ModulesActifsResponse response = + new OrganisationModuleService.ModulesActifsResponse(id, modules, "TONTINE"); + + assertThat(response.organisationId()).isEqualTo(id); + assertThat(response.modules()).isEqualTo(modules); + assertThat(response.typeOrganisation()).isEqualTo("TONTINE"); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/SouscriptionServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/SouscriptionServiceTest.java new file mode 100644 index 0000000..e5b41d2 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/SouscriptionServiceTest.java @@ -0,0 +1,1138 @@ +package dev.lions.unionflow.server.service; + +import dev.lions.unionflow.server.api.dto.souscription.FormuleAbonnementResponse; +import dev.lions.unionflow.server.api.dto.souscription.SouscriptionDemandeRequest; +import dev.lions.unionflow.server.api.dto.souscription.SouscriptionStatutResponse; +import dev.lions.unionflow.server.api.enums.abonnement.PlageMembres; +import dev.lions.unionflow.server.api.enums.abonnement.StatutSouscription; +import dev.lions.unionflow.server.api.enums.abonnement.StatutValidationSouscription; +import dev.lions.unionflow.server.api.enums.abonnement.TypeFormule; +import dev.lions.unionflow.server.api.enums.abonnement.TypeOrganisationFacturation; +import dev.lions.unionflow.server.api.enums.abonnement.TypePeriodeAbonnement; +import dev.lions.unionflow.server.api.enums.membre.StatutMembre; +import dev.lions.unionflow.server.entity.FormuleAbonnement; +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.entity.MembreOrganisation; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.entity.SouscriptionOrganisation; +import dev.lions.unionflow.server.repository.FormuleAbonnementRepository; +import dev.lions.unionflow.server.repository.MembreOrganisationRepository; +import dev.lions.unionflow.server.repository.MembreRepository; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import dev.lions.unionflow.server.repository.SouscriptionOrganisationRepository; +import dev.lions.unionflow.server.service.support.SecuriteHelper; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.NotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Pure Mockito unit tests for SouscriptionService. + * All Panache repository methods are mocked — no container required. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests SouscriptionService") +class SouscriptionServiceTest { + + @Mock SouscriptionOrganisationRepository souscriptionRepo; + @Mock FormuleAbonnementRepository formuleRepo; + @Mock WaveCheckoutService waveService; + @Mock SecuriteHelper securiteHelper; + @Mock MembreRepository membreRepository; + @Mock OrganisationRepository organisationRepo; + @Mock MembreService membreService; + @Mock MembreOrganisationRepository membreOrganisationRepository; + @Mock NotificationService notificationService; + @Mock MembreKeycloakSyncService keycloakSyncService; + @Mock EmailTemplateService emailTemplateService; + + @InjectMocks + SouscriptionService service; + + // Common test data + private static final UUID ORG_ID = UUID.randomUUID(); + private static final UUID SOUSCRIPTION_ID = UUID.randomUUID(); + private static final UUID SUPER_ADMIN_ID = UUID.randomUUID(); + private static final UUID MEMBRE_ID = UUID.randomUUID(); + + private Organisation org; + private FormuleAbonnement formule; + private SouscriptionOrganisation souscription; + + @BeforeEach + void setUp() { + org = new Organisation(); + org.setId(ORG_ID); + org.setNom("Association Test"); + org.setTypeOrganisation("ASSOCIATION"); + + formule = FormuleAbonnement.builder() + .code(TypeFormule.BASIC) + .plage(PlageMembres.PETITE) + .libelle("Basic Petite") + .prixMensuel(new BigDecimal("3000")) + .prixAnnuel(new BigDecimal("32400")) + .maxMembres(100) + .maxStockageMo(1024) + .apiAccess(false) + .federationAccess(false) + .supportPrioritaire(false) + .build(); + + souscription = SouscriptionOrganisation.builder() + .organisation(org) + .formule(formule) + .typePeriode(TypePeriodeAbonnement.MENSUEL) + .plage(PlageMembres.PETITE) + .typeOrganisationSouscription(TypeOrganisationFacturation.ASSOCIATION) + .coefficientApplique(BigDecimal.ONE) + .montantTotal(new BigDecimal("3000")) + .statutValidation(StatutValidationSouscription.EN_ATTENTE_PAIEMENT) + .statut(StatutSouscription.EN_ATTENTE) + .dateDebut(LocalDate.now()) + .dateFin(LocalDate.now().plusMonths(1)) + .quotaMax(100) + .quotaUtilise(0) + .build(); + souscription.setId(SOUSCRIPTION_ID); + } + + /** Helper to create a mock PanacheQuery that returns a given Optional */ + @SuppressWarnings("unchecked") + private PanacheQuery panacheQueryWithFirstResult(Optional result) { + PanacheQuery query = mock(PanacheQuery.class); + when(query.firstResultOptional()).thenReturn(result); + return query; + } + + /** Helper to create a mock PanacheQuery that returns a given list */ + @SuppressWarnings("unchecked") + private PanacheQuery panacheQueryWithList(List list) { + PanacheQuery query = mock(PanacheQuery.class); + when(query.list()).thenReturn(list); + when(query.page(anyInt(), anyInt())).thenReturn(query); + return query; + } + + // ─── getFormules ──────────────────────────────────────────────────────────── + + @Nested + @DisplayName("getFormules") + class GetFormuleTests { + + @Test + @DisplayName("returns mapped list from repository") + void returnsAllFormules() { + when(formuleRepo.findAllActifOrderByOrdre()).thenReturn(List.of(formule)); + + List result = service.getFormules(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getCode()).isEqualTo("BASIC"); + } + + @Test + @DisplayName("returns empty list when no formules") + void returnsEmptyList() { + when(formuleRepo.findAllActifOrderByOrdre()).thenReturn(Collections.emptyList()); + + List result = service.getFormules(); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("toFormuleResponse — null ordreAffichage defaults to 0") + void nullOrdreAffichage_defaultsToZero() { + formule.setOrdreAffichage(null); + when(formuleRepo.findAllActifOrderByOrdre()).thenReturn(List.of(formule)); + + List result = service.getFormules(); + assertThat(result.get(0).getOrdreAffichage()).isEqualTo(0); + } + + @Test + @DisplayName("toFormuleResponse — null maxAdmins defaults to -1") + void nullMaxAdmins_defaultsToMinusOne() { + formule.setMaxAdmins(null); + when(formuleRepo.findAllActifOrderByOrdre()).thenReturn(List.of(formule)); + + List result = service.getFormules(); + assertThat(result.get(0).getMaxAdmins()).isEqualTo(-1); + } + } + + // ─── creerDemande ────────────────────────────────────────────────────────── + + @Nested + @DisplayName("creerDemande") + class CreerDemandeTests { + + private SouscriptionDemandeRequest buildRequest(String orgId, String formule, + String plage, String periode, String typeOrg) { + SouscriptionDemandeRequest r = new SouscriptionDemandeRequest(); + r.setOrganisationId(orgId); + r.setTypeFormule(formule); + r.setPlageMembres(plage); + r.setTypePeriode(periode); + r.setTypeOrganisation(typeOrg); + return r; + } + + @Test + @DisplayName("invalid UUID for organisationId — throws BadRequestException") + void invalidUuid_throws() { + SouscriptionDemandeRequest req = buildRequest("not-a-uuid", + "BASIC", "PETITE", "MENSUEL", "ASSOCIATION"); + + assertThatThrownBy(() -> service.creerDemande(req)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("UUID invalide"); + } + + @Test + @DisplayName("organisation not found — throws NotFoundException") + void orgNotFound_throws() { + SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(), + "BASIC", "PETITE", "MENSUEL", "ASSOCIATION"); + + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.creerDemande(req)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Organisation introuvable"); + } + + @Test + @DisplayName("existing non-rejected souscription — throws BadRequestException") + void existingSouscription_throws() { + SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(), + "BASIC", "PETITE", "MENSUEL", "ASSOCIATION"); + + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + PanacheQuery q = panacheQueryWithFirstResult( + Optional.of(souscription)); + when(souscriptionRepo.find(anyString(), any(UUID.class), + eq(StatutValidationSouscription.REJETEE))).thenReturn(q); + + assertThatThrownBy(() -> service.creerDemande(req)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("souscription en cours"); + } + + @Test + @DisplayName("invalid typeFormule value — throws BadRequestException") + void invalidTypeFormule_throws() { + SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(), + "INVALID_FORMULE", "PETITE", "MENSUEL", "ASSOCIATION"); + + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + PanacheQuery q = panacheQueryWithFirstResult(Optional.empty()); + when(souscriptionRepo.find(anyString(), any(UUID.class), + eq(StatutValidationSouscription.REJETEE))).thenReturn(q); + + assertThatThrownBy(() -> service.creerDemande(req)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Valeur invalide"); + } + + @Test + @DisplayName("invalid plageMembres value — throws BadRequestException") + void invalidPlageMembres_throws() { + SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(), + "BASIC", "INVALID_PLAGE", "MENSUEL", "ASSOCIATION"); + + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + PanacheQuery q = panacheQueryWithFirstResult(Optional.empty()); + when(souscriptionRepo.find(anyString(), any(UUID.class), + eq(StatutValidationSouscription.REJETEE))).thenReturn(q); + + assertThatThrownBy(() -> service.creerDemande(req)) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("invalid typePeriode value — throws BadRequestException") + void invalidTypePeriode_throws() { + SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(), + "BASIC", "PETITE", "INVALID_PERIODE", "ASSOCIATION"); + + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + PanacheQuery q = panacheQueryWithFirstResult(Optional.empty()); + when(souscriptionRepo.find(anyString(), any(UUID.class), + eq(StatutValidationSouscription.REJETEE))).thenReturn(q); + + assertThatThrownBy(() -> service.creerDemande(req)) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("formule not found — throws NotFoundException") + void formuleNotFound_throws() { + SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(), + "BASIC", "PETITE", "MENSUEL", "ASSOCIATION"); + + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + PanacheQuery q = panacheQueryWithFirstResult(Optional.empty()); + when(souscriptionRepo.find(anyString(), any(UUID.class), + eq(StatutValidationSouscription.REJETEE))).thenReturn(q); + when(formuleRepo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.creerDemande(req)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Formule introuvable"); + } + + @Test + @DisplayName("happy path — creates souscription EN_ATTENTE_PAIEMENT") + void happyPath_createsSouscription() { + SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(), + "BASIC", "PETITE", "MENSUEL", "ASSOCIATION"); + + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + PanacheQuery q = panacheQueryWithFirstResult(Optional.empty()); + when(souscriptionRepo.find(anyString(), any(UUID.class), + eq(StatutValidationSouscription.REJETEE))).thenReturn(q); + when(formuleRepo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE)) + .thenReturn(Optional.of(formule)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + SouscriptionStatutResponse result = service.creerDemande(req); + + assertThat(result).isNotNull(); + assertThat(result.getStatutValidation()) + .isEqualTo(StatutValidationSouscription.EN_ATTENTE_PAIEMENT.name()); + verify(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + } + + @Test + @DisplayName("null typeOrganisation — derives from org entity (ASSOCIATION)") + void nullTypeOrganisation_derivedFromOrg() { + SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(), + "BASIC", "PETITE", "MENSUEL", null); // null typeOrganisation + + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + PanacheQuery q = panacheQueryWithFirstResult(Optional.empty()); + when(souscriptionRepo.find(anyString(), any(UUID.class), + eq(StatutValidationSouscription.REJETEE))).thenReturn(q); + when(formuleRepo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE)) + .thenReturn(Optional.of(formule)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + SouscriptionStatutResponse result = service.creerDemande(req); + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("null typeOrganisation and null org type — defaults to ASSOCIATION") + void nullTypeOrganisationAndNullOrgType_defaultsToAssociation() { + org.setTypeOrganisation(null); // no type on org either + SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(), + "BASIC", "PETITE", "MENSUEL", null); + + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + PanacheQuery q = panacheQueryWithFirstResult(Optional.empty()); + when(souscriptionRepo.find(anyString(), any(UUID.class), + eq(StatutValidationSouscription.REJETEE))).thenReturn(q); + when(formuleRepo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE)) + .thenReturn(Optional.of(formule)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + SouscriptionStatutResponse result = service.creerDemande(req); + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("blank typeOrganisation — defaults to org entity type") + void blankTypeOrganisation_usesOrgEntity() { + SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(), + "BASIC", "PETITE", "MENSUEL", " "); // blank + + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + PanacheQuery q = panacheQueryWithFirstResult(Optional.empty()); + when(souscriptionRepo.find(anyString(), any(UUID.class), + eq(StatutValidationSouscription.REJETEE))).thenReturn(q); + when(formuleRepo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE)) + .thenReturn(Optional.of(formule)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + SouscriptionStatutResponse result = service.creerDemande(req); + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("annuel period — calculates correct total with coefficient 0.80 × 12 months") + void annuelPeriod_correctTotalCalculation() { + SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(), + "BASIC", "PETITE", "ANNUEL", "ASSOCIATION"); + + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + PanacheQuery q = panacheQueryWithFirstResult(Optional.empty()); + when(souscriptionRepo.find(anyString(), any(UUID.class), + eq(StatutValidationSouscription.REJETEE))).thenReturn(q); + when(formuleRepo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE)) + .thenReturn(Optional.of(formule)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + SouscriptionStatutResponse result = service.creerDemande(req); + // 3000 × 1.0 × 0.80 × 12 = 28800 + assertThat(result.getMontantTotal()).isEqualByComparingTo(new BigDecimal("28800")); + } + + @Test + @DisplayName("FEDERATION + PREMIUM — applies premium coefficient 1.5") + void federationPremium_appliesPremiumCoefficient() { + FormuleAbonnement premiumFormule = FormuleAbonnement.builder() + .code(TypeFormule.PREMIUM) + .plage(PlageMembres.PETITE) + .libelle("Premium Petite") + .prixMensuel(new BigDecimal("10000")) + .prixAnnuel(new BigDecimal("96000")) + .maxMembres(100) + .maxStockageMo(1024) + .apiAccess(true) + .federationAccess(true) + .supportPrioritaire(true) + .build(); + + SouscriptionDemandeRequest req = buildRequest(ORG_ID.toString(), + "PREMIUM", "PETITE", "MENSUEL", "FEDERATION"); + + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + PanacheQuery q = panacheQueryWithFirstResult(Optional.empty()); + when(souscriptionRepo.find(anyString(), any(UUID.class), + eq(StatutValidationSouscription.REJETEE))).thenReturn(q); + when(formuleRepo.findByCodeAndPlage(TypeFormule.PREMIUM, PlageMembres.PETITE)) + .thenReturn(Optional.of(premiumFormule)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + SouscriptionStatutResponse result = service.creerDemande(req); + // 10000 × 1.5 × 1.0 × 1 = 15000 + assertThat(result.getMontantTotal()).isEqualByComparingTo(new BigDecimal("15000")); + } + } + + // ─── getSouscription ─────────────────────────────────────────────────────── + + @Nested + @DisplayName("getSouscription") + class GetSouscriptionTests { + + @Test + @DisplayName("throws NotFoundException when souscription not found") + void notFound_throws() { + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getSouscription(SOUSCRIPTION_ID)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Souscription introuvable"); + } + + @Test + @DisplayName("returns mapped response when found") + void found_returnsMapped() { + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + + SouscriptionStatutResponse result = service.getSouscription(SOUSCRIPTION_ID); + + assertThat(result).isNotNull(); + assertThat(result.getStatutValidation()) + .isEqualTo(StatutValidationSouscription.EN_ATTENTE_PAIEMENT.name()); + } + } + + // ─── getMaSouscription ───────────────────────────────────────────────────── + + @Nested + @DisplayName("getMaSouscription") + class GetMaSouscriptionTests { + + @Test + @DisplayName("membre not found — throws NotFoundException") + void membreNotFound_throws() { + when(securiteHelper.resolveMembreId()).thenReturn(MEMBRE_ID); + when(membreRepository.findByIdOptional(MEMBRE_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getMaSouscription()) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Membre introuvable"); + } + + @Test + @DisplayName("no souscription for membre — throws NotFoundException") + void noSouscription_throws() { + Membre membre = new Membre(); + membre.setId(MEMBRE_ID); + + when(securiteHelper.resolveMembreId()).thenReturn(MEMBRE_ID); + when(membreRepository.findByIdOptional(MEMBRE_ID)).thenReturn(Optional.of(membre)); + + PanacheQuery q = panacheQueryWithFirstResult(Optional.empty()); + when(souscriptionRepo.find(anyString(), eq(MEMBRE_ID))).thenReturn(q); + + assertThatThrownBy(() -> service.getMaSouscription()) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Aucune souscription"); + } + + @Test + @DisplayName("returns souscription when found") + void found_returnsMapped() { + Membre membre = new Membre(); + membre.setId(MEMBRE_ID); + + when(securiteHelper.resolveMembreId()).thenReturn(MEMBRE_ID); + when(membreRepository.findByIdOptional(MEMBRE_ID)).thenReturn(Optional.of(membre)); + + PanacheQuery q = panacheQueryWithFirstResult( + Optional.of(souscription)); + when(souscriptionRepo.find(anyString(), eq(MEMBRE_ID))).thenReturn(q); + + SouscriptionStatutResponse result = service.getMaSouscription(); + assertThat(result).isNotNull(); + } + } + + // ─── initierPaiementWave ─────────────────────────────────────────────────── + + @Nested + @DisplayName("initierPaiementWave") + class InitierPaiementWaveTests { + + @Test + @DisplayName("souscription not found — throws NotFoundException") + void notFound_throws() { + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.initierPaiementWave(SOUSCRIPTION_ID)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("statut cannot initiate payment — throws BadRequestException") + void cannotInitiatePaiement_throws() { + souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE); + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + + assertThatThrownBy(() -> service.initierPaiementWave(SOUSCRIPTION_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Impossible d'initier le paiement"); + } + + @Test + @DisplayName("null montant — throws BadRequestException") + void nullMontant_throws() { + souscription.setMontantTotal(null); + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + + assertThatThrownBy(() -> service.initierPaiementWave(SOUSCRIPTION_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Montant de souscription invalide"); + } + + @Test + @DisplayName("zero montant — throws BadRequestException") + void zeroMontant_throws() { + souscription.setMontantTotal(BigDecimal.ZERO); + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + + assertThatThrownBy(() -> service.initierPaiementWave(SOUSCRIPTION_ID)) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("Wave service throws WaveCheckoutException — wrapped as BadRequestException") + void waveException_wrappedAsBadRequest() { + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + when(waveService.createSession(anyString(), anyString(), anyString(), + anyString(), anyString(), any())) + .thenThrow(new WaveCheckoutService.WaveCheckoutException("Wave error")); + + assertThatThrownBy(() -> service.initierPaiementWave(SOUSCRIPTION_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Erreur de création de session Wave"); + } + + @Test + @DisplayName("happy path — returns PAIEMENT_INITIE with waveLaunchUrl") + void happyPath_returnsPaiementInitie() { + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + + WaveCheckoutService.WaveCheckoutSessionResponse session = + new WaveCheckoutService.WaveCheckoutSessionResponse("sess-123", + "https://pay.wave.com/checkout/sess-123"); + when(waveService.createSession(anyString(), anyString(), anyString(), + anyString(), anyString(), any())) + .thenReturn(session); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + SouscriptionStatutResponse result = service.initierPaiementWave(SOUSCRIPTION_ID); + + assertThat(result).isNotNull(); + assertThat(result.getStatutValidation()) + .isEqualTo(StatutValidationSouscription.PAIEMENT_INITIE.name()); + assertThat(result.getWaveLaunchUrl()) + .isEqualTo("https://pay.wave.com/checkout/sess-123"); + } + } + + // ─── confirmerPaiement ────────────────────────────────────────────────────── + + @Nested + @DisplayName("confirmerPaiement") + class ConfirmerPaiementTests { + + @Test + @DisplayName("souscription not found — throws NotFoundException") + void notFound_throws() { + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.confirmerPaiement(SOUSCRIPTION_ID, "wave-ref")) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("wrong statut — throws BadRequestException") + void wrongStatut_throws() { + souscription.setStatutValidation(StatutValidationSouscription.REJETEE); + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + + assertThatThrownBy(() -> service.confirmerPaiement(SOUSCRIPTION_ID, "wave-ref")) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Impossible de confirmer"); + } + + @Test + @DisplayName("happy path PAIEMENT_INITIE — confirms and activates admin account") + void happyPath_paiementInitie_confirmsPayment() { + souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + when(securiteHelper.resolveEmail()).thenReturn("admin@test.com"); + + Membre membre = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont"); + when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(membre)); + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(membreOrganisationRepository.findByMembreIdAndOrganisationId(MEMBRE_ID, ORG_ID)) + .thenReturn(Optional.empty()); + doNothing().when(membreService).lierMembreOrganisationEtIncrementerQuota( + any(), any(), anyString()); + doNothing().when(membreService).promouvoirAdminOrganisation(any()); + doNothing().when(keycloakSyncService).promouvoirAdminOrganisationDansKeycloak(any()); + doNothing().when(emailTemplateService).envoyerConfirmationSouscription( + anyString(), anyString(), anyString(), anyString(), any(), anyString(), + any(), any(), any(), any(), anyBoolean(), anyBoolean()); + + service.confirmerPaiement(SOUSCRIPTION_ID, "wave-ref-123"); + + verify(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + assertThat(souscription.getStatutValidation()) + .isEqualTo(StatutValidationSouscription.VALIDEE); + assertThat(souscription.getStatut()).isEqualTo(StatutSouscription.ACTIVE); + } + + @Test + @DisplayName("EN_ATTENTE_PAIEMENT statut — also accepted for confirmation") + void enAttentePaiement_alsoAccepted() { + souscription.setStatutValidation(StatutValidationSouscription.EN_ATTENTE_PAIEMENT); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + when(securiteHelper.resolveEmail()).thenReturn(null); // no email → activation skipped + + service.confirmerPaiement(SOUSCRIPTION_ID, "wave-ref-456"); + + assertThat(souscription.getStatutValidation()) + .isEqualTo(StatutValidationSouscription.VALIDEE); + } + + @Test + @DisplayName("activerAdmin — null email from securiteHelper — returns early without throwing") + void nullEmail_activationSkipped() { + souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + when(securiteHelper.resolveEmail()).thenReturn(null); + + // Should not throw + service.confirmerPaiement(SOUSCRIPTION_ID, "ref"); + } + + @Test + @DisplayName("activerAdmin — membre not found by email — returns early") + void memberNotFoundByEmail_activationSkipped() { + souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + when(securiteHelper.resolveEmail()).thenReturn("nobody@test.com"); + when(membreRepository.findByEmail("nobody@test.com")).thenReturn(Optional.empty()); + + service.confirmerPaiement(SOUSCRIPTION_ID, "ref"); + } + + @Test + @DisplayName("activerAdmin — org not found — returns early") + void orgNotFound_activationSkipped() { + souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + when(securiteHelper.resolveEmail()).thenReturn("admin@test.com"); + Membre m = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont"); + when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(m)); + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.empty()); + + service.confirmerPaiement(SOUSCRIPTION_ID, "ref"); + } + + @Test + @DisplayName("activerAdmin — lien existant INACTIF — activated to ACTIF") + void lienExistantInactif_activated() { + souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + when(securiteHelper.resolveEmail()).thenReturn("admin@test.com"); + + Membre membre = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont"); + when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(membre)); + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + + MembreOrganisation lien = new MembreOrganisation(); + lien.setStatutMembre(StatutMembre.SUSPENDU); // not ACTIF + when(membreOrganisationRepository.findByMembreIdAndOrganisationId(MEMBRE_ID, ORG_ID)) + .thenReturn(Optional.of(lien)); + + doNothing().when(membreService).promouvoirAdminOrganisation(any()); + doNothing().when(keycloakSyncService).promouvoirAdminOrganisationDansKeycloak(any()); + when(securiteHelper.resolveEmail()).thenReturn(null); // email call for email notification + + // Reload mock after first resolveEmail() returns null for notification + when(securiteHelper.resolveEmail()) + .thenReturn("admin@test.com") // first call in activerAdmin + .thenReturn(null); // second call in envoyerEmailSouscriptionActive + + service.confirmerPaiement(SOUSCRIPTION_ID, "ref"); + + assertThat(lien.getStatutMembre()).isEqualTo(StatutMembre.ACTIF); + } + + @Test + @DisplayName("activerAdmin — lien existant ACTIF — statut unchanged") + void lienExistantActif_unchanged() { + souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + Membre membre = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont"); + + MembreOrganisation lien = new MembreOrganisation(); + lien.setStatutMembre(StatutMembre.ACTIF); // already ACTIF + + when(securiteHelper.resolveEmail()) + .thenReturn("admin@test.com") + .thenReturn(null); + when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(membre)); + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(membreOrganisationRepository.findByMembreIdAndOrganisationId(MEMBRE_ID, ORG_ID)) + .thenReturn(Optional.of(lien)); + doNothing().when(membreService).promouvoirAdminOrganisation(any()); + doNothing().when(keycloakSyncService).promouvoirAdminOrganisationDansKeycloak(any()); + + service.confirmerPaiement(SOUSCRIPTION_ID, "ref"); + + assertThat(lien.getStatutMembre()).isEqualTo(StatutMembre.ACTIF); + } + + @Test + @DisplayName("activation DB exception — caught, souscription remains VALIDEE") + void dbActivationException_caught() { + souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + Membre membre = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont"); + when(securiteHelper.resolveEmail()) + .thenReturn("admin@test.com") + .thenReturn(null); + when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(membre)); + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(membreOrganisationRepository.findByMembreIdAndOrganisationId(MEMBRE_ID, ORG_ID)) + .thenReturn(Optional.empty()); + doNothing().when(membreService).lierMembreOrganisationEtIncrementerQuota( + any(), any(), anyString()); + doThrow(new RuntimeException("DB error")).when(membreService) + .promouvoirAdminOrganisation(any()); + + // Should not throw — exception is caught + service.confirmerPaiement(SOUSCRIPTION_ID, "ref"); + assertThat(souscription.getStatutValidation()) + .isEqualTo(StatutValidationSouscription.VALIDEE); + } + + @Test + @DisplayName("Keycloak sync exception — caught (non-blocking)") + void keycloakSyncException_caught() { + souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + Membre membre = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont"); + when(securiteHelper.resolveEmail()) + .thenReturn("admin@test.com") + .thenReturn(null); + when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(membre)); + when(organisationRepo.findByIdOptional(ORG_ID)).thenReturn(Optional.of(org)); + when(membreOrganisationRepository.findByMembreIdAndOrganisationId(MEMBRE_ID, ORG_ID)) + .thenReturn(Optional.empty()); + doNothing().when(membreService).lierMembreOrganisationEtIncrementerQuota( + any(), any(), anyString()); + doNothing().when(membreService).promouvoirAdminOrganisation(any()); + doThrow(new RuntimeException("Keycloak unavailable")).when(keycloakSyncService) + .promouvoirAdminOrganisationDansKeycloak(any()); + + service.confirmerPaiement(SOUSCRIPTION_ID, "ref"); + // Should not throw + } + } + + // ─── listerToutes ───────────────────────────────────────────────────────── + + @Nested + @DisplayName("listerToutes") + class ListerToutesTests { + + @Test + @DisplayName("with organisationId filter — returns filtered list") + void withOrgFilter_returnsFiltered() { + PanacheQuery q = panacheQueryWithList(List.of(souscription)); + when(souscriptionRepo.find(anyString(), eq(ORG_ID))).thenReturn(q); + + List result = service.listerToutes(ORG_ID, 0, 10); + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("without organisationId filter — returns all") + void withoutOrgFilter_returnsAll() { + PanacheQuery q = panacheQueryWithList(List.of(souscription)); + when(souscriptionRepo.findAll()).thenReturn(q); + + List result = service.listerToutes(null, 0, 10); + assertThat(result).hasSize(1); + } + } + + // ─── obtenirActiveParOrganisation ───────────────────────────────────────── + + @Test + @DisplayName("obtenirActiveParOrganisation — found — returns response") + void obtenirActiveParOrganisation_found() { + PanacheQuery q = panacheQueryWithFirstResult( + Optional.of(souscription)); + when(souscriptionRepo.find(anyString(), eq(ORG_ID), + eq(StatutSouscription.ACTIVE))).thenReturn(q); + + SouscriptionStatutResponse result = service.obtenirActiveParOrganisation(ORG_ID); + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("obtenirActiveParOrganisation — not found — returns null") + void obtenirActiveParOrganisation_notFound_returnsNull() { + PanacheQuery q = panacheQueryWithFirstResult(Optional.empty()); + when(souscriptionRepo.find(anyString(), eq(ORG_ID), + eq(StatutSouscription.ACTIVE))).thenReturn(q); + + SouscriptionStatutResponse result = service.obtenirActiveParOrganisation(ORG_ID); + assertThat(result).isNull(); + } + + // ─── getSouscriptionsEnAttenteValidation ────────────────────────────────── + + @Test + @DisplayName("getSouscriptionsEnAttenteValidation — returns list") + void getSouscriptionsEnAttenteValidation_returnsList() { + PanacheQuery q = panacheQueryWithList(List.of(souscription)); + when(souscriptionRepo.find(anyString(), + eq(StatutValidationSouscription.PAIEMENT_CONFIRME))).thenReturn(q); + + List result = service.getSouscriptionsEnAttenteValidation(); + assertThat(result).hasSize(1); + } + + // ─── approuver ──────────────────────────────────────────────────────────── + + @Nested + @DisplayName("approuver") + class ApprouverTests { + + @Test + @DisplayName("souscription not found — throws NotFoundException") + void notFound_throws() { + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.approuver(SOUSCRIPTION_ID, SUPER_ADMIN_ID)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("wrong statut (e.g. EN_ATTENTE_PAIEMENT) — throws BadRequestException") + void wrongStatut_throws() { + souscription.setStatutValidation(StatutValidationSouscription.EN_ATTENTE_PAIEMENT); + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + + assertThatThrownBy(() -> service.approuver(SOUSCRIPTION_ID, SUPER_ADMIN_ID)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Impossible d'approuver"); + } + + @Test + @DisplayName("already VALIDEE — skips without doing anything (early return)") + void alreadyValidee_skips() { + souscription.setStatutValidation(StatutValidationSouscription.VALIDEE); + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + + service.approuver(SOUSCRIPTION_ID, SUPER_ADMIN_ID); + + // persist should NOT be called since we return early + verify(souscriptionRepo, never()).persist(any(SouscriptionOrganisation.class)); + } + + @Test + @DisplayName("PAIEMENT_CONFIRME statut — approves and activates") + void paiementConfirme_approvesAndActivates() { + souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_CONFIRME); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + when(securiteHelper.resolveEmail()).thenReturn(null); // skip activation + + service.approuver(SOUSCRIPTION_ID, SUPER_ADMIN_ID); + + assertThat(souscription.getStatutValidation()) + .isEqualTo(StatutValidationSouscription.VALIDEE); + assertThat(souscription.getStatut()).isEqualTo(StatutSouscription.ACTIVE); + assertThat(souscription.getValidatedById()).isEqualTo(SUPER_ADMIN_ID); + verify(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + } + } + + // ─── rejeter ────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("rejeter") + class RejeterTests { + + @Test + @DisplayName("null commentaire — throws BadRequestException") + void nullCommentaire_throws() { + assertThatThrownBy(() -> service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, null)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("commentaire de rejet est obligatoire"); + } + + @Test + @DisplayName("blank commentaire — throws BadRequestException") + void blankCommentaire_throws() { + assertThatThrownBy(() -> service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, " ")) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("souscription not found — throws NotFoundException") + void notFound_throws() { + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, "motif")) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("already terminal statut — throws BadRequestException") + void terminalStatut_throws() { + souscription.setStatutValidation(StatutValidationSouscription.VALIDEE); + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + + assertThatThrownBy(() -> service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, "motif")) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("état terminal"); + } + + @Test + @DisplayName("happy path — rejects and persists") + void happyPath_rejects() { + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, "Dossier incomplet"); + + assertThat(souscription.getStatutValidation()) + .isEqualTo(StatutValidationSouscription.REJETEE); + assertThat(souscription.getStatut()).isEqualTo(StatutSouscription.RESILIEE); + assertThat(souscription.getCommentaireRejet()).isEqualTo("Dossier incomplet"); + assertThat(souscription.getValidatedById()).isEqualTo(SUPER_ADMIN_ID); + } + + @Test + @DisplayName("commentaire > 500 chars — truncated to 500") + void longCommentaire_truncated() { + String longComment = "A".repeat(600); + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, longComment); + + assertThat(souscription.getCommentaireRejet()).hasSize(500); + } + + @Test + @DisplayName("REJETEE terminal — also blocked from rejection") + void rejeteTerminal_throws() { + souscription.setStatutValidation(StatutValidationSouscription.REJETEE); + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + + assertThatThrownBy(() -> service.rejeter(SOUSCRIPTION_ID, SUPER_ADMIN_ID, "motif")) + .isInstanceOf(BadRequestException.class); + } + } + + // ─── toStatutResponse — coverage of null branches ───────────────────────── + + @Test + @DisplayName("toStatutResponse — all nullable fields are null — no NPE") + void toStatutResponse_allNullFields_noNpe() { + SouscriptionOrganisation s = new SouscriptionOrganisation(); + s.setId(null); + s.setOrganisation(null); + s.setFormule(null); + s.setStatutValidation(null); + s.setStatut(null); + s.setPlage(null); + s.setTypePeriode(null); + s.setTypeOrganisationSouscription(null); + s.setQuotaMax(null); + s.setQuotaUtilise(null); + s.setDateFin(null); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.of(s)); + + SouscriptionStatutResponse result = service.getSouscription(SOUSCRIPTION_ID); + assertThat(result).isNotNull(); + assertThat(result.getSouscriptionId()).isNull(); + assertThat(result.getStatutValidation()).isNull(); + assertThat(result.getOrganisationId()).isNull(); + } + + @Test + @DisplayName("toStatutResponse — quotaMax and quotaUtilise set — quotaRestant computed") + void toStatutResponse_quotaFields_computed() { + souscription.setQuotaMax(100); + souscription.setQuotaUtilise(30); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.of(souscription)); + + SouscriptionStatutResponse result = service.getSouscription(SOUSCRIPTION_ID); + assertThat(result.getQuotaRestant()).isEqualTo(70); + } + + @Test + @DisplayName("toStatutResponse — waveLaunchUrl prefers parameter over stored url") + void toStatutResponse_waveLaunchUrl_prefersParam() { + souscription.setWaveCheckoutUrl("https://stored-url.com"); + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)).thenReturn(Optional.of(souscription)); + + // getSouscription calls toStatutResponse with waveLaunchUrl=null → uses stored url + SouscriptionStatutResponse result = service.getSouscription(SOUSCRIPTION_ID); + assertThat(result.getWaveLaunchUrl()).isEqualTo("https://stored-url.com"); + } + + // ─── envoyerEmailSouscriptionActive — exception caught (non-blocking) ────── + + @Test + @DisplayName("email exception during confirmerPaiement — caught, does not throw") + void emailException_caughtDuringConfirmerPaiement() { + souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE); + + when(souscriptionRepo.findByIdOptional(SOUSCRIPTION_ID)) + .thenReturn(Optional.of(souscription)); + doNothing().when(souscriptionRepo).persist(any(SouscriptionOrganisation.class)); + + // resolveEmail for activerAdmin returns null (skip activation) + // then for email notification throws + when(securiteHelper.resolveEmail()) + .thenReturn(null) // activerAdmin → skip + .thenReturn("admin@test.com"); // envoyerEmail + Membre membre = buildMembre(MEMBRE_ID, "admin@test.com", "Jean", "Dupont"); + when(membreRepository.findByEmail("admin@test.com")).thenReturn(Optional.of(membre)); + doThrow(new RuntimeException("SMTP error")).when(emailTemplateService) + .envoyerConfirmationSouscription(anyString(), anyString(), anyString(), + anyString(), any(), anyString(), any(), any(), any(), any(), + anyBoolean(), anyBoolean()); + + // Should not throw — email exception is caught + service.confirmerPaiement(SOUSCRIPTION_ID, "ref"); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private Membre buildMembre(UUID id, String email, String prenom, String nom) { + Membre m = new Membre(); + m.setId(id); + m.setEmail(email); + m.setPrenom(prenom); + m.setNom(nom); + return m; + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/mutuelle/InteretsEpargneServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/mutuelle/InteretsEpargneServiceTest.java new file mode 100644 index 0000000..2bd579d --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/mutuelle/InteretsEpargneServiceTest.java @@ -0,0 +1,149 @@ +package dev.lions.unionflow.server.service.mutuelle; + +import dev.lions.unionflow.server.api.enums.mutuelle.epargne.StatutCompteEpargne; +import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeTransactionEpargne; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle; +import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne; +import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne; +import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales; +import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales; +import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository; +import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository; +import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository; +import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository; +import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@QuarkusTest +@DisplayName("InteretsEpargneService") +class InteretsEpargneServiceTest { + + @Inject InteretsEpargneService service; + + @InjectMock ParametresFinanciersMutuellRepository parametresRepo; + @InjectMock CompteEpargneRepository compteEpargneRepository; + @InjectMock TransactionEpargneRepository transactionEpargneRepository; + @InjectMock ComptePartsSocialesRepository comptePartsSocialesRepository; + @InjectMock TransactionPartsSocialesRepository transactionPartsSocialesRepository; + + private UUID orgId; + private Organisation org; + private ParametresFinanciersMutuelle params; + + @BeforeEach + void setUp() { + orgId = UUID.randomUUID(); + org = new Organisation(); + org.setId(orgId); + org.setNom("Mutuelle Test"); + + params = ParametresFinanciersMutuelle.builder() + .organisation(org) + .tauxInteretAnnuelEpargne(new BigDecimal("0.03")) + .tauxDividendePartsAnnuel(new BigDecimal("0.05")) + .periodiciteCalcul("MENSUEL") + .seuilMinEpargneInterets(BigDecimal.ZERO) + .dernierNbComptesTraites(0) + .build(); + } + + @Test + @DisplayName("calculerManuellement — lève IllegalArgumentException si aucun paramètre configuré") + void calculerManuellement_sansParametres_leveException() { + when(parametresRepo.findByOrganisation(orgId)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.calculerManuellement(orgId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("paramètre financier"); + } + + @Test + @DisplayName("calculerInteretsPourOrg — crédite les intérêts mensuels sur les comptes épargne") + @SuppressWarnings("unchecked") + void calculerInterets_comptesActifs_crediteInterets() { + CompteEpargne compte = CompteEpargne.builder() + .soldeActuel(new BigDecimal("100000")) + .soldeBloque(BigDecimal.ZERO) + .statut(StatutCompteEpargne.ACTIF) + .organisation(org) + .build(); + + io.quarkus.hibernate.orm.panache.PanacheQuery query = mock( + io.quarkus.hibernate.orm.panache.PanacheQuery.class); + // Use typed matchers to resolve find(String, Object...) unambiguously + when(compteEpargneRepository.find(anyString(), any(UUID.class), any(StatutCompteEpargne.class))) + .thenReturn(query); + when(query.list()).thenReturn(List.of(compte)); + when(comptePartsSocialesRepository.findByOrganisation(orgId)).thenReturn(List.of()); + // Use any(TransactionEpargne.class) to resolve persist(Entity) unambiguously + doNothing().when(transactionEpargneRepository).persist(any(TransactionEpargne.class)); + + Map result = service.calculerInteretsPourOrg(params); + + assertThat(result).containsKey("comptesEpargneTraites"); + assertThat((int) result.get("comptesEpargneTraites")).isEqualTo(1); + + // Taux mensuel = 3%/12 = 0.25% → 100000 × 0.0025 = 250 XOF crédités + BigDecimal attendu = new BigDecimal("250"); + assertThat(compte.getSoldeActuel()).isEqualByComparingTo(new BigDecimal("100000").add(attendu)); + + verify(transactionEpargneRepository).persist(argThat((TransactionEpargne tx) -> + tx.getType() == TypeTransactionEpargne.PAIEMENT_INTERETS + && tx.getMontant().compareTo(attendu) == 0)); + } + + @Test + @DisplayName("calculerInterets — ignore les comptes en dessous du seuil minimum") + @SuppressWarnings("unchecked") + void calculerInterets_sousLeSeuil_pasDeCredit() { + params.setSeuilMinEpargneInterets(new BigDecimal("50000")); + CompteEpargne compte = CompteEpargne.builder() + .soldeActuel(new BigDecimal("10000")) + .soldeBloque(BigDecimal.ZERO) + .statut(StatutCompteEpargne.ACTIF) + .organisation(org) + .build(); + + io.quarkus.hibernate.orm.panache.PanacheQuery query = mock( + io.quarkus.hibernate.orm.panache.PanacheQuery.class); + when(compteEpargneRepository.find(anyString(), any(UUID.class), any(StatutCompteEpargne.class))) + .thenReturn(query); + when(query.list()).thenReturn(List.of(compte)); + when(comptePartsSocialesRepository.findByOrganisation(orgId)).thenReturn(List.of()); + + service.calculerInteretsPourOrg(params); + + verify(transactionEpargneRepository, never()).persist(any(TransactionEpargne.class)); + assertThat(compte.getSoldeActuel()).isEqualByComparingTo(new BigDecimal("10000")); + } + + @Test + @DisplayName("calculerInterets — taux zéro → aucune transaction créée") + void calculerInterets_tauxZero_aucuneTransaction() { + params.setTauxInteretAnnuelEpargne(BigDecimal.ZERO); + params.setTauxDividendePartsAnnuel(BigDecimal.ZERO); + + when(comptePartsSocialesRepository.findByOrganisation(orgId)).thenReturn(List.of()); + + Map result = service.calculerInteretsPourOrg(params); + + verify(transactionEpargneRepository, never()).persist(any(TransactionEpargne.class)); + assertThat((int) result.get("comptesEpargneTraites")).isZero(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/mutuelle/ParametresFinanciersServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/mutuelle/ParametresFinanciersServiceTest.java new file mode 100644 index 0000000..bb53209 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/mutuelle/ParametresFinanciersServiceTest.java @@ -0,0 +1,96 @@ +package dev.lions.unionflow.server.service.mutuelle; + +import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellRequest; +import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellResponse; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@QuarkusTest +@DisplayName("ParametresFinanciersService") +class ParametresFinanciersServiceTest { + + @Inject ParametresFinanciersService service; + @InjectMock ParametresFinanciersMutuellRepository repo; + @InjectMock OrganisationRepository organisationRepository; + + @Test + @DisplayName("getByOrganisation — lève NotFoundException si absent") + void getByOrganisation_absent_leveException() { + UUID orgId = UUID.randomUUID(); + when(repo.findByOrganisation(orgId)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.getByOrganisation(orgId)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("creerOuMettrAJour — crée les paramètres si inexistants") + void creerOuMettrAJour_inexistants_créeSansErreur() { + UUID orgId = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setId(orgId); + org.setNom("Mutuelle Test"); + + ParametresFinanciersMutuellRequest req = ParametresFinanciersMutuellRequest.builder() + .organisationId(orgId.toString()) + .valeurNominaleParDefaut(new BigDecimal("5000")) + .tauxInteretAnnuelEpargne(new BigDecimal("0.03")) + .tauxDividendePartsAnnuel(new BigDecimal("0.05")) + .periodiciteCalcul("MENSUEL") + .seuilMinEpargneInterets(BigDecimal.ZERO) + .build(); + + when(organisationRepository.findByIdOptional(orgId)).thenReturn(Optional.of(org)); + when(repo.findByOrganisation(orgId)).thenReturn(Optional.empty()); + // any(ParametresFinanciersMutuelle.class) resolves persist(Entity) unambiguously + doNothing().when(repo).persist(any(ParametresFinanciersMutuelle.class)); + + ParametresFinanciersMutuellResponse resp = service.creerOuMettrAJour(req); + + assertThat(resp).isNotNull(); + verify(repo).persist(argThat((ParametresFinanciersMutuelle p) -> + p.getTauxInteretAnnuelEpargne().compareTo(new BigDecimal("0.03")) == 0 + && p.getPeriodiciteCalcul().equals("MENSUEL"))); + } + + @Test + @DisplayName("creerOuMettrAJour — la périodicité est normalisée en majuscules") + void creerOuMettrAJour_periodiciteMajuscules() { + UUID orgId = UUID.randomUUID(); + Organisation org = new Organisation(); + org.setId(orgId); + org.setNom("Test"); + + ParametresFinanciersMutuellRequest req = ParametresFinanciersMutuellRequest.builder() + .organisationId(orgId.toString()) + .valeurNominaleParDefaut(new BigDecimal("5000")) + .tauxInteretAnnuelEpargne(BigDecimal.ZERO) + .tauxDividendePartsAnnuel(BigDecimal.ZERO) + .periodiciteCalcul("trimestriel") + .build(); + + when(organisationRepository.findByIdOptional(orgId)).thenReturn(Optional.of(org)); + when(repo.findByOrganisation(orgId)).thenReturn(Optional.empty()); + doNothing().when(repo).persist(any(ParametresFinanciersMutuelle.class)); + + service.creerOuMettrAJour(req); + + verify(repo).persist(argThat((ParametresFinanciersMutuelle p) -> + p.getPeriodiciteCalcul().equals("TRIMESTRIEL"))); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/mutuelle/ReleveComptePdfServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/mutuelle/ReleveComptePdfServiceTest.java new file mode 100644 index 0000000..c7d80d1 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/mutuelle/ReleveComptePdfServiceTest.java @@ -0,0 +1,760 @@ +package dev.lions.unionflow.server.service.mutuelle; + +import dev.lions.unionflow.server.api.enums.mutuelle.epargne.StatutCompteEpargne; +import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeCompteEpargne; +import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales; +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne; +import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne; +import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales; +import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales; +import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeTransactionEpargne; +import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository; +import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository; +import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository; +import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import jakarta.ws.rs.NotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Tests ReleveComptePdfService") +class ReleveComptePdfServiceTest { + + @Mock CompteEpargneRepository compteEpargneRepository; + @Mock TransactionEpargneRepository transactionEpargneRepository; + @Mock ComptePartsSocialesRepository comptePartsSocialesRepository; + @Mock TransactionPartsSocialesRepository transactionPartsSocialesRepository; + + @InjectMocks + ReleveComptePdfService service; + + private static final UUID COMPTE_ID = UUID.randomUUID(); + private static final LocalDate DATE_DEBUT = LocalDate.of(2026, 1, 1); + private static final LocalDate DATE_FIN = LocalDate.of(2026, 3, 31); + + private Organisation org; + private Membre membre; + + @BeforeEach + void setUp() { + org = new Organisation(); + org.setNom("Mutuelle Test"); + + membre = new Membre(); + membre.setNom("Dupont"); + membre.setPrenom("Jean"); + } + + // ─── Builder helpers ──────────────────────────────────────────────────────── + + private CompteEpargne buildCompteEpargne(boolean withOrg, boolean withMembre) { + CompteEpargne compte = new CompteEpargne(); + compte.setId(COMPTE_ID); + compte.setNumeroCompte("CEP-001"); + compte.setTypeCompte(TypeCompteEpargne.EPARGNE_LIBRE); + compte.setSoldeActuel(new BigDecimal("50000")); + compte.setSoldeBloque(BigDecimal.ZERO); + compte.setStatut(StatutCompteEpargne.ACTIF); + compte.setOrganisation(withOrg ? org : null); + compte.setMembre(withMembre ? membre : null); + return compte; + } + + private TransactionEpargne buildTxEpargne(TypeTransactionEpargne type, + BigDecimal montant, + LocalDateTime dateTransaction) { + TransactionEpargne tx = new TransactionEpargne(); + tx.setType(type); + tx.setMontant(montant); + tx.setDateTransaction(dateTransaction); + tx.setSoldeAvant(new BigDecimal("40000")); + tx.setSoldeApres(new BigDecimal("50000")); + tx.setMotif("Test motif"); + return tx; + } + + private ComptePartsSociales buildComptePartsSociales(boolean withOrg, boolean withMembre) { + ComptePartsSociales compte = new ComptePartsSociales(); + compte.setId(COMPTE_ID); + compte.setNumeroCompte("CPS-001"); + compte.setNombreParts(10); + compte.setValeurNominale(new BigDecimal("5000")); + compte.setMontantTotal(new BigDecimal("50000")); + compte.setTotalDividendesRecus(new BigDecimal("2000")); + compte.setOrganisation(withOrg ? org : null); + compte.setMembre(withMembre ? membre : null); + return compte; + } + + private TransactionPartsSociales buildTxParts(TypeTransactionPartsSociales type, + BigDecimal montant, + LocalDateTime dateTransaction) { + TransactionPartsSociales tx = new TransactionPartsSociales(); + tx.setTypeTransaction(type); + tx.setMontant(montant); + tx.setNombreParts(5); + tx.setSoldePartsAvant(5); + tx.setSoldePartsApres(10); + tx.setDateTransaction(dateTransaction); + tx.setMotif(null); // will use type.getLibelle() + return tx; + } + + @SuppressWarnings("unchecked") + private PanacheQuery mockEpargneQuery(List txs) { + PanacheQuery q = mock(PanacheQuery.class); + when(q.list()).thenReturn(txs); + return q; + } + + // ─── genererReleveEpargne ───────────────────────────────────────────────── + + @Nested + @DisplayName("genererReleveEpargne") + class GenererReleveEpargneTests { + + @Test + @DisplayName("throws NotFoundException when compte not found") + void compteNotFound_throws() { + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Compte épargne introuvable"); + } + + @Test + @DisplayName("no transactions — returns PDF with solde ouverture = soldeActuel") + void noTransactions_returnsPdfWithSoldeActuel() { + CompteEpargne compte = buildCompteEpargne(true, true); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(Collections.emptyList())); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("DEPOT transaction — isDebitEpargne returns false — montant in credit column") + void depotTransaction_isCredit() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT, + new BigDecimal("10000"), + LocalDateTime.of(2026, 1, 15, 10, 0)); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("RETRAIT transaction — isDebitEpargne returns true — montant in debit column") + void retraitTransaction_isDebit() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.RETRAIT, + new BigDecimal("5000"), + LocalDateTime.of(2026, 1, 20, 14, 30)); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("PRELEVEMENT_FRAIS — isDebitEpargne true") + void prelevementFrais_isDebit() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.PRELEVEMENT_FRAIS, + new BigDecimal("500"), LocalDateTime.now()); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("TRANSFERT_SORTANT — isDebitEpargne true") + void transfertSortant_isDebit() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.TRANSFERT_SORTANT, + new BigDecimal("3000"), LocalDateTime.now()); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("REMBOURSEMENT_CREDIT — isDebitEpargne true") + void remboursementCredit_isDebit() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.REMBOURSEMENT_CREDIT, + new BigDecimal("8000"), LocalDateTime.now()); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("RETENUE_GARANTIE — isDebitEpargne true") + void retenueGarantie_isDebit() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.RETENUE_GARANTIE, + new BigDecimal("2000"), LocalDateTime.now()); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("LIBERATION_GARANTIE — isDebitEpargne false (credit)") + void liberationGarantie_isCredit() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.LIBERATION_GARANTIE, + new BigDecimal("2000"), LocalDateTime.now()); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("PAIEMENT_INTERETS — isDebitEpargne false (credit)") + void paiementInterets_isCredit() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.PAIEMENT_INTERETS, + new BigDecimal("1500"), LocalDateTime.now()); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("TRANSFERT_ENTRANT — isDebitEpargne false (credit)") + void transfertEntrant_isCredit() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.TRANSFERT_ENTRANT, + new BigDecimal("7000"), LocalDateTime.now()); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("null motif — uses type.name() as fallback") + void nullMotif_usesTypeName() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT, + new BigDecimal("1000"), LocalDateTime.now()); + tx.setMotif(null); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("null organisation — uses 'UnionFlow' as org name fallback") + void nullOrganisation_usesUnionFlowFallback() { + CompteEpargne compte = buildCompteEpargne(false, true); // no org + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(Collections.emptyList())); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("null membre — empty string for titulaire") + void nullMembre_emptyTitulaire() { + CompteEpargne compte = buildCompteEpargne(true, false); // no membre + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(Collections.emptyList())); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("dateDebut filter — transactions before dateDebut excluded") + void dateDebutFilter_excludesOlderTx() { + CompteEpargne compte = buildCompteEpargne(true, true); + + // Transaction before dateDebut — should be filtered out + TransactionEpargne txBefore = buildTxEpargne(TypeTransactionEpargne.DEPOT, + new BigDecimal("1000"), + LocalDateTime.of(2025, 12, 31, 12, 0)); + // Transaction within range + TransactionEpargne txIn = buildTxEpargne(TypeTransactionEpargne.DEPOT, + new BigDecimal("2000"), + LocalDateTime.of(2026, 1, 15, 12, 0)); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(txBefore, txIn))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("dateFin filter — transactions after dateFin excluded") + void dateFinFilter_excludesNewerTx() { + CompteEpargne compte = buildCompteEpargne(true, true); + + // Transaction after dateFin + TransactionEpargne txAfter = buildTxEpargne(TypeTransactionEpargne.DEPOT, + new BigDecimal("1000"), + LocalDateTime.of(2026, 4, 10, 12, 0)); + // Transaction within range + TransactionEpargne txIn = buildTxEpargne(TypeTransactionEpargne.DEPOT, + new BigDecimal("2000"), + LocalDateTime.of(2026, 2, 15, 12, 0)); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(txAfter, txIn))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("null dateDebut and dateFin — no filtering applied") + void nullDates_noFiltering() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT, + new BigDecimal("5000"), LocalDateTime.now()); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, null, null); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("only dateDebut set — only lower bound filtering") + void onlyDateDebut_filterOnlyLowerBound() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT, + new BigDecimal("5000"), + LocalDateTime.of(2026, 2, 1, 10, 0)); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, null); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("only dateFin set — only upper bound filtering") + void onlyDateFin_filterOnlyUpperBound() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT, + new BigDecimal("5000"), + LocalDateTime.of(2026, 1, 5, 10, 0)); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, null, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("non-empty txs — solde ouverture taken from first tx soldeAvant") + void nonEmptyTxs_soldeOuvertureFromFirstTx() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT, + new BigDecimal("10000"), + LocalDateTime.of(2026, 1, 5, 10, 0)); + tx.setSoldeAvant(new BigDecimal("25000")); + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + } + + // ─── genererReleveParts ──────────────────────────────────────────────────── + + @Nested + @DisplayName("genererReleveParts") + class GenererRelevePartsTests { + + @Test + @DisplayName("throws NotFoundException when compte not found") + void compteNotFound_throws() { + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Compte parts sociales introuvable"); + } + + @Test + @DisplayName("no transactions — returns PDF with empty table") + void noTransactions_returnsPdf() { + ComptePartsSociales compte = buildComptePartsSociales(true, true); + + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID)) + .thenReturn(Collections.emptyList()); + + byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("with transactions — generates PDF with rows") + void withTransactions_generatesPdf() { + ComptePartsSociales compte = buildComptePartsSociales(true, true); + TransactionPartsSociales tx = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION, + new BigDecimal("25000"), LocalDateTime.of(2026, 1, 10, 9, 0)); + + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID)) + .thenReturn(List.of(tx)); + + byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("null motif — uses typeTransaction.getLibelle()") + void nullMotif_usesTypeLibelle() { + ComptePartsSociales compte = buildComptePartsSociales(true, true); + TransactionPartsSociales tx = buildTxParts(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE, + new BigDecimal("1000"), LocalDateTime.now()); + tx.setMotif(null); + + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID)) + .thenReturn(List.of(tx)); + + byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("null organisation — uses 'UnionFlow' as fallback") + void nullOrganisation_usesUnionFlowFallback() { + ComptePartsSociales compte = buildComptePartsSociales(false, true); // no org + + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID)) + .thenReturn(Collections.emptyList()); + + byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("null membre — empty titulaire string") + void nullMembre_emptyTitulaire() { + ComptePartsSociales compte = buildComptePartsSociales(true, false); // no membre + + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID)) + .thenReturn(Collections.emptyList()); + + byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("dateDebut filter — transactions before dateDebut excluded") + void dateDebutFilter_excludesOlderTx() { + ComptePartsSociales compte = buildComptePartsSociales(true, true); + + TransactionPartsSociales txBefore = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION, + new BigDecimal("5000"), LocalDateTime.of(2025, 12, 15, 10, 0)); + TransactionPartsSociales txIn = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION, + new BigDecimal("5000"), LocalDateTime.of(2026, 1, 15, 10, 0)); + + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID)) + .thenReturn(new ArrayList<>(List.of(txBefore, txIn))); + + byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("dateFin filter — transactions after dateFin excluded") + void dateFinFilter_excludesNewerTx() { + ComptePartsSociales compte = buildComptePartsSociales(true, true); + + TransactionPartsSociales txAfter = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION, + new BigDecimal("5000"), LocalDateTime.of(2026, 4, 15, 10, 0)); + TransactionPartsSociales txIn = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION, + new BigDecimal("5000"), LocalDateTime.of(2026, 2, 15, 10, 0)); + + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID)) + .thenReturn(new ArrayList<>(List.of(txAfter, txIn))); + + byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("null dateDebut and dateFin — no filtering") + void nullDates_noFiltering() { + ComptePartsSociales compte = buildComptePartsSociales(true, true); + TransactionPartsSociales tx = buildTxParts(TypeTransactionPartsSociales.CESSION_PARTIELLE, + new BigDecimal("10000"), LocalDateTime.now()); + + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID)) + .thenReturn(new ArrayList<>(List.of(tx))); + + byte[] result = service.genererReleveParts(COMPTE_ID, null, null); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("only dateDebut set — lower bound filtering only") + void onlyDateDebut_lowerBoundOnly() { + ComptePartsSociales compte = buildComptePartsSociales(true, true); + TransactionPartsSociales tx = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION, + new BigDecimal("5000"), LocalDateTime.of(2026, 2, 1, 10, 0)); + + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID)) + .thenReturn(new ArrayList<>(List.of(tx))); + + byte[] result = service.genererReleveParts(COMPTE_ID, DATE_DEBUT, null); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("only dateFin set — upper bound filtering only") + void onlyDateFin_upperBoundOnly() { + ComptePartsSociales compte = buildComptePartsSociales(true, true); + TransactionPartsSociales tx = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION, + new BigDecimal("5000"), LocalDateTime.of(2026, 1, 5, 10, 0)); + + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID)) + .thenReturn(new ArrayList<>(List.of(tx))); + + byte[] result = service.genererReleveParts(COMPTE_ID, null, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("transactions from repository are reversed (DESC → ASC)") + void transactionsReversed() { + ComptePartsSociales compte = buildComptePartsSociales(true, true); + + // Repository returns DESC; service reverses to ASC + TransactionPartsSociales tx1 = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION, + new BigDecimal("5000"), LocalDateTime.of(2026, 3, 15, 10, 0)); // later + TransactionPartsSociales tx2 = buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION, + new BigDecimal("5000"), LocalDateTime.of(2026, 1, 10, 10, 0)); // earlier + + List descList = new ArrayList<>(List.of(tx1, tx2)); + + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID)) + .thenReturn(descList); + + byte[] result = service.genererReleveParts(COMPTE_ID, null, null); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("multiple transaction types — all rendered correctly") + void multipleTransactionTypes_allRendered() { + ComptePartsSociales compte = buildComptePartsSociales(true, true); + + List txs = new ArrayList<>(); + txs.add(buildTxParts(TypeTransactionPartsSociales.SOUSCRIPTION, + new BigDecimal("25000"), LocalDateTime.of(2026, 1, 5, 10, 0))); + txs.add(buildTxParts(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE, + new BigDecimal("1000"), LocalDateTime.of(2026, 2, 1, 10, 0))); + txs.add(buildTxParts(TypeTransactionPartsSociales.CESSION_PARTIELLE, + new BigDecimal("5000"), LocalDateTime.of(2026, 3, 1, 10, 0))); + txs.add(buildTxParts(TypeTransactionPartsSociales.RACHAT_TOTAL, + new BigDecimal("25000"), LocalDateTime.of(2026, 3, 15, 10, 0))); + txs.add(buildTxParts(TypeTransactionPartsSociales.CORRECTION, + new BigDecimal("500"), LocalDateTime.of(2026, 3, 20, 10, 0))); + + when(comptePartsSocialesRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionPartsSocialesRepository.findByCompte(COMPTE_ID)) + .thenReturn(txs); + + byte[] result = service.genererReleveParts(COMPTE_ID, null, null); + assertThat(result).isNotNull().isNotEmpty(); + } + } + + // ─── formatPeriode — indirect coverage via public methods ───────────────── + + @Test + @DisplayName("formatPeriode — debut only set — 'depuis le' prefix") + void formatPeriode_debutOnly_returnsDebuPrefix() { + CompteEpargne compte = buildCompteEpargne(true, true); + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(Collections.emptyList())); + + // dateDebut set, dateFin null → "depuis le ..." + byte[] result = service.genererReleveEpargne(COMPTE_ID, DATE_DEBUT, null); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("formatPeriode — fin only set — 'jusqu au' prefix") + void formatPeriode_finOnly_returnsFinPrefix() { + CompteEpargne compte = buildCompteEpargne(true, true); + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(Collections.emptyList())); + + // dateDebut null, dateFin set → "jusqu'au ..." + byte[] result = service.genererReleveEpargne(COMPTE_ID, null, DATE_FIN); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("formatPeriode — both null — 'Toutes operations'") + void formatPeriode_bothNull_toutesOperations() { + CompteEpargne compte = buildCompteEpargne(true, true); + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(Collections.emptyList())); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, null, null); + assertThat(result).isNotNull().isNotEmpty(); + } + + // ─── cellAmount null/zero branch ────────────────────────────────────────── + + @Test + @DisplayName("cellAmount — null soldeApres renders empty cell") + void cellAmount_nullSoldeApres_emptyCell() { + CompteEpargne compte = buildCompteEpargne(true, true); + TransactionEpargne tx = buildTxEpargne(TypeTransactionEpargne.DEPOT, + new BigDecimal("1000"), LocalDateTime.now()); + tx.setSoldeApres(null); // null — cellAmount should handle gracefully + + when(compteEpargneRepository.findByIdOptional(COMPTE_ID)) + .thenReturn(Optional.of(compte)); + when(transactionEpargneRepository.find(anyString(), eq(COMPTE_ID))) + .thenReturn(mockEpargneQuery(List.of(tx))); + + byte[] result = service.genererReleveEpargne(COMPTE_ID, null, null); + assertThat(result).isNotNull().isNotEmpty(); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/mutuelle/parts/ComptePartsSocialesServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/mutuelle/parts/ComptePartsSocialesServiceTest.java new file mode 100644 index 0000000..43a42c5 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/mutuelle/parts/ComptePartsSocialesServiceTest.java @@ -0,0 +1,207 @@ +package dev.lions.unionflow.server.service.mutuelle.parts; + +import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesRequest; +import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesRequest; +import dev.lions.unionflow.server.api.enums.mutuelle.parts.StatutComptePartsSociales; +import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales; +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales; +import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales; +import dev.lions.unionflow.server.mapper.mutuelle.parts.ComptePartsSocialesMapper; +import dev.lions.unionflow.server.mapper.mutuelle.parts.TransactionPartsSocialesMapper; +import dev.lions.unionflow.server.repository.MembreRepository; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository; +import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository; +import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@QuarkusTest +@DisplayName("ComptePartsSocialesService") +class ComptePartsSocialesServiceTest { + + @Inject ComptePartsSocialesService service; + + @InjectMock ComptePartsSocialesRepository compteRepo; + @InjectMock TransactionPartsSocialesRepository txRepo; + @InjectMock MembreRepository membreRepository; + @InjectMock OrganisationRepository organisationRepository; + @InjectMock ParametresFinanciersMutuellRepository parametresRepo; + @InjectMock ComptePartsSocialesMapper compteMapper; + @InjectMock TransactionPartsSocialesMapper txMapper; + + private Membre membre; + private Organisation org; + private UUID membreId; + private UUID orgId; + + @BeforeEach + void setUp() { + membreId = UUID.randomUUID(); + orgId = UUID.randomUUID(); + membre = new Membre(); + membre.setId(membreId); + membre.setNom("GBANE"); + membre.setPrenom("Allassane"); + org = new Organisation(); + org.setId(orgId); + org.setNom("Mutuelle GBANE"); + org.setNomCourt("MEG"); + } + + @Nested + @DisplayName("ouvrirCompte") + class OuvrirCompte { + + @Test + @DisplayName("Ouvre un compte avec la valeur nominale de la requête") + void ouvrirCompte_valeurNominaleExplicite_compteCreé() { + ComptePartsSocialesRequest req = ComptePartsSocialesRequest.builder() + .membreId(membreId.toString()) + .organisationId(orgId.toString()) + .nombreParts(2) + .valeurNominale(new BigDecimal("5000")) + .build(); + + when(membreRepository.findByIdOptional(membreId)).thenReturn(Optional.of(membre)); + when(organisationRepository.findByIdOptional(orgId)).thenReturn(Optional.of(org)); + when(compteRepo.findByMembreAndOrg(membreId, orgId)).thenReturn(Optional.empty()); + // count(String, Object) — use typed matcher to resolve count(String, Map) vs count(String, Parameters) + when(compteRepo.count(anyString(), any(UUID.class))).thenReturn(0L); + // any(ComptePartsSociales.class) resolves persist(Entity) unambiguously + doNothing().when(compteRepo).persist(any(ComptePartsSociales.class)); + doNothing().when(txRepo).persist(any(TransactionPartsSociales.class)); + when(compteMapper.toDto(any())).thenReturn(null); + + service.ouvrirCompte(req); + + verify(compteRepo).persist(argThat((ComptePartsSociales c) -> + c.getValeurNominale().compareTo(new BigDecimal("5000")) == 0 + && c.getOrganisation() == org + && c.getMembre() == membre)); + } + + @Test + @DisplayName("Lève NotFoundException si le membre est inconnu") + void ouvrirCompte_membreInconnu_leveNotFoundException() { + ComptePartsSocialesRequest req = ComptePartsSocialesRequest.builder() + .membreId(UUID.randomUUID().toString()) + .organisationId(orgId.toString()) + .nombreParts(1) + .build(); + when(membreRepository.findByIdOptional(any())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.ouvrirCompte(req)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("Lève IllegalStateException si un compte existe déjà pour ce membre+org") + void ouvrirCompte_compteExistant_leveIllegalState() { + ComptePartsSocialesRequest req = ComptePartsSocialesRequest.builder() + .membreId(membreId.toString()) + .organisationId(orgId.toString()) + .nombreParts(1) + .build(); + when(membreRepository.findByIdOptional(membreId)).thenReturn(Optional.of(membre)); + when(organisationRepository.findByIdOptional(orgId)).thenReturn(Optional.of(org)); + when(compteRepo.findByMembreAndOrg(membreId, orgId)) + .thenReturn(Optional.of(new ComptePartsSociales())); + + assertThatThrownBy(() -> service.ouvrirCompte(req)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("existe déjà"); + } + } + + @Nested + @DisplayName("enregistrerTransaction — règles métier") + class EnregistrerTransaction { + + private ComptePartsSociales compte; + + @BeforeEach + void setup() { + compte = ComptePartsSociales.builder() + .membre(membre) + .organisation(org) + .numeroCompte("PS-MEG-00001") + .nombreParts(10) + .valeurNominale(new BigDecimal("5000")) + .montantTotal(new BigDecimal("50000")) + .totalDividendesRecus(BigDecimal.ZERO) + .statut(StatutComptePartsSociales.ACTIF) + .build(); + } + + @Test + @DisplayName("SOUSCRIPTION augmente le solde de parts") + void souscription_augmenteSolde() { + doNothing().when(txRepo).persist(any(TransactionPartsSociales.class)); + service.enregistrerTransaction(compte, TypeTransactionPartsSociales.SOUSCRIPTION, 5, null, "test", null); + assertThat(compte.getNombreParts()).isEqualTo(15); + assertThat(compte.getMontantTotal()).isEqualByComparingTo(new BigDecimal("75000")); + } + + @Test + @DisplayName("CESSION_PARTIELLE réduit le solde de parts") + void cession_réduitSolde() { + doNothing().when(txRepo).persist(any(TransactionPartsSociales.class)); + service.enregistrerTransaction(compte, TypeTransactionPartsSociales.CESSION_PARTIELLE, 3, null, "test", null); + assertThat(compte.getNombreParts()).isEqualTo(7); + } + + @Test + @DisplayName("CESSION_PARTIELLE lève IllegalArgumentException si parts insuffisantes") + void cession_soldeInsuffisant_leveException() { + assertThatThrownBy(() -> + service.enregistrerTransaction(compte, TypeTransactionPartsSociales.CESSION_PARTIELLE, 20, null, "test", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("supérieur au solde"); + } + + @Test + @DisplayName("RACHAT_TOTAL clôture le compte") + void rachat_cloreLe_compte() { + doNothing().when(txRepo).persist(any(TransactionPartsSociales.class)); + service.enregistrerTransaction(compte, TypeTransactionPartsSociales.RACHAT_TOTAL, 10, null, "rachat", null); + assertThat(compte.getNombreParts()).isZero(); + assertThat(compte.getStatut()).isEqualTo(StatutComptePartsSociales.CLOS); + } + + @Test + @DisplayName("PAIEMENT_DIVIDENDE accumule totalDividendesRecus") + void dividende_accumuleTotalDividendes() { + doNothing().when(txRepo).persist(any(TransactionPartsSociales.class)); + BigDecimal dividende = new BigDecimal("2500"); + service.enregistrerTransaction(compte, TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE, 1, dividende, "dividende", null); + assertThat(compte.getTotalDividendesRecus()).isEqualByComparingTo(dividende); + } + + @Test + @DisplayName("Transaction sur compte non-ACTIF lève IllegalArgumentException") + void transaction_compteInactif_leveException() { + compte.setStatut(StatutComptePartsSociales.SUSPENDU); + assertThatThrownBy(() -> + service.enregistrerTransaction(compte, TypeTransactionPartsSociales.SOUSCRIPTION, 1, null, "test", null)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/src/test/resources/application-integration-test.properties b/src/test/resources/application-integration-test.properties new file mode 100644 index 0000000..299c1ed --- /dev/null +++ b/src/test/resources/application-integration-test.properties @@ -0,0 +1,41 @@ +# Profil test d'intégration — utilise Quarkus DevServices (PostgreSQL via Testcontainers) +# Activé via @QuarkusTestProfile ou -Dquarkus.test.profile=integration-test +# Quarkus démarre automatiquement un conteneur PostgreSQL (Docker requis) + +quarkus.datasource.db-kind=postgresql +# Pas de JDBC URL → Quarkus DevServices démarre un conteneur PostgreSQL automatiquement +quarkus.datasource.username=unionflow_test +quarkus.datasource.password=unionflow_test + +quarkus.hibernate-orm.database.generation=drop-and-create +quarkus.flyway.enabled=true +quarkus.flyway.migrate-at-start=true + +# Keycloak — désactivé pour les tests d'intégration DB +quarkus.oidc.tenant-enabled=false +quarkus.keycloak.policy-enforcer.enable=false + +# Kafka — in-memory connector (pas de broker requis) +mp.messaging.outgoing.finance-approvals-out.connector=smallrye-in-memory +mp.messaging.outgoing.dashboard-stats-out.connector=smallrye-in-memory +mp.messaging.outgoing.notifications-out.connector=smallrye-in-memory +mp.messaging.outgoing.members-events-out.connector=smallrye-in-memory +mp.messaging.outgoing.contributions-events-out.connector=smallrye-in-memory +mp.messaging.incoming.finance-approvals-in.connector=smallrye-in-memory +mp.messaging.incoming.dashboard-stats-in.connector=smallrye-in-memory +mp.messaging.incoming.notifications-in.connector=smallrye-in-memory +mp.messaging.incoming.members-events-in.connector=smallrye-in-memory +mp.messaging.incoming.contributions-events-in.connector=smallrye-in-memory + +# HTTP port dynamique +quarkus.http.port=0 +quarkus.http.test-port=0 + +# Mailer — mock +quarkus.mailer.mock=true + +# Wave — mock +wave.mock.enabled=true +wave.api.key=test-wave-api-key +wave.api.secret=test-wave-api-secret +wave.redirect.base.url=http://localhost:8080