fix(security): audit RBAC complet v3.0 — rôles normalisés, lifecycle, changement mdp mobile

RBAC:
- HealthResource: @PermitAll
- RoleResource: @RolesAllowed ADMIN/SUPER_ADMIN/ADMIN_ORGANISATION class-level
- PropositionAideResource: @RolesAllowed MEMBRE/USER class-level
- AuthCallbackResource: @PermitAll
- EvenementResource: @PermitAll /publics et /test, count restreint
- BackupResource/LogsMonitoringResource/SystemResource: MODERATOR → MODERATEUR
- AnalyticsResource: MANAGER/MEMBER → ADMIN_ORGANISATION/MEMBRE
- RoleConstant.java: constantes de rôles centralisées

Cycle de vie membres:
- MemberLifecycleService: ajouterMembre()/retirerMembre() sur activation/radiation/archivage
- MembreResource: endpoint GET /numero/{numeroMembre}
- MembreService: méthode trouverParNumeroMembre()

Changement mot de passe:
- CompteAdherentResource: endpoint POST /auth/change-password (mobile)
- MembreKeycloakSyncService: changerMotDePasseDirectKeycloak() via API Admin Keycloak directe
- Fallback automatique si lions-user-manager indisponible

Workflow:
- Flyway V17-V23: rôles, types org, formules Option C, lifecycle columns, bareme cotisation
- Nouvelles classes: MemberLifecycleService, OrganisationModuleService, scheduler
- Security: OrganisationContextFilter, OrganisationContextHolder, ModuleAccessFilter
This commit is contained in:
dahoud
2026-04-07 20:52:26 +00:00
parent c74ae25ad6
commit a2dfae9a0b
78 changed files with 5637 additions and 271 deletions

View File

@@ -0,0 +1,331 @@
package dev.lions.unionflow.server.entity;
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.TypeFormule;
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;
/**
* Tests unitaires pour la logique de quota (Option C) et les champs de plan commercial.
*
* <p>Couvre :
* <ul>
* <li>{@link SouscriptionOrganisation#isActive()} — selon statut + dateFin</li>
* <li>{@link SouscriptionOrganisation#isQuotaDepasse()} — selon quotaMax et quotaUtilise</li>
* <li>{@link SouscriptionOrganisation#getPlacesRestantes()} — calcul des places libres</li>
* <li>{@link FormuleAbonnement#isIllimitee()} — maxMembres null = illimité</li>
* <li>{@link FormuleAbonnement#accepteNouveauMembre(int)} — contrôle admission</li>
* <li>Champs Option C (planCommercial, apiAccess, federationAccess, maxAdmins)</li>
* </ul>
*/
@DisplayName("SouscriptionOrganisation + FormuleAbonnement — Quota & Option C")
class SouscriptionQuotaOptionCTest {
// ─── Builders ─────────────────────────────────────────────────────────────
private static Organisation newOrganisation() {
Organisation o = new Organisation();
o.setId(UUID.randomUUID());
o.setNom("Association Test");
o.setTypeOrganisation("ASSOCIATION");
o.setStatut("ACTIVE");
o.setEmail("test@asso.sn");
return o;
}
private static FormuleAbonnement newFormule(int maxMembres) {
FormuleAbonnement f = new FormuleAbonnement();
f.setId(UUID.randomUUID());
f.setCode(TypeFormule.STANDARD);
f.setPlage(PlageMembres.PETITE);
f.setLibelle("Standard Petite");
f.setMaxMembres(maxMembres);
f.setPrixMensuel(BigDecimal.valueOf(7500));
f.setPrixAnnuel(BigDecimal.valueOf(75000));
return f;
}
private static FormuleAbonnement newFormuleIllimitee() {
FormuleAbonnement f = new FormuleAbonnement();
f.setId(UUID.randomUUID());
f.setCode(TypeFormule.PREMIUM);
f.setPlage(PlageMembres.GRANDE);
f.setLibelle("Enterprise");
f.setMaxMembres(null); // illimité
f.setPrixMensuel(BigDecimal.valueOf(30000));
f.setPrixAnnuel(BigDecimal.valueOf(300000));
return f;
}
private static SouscriptionOrganisation newSouscription(
Organisation org, FormuleAbonnement formule,
StatutSouscription statut, LocalDate dateFin,
int quotaMax, int quotaUtilise) {
SouscriptionOrganisation s = new SouscriptionOrganisation();
s.setId(UUID.randomUUID());
s.setOrganisation(org);
s.setFormule(formule);
s.setStatut(statut);
s.setDateDebut(LocalDate.now().minusMonths(1));
s.setDateFin(dateFin);
s.setQuotaMax(quotaMax);
s.setQuotaUtilise(quotaUtilise);
return s;
}
// ═════════════════════════════════════════════════════════════════════════
// isActive()
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("isActive: statut ACTIVE et dateFin future → true")
void isActive_activePlusFutureDateFin_returnsTrue() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.ACTIVE, LocalDate.now().plusMonths(6), 100, 0);
assertThat(s.isActive()).isTrue();
}
@Test
@DisplayName("isActive: statut ACTIVE mais dateFin hier → false (expirée)")
void isActive_activePlusExpiredDate_returnsFalse() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.ACTIVE, LocalDate.now().minusDays(1), 100, 0);
assertThat(s.isActive()).isFalse();
}
@Test
@DisplayName("isActive: statut SUSPENDUE → false")
void isActive_suspendue_returnsFalse() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.SUSPENDUE, LocalDate.now().plusMonths(1), 100, 0);
assertThat(s.isActive()).isFalse();
}
@Test
@DisplayName("isActive: statut EXPIREE → false")
void isActive_expiree_returnsFalse() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.EXPIREE, LocalDate.now().plusMonths(1), 100, 0);
assertThat(s.isActive()).isFalse();
}
@Test
@DisplayName("isActive: dateFin aujourd'hui → true (borne inclusive)")
void isActive_dateFin_today_returnsTrue() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.ACTIVE, LocalDate.now(), 100, 0);
assertThat(s.isActive()).isTrue();
}
// ═════════════════════════════════════════════════════════════════════════
// isQuotaDepasse()
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("isQuotaDepasse: quotaUtilise < quotaMax → false")
void isQuotaDepasse_utiliseInferieur_returnsFalse() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 50);
assertThat(s.isQuotaDepasse()).isFalse();
}
@Test
@DisplayName("isQuotaDepasse: quotaUtilise == quotaMax → true (atteint)")
void isQuotaDepasse_utiliseEgalMax_returnsTrue() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 100);
assertThat(s.isQuotaDepasse()).isTrue();
}
@Test
@DisplayName("isQuotaDepasse: quotaUtilise > quotaMax → true (dépassé)")
void isQuotaDepasse_utiliseSuperieur_returnsTrue() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 110);
assertThat(s.isQuotaDepasse()).isTrue();
}
@Test
@DisplayName("isQuotaDepasse: quotaMax null (illimité) → false")
void isQuotaDepasse_quotaMaxNull_returnsFalse() {
var s = newSouscription(newOrganisation(), newFormuleIllimitee(),
StatutSouscription.ACTIVE, LocalDate.now().plusYears(1), 0, 500);
s.setQuotaMax(null); // explicitement illimité
assertThat(s.isQuotaDepasse()).isFalse();
}
// ═════════════════════════════════════════════════════════════════════════
// getPlacesRestantes()
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("getPlacesRestantes: 100 max - 30 utilisés = 70 restantes")
void getPlacesRestantes_partialUsage_returnsCorrectValue() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 30);
assertThat(s.getPlacesRestantes()).isEqualTo(70);
}
@Test
@DisplayName("getPlacesRestantes: quota plein → 0 (jamais négatif)")
void getPlacesRestantes_quotaFull_returnsZero() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 100);
assertThat(s.getPlacesRestantes()).isZero();
}
@Test
@DisplayName("getPlacesRestantes: quotaMax null (illimité) → Integer.MAX_VALUE")
void getPlacesRestantes_illimite_returnsMaxValue() {
var s = newSouscription(newOrganisation(), newFormuleIllimitee(),
StatutSouscription.ACTIVE, LocalDate.now().plusYears(1), 0, 0);
s.setQuotaMax(null);
assertThat(s.getPlacesRestantes()).isEqualTo(Integer.MAX_VALUE);
}
// ═════════════════════════════════════════════════════════════════════════
// FormuleAbonnement — isIllimitee() + accepteNouveauMembre()
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("FormuleAbonnement.isIllimitee: maxMembres null → true")
void formule_maxMembresNull_isIllimitee() {
var f = newFormuleIllimitee();
assertThat(f.isIllimitee()).isTrue();
}
@Test
@DisplayName("FormuleAbonnement.isIllimitee: maxMembres défini → false")
void formule_maxMembresDefined_notIllimitee() {
var f = newFormule(200);
assertThat(f.isIllimitee()).isFalse();
}
@Test
@DisplayName("FormuleAbonnement.accepteNouveauMembre: quota non atteint → true")
void formule_accepteNouveauMembre_quotaNotReached_returnsTrue() {
var f = newFormule(100);
assertThat(f.accepteNouveauMembre(99)).isTrue();
}
@Test
@DisplayName("FormuleAbonnement.accepteNouveauMembre: quota atteint → false")
void formule_accepteNouveauMembre_quotaReached_returnsFalse() {
var f = newFormule(100);
assertThat(f.accepteNouveauMembre(100)).isFalse();
}
@Test
@DisplayName("FormuleAbonnement.accepteNouveauMembre: formule illimitée → toujours true")
void formule_accepteNouveauMembre_illimitee_alwaysTrue() {
var f = newFormuleIllimitee();
assertThat(f.accepteNouveauMembre(10000)).isTrue();
}
// ═════════════════════════════════════════════════════════════════════════
// Champs Option C sur FormuleAbonnement
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("FormuleAbonnement Option C: plan ENTERPRISE — tous les droits activés")
void formule_enterprisePlan_allFeaturesEnabled() {
FormuleAbonnement f = new FormuleAbonnement();
f.setCode(TypeFormule.PREMIUM);
f.setPlage(PlageMembres.GRANDE);
f.setLibelle("Enterprise");
f.setMaxMembres(null);
f.setPrixMensuel(BigDecimal.valueOf(50000));
f.setPrixAnnuel(BigDecimal.valueOf(500000));
f.setPlanCommercial("ENTERPRISE");
f.setNiveauReporting("AVANCE");
f.setApiAccess(true);
f.setFederationAccess(true);
f.setSupportPrioritaire(true);
f.setSlaGaranti("99.9%");
f.setMaxAdmins(null); // illimité
assertThat(f.getPlanCommercial()).isEqualTo("ENTERPRISE");
assertThat(f.getNiveauReporting()).isEqualTo("AVANCE");
assertThat(f.getApiAccess()).isTrue();
assertThat(f.getFederationAccess()).isTrue();
assertThat(f.getSupportPrioritaire()).isTrue();
assertThat(f.getSlaGaranti()).isEqualTo("99.9%");
assertThat(f.getMaxAdmins()).isNull();
}
@Test
@DisplayName("FormuleAbonnement Option C: plan BASIC — accès limités")
void formule_basicPlan_limitedFeatures() {
FormuleAbonnement f = newFormule(50);
f.setPlanCommercial("MICRO");
f.setNiveauReporting("BASIQUE");
f.setApiAccess(false);
f.setFederationAccess(false);
f.setSupportPrioritaire(false);
f.setSlaGaranti("99.0%");
f.setMaxAdmins(1);
assertThat(f.getApiAccess()).isFalse();
assertThat(f.getFederationAccess()).isFalse();
assertThat(f.getSupportPrioritaire()).isFalse();
assertThat(f.getMaxAdmins()).isEqualTo(1);
assertThat(f.getNiveauReporting()).isEqualTo("BASIQUE");
}
// ═════════════════════════════════════════════════════════════════════════
// incrementerQuota() + decrementerQuota()
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("incrementerQuota: 30 utilisés → 31 après incrémentation")
void incrementerQuota_increment() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 30);
s.incrementerQuota();
assertThat(s.getQuotaUtilise()).isEqualTo(31);
}
@Test
@DisplayName("decrementerQuota: 30 utilisés → 29 après décrémentation")
void decrementerQuota_decrement() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 30);
s.decrementerQuota();
assertThat(s.getQuotaUtilise()).isEqualTo(29);
}
@Test
@DisplayName("decrementerQuota: 0 utilisés → reste à 0 (pas négatif)")
void decrementerQuota_atZero_staysZero() {
var s = newSouscription(newOrganisation(), newFormule(100),
StatutSouscription.ACTIVE, LocalDate.now().plusMonths(1), 100, 0);
s.decrementerQuota();
assertThat(s.getQuotaUtilise()).isZero();
}
}

View File

@@ -225,6 +225,64 @@ class CompteAdherentResourceTest {
verify(membreService, never()).activerMembre(any());
}
// ============================================================
// Error cases
// ============================================================
@Test
@DisplayName("GET /api/membres/mon-compte sans authentification retourne 401")
void getMonCompte_sansAuthentification_returns401() {
given()
.when()
.get("/api/membres/mon-compte")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("GET /api/membres/mon-compte retourne 500 quand le service lève une exception")
void getMonCompte_serviceException_returns500() {
when(compteAdherentService.getMonCompte())
.thenThrow(new RuntimeException("Erreur base de données"));
given()
.when()
.get("/api/membres/mon-compte")
.then()
.statusCode(500);
}
@Test
@DisplayName("GET /api/membres/mon-statut sans authentification retourne 401")
void getMonStatut_sansAuthentification_returns401() {
given()
.when()
.get("/api/membres/mon-statut")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("GET /api/membres/mon-statut retourne EN_ATTENTE_VALIDATION quand aucune org trouvée")
void getMonStatut_enAttenteMembreOrganisationAbsente_retourneEnAttente() {
UUID membreId = UUID.randomUUID();
Membre membre = membreFixture("membre@test.com", "EN_ATTENTE_VALIDATION");
membre.setId(membreId);
when(membreRepository.findByEmail("membre@test.com")).thenReturn(Optional.of(membre));
when(membreOrganisationRepository.findFirstByMembreId(membreId)).thenReturn(Optional.empty());
given()
.when()
.get("/api/membres/mon-statut")
.then()
.statusCode(200)
.body("statutCompte", equalTo("EN_ATTENTE_VALIDATION"));
}
// ─── Helper ────────────────────────────────────────────────────────────────
private Membre membreFixture(String email, String statut) {

View File

@@ -8,7 +8,6 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationResponse;
import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationSummaryResponse;
import dev.lions.unionflow.server.service.CotisationService;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
@@ -520,12 +519,15 @@ class CotisationResourceMockTest {
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
@DisplayName("GET /api/cotisations/public retourne 200 avec données mockées")
void getCotisationsPublic_withMockedData_returns200() {
CotisationSummaryResponse summary = new CotisationSummaryResponse(
UUID.randomUUID(), "COT-001", "Jean Dupont",
new BigDecimal("5000"), new BigDecimal("0"),
"EN_ATTENTE", "En attente", LocalDate.now().plusMonths(1),
LocalDate.now().getYear(), Boolean.TRUE
);
CotisationResponse summary = CotisationResponse.builder()
.numeroReference("COT-001")
.nomMembre("Jean Dupont")
.montantDu(new BigDecimal("5000"))
.montantPaye(new BigDecimal("0"))
.statut("EN_ATTENTE")
.statutLibelle("En attente")
.dateEcheance(LocalDate.now().plusMonths(1))
.build();
when(cotisationService.getAllCotisations(anyInt(), anyInt()))
.thenReturn(List.of(summary));
when(cotisationService.getStatistiquesCotisations())
@@ -552,12 +554,15 @@ class CotisationResourceMockTest {
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
@DisplayName("GET /api/cotisations/public avec totalCotisations null utilise content.size() (branche ligne 82)")
void getCotisationsPublic_totalCotisationsNull_usesContentSize() {
CotisationSummaryResponse summary = new CotisationSummaryResponse(
UUID.randomUUID(), "COT-002", "Marie Dupont",
new BigDecimal("3000"), new BigDecimal("0"),
"EN_ATTENTE", "En attente", LocalDate.now().plusMonths(2),
LocalDate.now().getYear(), Boolean.FALSE
);
CotisationResponse summary = CotisationResponse.builder()
.numeroReference("COT-002")
.nomMembre("Marie Dupont")
.montantDu(new BigDecimal("3000"))
.montantPaye(new BigDecimal("0"))
.statut("EN_ATTENTE")
.statutLibelle("En attente")
.dateEcheance(LocalDate.now().plusMonths(2))
.build();
when(cotisationService.getAllCotisations(anyInt(), anyInt()))
.thenReturn(List.of(summary));
// getStatistiquesCotisations() retourne une map SANS "totalCotisations"

View File

@@ -66,4 +66,35 @@ class DashboardWebSocketEndpointTest {
endpoint.onClose(connection);
// pas d'exception attendue
}
@Test
@DisplayName("onMessage avec message vide retourne ack")
void onMessage_emptyMessage_returnsAck() {
String result = endpoint.onMessage("", connection);
assertThat(result).isNotNull();
assertThat(result).contains("ack");
}
@Test
@DisplayName("onMessage avec null retourne une réponse non nulle")
void onMessage_nullMessage_returnsNonNull() {
String result = endpoint.onMessage(null, connection);
assertThat(result).isNotNull();
}
@Test
@DisplayName("onOpen avec connection id null ne lève pas d'exception")
void onOpen_connectionIdNull_doesNotThrow() {
when(connection.id()).thenReturn(null);
String result = endpoint.onOpen(connection);
assertThat(result).isNotNull();
assertThat(result).contains("connected");
}
@Test
@DisplayName("onMessage avec JSON inconnu retourne ack")
void onMessage_unknownJson_returnsAck() {
String result = endpoint.onMessage("{\"type\":\"unknown\",\"data\":{}}", connection);
assertThat(result).isNotNull();
}
}

View File

@@ -155,6 +155,60 @@ class DemandeAideMockResourceTest {
when(membreRepository.findByEmail(anyString())).thenReturn(Optional.of(membre));
}
// -------------------------------------------------------------------------
// Error cases
// -------------------------------------------------------------------------
@Test
@DisplayName("GET /api/demandes-aide/mes sans authentification retourne 401")
void mesDemandes_sansAuthentification_returns401() {
given()
.when()
.get("/api/demandes-aide/mes")
.then()
.statusCode(401);
}
@Test
@DisplayName("GET /api/demandes-aide sans authentification retourne 401")
void listerToutes_sansAuthentification_returns401() {
given()
.when()
.get("/api/demandes-aide")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("GET /api/demandes-aide/{id} avec ID inexistant retourne 404")
void obtenirParId_notFound_returns404() {
UUID id = UUID.randomUUID();
when(demandeAideService.obtenirParId(any(UUID.class))).thenReturn(null);
given()
.pathParam("id", id)
.when()
.get("/api/demandes-aide/{id}")
.then()
.statusCode(404);
}
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("PUT /api/demandes-aide/{id}/approuver avec rôle MEMBRE retourne 403")
void approuver_avecRoleMembre_returns403() {
UUID id = UUID.randomUUID();
given()
.queryParam("motif", "Test")
.pathParam("id", id)
.when()
.put("/api/demandes-aide/{id}/approuver")
.then()
.statusCode(403);
}
// -------------------------------------------------------------------------
// obtenirParId — response != null (found)
// -------------------------------------------------------------------------

View File

@@ -251,6 +251,47 @@ class ExportResourceTest {
.header("Content-Disposition", containsString("rapport-2026-03.pdf"));
}
// -------------------------------------------------------------------------
// Error cases
// -------------------------------------------------------------------------
@Test
@DisplayName("GET /api/export/cotisations/csv sans authentification retourne 401")
void exporterCotisationsCSV_sansAuthentification_returns401() {
given()
.when()
.get("/api/export/cotisations/csv")
.then()
.statusCode(401);
}
@Test
@DisplayName("GET /api/export/rapport/mensuel sans authentification retourne 401")
void genererRapportMensuel_sansAuthentification_returns401() {
given()
.queryParam("annee", 2026)
.queryParam("mois", 3)
.when()
.get("/api/export/rapport/mensuel")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
@DisplayName("GET /api/export/cotisations/{id}/recu/pdf quand service lève exception retourne 500")
void genererRecuPDF_serviceException_returns500() {
when(exportService.genererRecuPaiementPDF(org.mockito.ArgumentMatchers.any(UUID.class)))
.thenThrow(new RuntimeException("Cotisation introuvable"));
given()
.pathParam("cotisationId", COTISATION_ID)
.when()
.get("/api/export/cotisations/{cotisationId}/recu/pdf")
.then()
.statusCode(500);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
@DisplayName("GET /api/export/rapport/mensuel/pdf avec associationId retourne 200")

View File

@@ -208,4 +208,52 @@ class FinanceWorkflowResourceTest {
.body("format", equalTo("csv"));
}
// -------------------------------------------------------------------------
// Error cases
// -------------------------------------------------------------------------
@Test
@DisplayName("GET /api/finance/stats sans authentification retourne 401")
void getStats_sansAuthentification_returns401() {
given()
.when()
.get(BASE_PATH + "/stats")
.then()
.statusCode(401);
}
@Test
@DisplayName("GET /api/finance/audit-logs sans authentification retourne 401")
void getAuditLogs_sansAuthentification_returns401() {
given()
.when()
.get(BASE_PATH + "/audit-logs")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("GET /api/finance/stats avec rôle MEMBRE retourne 403")
void getStats_avecRoleMembre_returns403() {
given()
.when()
.get(BASE_PATH + "/stats")
.then()
.statusCode(403);
}
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("POST /api/finance/audit-logs/export avec rôle MEMBRE retourne 403")
void exportAuditLogs_avecRoleMembre_returns403() {
given()
.contentType(ContentType.JSON)
.body("{\"organizationId\":\"00000000-0000-0000-0000-000000000001\"}")
.when()
.post(BASE_PATH + "/audit-logs/export")
.then()
.statusCode(403);
}
}

View File

@@ -26,4 +26,38 @@ class HealthResourceTest {
.body("timestamp", notNullValue())
.body("message", equalTo("Serveur opérationnel"));
}
@Test
@DisplayName("GET /api/status avec Accept inconnu retourne 200 (endpoint toujours accessible)")
void getStatus_acceptHeaderDifferent_returns200() {
given()
.header("Accept", "application/json")
.when()
.get("/api/status")
.then()
.statusCode(200)
.body("status", equalTo("UP"));
}
@Test
@DisplayName("GET /api/status/inexistant retourne 404")
void getStatusInexistant_returns404() {
given()
.when()
.get("/api/status/inexistant")
.then()
.statusCode(404);
}
@Test
@DisplayName("POST /api/status retourne 405 (méthode non autorisée)")
void postStatus_returns405() {
given()
.contentType(ContentType.JSON)
.body("{}")
.when()
.post("/api/status")
.then()
.statusCode(405);
}
}

View File

@@ -57,4 +57,46 @@ class LogsMonitoringResourceCoverageTest {
.statusCode(200)
.body(containsString("Stack trace ligne 42"));
}
// -------------------------------------------------------------------------
// Error cases
// -------------------------------------------------------------------------
@Test
@DisplayName("GET /api/logs/export sans authentification retourne 401")
void exportLogs_sansAuthentification_returns401() {
given()
.queryParam("level", "ERROR")
.when()
.get("/api/logs/export")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("GET /api/logs/export avec rôle MEMBRE retourne 403")
void exportLogs_avecRoleMembre_returns403() {
given()
.queryParam("level", "ERROR")
.when()
.get("/api/logs/export")
.then()
.statusCode(403);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
@DisplayName("GET /api/logs/export quand service lève exception retourne 500")
void exportLogs_serviceException_returns500() {
when(logsMonitoringService.searchLogs(any(LogSearchRequest.class)))
.thenThrow(new RuntimeException("Erreur de lecture des logs"));
given()
.queryParam("level", "ERROR")
.when()
.get("/api/logs/export")
.then()
.statusCode(500);
}
}

View File

@@ -58,4 +58,43 @@ class MembreDashboardMockResourceTest {
.then()
.statusCode(200);
}
// =========================================================================
// Error cases
// =========================================================================
@Test
@DisplayName("GET /api/dashboard/membre/me sans authentification retourne 401")
void getMonDashboard_sansAuthentification_returns401() {
given()
.when()
.get("/api/dashboard/membre/me")
.then()
.statusCode(anyOf(equalTo(401), equalTo(403)));
}
@Test
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
@DisplayName("GET /api/dashboard/membre/me quand service lève exception retourne 500")
void getMonDashboard_serviceException_returns500() {
when(dashboardService.getDashboardData())
.thenThrow(new RuntimeException("Erreur interne dashboard"));
given()
.when()
.get("/api/dashboard/membre/me")
.then()
.statusCode(500);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
@DisplayName("GET /api/dashboard/membre/me avec rôle ADMIN retourne 403")
void getMonDashboard_avecRoleAdmin_returns403() {
given()
.when()
.get("/api/dashboard/membre/me")
.then()
.statusCode(403);
}
}

View File

@@ -0,0 +1,371 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.enums.membre.StatutMembre;
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.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.service.MemberLifecycleService;
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
import dev.lions.unionflow.server.service.MembreService;
import dev.lions.unionflow.server.service.MembreSuiviService;
import dev.lions.unionflow.server.service.OrganisationService;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.notNullValue;
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;
/**
* Tests RBAC pour les endpoints lifecycle du cycle de vie des membres.
*
* <p>Vérifie que :
* <ul>
* <li>MEMBRE ne peut pas activer/suspendre/radier (403)</li>
* <li>ADMIN_ORGANISATION peut activer/suspendre/radier (200)</li>
* <li>SUPER_ADMIN peut accéder à tous les endpoints admin (200)</li>
* <li>accepter-invitation est PermitAll (accessible sans auth)</li>
* </ul>
*/
@QuarkusTest
@DisplayName("MembreResource — RBAC lifecycle endpoints")
class MembreResourceLifecycleRbacTest {
@InjectMock
MembreService membreService;
@InjectMock
MembreKeycloakSyncService keycloakSyncService;
@InjectMock
MembreSuiviService membreSuiviService;
@InjectMock
OrganisationService organisationService;
@InjectMock
MemberLifecycleService memberLifecycleService;
@InjectMock
MembreOrganisationRepository membreOrgRepository;
@InjectMock
org.eclipse.microprofile.jwt.JsonWebToken jwt;
// ─── Helper ──────────────────────────────────────────────────────────────
private MembreOrganisation buildLien(UUID id, StatutMembre statut) {
Membre membre = new Membre();
membre.setId(UUID.randomUUID());
membre.setNom("Test");
membre.setPrenom("User");
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
org.setNom("Org Test");
MembreOrganisation lien = MembreOrganisation.builder()
.membre(membre)
.organisation(org)
.statutMembre(statut)
.build();
lien.setId(id);
return lien;
}
// ═════════════════════════════════════════════════════════════════════════
// activer-adhesion (PUT /{membreOrgId}/activer-adhesion)
// ═════════════════════════════════════════════════════════════════════════
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("PUT activer-adhesion: MEMBRE → 403 Forbidden")
void activerAdhesion_membreRole_returns403() {
given()
.contentType(ContentType.JSON)
.body(Map.of("motif", "test"))
.when()
.put("/api/membres/" + UUID.randomUUID() + "/activer-adhesion")
.then()
.statusCode(403);
}
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("PUT activer-adhesion: ADMIN_ORGANISATION → 200")
void activerAdhesion_adminOrganisation_returns200() {
UUID membreOrgId = UUID.randomUUID();
var lien = buildLien(membreOrgId, StatutMembre.EN_ATTENTE_VALIDATION);
lien.setStatutMembre(StatutMembre.ACTIF); // Résultat après activation
when(memberLifecycleService.activerMembre(any(UUID.class), any(), any()))
.thenReturn(lien);
when(jwt.getSubject()).thenReturn(UUID.randomUUID().toString());
given()
.contentType(ContentType.JSON)
.body(Map.of("motif", "validation"))
.when()
.put("/api/membres/" + membreOrgId + "/activer-adhesion")
.then()
.statusCode(200);
}
@Test
@TestSecurity(user = "superadmin@test.com", roles = {"SUPER_ADMIN"})
@DisplayName("PUT activer-adhesion: SUPER_ADMIN → 200")
void activerAdhesion_superAdmin_returns200() {
UUID membreOrgId = UUID.randomUUID();
var lien = buildLien(membreOrgId, StatutMembre.ACTIF);
when(memberLifecycleService.activerMembre(any(UUID.class), any(), any()))
.thenReturn(lien);
when(jwt.getSubject()).thenReturn(UUID.randomUUID().toString());
given()
.contentType(ContentType.JSON)
.body(Map.of())
.when()
.put("/api/membres/" + membreOrgId + "/activer-adhesion")
.then()
.statusCode(200);
}
// ═════════════════════════════════════════════════════════════════════════
// suspendre-adhesion (PUT /{membreOrgId}/suspendre-adhesion)
// ═════════════════════════════════════════════════════════════════════════
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("PUT suspendre-adhesion: MEMBRE → 403 Forbidden")
void suspendrAdhesion_membreRole_returns403() {
given()
.contentType(ContentType.JSON)
.body(Map.of("motif", "test"))
.when()
.put("/api/membres/" + UUID.randomUUID() + "/suspendre-adhesion")
.then()
.statusCode(403);
}
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("PUT suspendre-adhesion: ADMIN_ORGANISATION → 200")
void suspendrAdhesion_adminOrganisation_returns200() {
UUID membreOrgId = UUID.randomUUID();
var lien = buildLien(membreOrgId, StatutMembre.SUSPENDU);
when(memberLifecycleService.suspendreMembre(any(UUID.class), any(), any()))
.thenReturn(lien);
when(jwt.getSubject()).thenReturn(UUID.randomUUID().toString());
given()
.contentType(ContentType.JSON)
.body(Map.of("motif", "manquements répétés"))
.when()
.put("/api/membres/" + membreOrgId + "/suspendre-adhesion")
.then()
.statusCode(200);
}
// ═════════════════════════════════════════════════════════════════════════
// radier-adhesion (PUT /{membreOrgId}/radier-adhesion)
// ═════════════════════════════════════════════════════════════════════════
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("PUT radier-adhesion: MEMBRE → 403 Forbidden")
void radierAdhesion_membreRole_returns403() {
given()
.contentType(ContentType.JSON)
.body(Map.of())
.when()
.put("/api/membres/" + UUID.randomUUID() + "/radier-adhesion")
.then()
.statusCode(403);
}
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
@DisplayName("PUT radier-adhesion: ADMIN → 200")
void radierAdhesion_admin_returns200() {
UUID membreOrgId = UUID.randomUUID();
var lien = buildLien(membreOrgId, StatutMembre.RADIE);
when(memberLifecycleService.radierMembre(any(UUID.class), any(), any()))
.thenReturn(lien);
when(jwt.getSubject()).thenReturn(UUID.randomUUID().toString());
given()
.contentType(ContentType.JSON)
.body(Map.of("motif", "exclusion définitive"))
.when()
.put("/api/membres/" + membreOrgId + "/radier-adhesion")
.then()
.statusCode(200);
}
// ═════════════════════════════════════════════════════════════════════════
// inviter-organisation (PUT /{membreId}/inviter-organisation)
// ═════════════════════════════════════════════════════════════════════════
@Test
@TestSecurity(user = "user@test.com", roles = {"USER"})
@DisplayName("PUT inviter-organisation: USER → 403 Forbidden")
void inviterOrganisation_userRole_returns403() {
given()
.contentType(ContentType.JSON)
.queryParam("organisationId", UUID.randomUUID().toString())
.when()
.put("/api/membres/" + UUID.randomUUID() + "/inviter-organisation")
.then()
.statusCode(403);
}
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("PUT inviter-organisation: ADMIN_ORGANISATION avec membre inexistant → 404")
void inviterOrganisation_adminOrganisation_membreNotFound_returns404() {
UUID membreId = UUID.randomUUID();
UUID orgId = UUID.randomUUID();
when(membreService.trouverParId(membreId)).thenReturn(Optional.empty());
given()
.contentType(ContentType.JSON)
.queryParam("organisationId", orgId.toString())
.when()
.put("/api/membres/" + membreId + "/inviter-organisation")
.then()
.statusCode(404);
}
// ═════════════════════════════════════════════════════════════════════════
// accepter-invitation (POST /accepter-invitation/{token}) — PermitAll
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("POST accepter-invitation: sans authentification → PermitAll (token invalide → 400 ou 404)")
void accepterInvitation_noAuth_permitAll() {
// Pas d'auth — le endpoint est PermitAll
when(memberLifecycleService.accepterInvitation(anyString()))
.thenThrow(new IllegalArgumentException("Invitation introuvable ou déjà utilisée."));
given()
.contentType(ContentType.JSON)
.when()
.post("/api/membres/accepter-invitation/invalidtoken123")
.then()
.statusCode(400); // BadRequest pour IllegalArgumentException
}
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("POST accepter-invitation: MEMBRE avec token valide → 200")
void accepterInvitation_membre_validToken_returns200() {
String token = "validtoken1234567890abcdefghi12";
var lien = buildLien(UUID.randomUUID(), StatutMembre.EN_ATTENTE_VALIDATION);
when(memberLifecycleService.accepterInvitation(token)).thenReturn(lien);
given()
.contentType(ContentType.JSON)
.when()
.post("/api/membres/accepter-invitation/" + token)
.then()
.statusCode(200)
.body("statut", notNullValue());
}
// ═════════════════════════════════════════════════════════════════════════
// adhesion statut (GET /{membreId}/adhesion) — lecture multi-rôle
// ═════════════════════════════════════════════════════════════════════════
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("GET /{membreId}/adhesion: MEMBRE peut consulter son statut (200 ou 404)")
void getAdhesionStatut_membreRole_allowedToRead() {
UUID membreId = UUID.randomUUID();
UUID orgId = UUID.randomUUID();
when(membreOrgRepository.findByMembreIdAndOrganisationId(any(), any()))
.thenReturn(Optional.empty());
given()
.queryParam("organisationId", orgId.toString())
.when()
.get("/api/membres/" + membreId + "/adhesion")
.then()
.statusCode(404); // Not found, mais pas 403
}
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("GET /{membreId}/adhesion: ADMIN_ORGANISATION peut consulter (200)")
void getAdhesionStatut_adminOrganisation_returns200() {
UUID membreId = UUID.randomUUID();
UUID orgId = UUID.randomUUID();
var lien = buildLien(UUID.randomUUID(), StatutMembre.ACTIF);
when(membreOrgRepository.findByMembreIdAndOrganisationId(any(), any()))
.thenReturn(Optional.of(lien));
given()
.queryParam("organisationId", orgId.toString())
.when()
.get("/api/membres/" + membreId + "/adhesion")
.then()
.statusCode(200)
.body("statut", notNullValue());
}
// ═════════════════════════════════════════════════════════════════════════
// archiver-adhesion (PUT /{membreOrgId}/archiver-adhesion)
// ═════════════════════════════════════════════════════════════════════════
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("PUT archiver-adhesion: MEMBRE → 403 Forbidden")
void archiverAdhesion_membreRole_returns403() {
given()
.contentType(ContentType.JSON)
.body(Map.of())
.when()
.put("/api/membres/" + UUID.randomUUID() + "/archiver-adhesion")
.then()
.statusCode(403);
}
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("PUT archiver-adhesion: ADMIN_ORGANISATION → 200")
void archiverAdhesion_adminOrganisation_returns200() {
UUID membreOrgId = UUID.randomUUID();
var lien = buildLien(membreOrgId, StatutMembre.ARCHIVE);
when(memberLifecycleService.archiverMembre(any(UUID.class), any()))
.thenReturn(lien);
given()
.contentType(ContentType.JSON)
.body(Map.of("motif", "archivage annuel"))
.when()
.put("/api/membres/" + membreOrgId + "/archiver-adhesion")
.then()
.statusCode(200);
}
}

View File

@@ -303,4 +303,47 @@ class OrganisationResourceLambdaFilterTest {
.then()
.statusCode(200);
}
// =========================================================================
// Error cases
// =========================================================================
@Test
@DisplayName("GET /api/organisations sans authentification retourne 401")
void listerOrganisations_sansAuthentification_returns401() {
given()
.when()
.get("/api/organisations")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "orgadmin@test.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("GET /api/organisations quand service lève exception retourne 500")
void listerOrganisations_serviceException_returns500() {
when(organisationService.listerOrganisationsPourUtilisateur(any()))
.thenThrow(new RuntimeException("Erreur base de données"));
given()
.when()
.get("/api/organisations")
.then()
.statusCode(500);
}
@Test
@TestSecurity(user = "orgadmin@test.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("GET /api/organisations/{id} avec ID inexistant retourne 404")
void obtenirOrganisation_notFound_returns404() {
when(organisationService.trouverParId(any(UUID.class)))
.thenReturn(java.util.Optional.empty());
given()
.pathParam("id", UUID.randomUUID())
.when()
.get("/api/organisations/{id}")
.then()
.statusCode(404);
}
}

View File

@@ -214,4 +214,50 @@ class OrganisationResourceMissingBranchesTest {
assertThat(result).isNotNull();
}
// =========================================================================
// Error cases
// =========================================================================
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("listerMesOrganisations — service lève exception → exception propagée")
void listerMesOrganisations_serviceException_propagee() {
Principal principal = () -> "membre@test.com";
when(securityIdentity.getPrincipal()).thenReturn(principal);
when(organisationService.listerOrganisationsPourUtilisateur(anyString()))
.thenThrow(new RuntimeException("Erreur base de données"));
org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,
() -> organisationResource.listerMesOrganisations());
}
@Test
@TestSecurity(user = "superadmin@test.com", roles = {"SUPER_ADMIN"})
@DisplayName("listerOrganisations — SUPER_ADMIN + service retourne liste vide → résultat non null")
void listerOrganisations_superAdmin_listeVide_retourneResultatNonNull() {
when(securityIdentity.getRoles()).thenReturn(Set.of("SUPER_ADMIN"));
when(organisationService.listerOrganisationsActives(anyInt(), anyInt())).thenReturn(List.of());
when(organisationService.compterOrganisationsActives()).thenReturn(0L);
PagedResponse<OrganisationSummaryResponse> result = organisationResource.listerOrganisations(
0, 20, null);
assertThat(result).isNotNull();
assertThat(result.getData()).isEmpty();
}
@Test
@TestSecurity(user = "orgadmin@test.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("listerOrganisations — ADMIN_ORGANISATION + service lève exception → exception propagée")
void listerOrganisations_adminOrg_serviceException_propagee() {
when(securityIdentity.getRoles()).thenReturn(Set.of("ADMIN_ORGANISATION"));
Principal principal = () -> "orgadmin@test.com";
when(securityIdentity.getPrincipal()).thenReturn(principal);
when(organisationService.listerOrganisationsPourUtilisateur(anyString()))
.thenThrow(new RuntimeException("Accès base de données impossible"));
org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,
() -> organisationResource.listerOrganisations(0, 20, null));
}
}

View File

@@ -63,4 +63,46 @@ class PreferencesResourceTest {
.then()
.statusCode(200);
}
// -------------------------------------------------------------------------
// Error cases
// -------------------------------------------------------------------------
@Test
@DisplayName("GET /api/preferences/{id} sans authentification retourne 401")
void obtenirPreferences_sansAuthentification_returns401() {
given()
.pathParam("utilisateurId", UUID.randomUUID())
.when()
.get("/api/preferences/{utilisateurId}")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "membre@unionflow.com", roles = { "MEMBRE" })
@DisplayName("PUT /api/preferences/{id} avec rôle MEMBRE retourne 403")
void mettreAJourPreferences_avecRoleMembre_returns403() {
given()
.pathParam("utilisateurId", UUID.randomUUID())
.contentType(ContentType.JSON)
.body("{}")
.when()
.put("/api/preferences/{utilisateurId}")
.then()
.statusCode(403);
}
@Test
@DisplayName("PUT /api/preferences/{id} sans authentification retourne 401")
void mettreAJourPreferences_sansAuthentification_returns401() {
given()
.pathParam("utilisateurId", UUID.randomUUID())
.contentType(ContentType.JSON)
.body("{}")
.when()
.put("/api/preferences/{utilisateurId}")
.then()
.statusCode(401);
}
}

View File

@@ -150,4 +150,64 @@ class PropositionAideMockResourceTest {
.statusCode(200)
.body("titre", equalTo("Aide trouvée"));
}
// =========================================================================
// Error cases
// =========================================================================
@Test
@DisplayName("GET /api/propositions-aide sans authentification retourne 401")
void listerToutes_sansAuthentification_returns401() {
given()
.when()
.get("/api/propositions-aide")
.then()
.statusCode(401);
}
@Test
@DisplayName("GET /api/propositions-aide/{id} sans authentification retourne 401")
void obtenirParId_sansAuthentification_returns401() {
given()
.pathParam("id", "id-quelconque")
.when()
.get("/api/propositions-aide/{id}")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
@DisplayName("GET /api/propositions-aide/{id} — service retourne null → 404 (branche not found)")
void obtenirParId_notFound_returns404() {
when(propositionAideService.obtenirParId("id-inexistant")).thenReturn(null);
given()
.pathParam("id", "id-inexistant")
.when()
.get("/api/propositions-aide/{id}")
.then()
.statusCode(404);
}
@Test
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
@DisplayName("PUT /api/propositions-aide/{id} avec rôle MEMBRE retourne 403")
void mettreAJour_avecRoleMembre_returns403() {
String body = """
{
"typeAide": "AIDE_FRAIS_MEDICAUX",
"titre": "Aide non autorisée"
}
""";
given()
.contentType(ContentType.JSON)
.pathParam("id", "prop-id-001")
.body(body)
.when()
.put("/api/propositions-aide/{id}")
.then()
.statusCode(403);
}
}

View File

@@ -0,0 +1,720 @@
package dev.lions.unionflow.server.resource;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.notNullValue;
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.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
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 io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import io.restassured.http.ContentType;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.NotFoundException;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Tests mock pour SouscriptionResource — couvre tous les endpoints.
*
* <p>Catégories testées :
* <ul>
* <li>Endpoints publics (PermitAll) : GET /formules, POST /confirmer-paiement</li>
* <li>Endpoints @Authenticated : GET /ma-souscription, POST /demande, POST /{id}/initier-paiement</li>
* <li>Endpoints SUPER_ADMIN : GET /admin/toutes, GET /admin/organisation/{id}/active,
* GET /admin/en-attente, POST /admin/{id}/approuver, POST /admin/{id}/rejeter</li>
* </ul>
*/
@QuarkusTest
@DisplayName("SouscriptionResource (mock)")
class SouscriptionResourceMockTest {
@InjectMock
SouscriptionService souscriptionService;
@InjectMock
SecuriteHelper securiteHelper;
// ── Helper ────────────────────────────────────────────────────────────────
private SouscriptionStatutResponse buildStatut(String statutValidation) {
SouscriptionStatutResponse r = new SouscriptionStatutResponse();
r.setSouscriptionId(UUID.randomUUID().toString());
r.setStatutValidation(statutValidation);
r.setStatutLibelle(statutValidation);
r.setMontantTotal(new BigDecimal("10000"));
r.setOrganisationId(UUID.randomUUID().toString());
r.setOrganisationNom("Org Test");
return r;
}
private FormuleAbonnementResponse buildFormule() {
FormuleAbonnementResponse f = new FormuleAbonnementResponse();
f.setCode("BASIC");
f.setLibelle("Formule Basic");
f.setDescription("Formule de base");
f.setPlage("PETITE");
f.setPrixMensuel(new BigDecimal("3000"));
return f;
}
// ── GET /api/souscriptions/formules — PUBLIC ──────────────────────────────
@Test
@DisplayName("GET /formules — retourne 200 avec la liste des formules (PermitAll)")
void getFormules_success_returns200() {
when(souscriptionService.getFormules()).thenReturn(List.of(buildFormule()));
given()
.when()
.get("/api/souscriptions/formules")
.then()
.statusCode(200);
}
@Test
@DisplayName("GET /formules — retourne 200 même avec liste vide (PermitAll)")
void getFormules_listeVide_returns200() {
when(souscriptionService.getFormules()).thenReturn(List.of());
given()
.when()
.get("/api/souscriptions/formules")
.then()
.statusCode(200);
}
// ── POST /api/souscriptions/confirmer-paiement — PUBLIC ──────────────────
@Test
@DisplayName("POST /confirmer-paiement — retourne 200 quand paiement confirmé avec succès")
void confirmerPaiement_success_returns200() {
UUID souscriptionId = UUID.randomUUID();
doNothing().when(souscriptionService).confirmerPaiement(eq(souscriptionId), anyString());
given()
.queryParam("id", souscriptionId.toString())
.queryParam("wave_id", "WAVE-TXN-12345")
.when()
.post("/api/souscriptions/confirmer-paiement")
.then()
.statusCode(200)
.body("message", equalTo("Paiement confirmé — compte activé"));
}
@Test
@DisplayName("POST /confirmer-paiement — retourne 200 sans wave_id (optionnel)")
void confirmerPaiement_sansWaveId_returns200() {
UUID souscriptionId = UUID.randomUUID();
doNothing().when(souscriptionService).confirmerPaiement(eq(souscriptionId), isNull());
given()
.queryParam("id", souscriptionId.toString())
.when()
.post("/api/souscriptions/confirmer-paiement")
.then()
.statusCode(200);
}
@Test
@DisplayName("POST /confirmer-paiement — retourne 400 si paramètre id manquant")
void confirmerPaiement_idManquant_returns400() {
given()
.queryParam("wave_id", "WAVE-TXN-99")
.when()
.post("/api/souscriptions/confirmer-paiement")
.then()
.statusCode(400);
}
@Test
@DisplayName("POST /confirmer-paiement — retourne 400 si service lève BadRequestException (statut invalide)")
void confirmerPaiement_statutInvalide_returns400() {
UUID souscriptionId = UUID.randomUUID();
doThrow(new BadRequestException("Impossible de confirmer depuis le statut: REJETEE"))
.when(souscriptionService).confirmerPaiement(eq(souscriptionId), anyString());
given()
.queryParam("id", souscriptionId.toString())
.queryParam("wave_id", "WAVE-TXN-BAD")
.when()
.post("/api/souscriptions/confirmer-paiement")
.then()
.statusCode(400);
}
@Test
@DisplayName("POST /confirmer-paiement — retourne 404 si souscription introuvable")
void confirmerPaiement_souscriptionInconnue_returns404() {
UUID souscriptionId = UUID.randomUUID();
doThrow(new NotFoundException("Souscription introuvable: " + souscriptionId))
.when(souscriptionService).confirmerPaiement(eq(souscriptionId), anyString());
given()
.queryParam("id", souscriptionId.toString())
.queryParam("wave_id", "WAVE-TXN-INCO")
.when()
.post("/api/souscriptions/confirmer-paiement")
.then()
.statusCode(404);
}
// ── GET /api/souscriptions/ma-souscription — @Authenticated ──────────────
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("GET /ma-souscription — retourne 200 quand souscription trouvée")
void getMaSouscription_success_returns200() {
SouscriptionStatutResponse statut = buildStatut("ACTIVE");
when(souscriptionService.getMaSouscription()).thenReturn(statut);
given()
.when()
.get("/api/souscriptions/ma-souscription")
.then()
.statusCode(200);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("GET /ma-souscription — retourne 404 si aucune souscription pour ce membre")
void getMaSouscription_aucuneSouscription_returns404() {
when(souscriptionService.getMaSouscription())
.thenThrow(new NotFoundException("Aucune souscription trouvée pour ce membre"));
given()
.when()
.get("/api/souscriptions/ma-souscription")
.then()
.statusCode(404);
}
@Test
@DisplayName("GET /ma-souscription — retourne 401 sans authentification")
void getMaSouscription_nonAuthentifie_returns401() {
given()
.when()
.get("/api/souscriptions/ma-souscription")
.then()
.statusCode(401);
}
// ── POST /api/souscriptions/demande — @Authenticated ─────────────────────
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("POST /demande — retourne 201 quand demande créée avec succès")
void creerDemande_success_returns201() {
SouscriptionStatutResponse statut = buildStatut("EN_ATTENTE_PAIEMENT");
when(souscriptionService.creerDemande(any(SouscriptionDemandeRequest.class))).thenReturn(statut);
String body = """
{
"typeFormule": "BASIC",
"plageMembres": "PETITE",
"typePeriode": "MENSUEL",
"typeOrganisation": "ASSOCIATION",
"organisationId": "%s"
}
""".formatted(UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post("/api/souscriptions/demande")
.then()
.statusCode(201);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("POST /demande — retourne 400 si organisation introuvable")
void creerDemande_orgInconnue_returns400() {
when(souscriptionService.creerDemande(any(SouscriptionDemandeRequest.class)))
.thenThrow(new NotFoundException("Organisation introuvable"));
String body = """
{
"typeFormule": "BASIC",
"plageMembres": "PETITE",
"typePeriode": "MENSUEL",
"organisationId": "%s"
}
""".formatted(UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post("/api/souscriptions/demande")
.then()
.statusCode(404);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("POST /demande — retourne 400 si souscription déjà existante pour l'organisation")
void creerDemande_souscriptionExistante_returns400() {
when(souscriptionService.creerDemande(any(SouscriptionDemandeRequest.class)))
.thenThrow(new BadRequestException("Une souscription en cours existe déjà pour cette organisation"));
String body = """
{
"typeFormule": "BASIC",
"plageMembres": "PETITE",
"typePeriode": "MENSUEL",
"organisationId": "%s"
}
""".formatted(UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post("/api/souscriptions/demande")
.then()
.statusCode(400);
}
@Test
@DisplayName("POST /demande — retourne 401 sans authentification")
void creerDemande_nonAuthentifie_returns401() {
given()
.contentType(ContentType.JSON)
.body("{}")
.when()
.post("/api/souscriptions/demande")
.then()
.statusCode(401);
}
// ── POST /api/souscriptions/{id}/initier-paiement — @Authenticated ───────
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("POST /{id}/initier-paiement — retourne 200 avec waveLaunchUrl quand session créée")
void initierPaiement_success_returns200() {
UUID souscriptionId = UUID.randomUUID();
SouscriptionStatutResponse statut = buildStatut("PAIEMENT_INITIE");
statut.setWaveLaunchUrl("https://pay.wave.com/checkout/abc123");
when(souscriptionService.initierPaiementWave(souscriptionId)).thenReturn(statut);
given()
.contentType(ContentType.JSON)
.pathParam("id", souscriptionId.toString())
.when()
.post("/api/souscriptions/{id}/initier-paiement")
.then()
.statusCode(200);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("POST /{id}/initier-paiement — retourne 404 si souscription introuvable")
void initierPaiement_souscriptionInconnue_returns404() {
UUID souscriptionId = UUID.randomUUID();
when(souscriptionService.initierPaiementWave(souscriptionId))
.thenThrow(new NotFoundException("Souscription introuvable: " + souscriptionId));
given()
.contentType(ContentType.JSON)
.pathParam("id", souscriptionId.toString())
.when()
.post("/api/souscriptions/{id}/initier-paiement")
.then()
.statusCode(404);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("POST /{id}/initier-paiement — retourne 400 si statut invalide pour paiement")
void initierPaiement_statutInvalide_returns400() {
UUID souscriptionId = UUID.randomUUID();
when(souscriptionService.initierPaiementWave(souscriptionId))
.thenThrow(new BadRequestException("Impossible d'initier le paiement depuis le statut: VALIDEE"));
given()
.contentType(ContentType.JSON)
.pathParam("id", souscriptionId.toString())
.when()
.post("/api/souscriptions/{id}/initier-paiement")
.then()
.statusCode(400);
}
@Test
@DisplayName("POST /{id}/initier-paiement — retourne 401 sans authentification")
void initierPaiement_nonAuthentifie_returns401() {
given()
.contentType(ContentType.JSON)
.pathParam("id", UUID.randomUUID().toString())
.when()
.post("/api/souscriptions/{id}/initier-paiement")
.then()
.statusCode(401);
}
// ── GET /api/souscriptions/admin/toutes — SUPER_ADMIN ────────────────────
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("GET /admin/toutes — retourne 200 avec liste complète")
void getSouscriptionsToutes_success_returns200() {
when(souscriptionService.listerToutes(isNull(), eq(0), eq(1000)))
.thenReturn(List.of(buildStatut("ACTIVE"), buildStatut("EN_ATTENTE_PAIEMENT")));
given()
.when()
.get("/api/souscriptions/admin/toutes")
.then()
.statusCode(200);
}
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("GET /admin/toutes — retourne 200 avec filtre organisationId")
void getSouscriptionsToutes_avecFiltreOrg_returns200() {
UUID orgId = UUID.randomUUID();
when(souscriptionService.listerToutes(eq(orgId), anyInt(), anyInt()))
.thenReturn(List.of(buildStatut("ACTIVE")));
given()
.queryParam("organisationId", orgId.toString())
.when()
.get("/api/souscriptions/admin/toutes")
.then()
.statusCode(200);
}
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("GET /admin/toutes — retourne 200 avec liste vide")
void getSouscriptionsToutes_listeVide_returns200() {
when(souscriptionService.listerToutes(isNull(), anyInt(), anyInt()))
.thenReturn(List.of());
given()
.when()
.get("/api/souscriptions/admin/toutes")
.then()
.statusCode(200);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("GET /admin/toutes — retourne 403 si rôle insuffisant (ADMIN_ORGANISATION)")
void getSouscriptionsToutes_roleInsuffisant_returns403() {
given()
.when()
.get("/api/souscriptions/admin/toutes")
.then()
.statusCode(403);
}
// ── GET /api/souscriptions/admin/organisation/{id}/active — SUPER_ADMIN ──
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("GET /admin/organisation/{orgId}/active — retourne 200 quand souscription active trouvée")
void getActiveParOrganisation_success_returns200() {
UUID orgId = UUID.randomUUID();
when(souscriptionService.obtenirActiveParOrganisation(orgId))
.thenReturn(buildStatut("ACTIVE"));
given()
.pathParam("organisationId", orgId.toString())
.when()
.get("/api/souscriptions/admin/organisation/{organisationId}/active")
.then()
.statusCode(200);
}
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("GET /admin/organisation/{orgId}/active — retourne 404 si aucune souscription active")
void getActiveParOrganisation_aucuneActive_returns404() {
UUID orgId = UUID.randomUUID();
when(souscriptionService.obtenirActiveParOrganisation(orgId)).thenReturn(null);
given()
.pathParam("organisationId", orgId.toString())
.when()
.get("/api/souscriptions/admin/organisation/{organisationId}/active")
.then()
.statusCode(404);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("GET /admin/organisation/{orgId}/active — retourne 403 si rôle insuffisant")
void getActiveParOrganisation_roleInsuffisant_returns403() {
given()
.pathParam("organisationId", UUID.randomUUID().toString())
.when()
.get("/api/souscriptions/admin/organisation/{organisationId}/active")
.then()
.statusCode(403);
}
// ── GET /api/souscriptions/admin/en-attente — SUPER_ADMIN ────────────────
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("GET /admin/en-attente — retourne 200 avec liste des souscriptions en attente")
void getSouscriptionsEnAttente_success_returns200() {
when(souscriptionService.getSouscriptionsEnAttenteValidation())
.thenReturn(List.of(buildStatut("PAIEMENT_CONFIRME")));
given()
.when()
.get("/api/souscriptions/admin/en-attente")
.then()
.statusCode(200);
}
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("GET /admin/en-attente — retourne 200 avec liste vide si aucune en attente")
void getSouscriptionsEnAttente_listeVide_returns200() {
when(souscriptionService.getSouscriptionsEnAttenteValidation()).thenReturn(List.of());
given()
.when()
.get("/api/souscriptions/admin/en-attente")
.then()
.statusCode(200);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("GET /admin/en-attente — retourne 403 si rôle insuffisant (ADMIN_ORGANISATION)")
void getSouscriptionsEnAttente_roleInsuffisant_returns403() {
given()
.when()
.get("/api/souscriptions/admin/en-attente")
.then()
.statusCode(403);
}
@Test
@DisplayName("GET /admin/en-attente — retourne 401 sans authentification")
void getSouscriptionsEnAttente_nonAuthentifie_returns401() {
given()
.when()
.get("/api/souscriptions/admin/en-attente")
.then()
.statusCode(401);
}
// ── POST /api/souscriptions/admin/{id}/approuver — SUPER_ADMIN ───────────
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("POST /admin/{id}/approuver — retourne 200 quand approbation réussie")
void approuver_success_returns200() {
UUID souscriptionId = UUID.randomUUID();
UUID superAdminId = UUID.randomUUID();
when(securiteHelper.resolveMembreId()).thenReturn(superAdminId);
doNothing().when(souscriptionService).approuver(souscriptionId, superAdminId);
given()
.contentType(ContentType.JSON)
.pathParam("id", souscriptionId.toString())
.when()
.post("/api/souscriptions/admin/{id}/approuver")
.then()
.statusCode(200)
.body("message", equalTo("Souscription approuvée — compte activé"));
}
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("POST /admin/{id}/approuver — retourne 404 si souscription introuvable")
void approuver_souscriptionInconnue_returns404() {
UUID souscriptionId = UUID.randomUUID();
UUID superAdminId = UUID.randomUUID();
when(securiteHelper.resolveMembreId()).thenReturn(superAdminId);
doThrow(new NotFoundException("Souscription introuvable: " + souscriptionId))
.when(souscriptionService).approuver(souscriptionId, superAdminId);
given()
.contentType(ContentType.JSON)
.pathParam("id", souscriptionId.toString())
.when()
.post("/api/souscriptions/admin/{id}/approuver")
.then()
.statusCode(404);
}
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("POST /admin/{id}/approuver — retourne 400 si statut non approuvable")
void approuver_statutNonApprouvable_returns400() {
UUID souscriptionId = UUID.randomUUID();
UUID superAdminId = UUID.randomUUID();
when(securiteHelper.resolveMembreId()).thenReturn(superAdminId);
doThrow(new BadRequestException("Impossible d'approuver depuis le statut: EN_ATTENTE_PAIEMENT"))
.when(souscriptionService).approuver(souscriptionId, superAdminId);
given()
.contentType(ContentType.JSON)
.pathParam("id", souscriptionId.toString())
.when()
.post("/api/souscriptions/admin/{id}/approuver")
.then()
.statusCode(400);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("POST /admin/{id}/approuver — retourne 403 si rôle insuffisant")
void approuver_roleInsuffisant_returns403() {
given()
.contentType(ContentType.JSON)
.pathParam("id", UUID.randomUUID().toString())
.when()
.post("/api/souscriptions/admin/{id}/approuver")
.then()
.statusCode(403);
}
// ── POST /api/souscriptions/admin/{id}/rejeter — SUPER_ADMIN ─────────────
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("POST /admin/{id}/rejeter — retourne 200 quand rejet réussi avec commentaire")
void rejeter_success_returns200() {
UUID souscriptionId = UUID.randomUUID();
UUID superAdminId = UUID.randomUUID();
when(securiteHelper.resolveMembreId()).thenReturn(superAdminId);
doNothing().when(souscriptionService).rejeter(eq(souscriptionId), eq(superAdminId), anyString());
given()
.contentType(ContentType.JSON)
.pathParam("id", souscriptionId.toString())
.body(Map.of("commentaire", "Documents manquants — dossier incomplet"))
.when()
.post("/api/souscriptions/admin/{id}/rejeter")
.then()
.statusCode(200)
.body("message", equalTo("Souscription rejetée"));
}
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("POST /admin/{id}/rejeter — retourne 400 si commentaire absent")
void rejeter_sansCommentaire_returns400() {
UUID souscriptionId = UUID.randomUUID();
when(securiteHelper.resolveMembreId()).thenReturn(UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.pathParam("id", souscriptionId.toString())
.body(Map.of())
.when()
.post("/api/souscriptions/admin/{id}/rejeter")
.then()
.statusCode(400);
}
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("POST /admin/{id}/rejeter — retourne 400 si commentaire vide (blank)")
void rejeter_commentaireVide_returns400() {
UUID souscriptionId = UUID.randomUUID();
when(securiteHelper.resolveMembreId()).thenReturn(UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.pathParam("id", souscriptionId.toString())
.body(Map.of("commentaire", " "))
.when()
.post("/api/souscriptions/admin/{id}/rejeter")
.then()
.statusCode(400);
}
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("POST /admin/{id}/rejeter — retourne 404 si souscription introuvable")
void rejeter_souscriptionInconnue_returns404() {
UUID souscriptionId = UUID.randomUUID();
UUID superAdminId = UUID.randomUUID();
when(securiteHelper.resolveMembreId()).thenReturn(superAdminId);
doThrow(new NotFoundException("Souscription introuvable: " + souscriptionId))
.when(souscriptionService).rejeter(eq(souscriptionId), eq(superAdminId), anyString());
given()
.contentType(ContentType.JSON)
.pathParam("id", souscriptionId.toString())
.body(Map.of("commentaire", "Motif de rejet"))
.when()
.post("/api/souscriptions/admin/{id}/rejeter")
.then()
.statusCode(404);
}
@Test
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
@DisplayName("POST /admin/{id}/rejeter — retourne 400 si souscription déjà dans état terminal")
void rejeter_etatTerminal_returns400() {
UUID souscriptionId = UUID.randomUUID();
UUID superAdminId = UUID.randomUUID();
when(securiteHelper.resolveMembreId()).thenReturn(superAdminId);
doThrow(new BadRequestException("La souscription est déjà dans un état terminal: REJETEE"))
.when(souscriptionService).rejeter(eq(souscriptionId), eq(superAdminId), anyString());
given()
.contentType(ContentType.JSON)
.pathParam("id", souscriptionId.toString())
.body(Map.of("commentaire", "Motif de rejet"))
.when()
.post("/api/souscriptions/admin/{id}/rejeter")
.then()
.statusCode(400);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("POST /admin/{id}/rejeter — retourne 403 si rôle insuffisant")
void rejeter_roleInsuffisant_returns403() {
given()
.contentType(ContentType.JSON)
.pathParam("id", UUID.randomUUID().toString())
.body(Map.of("commentaire", "Rejet non autorisé"))
.when()
.post("/api/souscriptions/admin/{id}/rejeter")
.then()
.statusCode(403);
}
@Test
@DisplayName("POST /admin/{id}/rejeter — retourne 401 sans authentification")
void rejeter_nonAuthentifie_returns401() {
given()
.contentType(ContentType.JSON)
.pathParam("id", UUID.randomUUID().toString())
.body(Map.of("commentaire", "test"))
.when()
.post("/api/souscriptions/admin/{id}/rejeter")
.then()
.statusCode(401);
}
}

View File

@@ -118,6 +118,54 @@ class WaveRedirectResourceMockDisabledUnitTest {
assertThat(location).isNotNull().contains("success").contains(ref);
}
// =========================================================================
// Error cases / edge cases
// =========================================================================
/**
* success() avec ref null et mockEnabled=false : la condition mockEnabled && ref != null
* est fausse → branche false, retourne 303 sans appel de service.
*/
@Test
@DisplayName("success avec mockEnabled=false + ref null → 303 (branche mockEnabled false, ref null)")
void success_mockDisabled_nullRef_returns303() {
Response response = resource.success(null);
assertThat(response.getStatus()).isEqualTo(303);
String location = response.getHeaderString("Location");
assertThat(location).isNotNull();
assertThat(location).startsWith("unionflow://");
}
/**
* success() avec ref blank et mockEnabled=false : la condition mockEnabled && ref != null
* est fausse (mockEnabled=false) → retourne 303.
*/
@Test
@DisplayName("success avec mockEnabled=false + ref blank → 303 (branche mockEnabled false, ref blank)")
void success_mockDisabled_blankRef_returns303() {
Response response = resource.success(" ");
assertThat(response.getStatus()).isEqualTo(303);
String location = response.getHeaderString("Location");
assertThat(location).isNotNull();
}
/**
* mockComplete() avec un ref long (UUID complet) : vérifie que la construction du deep link
* ne tronque pas le ref et inclut bien sa valeur complète.
*/
@Test
@DisplayName("mockComplete avec ref UUID complet → deep link contient le ref intégralement")
void mockComplete_mockDisabled_fullUuidRef_deepLinkContainsRef() {
String longRef = "12345678-1234-1234-1234-123456789012";
Response response = resource.mockComplete(longRef);
assertThat(response.getStatus()).isEqualTo(303);
String location = response.getHeaderString("Location");
assertThat(location).isNotNull().contains("error");
}
// =========================================================================
// Helper
// =========================================================================

View File

@@ -180,4 +180,61 @@ class DemandeCreditMockResourceTest {
.then()
.statusCode(200);
}
// -------------------------------------------------------------------------
// Error cases
// -------------------------------------------------------------------------
@Test
@DisplayName("POST /api/v1/mutuelle/credits sans authentification retourne 401")
void soumettreDemande_sansAuthentification_returns401() {
String body = """
{
"membreId": "%s",
"typeCredit": "CONSOMMATION",
"montantDemande": 25000,
"dureeMois": 12,
"justificationDetaillee": "Besoin urgent"
}
""".formatted(UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post("/api/v1/mutuelle/credits")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
@DisplayName("POST /{id}/approbation avec rôle MEMBRE retourne 403")
void approuver_avecRoleMembre_returns403() {
given()
.contentType(ContentType.JSON)
.pathParam("id", UUID.randomUUID())
.queryParam("montant", "50000")
.queryParam("duree", "12")
.queryParam("taux", "5.0")
.when()
.post("/api/v1/mutuelle/credits/{id}/approbation")
.then()
.statusCode(403);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
@DisplayName("GET /{id} — service lève exception retourne 500")
void getDemandeById_serviceException_returns500() {
when(demandeCreditService.getDemandeById(any(UUID.class)))
.thenThrow(new RuntimeException("Crédit introuvable"));
given()
.pathParam("id", UUID.randomUUID())
.when()
.get("/api/v1/mutuelle/credits/{id}")
.then()
.statusCode(500);
}
}

View File

@@ -50,4 +50,69 @@ class TransactionEpargneResourceMockTest {
.then()
.statusCode(201);
}
// -------------------------------------------------------------------------
// Error cases
// -------------------------------------------------------------------------
@Test
@DisplayName("POST /api/v1/epargne/transactions/transfert sans authentification retourne 401")
void transferer_sansAuthentification_returns401() {
String body = String.format("""
{
"compteId": "%s",
"typeTransaction": "TRANSFERT_SORTANT",
"montant": 5000,
"compteDestinationId": "%s",
"motif": "Transfert test"
}
""", UUID.randomUUID(), UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post("/api/v1/epargne/transactions/transfert")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
@DisplayName("POST /api/v1/epargne/transactions/transfert quand service lève exception retourne 500")
void transferer_serviceException_returns500() {
when(transactionEpargneService.transferer(any()))
.thenThrow(new RuntimeException("Solde insuffisant pour le transfert"));
String body = String.format("""
{
"compteId": "%s",
"typeTransaction": "TRANSFERT_SORTANT",
"montant": 5000,
"compteDestinationId": "%s",
"motif": "Transfert erreur"
}
""", UUID.randomUUID(), UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post("/api/v1/epargne/transactions/transfert")
.then()
.statusCode(500);
}
@Test
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
@DisplayName("POST /api/v1/epargne/transactions/transfert avec corps vide retourne 400")
void transferer_corpsVide_returns400() {
given()
.contentType(ContentType.JSON)
.body("{}")
.when()
.post("/api/v1/epargne/transactions/transfert")
.then()
.statusCode(400);
}
}

View File

@@ -0,0 +1,217 @@
package dev.lions.unionflow.server.security;
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.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import dev.lions.unionflow.server.api.enums.membre.StatutMembre;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import java.util.UUID;
import static io.restassured.RestAssured.given;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
/**
* Tests d'intégration pour {@link OrganisationContextFilter}.
*
* <p>Vérifie le comportement du filtre multi-org selon le header
* {@code X-Active-Organisation-Id} :
* <ul>
* <li>Absent → passe (pas de contexte org requis)</li>
* <li>UUID invalide → 400 Bad Request</li>
* <li>Organisation inexistante → 404 Not Found</li>
* <li>Utilisateur non membre de l'org → 403 Forbidden</li>
* <li>Membre actif → contexte résolu, requête passée</li>
* <li>SUPER_ADMIN → toujours autorisé (bypass membre check)</li>
* </ul>
*
* <p>Utilise l'endpoint {@code GET /api/v1/dashboard/health} (PermitAll-like)
* comme cible neutre pour tester le filtre.
*/
@QuarkusTest
@DisplayName("OrganisationContextFilter — multi-org header validation")
class OrganisationContextFilterMultiOrgTest {
@InjectMock
OrganisationRepository organisationRepository;
@InjectMock
MembreRepository membreRepository;
@InjectMock
MembreOrganisationRepository membreOrganisationRepository;
// ─── Aucun header ────────────────────────────────────────────────────────
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
@DisplayName("Sans header X-Active-Organisation-Id : filtre passe, endpoint répond")
void noHeader_filterPasses_dashboardReachable() {
given()
.when()
.get("/api/v1/dashboard/health")
.then()
.statusCode(200);
}
// ─── UUID invalide ────────────────────────────────────────────────────────
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
@DisplayName("Header avec UUID invalide → 400 Bad Request")
void invalidUuidHeader_returns400() {
given()
.header("X-Active-Organisation-Id", "not-a-valid-uuid")
.when()
.get("/api/v1/dashboard/health")
.then()
.statusCode(400);
}
// ─── Organisation introuvable ─────────────────────────────────────────────
@Test
@TestSecurity(user = "admin@test.com", roles = {"ADMIN_ORGANISATION"})
@DisplayName("Header avec UUID valide mais org inexistante → 404 Not Found")
void validUuidButOrgNotFound_returns404() {
UUID unknownOrgId = UUID.randomUUID();
when(organisationRepository.findByIdOptional(unknownOrgId)).thenReturn(Optional.empty());
given()
.header("X-Active-Organisation-Id", unknownOrgId.toString())
.when()
.get("/api/v1/dashboard/health")
.then()
.statusCode(404);
}
// ─── Utilisateur non membre ───────────────────────────────────────────────
@Test
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("Header valide + utilisateur non membre de l'org → 403 Forbidden")
void memberNotInOrg_returns403() {
UUID orgId = UUID.randomUUID();
Organisation org = new Organisation();
org.setId(orgId);
org.setNom("Org Tierce");
Membre membre = new Membre();
membre.setId(UUID.randomUUID());
membre.setEmail("membre@test.com");
when(organisationRepository.findByIdOptional(orgId)).thenReturn(Optional.of(org));
when(membreRepository.findByEmail("membre@test.com")).thenReturn(Optional.of(membre));
when(membreOrganisationRepository.findByMembreIdAndOrganisationId(membre.getId(), orgId))
.thenReturn(Optional.empty()); // Pas membre
given()
.header("X-Active-Organisation-Id", orgId.toString())
.when()
.get("/api/v1/dashboard/health")
.then()
.statusCode(403);
}
// ─── Membre non actif ─────────────────────────────────────────────────────
@Test
@TestSecurity(user = "suspendu@test.com", roles = {"MEMBRE"})
@DisplayName("Header valide + membre suspendu dans l'org → 403 Forbidden")
void memberSuspended_returns403() {
UUID orgId = UUID.randomUUID();
Organisation org = new Organisation();
org.setId(orgId);
org.setNom("Org Test");
Membre membre = new Membre();
membre.setId(UUID.randomUUID());
membre.setEmail("suspendu@test.com");
MembreOrganisation membreOrg = MembreOrganisation.builder()
.membre(membre)
.organisation(org)
.statutMembre(StatutMembre.SUSPENDU)
.build();
membreOrg.setId(UUID.randomUUID());
when(organisationRepository.findByIdOptional(orgId)).thenReturn(Optional.of(org));
when(membreRepository.findByEmail("suspendu@test.com")).thenReturn(Optional.of(membre));
when(membreOrganisationRepository.findByMembreIdAndOrganisationId(membre.getId(), orgId))
.thenReturn(Optional.of(membreOrg));
given()
.header("X-Active-Organisation-Id", orgId.toString())
.when()
.get("/api/v1/dashboard/health")
.then()
.statusCode(403);
}
// ─── SUPER_ADMIN bypass ───────────────────────────────────────────────────
@Test
@TestSecurity(user = "superadmin@test.com", roles = {"SUPER_ADMIN", "ADMIN"})
@DisplayName("SUPER_ADMIN avec header valide → bypass check membre, contexte résolu (200)")
void superAdmin_validOrgId_bypassesMemberCheck() {
UUID orgId = UUID.randomUUID();
Organisation org = new Organisation();
org.setId(orgId);
org.setNom("Org Admin");
when(organisationRepository.findByIdOptional(orgId)).thenReturn(Optional.of(org));
// Pas besoin de mocker membreRepository — SUPER_ADMIN bypass
given()
.header("X-Active-Organisation-Id", orgId.toString())
.when()
.get("/api/v1/dashboard/health")
.then()
.statusCode(200);
}
// ─── Membre actif ─────────────────────────────────────────────────────────
@Test
@TestSecurity(user = "actif@test.com", roles = {"MEMBRE"})
@DisplayName("Header valide + membre ACTIF dans l'org → contexte résolu (200)")
void activeMember_validOrgId_contextResolved() {
UUID orgId = UUID.randomUUID();
Organisation org = new Organisation();
org.setId(orgId);
org.setNom("Ma Tontine");
Membre membre = new Membre();
membre.setId(UUID.randomUUID());
membre.setEmail("actif@test.com");
MembreOrganisation membreOrg = MembreOrganisation.builder()
.membre(membre)
.organisation(org)
.statutMembre(StatutMembre.ACTIF)
.build();
membreOrg.setId(UUID.randomUUID());
when(organisationRepository.findByIdOptional(orgId)).thenReturn(Optional.of(org));
when(membreRepository.findByEmail("actif@test.com")).thenReturn(Optional.of(membre));
when(membreOrganisationRepository.findByMembreIdAndOrganisationId(membre.getId(), orgId))
.thenReturn(Optional.of(membreOrg));
given()
.header("X-Active-Organisation-Id", orgId.toString())
.when()
.get("/api/v1/dashboard/health")
.then()
.statusCode(200);
}
}

View File

@@ -0,0 +1,85 @@
package dev.lions.unionflow.server.security;
import dev.lions.unionflow.server.entity.Organisation;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests unitaires pour {@link OrganisationContextHolder}.
*
* <p>Couvre la logique de résolution du contexte multi-organisation.
*/
@DisplayName("OrganisationContextHolder — logique du contexte multi-org")
class OrganisationContextHolderTest {
@Test
@DisplayName("hasContext: non résolu et orgId null → false")
void hasContext_notResolvedAndNullOrgId_returnsFalse() {
OrganisationContextHolder holder = new OrganisationContextHolder();
assertThat(holder.hasContext()).isFalse();
}
@Test
@DisplayName("hasContext: résolu mais orgId null → false")
void hasContext_resolvedButNullOrgId_returnsFalse() {
OrganisationContextHolder holder = new OrganisationContextHolder();
holder.setResolved(true);
holder.setOrganisationId(null);
assertThat(holder.hasContext()).isFalse();
}
@Test
@DisplayName("hasContext: orgId défini mais non résolu → false")
void hasContext_orgIdSetButNotResolved_returnsFalse() {
OrganisationContextHolder holder = new OrganisationContextHolder();
holder.setOrganisationId(UUID.randomUUID());
holder.setResolved(false);
assertThat(holder.hasContext()).isFalse();
}
@Test
@DisplayName("hasContext: résolu ET orgId défini → true")
void hasContext_resolvedAndOrgIdSet_returnsTrue() {
OrganisationContextHolder holder = new OrganisationContextHolder();
holder.setOrganisationId(UUID.randomUUID());
holder.setResolved(true);
assertThat(holder.hasContext()).isTrue();
}
@Test
@DisplayName("setOrganisation: organisation correctement stockée")
void setOrganisation_storesCorrectly() {
OrganisationContextHolder holder = new OrganisationContextHolder();
Organisation org = new Organisation();
UUID orgId = UUID.randomUUID();
org.setId(orgId);
org.setNom("Tontine Dakar");
holder.setOrganisation(org);
holder.setOrganisationId(orgId);
holder.setResolved(true);
assertThat(holder.getOrganisation()).isSameAs(org);
assertThat(holder.getOrganisationId()).isEqualTo(orgId);
assertThat(holder.isResolved()).isTrue();
assertThat(holder.hasContext()).isTrue();
}
@Test
@DisplayName("État initial: tous les champs sont null/false")
void initialState_allFieldsAreDefault() {
OrganisationContextHolder holder = new OrganisationContextHolder();
assertThat(holder.getOrganisationId()).isNull();
assertThat(holder.getOrganisation()).isNull();
assertThat(holder.isResolved()).isFalse();
assertThat(holder.hasContext()).isFalse();
}
}

View File

@@ -9,7 +9,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationSummaryResponse;
import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationResponse;
import dev.lions.unionflow.server.entity.Cotisation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
@@ -108,10 +108,10 @@ class CotisationServiceAdminBranchesTest {
any(), any(Page.class), any(Sort.class)))
.thenReturn(List.of(cotisation));
List<CotisationSummaryResponse> result = cotisationService.getMesCotisations(0, 10);
List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);
assertThat(result).isNotNull().hasSize(1);
assertThat(result.get(0).id()).isEqualTo(cotisation.getId());
assertThat(result.get(0).getId()).isEqualTo(cotisation.getId());
}
@Test
@@ -122,7 +122,7 @@ class CotisationServiceAdminBranchesTest {
when(organisationService.listerOrganisationsPourUtilisateur("admin@unionflow.com"))
.thenReturn(Collections.emptyList());
List<CotisationSummaryResponse> result = cotisationService.getMesCotisations(0, 10);
List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);
assertThat(result).isNotNull().isEmpty();
}
@@ -143,7 +143,7 @@ class CotisationServiceAdminBranchesTest {
any(), any(Page.class), any(Sort.class)))
.thenReturn(Collections.emptyList());
List<CotisationSummaryResponse> result = cotisationService.getMesCotisations(0, 10);
List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);
assertThat(result).isNotNull().isEmpty();
}
@@ -156,7 +156,7 @@ class CotisationServiceAdminBranchesTest {
when(organisationService.listerOrganisationsPourUtilisateur("admin@unionflow.com"))
.thenReturn(null);
List<CotisationSummaryResponse> result = cotisationService.getMesCotisations(0, 10);
List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);
assertThat(result).isNotNull().isEmpty();
}
@@ -166,7 +166,7 @@ class CotisationServiceAdminBranchesTest {
void getMesCotisations_emailNull_retourneListeVide() {
when(securiteHelper.resolveEmail()).thenReturn(null);
List<CotisationSummaryResponse> result = cotisationService.getMesCotisations(0, 10);
List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);
assertThat(result).isNotNull().isEmpty();
}
@@ -176,7 +176,7 @@ class CotisationServiceAdminBranchesTest {
void getMesCotisations_emailBlank_retourneListeVide() {
when(securiteHelper.resolveEmail()).thenReturn(" ");
List<CotisationSummaryResponse> result = cotisationService.getMesCotisations(0, 10);
List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);
assertThat(result).isNotNull().isEmpty();
}
@@ -189,7 +189,7 @@ class CotisationServiceAdminBranchesTest {
when(membreRepository.findByEmail("membre-inconnu@unionflow.com"))
.thenReturn(Optional.empty());
List<CotisationSummaryResponse> result = cotisationService.getMesCotisations(0, 10);
List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);
assertThat(result).isNotNull().isEmpty();
}
@@ -228,10 +228,10 @@ class CotisationServiceAdminBranchesTest {
when(cotisationRepository.findEnAttenteByOrganisationIdIn(any()))
.thenReturn(List.of(cotisation));
List<CotisationSummaryResponse> result = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();
assertThat(result).isNotNull().hasSize(1);
assertThat(result.get(0).statut()).isEqualTo("EN_ATTENTE");
assertThat(result.get(0).getStatut()).isEqualTo("EN_ATTENTE");
}
@Test
@@ -259,10 +259,10 @@ class CotisationServiceAdminBranchesTest {
when(cotisationRepository.findEnAttenteByOrganisationIdIn(any()))
.thenReturn(List.of(cotisation));
List<CotisationSummaryResponse> result = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();
assertThat(result).isNotNull().hasSize(1);
assertThat(result.get(0).statut()).isEqualTo("EN_ATTENTE");
assertThat(result.get(0).getStatut()).isEqualTo("EN_ATTENTE");
}
@Test
@@ -273,7 +273,7 @@ class CotisationServiceAdminBranchesTest {
when(organisationService.listerOrganisationsPourUtilisateur("admin-vide@unionflow.com"))
.thenReturn(Collections.emptyList());
List<CotisationSummaryResponse> result = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();
assertThat(result).isNotNull().isEmpty();
}
@@ -283,7 +283,7 @@ class CotisationServiceAdminBranchesTest {
void getMesCotisationsEnAttente_emailNull_retourneListeVide() {
when(securiteHelper.resolveEmail()).thenReturn(null);
List<CotisationSummaryResponse> result = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();
assertThat(result).isNotNull().isEmpty();
}
@@ -295,7 +295,7 @@ class CotisationServiceAdminBranchesTest {
when(securiteHelper.getRoles()).thenReturn(Set.of("MEMBRE"));
when(membreRepository.findByEmail("inconnu@unionflow.com")).thenReturn(Optional.empty());
List<CotisationSummaryResponse> result = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();
assertThat(result).isNotNull().isEmpty();
}
@@ -308,7 +308,7 @@ class CotisationServiceAdminBranchesTest {
when(securiteHelper.getRoles()).thenReturn(null);
when(membreRepository.findByEmail("roles.null@unionflow.com")).thenReturn(Optional.empty());
List<CotisationSummaryResponse> result = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();
assertThat(result).isNotNull().isEmpty();
}
@@ -318,7 +318,7 @@ class CotisationServiceAdminBranchesTest {
void getMesCotisationsEnAttente_emailBlank_retourneListeVide() {
when(securiteHelper.resolveEmail()).thenReturn(" "); // blank → L736 true
List<CotisationSummaryResponse> result = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();
assertThat(result).isNotNull().isEmpty();
}
@@ -499,7 +499,7 @@ class CotisationServiceAdminBranchesTest {
when(organisationService.listerOrganisationsPourUtilisateur("admin-null-ea@unionflow.com"))
.thenReturn(null); // null → orgs == null → true → return empty
List<CotisationSummaryResponse> result = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();
assertThat(result).isNotNull().isEmpty();
}
@@ -570,7 +570,7 @@ class CotisationServiceAdminBranchesTest {
org.mockito.ArgumentMatchers.any(Sort.class)))
.thenReturn(java.util.Collections.emptyList());
List<CotisationSummaryResponse> result = cotisationService.getMesCotisations(0, 10);
List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);
assertThat(result).isNotNull();
}
@@ -595,7 +595,7 @@ class CotisationServiceAdminBranchesTest {
when(q.setParameter(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(q);
when(q.getResultList()).thenReturn(java.util.Collections.emptyList());
List<CotisationSummaryResponse> result = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();
assertThat(result).isNotNull();
}

View File

@@ -6,7 +6,6 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationResponse;
import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationSummaryResponse;
import dev.lions.unionflow.server.entity.Cotisation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
@@ -288,7 +287,7 @@ class CotisationServiceFinalBranchesTest {
.thenReturn(cotQuery);
when(cotQuery.getResultList()).thenReturn(Collections.emptyList());
List<CotisationSummaryResponse> result = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();
assertThat(result).isNotNull().isEmpty();
}
@@ -329,7 +328,7 @@ class CotisationServiceFinalBranchesTest {
cot.setMembre(membre);
when(cotQuery.getResultList()).thenReturn(List.of(cot));
List<CotisationSummaryResponse> result = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> result = cotisationService.getMesCotisationsEnAttente();
assertThat(result).isNotNull().hasSize(1);
}

View File

@@ -4,7 +4,7 @@ import static org.assertj.core.api.Assertions.*;
import dev.lions.unionflow.server.api.dto.cotisation.request.CreateCotisationRequest;
import dev.lions.unionflow.server.api.dto.cotisation.request.UpdateCotisationRequest;
import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationSummaryResponse;
import dev.lions.unionflow.server.api.dto.cotisation.response.CotisationResponse;
import dev.lions.unionflow.server.entity.Cotisation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
@@ -311,7 +311,7 @@ class CotisationServiceTest {
void getCotisationsByMembre_existant_returnsList() {
var list = cotisationService.getCotisationsByMembre(membre.getId(), 0, 10);
assertThat(list).isNotEmpty();
assertThat(list.get(0).id()).isEqualTo(cotisation.getId());
assertThat(list.get(0).getId()).isEqualTo(cotisation.getId());
}
@Test
@@ -352,12 +352,12 @@ class CotisationServiceTest {
@TestSecurity(user = TEST_USER_EMAIL, roles = {"MEMBRE"})
@DisplayName("getMesCotisationsEnAttente → retourne seulement les cotisations EN_ATTENTE du membre connecté")
void getMesCotisationsEnAttente_returnsOnlyMemberCotisations() {
List<CotisationSummaryResponse> results = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> results = cotisationService.getMesCotisationsEnAttente();
assertThat(results).isNotNull();
assertThat(results).isNotEmpty();
assertThat(results).allMatch(c -> c.statut().equals("EN_ATTENTE"));
assertThat(results.get(0).id()).isEqualTo(cotisation.getId());
assertThat(results).allMatch(c -> c.getStatut().equals("EN_ATTENTE"));
assertThat(results.get(0).getId()).isEqualTo(cotisation.getId());
}
@Test
@@ -382,12 +382,12 @@ class CotisationServiceTest {
cotisationNextYear.setNumeroReference("COT-TEST-NY-" + java.util.UUID.randomUUID().toString().substring(0, 8));
cotisationRepository.persist(cotisationNextYear);
List<CotisationSummaryResponse> results = cotisationService.getMesCotisationsEnAttente();
List<CotisationResponse> results = cotisationService.getMesCotisationsEnAttente();
// Ne doit retourner que la cotisation de l'année en cours
assertThat(results).isNotNull();
assertThat(results).allMatch(c ->
c.dateEcheance().getYear() == LocalDate.now().getYear()
c.getDateEcheance().getYear() == LocalDate.now().getYear()
);
// Cleanup
@@ -709,9 +709,9 @@ class CotisationServiceTest {
@DisplayName("getMesCotisations comme MEMBRE → retourne les cotisations du membre connecté")
void getMesCotisations_commeMembreConnecte_retourneSesCotsisations() {
// Couvre la branche MEMBRE : membreConnecte != null → getCotisationsByMembre(...)
List<CotisationSummaryResponse> result = cotisationService.getMesCotisations(0, 10);
List<CotisationResponse> result = cotisationService.getMesCotisations(0, 10);
assertThat(result).isNotNull().isNotEmpty();
assertThat(result.stream().anyMatch(c -> c.id().equals(cotisation.getId()))).isTrue();
assertThat(result.stream().anyMatch(c -> c.getId().equals(cotisation.getId()))).isTrue();
}
@Test

View File

@@ -0,0 +1,453 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.enums.membre.StatutMembre;
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.repository.MembreOrganisationRepository;
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 org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
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.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.*;
/**
* Tests unitaires pour {@link MemberLifecycleService}.
*
* <p>Couvre les transitions de statut manuel (activer, suspendre, radier, archiver, inviter)
* ainsi que les traitements automatiques (expiration des invitations, rappels).
*/
@DisplayName("MemberLifecycleService — transitions de statut")
class MemberLifecycleServiceTest {
@InjectMocks
MemberLifecycleService service;
@Mock
MembreOrganisationRepository membreOrgRepository;
@Mock
NotificationService notificationService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
// ─── Helpers ─────────────────────────────────────────────────────────────
private MembreOrganisation buildLien(StatutMembre statut) {
Membre membre = new Membre();
membre.setId(UUID.randomUUID());
membre.setNom("Dupont");
membre.setPrenom("Marie");
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
org.setNom("Association Test");
MembreOrganisation lien = MembreOrganisation.builder()
.membre(membre)
.organisation(org)
.statutMembre(statut)
.build();
lien.setId(UUID.randomUUID());
return lien;
}
// ═════════════════════════════════════════════════════════════════════════
// activerMembre
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("activerMembre: EN_ATTENTE_VALIDATION → ACTIF")
void activerMembre_enAttenteValidation_becomesActif() {
var lien = buildLien(StatutMembre.EN_ATTENTE_VALIDATION);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
var result = service.activerMembre(lien.getId(), UUID.randomUUID(), "validation admin");
assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.ACTIF);
assertThat(result.getMotifStatut()).isEqualTo("validation admin");
verify(membreOrgRepository).persist(lien);
}
@Test
@DisplayName("activerMembre: INVITE → ACTIF (validation directe)")
void activerMembre_invite_becomesActif() {
var lien = buildLien(StatutMembre.INVITE);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
var result = service.activerMembre(lien.getId(), UUID.randomUUID(), null);
assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.ACTIF);
assertThat(result.getMotifStatut()).isEqualTo("Validation par l'administrateur");
}
@Test
@DisplayName("activerMembre: SUSPENDU → ACTIF (réactivation)")
void activerMembre_suspendu_becomesActif() {
var lien = buildLien(StatutMembre.SUSPENDU);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
var result = service.activerMembre(lien.getId(), UUID.randomUUID(), "levée suspension");
assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.ACTIF);
}
@Test
@DisplayName("activerMembre: RADIE → IllegalStateException (transition non autorisée)")
void activerMembre_radie_throwsIllegalState() {
var lien = buildLien(StatutMembre.RADIE);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
assertThatThrownBy(() -> service.activerMembre(lien.getId(), UUID.randomUUID(), null))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Transition non autorisée");
verify(membreOrgRepository, never()).persist(any(MembreOrganisation.class));
}
@Test
@DisplayName("activerMembre: ARCHIVE → IllegalStateException")
void activerMembre_archive_throwsIllegalState() {
var lien = buildLien(StatutMembre.ARCHIVE);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
assertThatThrownBy(() -> service.activerMembre(lien.getId(), UUID.randomUUID(), null))
.isInstanceOf(IllegalStateException.class);
}
@Test
@DisplayName("activerMembre: ID inexistant → IllegalArgumentException")
void activerMembre_unknownId_throwsIllegalArgument() {
UUID unknown = UUID.randomUUID();
when(membreOrgRepository.findByIdOptional(unknown)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.activerMembre(unknown, UUID.randomUUID(), null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("introuvable");
}
// ═════════════════════════════════════════════════════════════════════════
// suspendreMembre
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("suspendreMembre: ACTIF → SUSPENDU avec motif")
void suspendreMembre_actif_becomesSuspenduWithMotif() {
var lien = buildLien(StatutMembre.ACTIF);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
var result = service.suspendreMembre(lien.getId(), UUID.randomUUID(), "comportement inapproprié");
assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.SUSPENDU);
assertThat(result.getMotifStatut()).isEqualTo("comportement inapproprié");
}
@Test
@DisplayName("suspendreMembre: motif null → motif par défaut")
void suspendreMembre_nullMotif_usesDefaultMotif() {
var lien = buildLien(StatutMembre.ACTIF);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
var result = service.suspendreMembre(lien.getId(), UUID.randomUUID(), null);
assertThat(result.getMotifStatut()).isEqualTo("Suspension par l'administrateur");
}
@Test
@DisplayName("suspendreMembre: INVITE → IllegalStateException")
void suspendreMembre_invite_throwsIllegalState() {
var lien = buildLien(StatutMembre.INVITE);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
assertThatThrownBy(() -> service.suspendreMembre(lien.getId(), UUID.randomUUID(), null))
.isInstanceOf(IllegalStateException.class);
}
@Test
@DisplayName("suspendreMembre: EN_ATTENTE_VALIDATION → IllegalStateException")
void suspendreMembre_enAttente_throwsIllegalState() {
var lien = buildLien(StatutMembre.EN_ATTENTE_VALIDATION);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
assertThatThrownBy(() -> service.suspendreMembre(lien.getId(), UUID.randomUUID(), null))
.isInstanceOf(IllegalStateException.class);
}
// ═════════════════════════════════════════════════════════════════════════
// radierMembre
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("radierMembre: ACTIF → RADIE")
void radierMembre_actif_becomesRadie() {
var lien = buildLien(StatutMembre.ACTIF);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
var result = service.radierMembre(lien.getId(), UUID.randomUUID(), "grave faute");
assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.RADIE);
assertThat(result.getMotifStatut()).isEqualTo("grave faute");
}
@Test
@DisplayName("radierMembre: SUSPENDU → RADIE (radiation depuis suspension)")
void radierMembre_suspendu_becomesRadie() {
var lien = buildLien(StatutMembre.SUSPENDU);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
var result = service.radierMembre(lien.getId(), UUID.randomUUID(), null);
assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.RADIE);
assertThat(result.getMotifStatut()).isEqualTo("Radiation par l'administrateur");
}
// ═════════════════════════════════════════════════════════════════════════
// archiverMembre
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("archiverMembre: RADIE → ARCHIVE")
void archiverMembre_radie_becomesArchive() {
var lien = buildLien(StatutMembre.RADIE);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
var result = service.archiverMembre(lien.getId(), "nettoyage annuel");
assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.ARCHIVE);
assertThat(result.getMotifArchivage()).isEqualTo("nettoyage annuel");
}
@Test
@DisplayName("archiverMembre: ACTIF → ARCHIVE (archivage direct)")
void archiverMembre_actif_becomesArchive() {
var lien = buildLien(StatutMembre.ACTIF);
when(membreOrgRepository.findByIdOptional(lien.getId())).thenReturn(Optional.of(lien));
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
var result = service.archiverMembre(lien.getId(), null);
assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.ARCHIVE);
}
// ═════════════════════════════════════════════════════════════════════════
// inviterMembre
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("inviterMembre: crée un lien INVITE avec token 32 chars et expiration 7j")
void inviterMembre_createsInviteLienWithToken() {
Membre membre = new Membre();
membre.setId(UUID.randomUUID());
membre.setNom("Sow");
membre.setPrenom("Amadou");
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
org.setNom("Coopérative Agricole");
when(membreOrgRepository.findByMembreIdAndOrganisationId(membre.getId(), org.getId()))
.thenReturn(Optional.empty());
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
var result = service.inviterMembre(membre, org, UUID.randomUUID(), "TRESORIER");
assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.INVITE);
assertThat(result.getTokenInvitation()).isNotNull().hasSize(32);
assertThat(result.getRoleOrg()).isEqualTo("TRESORIER");
assertThat(result.getDateExpirationInvitation())
.isAfter(LocalDateTime.now().plusDays(6));
}
@Test
@DisplayName("inviterMembre: roleOrg null accepté")
void inviterMembre_nullRoleOrg_accepted() {
Membre membre = new Membre();
membre.setId(UUID.randomUUID());
membre.setNom("Diallo");
membre.setPrenom("Fatou");
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
org.setNom("Tontine");
when(membreOrgRepository.findByMembreIdAndOrganisationId(any(), any()))
.thenReturn(Optional.empty());
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
var result = service.inviterMembre(membre, org, UUID.randomUUID(), null);
assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.INVITE);
assertThat(result.getRoleOrg()).isNull();
}
@Test
@DisplayName("inviterMembre: membre déjà lié → IllegalStateException")
void inviterMembre_alreadyLinked_throwsIllegalState() {
Membre membre = new Membre();
membre.setId(UUID.randomUUID());
membre.setNom("Test");
membre.setPrenom("Test");
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
org.setNom("Org");
var existingLien = buildLien(StatutMembre.ACTIF);
when(membreOrgRepository.findByMembreIdAndOrganisationId(membre.getId(), org.getId()))
.thenReturn(Optional.of(existingLien));
assertThatThrownBy(() -> service.inviterMembre(membre, org, UUID.randomUUID(), null))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("déjà lié");
}
// ═════════════════════════════════════════════════════════════════════════
// accepterInvitation
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("accepterInvitation: token valide → EN_ATTENTE_VALIDATION, token invalidé")
@SuppressWarnings("unchecked")
void accepterInvitation_validToken_becomesEnAttenteValidation() {
String token = "abc123validtokenabcde1234567890a";
var lien = buildLien(StatutMembre.INVITE);
lien.setTokenInvitation(token);
lien.setDateExpirationInvitation(LocalDateTime.now().plusDays(7));
PanacheQuery<MembreOrganisation> mockQuery = mock(PanacheQuery.class);
when(membreOrgRepository.find(anyString(), eq(token), eq(StatutMembre.INVITE)))
.thenReturn(mockQuery);
when(mockQuery.firstResultOptional()).thenReturn(Optional.of(lien));
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
var result = service.accepterInvitation(token);
assertThat(result.getStatutMembre()).isEqualTo(StatutMembre.EN_ATTENTE_VALIDATION);
assertThat(result.getTokenInvitation()).isNull(); // Token invalidé
}
@Test
@DisplayName("accepterInvitation: invitation expirée → IllegalStateException")
@SuppressWarnings("unchecked")
void accepterInvitation_expiredToken_throwsIllegalState() {
String token = "expiredtokenabcde1234567890abc12";
var lien = buildLien(StatutMembre.INVITE);
lien.setTokenInvitation(token);
lien.setDateExpirationInvitation(LocalDateTime.now().minusDays(1)); // Expiré hier
PanacheQuery<MembreOrganisation> mockQuery = mock(PanacheQuery.class);
when(membreOrgRepository.find(anyString(), eq(token), eq(StatutMembre.INVITE)))
.thenReturn(mockQuery);
when(mockQuery.firstResultOptional()).thenReturn(Optional.of(lien));
assertThatThrownBy(() -> service.accepterInvitation(token))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("expir");
}
@Test
@DisplayName("accepterInvitation: token inexistant → IllegalArgumentException")
@SuppressWarnings("unchecked")
void accepterInvitation_unknownToken_throwsIllegalArgument() {
String token = "unknowntoken0000000000000000000";
PanacheQuery<MembreOrganisation> mockQuery = mock(PanacheQuery.class);
when(membreOrgRepository.find(anyString(), eq(token), eq(StatutMembre.INVITE)))
.thenReturn(mockQuery);
when(mockQuery.firstResultOptional()).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.accepterInvitation(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invitation introuvable");
}
// ═════════════════════════════════════════════════════════════════════════
// expirerInvitations (automatique)
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("expirerInvitations: expire 3 invitations périmées → retourne 3")
void expirerInvitations_expiresAll_returnsCount() {
var lien1 = buildLien(StatutMembre.INVITE);
var lien2 = buildLien(StatutMembre.INVITE);
var lien3 = buildLien(StatutMembre.INVITE);
when(membreOrgRepository.findInvitationsExpirees(any(LocalDateTime.class)))
.thenReturn(List.of(lien1, lien2, lien3));
doNothing().when(membreOrgRepository).persist(any(MembreOrganisation.class));
int count = service.expirerInvitations();
assertThat(count).isEqualTo(3);
assertThat(lien1.getStatutMembre()).isEqualTo(StatutMembre.RADIE);
assertThat(lien2.getStatutMembre()).isEqualTo(StatutMembre.RADIE);
assertThat(lien3.getStatutMembre()).isEqualTo(StatutMembre.RADIE);
assertThat(lien1.getMotifStatut()).isEqualTo("Invitation expirée sans réponse");
}
@Test
@DisplayName("expirerInvitations: aucune invitation expirée → retourne 0")
void expirerInvitations_noneExpired_returnsZero() {
when(membreOrgRepository.findInvitationsExpirees(any(LocalDateTime.class)))
.thenReturn(List.of());
int count = service.expirerInvitations();
assertThat(count).isZero();
verify(membreOrgRepository, never()).persist(any(MembreOrganisation.class));
}
// ═════════════════════════════════════════════════════════════════════════
// envoyerRappelsInvitation (automatique)
// ═════════════════════════════════════════════════════════════════════════
@Test
@DisplayName("envoyerRappelsInvitation: 2 invitations expirant bientôt → retourne 2")
void envoyerRappelsInvitation_twoExpiringSoon_sendsTwo() {
var lien1 = buildLien(StatutMembre.INVITE);
var lien2 = buildLien(StatutMembre.INVITE);
when(membreOrgRepository.findInvitationsExpirantBientot(
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(lien1, lien2));
int count = service.envoyerRappelsInvitation();
assertThat(count).isEqualTo(2);
}
@Test
@DisplayName("envoyerRappelsInvitation: aucune invitation → retourne 0")
void envoyerRappelsInvitation_none_returnsZero() {
when(membreOrgRepository.findInvitationsExpirantBientot(
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
int count = service.envoyerRappelsInvitation();
assertThat(count).isZero();
}
}