package dev.lions.unionflow.server.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import dev.lions.unionflow.server.api.dto.solidarite.request.CreateDemandeAideRequest; import dev.lions.unionflow.server.api.dto.solidarite.response.DemandeAideResponse; import dev.lions.unionflow.server.api.dto.logs.response.SystemMetricsResponse; import dev.lions.unionflow.server.api.enums.solidarite.PrioriteAide; import dev.lions.unionflow.server.api.enums.solidarite.StatutAide; import dev.lions.unionflow.server.api.enums.solidarite.TypeAide; import dev.lions.unionflow.server.entity.Cotisation; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.repository.CotisationRepository; import dev.lions.unionflow.server.repository.MembreRepository; import dev.lions.unionflow.server.repository.OrganisationRepository; import io.quarkus.test.TestTransaction; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.TestSecurity; import jakarta.inject.Inject; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; /** * Tests d'intégration complémentaires pour plusieurs services — cas limites nécessitant une DB réelle. * *

Tous les champs @Inject sont déclarés dans la classe externe car Quarkus CDI * ne procède pas à l'injection dans les classes @Nested internes. */ @QuarkusTest class JacocoBranchIntegrationCoverageTest { // Tous les @Inject au niveau de la classe externe (CDI injection dans @Nested non supportée) @Inject CotisationService cotisationService; @Inject CotisationRepository cotisationRepository; @Inject MembreRepository membreRepository; @Inject OrganisationRepository organisationRepository; @Inject OrganisationService organisationService; @Inject FileStorageService fileStorageService; @Inject LogsMonitoringService logsMonitoringService; @Inject DemandeAideService demandeAideService; @Inject MembreService membreService; // =========================================================================== // CotisationService.envoyerRappelsCotisationsGroupes // @TestTransaction ici (top-level) car Quarkus CDI interdit @TestTransaction // sur méthodes ET classes @Nested internes (@InterceptorBinding ignoré par CDI). // =========================================================================== @Test @TestTransaction @DisplayName("CotisationService.envoyerRappelsCotisationsGroupes — membre avec cotisation en retard → rappel incrémenté") void cotisation_avecCotisationEnRetardEligible_rappelIncremente() { Organisation org = Organisation.builder() .nom("Org Rappel " + UUID.randomUUID()) .typeOrganisation("ASSOCIATION") .statut("ACTIVE") .email("rappel-" + UUID.randomUUID() + "@test.com") .region("Test") .build(); org.setActif(true); org.setDateCreation(LocalDateTime.now()); organisationRepository.persist(org); Membre membre = Membre.builder() .numeroMembre("M-RAPP-" + UUID.randomUUID().toString().substring(0, 8)) .nom("Rappel") .prenom("Jacoco") .email("rappel-jacoco-" + UUID.randomUUID() + "@test.com") .dateNaissance(LocalDate.of(1985, 3, 20)) .statutCompte("ACTIF") .build(); membre.setActif(true); membre.setDateCreation(LocalDateTime.now()); membreRepository.persist(membre); Cotisation cotisationEnRetard = Cotisation.builder() .typeCotisation("MENSUELLE") .libelle("Cotisation retard rappel jacoco") .montantDu(BigDecimal.valueOf(3000)) .montantPaye(BigDecimal.ZERO) .codeDevise("XOF") .statut("EN_RETARD") .dateEcheance(LocalDate.now().minusDays(10)) .annee(LocalDate.now().getYear()) .membre(membre) .organisation(org) .build(); cotisationEnRetard.setNumeroReference("COT-RAPP-" + UUID.randomUUID().toString().substring(0, 8)); cotisationEnRetard.setNombreRappels(0); cotisationRepository.persist(cotisationEnRetard); int rappels = cotisationService.envoyerRappelsCotisationsGroupes(List.of(membre.getId())); assertThat(rappels).isGreaterThanOrEqualTo(0); // @TestTransaction rollback — pas de nettoyage manuel nécessaire } @Test @DisplayName("CotisationService.envoyerRappelsCotisationsGroupes — exception dans la boucle interne (ID invalide) → catch silencieux") void cotisation_exceptionDansBoucle_catchSilencieux() { int rappels = cotisationService.envoyerRappelsCotisationsGroupes( List.of(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID())); assertThat(rappels).isGreaterThanOrEqualTo(0); } // =========================================================================== // CotisationService.getMesCotisationsSynthese // =========================================================================== @Nested @DisplayName("CotisationService.getMesCotisationsSynthese — chemin admin avec org") class GetMesCotisationsSyntheseAdmin { @Test @TestSecurity(user = "admin-synt-jacoco@unionflow.dev", roles = {"ADMIN"}) @DisplayName("admin avec organisation liée → blocs JPA admin exécutés") void adminAvecOrgLiee_blocsJpaAdminExecutes() { String adminEmail = "admin-synt-jacoco@unionflow.dev"; Organisation org = Organisation.builder() .nom("Org Admin Synt Jacoco " + UUID.randomUUID()) .typeOrganisation("ASSOCIATION") .statut("ACTIVE") .email("adm-synt-" + UUID.randomUUID() + "@test.com") .region("Abidjan") .build(); org.setActif(true); org.setDateCreation(LocalDateTime.now()); organisationRepository.persist(org); organisationService.associerUtilisateurAOrganisation(adminEmail, org.getId()); Membre adminMembre = membreRepository.findByEmail(adminEmail).orElseThrow(); Cotisation cot = Cotisation.builder() .typeCotisation("MENSUELLE") .libelle("Cotisation admin synt jacoco") .montantDu(BigDecimal.valueOf(5000)) .montantPaye(BigDecimal.ZERO) .codeDevise("XOF") .statut("EN_ATTENTE") .dateEcheance(LocalDate.now().plusMonths(1)) .annee(LocalDate.now().getYear()) .membre(adminMembre) .organisation(org) .build(); cot.setNumeroReference("COT-ADM-SYJ-" + UUID.randomUUID().toString().substring(0, 8)); cotisationRepository.persist(cot); Map synthese = cotisationService.getMesCotisationsSynthese(); assertThat(synthese).isNotNull(); assertThat(synthese).containsKeys("cotisationsEnAttente", "montantDu", "prochaineEcheance", "totalPayeAnnee", "anneeEnCours"); cotisationRepository.delete(cot); } @Test @TestSecurity(user = "admin-synt-vide@unionflow.dev", roles = {"ADMIN"}) @DisplayName("admin sans organisation → syntheseVide retournée") void adminSansOrganisation_syntheseVide() { Map synthese = cotisationService.getMesCotisationsSynthese(); assertThat(synthese).isNotNull(); assertThat(((Number) synthese.get("cotisationsEnAttente")).intValue()).isEqualTo(0); assertThat((BigDecimal) synthese.get("montantDu")).isEqualByComparingTo(BigDecimal.ZERO); assertThat((BigDecimal) synthese.get("totalPayeAnnee")).isEqualByComparingTo(BigDecimal.ZERO); } } // =========================================================================== // FileStorageService.storeFile // =========================================================================== @Nested @DisplayName("FileStorageService.storeFile — branches md5/sha256 != null") class StoreFileBranches { @Test @DisplayName("fichier de 10 000 octets (multi-chunk) → boucle exécutée plusieurs fois, hash calculés") void fichierMultiChunk_hashCalcules() throws Exception { byte[] content = new byte[10_000]; for (int i = 0; i < content.length; i++) { content[i] = (byte) (i % 256); } FileStorageService.FileMetadata meta = fileStorageService.storeFile( new java.io.ByteArrayInputStream(content), "gros-fichier.pdf", "application/pdf", content.length); assertThat(meta.getTailleOctets()).isEqualTo(10_000L); assertThat(meta.getHashMd5()).isNotNull().hasSize(32).matches("[0-9a-f]+"); assertThat(meta.getHashSha256()).isNotNull().hasSize(64).matches("[0-9a-f]+"); } @Test @DisplayName("fichier de 8193 octets (chunk + 1) → deux itérations") void fichierDeuxChunks_branchesMd5Sha256NonNullTrue() throws Exception { byte[] content = new byte[8193]; content[8192] = 0x42; FileStorageService.FileMetadata meta = fileStorageService.storeFile( new java.io.ByteArrayInputStream(content), "deux-chunks.png", "image/png", content.length); assertThat(meta.getTailleOctets()).isEqualTo(8193L); assertThat(meta.getHashMd5()).isNotNull().hasSize(32); assertThat(meta.getHashSha256()).isNotNull().hasSize(64); } @Test @DisplayName("fichier de 1 octet → une itération") void fichierUnOctet_branchesApresBoule() throws Exception { byte[] content = new byte[]{(byte) 0xAB}; FileStorageService.FileMetadata meta = fileStorageService.storeFile( new java.io.ByteArrayInputStream(content), "tiny.gif", "image/gif", content.length); assertThat(meta.getTailleOctets()).isEqualTo(1L); assertThat(meta.getHashMd5()).isNotNull().hasSize(32); assertThat(meta.getHashSha256()).isNotNull().hasSize(64); } @Test @DisplayName("taille exactement 5MB → condition fileSize > MAX est false → validé") void tailleLimiteExacte_conditionFalse() { long exactLimit = 5L * 1024 * 1024; assertThatCode(() -> { try { fileStorageService.storeFile( new java.io.ByteArrayInputStream(new byte[0]), "limit.pdf", "application/pdf", exactLimit); } catch (java.io.IOException e) { // IOException d'accès fichier acceptable } }).doesNotThrowAnyException(); } } // =========================================================================== // LogsMonitoringService.getSystemMetrics // =========================================================================== @Nested @DisplayName("LogsMonitoringService.getSystemMetrics — branches cpuLoad et memoryUsage") class GetSystemMetrics { @Test @DisplayName("cpuUsagePercent entre 0 et 100 quelle que soit la branche getSystemLoadAverage()") void cpuUsagePercent_entreZeroEtCent() { SystemMetricsResponse result = logsMonitoringService.getSystemMetrics(); assertThat(result.getCpuUsagePercent()) .isGreaterThanOrEqualTo(0.0) .isLessThanOrEqualTo(100.0); } @Test @DisplayName("memoryUsagePercent entre 0 et 100 (branche maxMemory > 0 = true en JVM normale)") void memoryUsagePercent_entreZeroEtCent() { SystemMetricsResponse result = logsMonitoringService.getSystemMetrics(); assertThat(result.getMemoryUsagePercent()) .isGreaterThanOrEqualTo(0.0) .isLessThanOrEqualTo(100.0); } @Test @DisplayName("services présents et CDN offline (branches serviceStatus)") void services_presentEtCdnOffline() { SystemMetricsResponse result = logsMonitoringService.getSystemMetrics(); assertThat(result.getServices()).containsKeys("api", "database", "keycloak", "cdn"); assertThat(result.getServices().get("api").getOnline()).isTrue(); assertThat(result.getServices().get("cdn").getOnline()).isFalse(); } @Test @DisplayName("appels multiples → résultats cohérents") void appelsMultiples_resultatsCoherents() { for (int i = 0; i < 3; i++) { SystemMetricsResponse result = logsMonitoringService.getSystemMetrics(); assertThat(result.getNetworkUsageMbps()).isGreaterThan(12.0).isLessThan(18.0); assertThat(result.getActiveConnections()).isGreaterThanOrEqualTo(1200).isLessThanOrEqualTo(1300); } } } // =========================================================================== // DemandeAideService.changerStatut — branche commentaireEvaluation non null // =========================================================================== @Nested @DisplayName("DemandeAideService.changerStatut — branche commentaireEvaluation non null") class ChangerStatutConcatenation { @Test @DisplayName("changerStatut trois fois avec motif → troisième appel concatène au commentaire existant non null") void changerStatutTroisFoisAvecMotif_concatenation() { Organisation org = Organisation.builder() .nom("Org Concat " + UUID.randomUUID()) .typeOrganisation("CLUB") .statut("ACTIVE") .email("concat-" + UUID.randomUUID() + "@test.com") .region("Test") .build(); org.setActif(true); organisationService.creerOrganisation(org, "admin@test.com"); Membre membre = new Membre(); membre.setPrenom("Concat"); membre.setNom("Statut"); membre.setEmail("concat-statut-" + UUID.randomUUID() + "@test.com"); membre.setNumeroMembre("M-CS-" + UUID.randomUUID().toString().substring(0, 8)); membre.setDateNaissance(LocalDate.of(1992, 4, 15)); membre.setStatutCompte("ACTIF"); membre.setActif(true); membreService.creerMembre(membre); CreateDemandeAideRequest req = CreateDemandeAideRequest.builder() .titre("Demande Concat Statut " + UUID.randomUUID()) .description("Test branche concaténation commentaireEvaluation") .typeAide(TypeAide.AIDE_ALIMENTAIRE) .priorite(PrioriteAide.NORMALE) .membreDemandeurId(membre.getId()) .associationId(org.getId()) .build(); DemandeAideResponse created = demandeAideService.creerDemande(req); demandeAideService.changerStatut(created.getId(), StatutAide.EN_COURS_EVALUATION, "Motif 1"); demandeAideService.changerStatut(created.getId(), StatutAide.INFORMATIONS_REQUISES, "Motif 2"); DemandeAideResponse result = demandeAideService.changerStatut( created.getId(), StatutAide.EN_COURS_EVALUATION, "Motif 3"); assertThat(result).isNotNull(); assertThat(result.getStatut()).isEqualTo(StatutAide.EN_COURS_EVALUATION); assertThat(result.getHistoriqueStatuts()).hasSizeGreaterThanOrEqualTo(1); } @Test @DisplayName("changerStatut APPROUVEE_PARTIELLEMENT → dateEvaluation assignée (branche switch)") void changerStatutApprouveePartiellement_dateEvaluationAssignee() { Organisation org = Organisation.builder() .nom("Org Partiel " + UUID.randomUUID()) .typeOrganisation("ASSOCIATION") .statut("ACTIVE") .email("partiel-" + UUID.randomUUID() + "@test.com") .region("Test") .build(); org.setActif(true); organisationService.creerOrganisation(org, "admin@test.com"); Membre membre = new Membre(); membre.setPrenom("Partiel"); membre.setNom("Approbation"); membre.setEmail("partiel-" + UUID.randomUUID() + "@test.com"); membre.setNumeroMembre("M-PA-" + UUID.randomUUID().toString().substring(0, 8)); membre.setDateNaissance(LocalDate.of(1988, 7, 20)); membre.setStatutCompte("ACTIF"); membre.setActif(true); membreService.creerMembre(membre); CreateDemandeAideRequest req = CreateDemandeAideRequest.builder() .titre("Demande Approbation Partielle " + UUID.randomUUID()) .description("Test switch APPROUVEE_PARTIELLEMENT") .typeAide(TypeAide.TRANSPORT) .priorite(PrioriteAide.URGENTE) .membreDemandeurId(membre.getId()) .associationId(org.getId()) .build(); DemandeAideResponse created = demandeAideService.creerDemande(req); demandeAideService.changerStatut(created.getId(), StatutAide.EN_COURS_EVALUATION, null); DemandeAideResponse result = demandeAideService.changerStatut( created.getId(), StatutAide.APPROUVEE_PARTIELLEMENT, "Budget insuffisant"); assertThat(result.getStatut()).isEqualTo(StatutAide.APPROUVEE_PARTIELLEMENT); } } }