Compare commits

...

10 Commits

Author SHA1 Message Date
dahoud
4e1a6d4007 test: couverture Messaging + Versement + ContactPolicy + MemberBlock
Tests unitaires pour les nouveaux modules :
- Entity tests : ContactPolicyTest, ConversationParticipantTest, MemberBlockTest,
  VersementTest, VersementObjetTest
- Repository tests : ContactPolicyRepositoryTest, ConversationParticipantRepositoryTest,
  MemberBlockRepositoryTest, VersementRepositoryTest
- Resource tests : MessagingResourceTest, VersementResourceTest
- Service tests : MessagingServiceTest, VersementServiceTest
2026-04-15 20:24:33 +00:00
dahoud
2f7bb545d0 chore: pom.xml + application.properties + tests + gitignore
- pom.xml : mise à jour dépendances
- application.properties : ajustements config
- MembreServiceTest, EntityCoverageTest : tests mis à jour pour nouveautés
- .gitignore : ajout du.exe.stackdump (dump Windows bash)
2026-04-15 20:24:16 +00:00
dahoud
66151b4fd1 feat(dashboard): DashboardServiceImpl + KafkaEventConsumer mis à jour
- DashboardServiceImpl : stats enrichies
- KafkaEventConsumer : consommation events pour refresh stats temps réel
- BackupRecordRepository, SystemLogRepository : petits ajustements
2026-04-15 20:24:05 +00:00
dahoud
6ff85bd503 feat(wave): webhooks + redirect handler
- WebhookWave : entité pour logs webhooks Wave (idempotence + audit)
- WaveRedirectResource : endpoint de retour après paiement Wave
  (redirige vers l'app mobile avec le statut)
2026-04-15 20:23:58 +00:00
dahoud
e482ad5a4d feat(admin): KeycloakAdminHttpClient + AdminUserService amélioré
- KeycloakAdminHttpClient (nouveau) : client HTTP natif (java.net.http.HttpClient)
  pour contourner les problèmes de désérialisation avec RESTEasy sur certains
  endpoints Keycloak 26+ (bruteForceStrategy, cpuInfo inconnus).
  Utilise ObjectMapper avec FAIL_ON_UNKNOWN_PROPERTIES=false.
- AdminUserService : utilisation correcte de AdminUserServiceClient + AdminRoleServiceClient
  avec AdminServiceTokenHeadersFactory pour l'auth.
- ModuleAccessFilter : améliorations de la logique @RequiresModule.
2026-04-15 20:23:50 +00:00
dahoud
9a270995ee feat(system-config): persistance configuration système en DB
- Migration V29 : table system_config (key-value avec type/description)
- SystemConfigPersistence : entité pour stocker les paramètres système
- SystemConfigPersistenceRepository : findByKey + upsert
- SystemConfigService : lecture/écriture typée (String/Int/Bool) avec fallback defaults
- SystemResource : endpoints de config exposés aux SuperAdmins
2026-04-15 20:23:39 +00:00
dahoud
217021933e fix(paiement): rendre colonnes legacy nullables + refactor Paiement/PaiementObjet
Migrations :
- V25 : numero_transaction nullable dans paiements (legacy V1 NOT NULL bloquant INSERT)
- V26 : autres colonnes legacy NOT NULL V1 (type_paiement, statut_paiement, etc.)
  rendues nullables pour alignement avec l'entité Paiement

Refactor Paiement/PaiementObjet : mise à jour entités, repository, resource, service
pour cohérence avec le nouveau module Versement. Tests associés supprimés/ajustés.
2026-04-15 20:23:30 +00:00
dahoud
5d028a10bf feat(versement): nouveau module Versement (paiements rattachés à des objets)
- Entités : Versement, VersementObjet (lien polymorphique vers cotisation/adhesion/etc.)
- VersementRepository : requêtes par membre, org, période
- VersementResource : endpoints REST /api/versements
- VersementService : logique métier (validation, rattachement objets)
- Migration V27 : ajout numeroTelephone sur versements
2026-04-15 20:23:17 +00:00
dahoud
719d45e1fe feat(messaging): module messagerie unifié avec contact policies + member blocks
Refactor complet : fusion de Conversation + Message en un module Messaging unique
avec ContactPolicy (règles qui-peut-parler-à-qui) et MemberBlock (blocages utilisateur).

- Migration V28 : tables conversations/conversation_participants/messages/
  contact_policies/member_blocks
- Nouvelles entités : ContactPolicy, ConversationParticipant, MemberBlock
  (Conversation/Message mises à jour avec relations)
- Nouvelles repositories : ContactPolicyRepository, ConversationParticipantRepository,
  MemberBlockRepository
- MessagingResource (nouveau) remplace ConversationResource + MessageResource
- MessagingService (nouveau) remplace ConversationService + MessageService
  avec vérifications appartenance org + policies + blocages avant envoi
- Anciens fichiers Conversation/Message Resource/Service/Tests supprimés
2026-04-15 20:23:04 +00:00
dahoud
a650b372f1 chore: untrack target/ (déjà dans .gitignore mais tracké par erreur) 2026-04-15 20:17:37 +00:00
227 changed files with 7680 additions and 6951 deletions

2
.gitignore vendored
View File

@@ -120,3 +120,5 @@ uploads/
# Claude Code agent worktrees
.claude/
du.exe.stackdump
du.exe.stackdump

View File

@@ -7,7 +7,7 @@
<parent>
<groupId>dev.lions.unionflow</groupId>
<artifactId>unionflow-parent</artifactId>
<version>1.0.4</version>
<version>1.0.5</version>
<relativePath>../unionflow-server-api/parent-pom.xml</relativePath>
</parent>
@@ -47,7 +47,7 @@
<dependency>
<groupId>dev.lions.unionflow</groupId>
<artifactId>unionflow-server-api</artifactId>
<version>1.0.4</version>
<version>1.0.5</version>
</dependency>
<!-- Lions User Manager API (pour DTOs et client Keycloak) -->

View File

@@ -0,0 +1,91 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.messagerie.TypePolitiqueCommunication;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Politique de communication d'une organisation.
*
* <p>Chaque organisation possède exactement une politique, créée automatiquement
* lors de la création de l'organisation avec les valeurs par défaut.
* L'administrateur peut la modifier via l'API.
*
* <p>Table : {@code contact_policies}
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(
name = "contact_policies",
indexes = {
@Index(name = "idx_contact_policies_org", columnList = "organisation_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_contact_policy_org", columnNames = "organisation_id")
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ContactPolicy extends BaseEntity {
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation;
@Enumerated(EnumType.STRING)
@Builder.Default
@Column(name = "type_politique", nullable = false, length = 30)
private TypePolitiqueCommunication typePolitique = TypePolitiqueCommunication.OUVERT;
@Builder.Default
@Column(name = "autoriser_membre_vers_membre", nullable = false)
private Boolean autoriserMembreVersMembre = Boolean.TRUE;
@Builder.Default
@Column(name = "autoriser_membre_vers_role", nullable = false)
private Boolean autoriserMembreVersRole = Boolean.TRUE;
@Builder.Default
@Column(name = "autoriser_notes_vocales", nullable = false)
private Boolean autoriserNotesVocales = Boolean.TRUE;
@PrePersist
@Override
protected void onCreate() {
super.onCreate();
if (typePolitique == null) {
typePolitique = TypePolitiqueCommunication.OUVERT;
}
if (autoriserMembreVersMembre == null) {
autoriserMembreVersMembre = Boolean.TRUE;
}
if (autoriserMembreVersRole == null) {
autoriserMembreVersRole = Boolean.TRUE;
}
if (autoriserNotesVocales == null) {
autoriserNotesVocales = Boolean.TRUE;
}
}
}

View File

@@ -1,119 +1,129 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.communication.ConversationType;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Entité Conversation pour le système de messagerie UnionFlow.
* Représente un fil de discussion entre membres.
* Fil de discussion entre membres d'une organisation.
*
* <p>Deux types sont supportés en V1 :
* <ul>
* <li>{@link TypeConversation#DIRECTE} — 1-1 entre deux membres</li>
* <li>{@link TypeConversation#ROLE_CANAL} — membre vers un rôle officiel
* (PRESIDENT, TRESORIER, SECRETAIRE…). Tous les porteurs du rôle répondent.</li>
* </ul>
*
* <p>Table : {@code conversations}
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(name = "conversations", indexes = {
@Index(name = "idx_conversation_organisation", columnList = "organisation_id"),
@Index(name = "idx_conversation_type", columnList = "type"),
@Index(name = "idx_conversation_archived", columnList = "is_archived"),
@Index(name = "idx_conversation_created", columnList = "date_creation")
})
@Getter
@Setter
@Table(
name = "conversations",
indexes = {
@Index(name = "idx_conversations_organisation", columnList = "organisation_id"),
@Index(name = "idx_conversations_statut", columnList = "statut"),
@Index(name = "idx_conversations_dernier_msg", columnList = "dernier_message_at")
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class Conversation extends BaseEntity {
/**
* Nom de la conversation
*/
@Column(name = "name", nullable = false, length = 255)
private String name;
/**
* Description optionnelle
*/
@Column(name = "description", length = 1000)
private String description;
/**
* Type de conversation (INDIVIDUAL, GROUP, BROADCAST, ANNOUNCEMENT)
*/
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false, length = 20)
private ConversationType type;
/**
* Organisation associée (optionnelle)
*/
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id")
@JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation;
/**
* URL de l'avatar de la conversation
*/
@Column(name = "avatar_url", length = 500)
private String avatarUrl;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "type_conversation", nullable = false, length = 30)
private TypeConversation typeConversation;
/**
* Conversation muette
* Rôle cible pour les ROLE_CANAL (ex : "TRESORIER", "PRESIDENT").
* Null pour les conversations DIRECTE.
*/
@Column(name = "is_muted", nullable = false)
private Boolean isMuted = false;
@Column(name = "role_cible", length = 50)
private String roleCible;
/**
* Conversation épinglée
*/
@Column(name = "is_pinned", nullable = false)
private Boolean isPinned = false;
/** Titre affiché (nom du rôle ou du groupe, null pour DIRECTE). */
@Column(name = "titre", length = 200)
private String titre;
/**
* Conversation archivée
*/
@Column(name = "is_archived", nullable = false)
private Boolean isArchived = false;
@Enumerated(EnumType.STRING)
@Builder.Default
@Column(name = "statut", nullable = false, length = 20)
private StatutConversation statut = StatutConversation.ACTIVE;
/**
* Métadonnées additionnelles (JSON)
*/
@Column(name = "metadata", columnDefinition = "TEXT")
private String metadata;
@Column(name = "dernier_message_at")
private LocalDateTime dernierMessageAt;
/**
* Date de dernière mise à jour
*/
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Builder.Default
@Column(name = "nombre_messages", nullable = false)
private Integer nombreMessages = 0;
/**
* Participants de la conversation (many-to-many)
*/
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "conversation_participants",
joinColumns = @JoinColumn(name = "conversation_id"),
inverseJoinColumns = @JoinColumn(name = "membre_id")
)
private List<Membre> participants = new ArrayList<>();
@Builder.Default
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<ConversationParticipant> participants = new ArrayList<>();
/**
* Messages de la conversation (one-to-many)
*/
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Message> messages = new ArrayList<>();
/**
* Met à jour le timestamp
*/
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
@PrePersist
@Override
protected void onCreate() {
super.onCreate();
if (statut == null) {
statut = StatutConversation.ACTIVE;
}
if (nombreMessages == null) {
nombreMessages = 0;
}
}
// ── Méthodes métier ───────────────────────────────────────────────────────
/** Retourne true si la conversation accepte encore de nouveaux messages. */
public boolean estActive() {
return StatutConversation.ACTIVE.equals(statut);
}
/** Archive la conversation — plus aucun message n'est accepté. */
public void archiver() {
this.statut = StatutConversation.ARCHIVEE;
}
/** Incrémente le compteur et met à jour l'horodatage du dernier message. */
public void enregistrerNouveauMessage() {
this.nombreMessages = (this.nombreMessages == null ? 0 : this.nombreMessages) + 1;
this.dernierMessageAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,91 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Participation d'un membre à une conversation.
*
* <p>Stocke l'état de lecture individuel ({@code luJusqua}) et
* les préférences de notification du participant.
*
* <p>Table : {@code conversation_participants}
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(
name = "conversation_participants",
indexes = {
@Index(name = "idx_conv_part_conversation", columnList = "conversation_id"),
@Index(name = "idx_conv_part_membre", columnList = "membre_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_conv_participant",
columnNames = {"conversation_id", "membre_id"})
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ConversationParticipant extends BaseEntity {
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id", nullable = false)
private Conversation conversation;
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false)
private Membre membre;
/**
* Rôle de ce participant dans la conversation.
* Ex : INITIATEUR, PARTICIPANT, MODERATEUR.
*/
@Builder.Default
@Column(name = "role_dans_conversation", length = 50)
private String roleDansConversation = "PARTICIPANT";
/**
* Horodatage du dernier message lu.
* Permet de calculer le nombre de messages non lus.
*/
@Column(name = "lu_jusqu_a")
private LocalDateTime luJusqua;
/** Si false, ce participant ne reçoit plus de notifications pour cette conversation. */
@Builder.Default
@Column(name = "notifier", nullable = false)
private Boolean notifier = Boolean.TRUE;
// ── Méthodes métier ───────────────────────────────────────────────────────
/** Marque tous les messages jusqu'à maintenant comme lus. */
public void marquerLu() {
this.luJusqua = LocalDateTime.now();
}
/** Retourne true si ce participant est l'initiateur de la conversation. */
public boolean estInitiateur() {
return "INITIATEUR".equals(roleDansConversation);
}
}

View File

@@ -0,0 +1,68 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import java.util.UUID;
/**
* Blocage unilatéral entre deux membres au sein d'une organisation.
*
* <p>Un membre bloqué ne peut plus envoyer de messages au bloqueur.
* Le blocage est limité à une organisation (un membre bloqué dans l'asso X
* peut encore écrire dans la tontine Y).
*
* <p>Table : {@code member_blocks}
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(
name = "member_blocks",
indexes = {
@Index(name = "idx_member_blocks_bloqueur", columnList = "bloqueur_id"),
@Index(name = "idx_member_blocks_bloque", columnList = "bloque_id, organisation_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_member_block",
columnNames = {"bloqueur_id", "bloque_id", "organisation_id"})
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class MemberBlock extends BaseEntity {
/** Membre qui effectue le blocage */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bloqueur_id", nullable = false)
private Membre bloqueur;
/** Membre qui est bloqué */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bloque_id", nullable = false)
private Membre bloque;
/** Organisation dans laquelle le blocage est actif */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation;
}

View File

@@ -1,156 +1,140 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.communication.MessagePriority;
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
import dev.lions.unionflow.server.api.enums.communication.MessageType;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import dev.lions.unionflow.server.api.enums.messagerie.TypeContenu;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Entité Message pour le système de messagerie UnionFlow.
* Représente un message individuel dans une conversation.
* Message envoyé dans une conversation.
*
* <p>Supporte trois types de contenu :
* <ul>
* <li>{@link TypeContenu#TEXTE} — message texte classique</li>
* <li>{@link TypeContenu#VOCAL} — note vocale (Opus/AAC), stockée sur object storage.
* Champs {@code urlFichier} + {@code dureeAudio} obligatoires.</li>
* <li>{@link TypeContenu#IMAGE} — image JPEG/PNG. Champ {@code urlFichier} obligatoire.</li>
* </ul>
*
* <p>La suppression est douce : {@code supprimeLe} est renseigné au lieu de
* supprimer la ligne. Le contenu devient {@code "[Message supprimé]"}.
*
* <p>Table : {@code messages}
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(name = "messages", indexes = {
@Index(name = "idx_message_conversation", columnList = "conversation_id"),
@Index(name = "idx_message_sender", columnList = "sender_id"),
@Index(name = "idx_message_organisation", columnList = "organisation_id"),
@Index(name = "idx_message_status", columnList = "status"),
@Index(name = "idx_message_created", columnList = "date_creation"),
@Index(name = "idx_message_deleted", columnList = "is_deleted")
})
@Getter
@Setter
@Table(
name = "messages",
indexes = {
@Index(name = "idx_messages_conversation", columnList = "conversation_id"),
@Index(name = "idx_messages_expediteur", columnList = "expediteur_id"),
@Index(name = "idx_messages_date_creation", columnList = "date_creation"),
@Index(name = "idx_messages_parent", columnList = "message_parent_id")
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class Message extends BaseEntity {
/**
* Conversation parente
*/
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id", nullable = false)
private Conversation conversation;
/**
* Expéditeur du message
*/
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sender_id", nullable = false)
private Membre sender;
@JoinColumn(name = "expediteur_id", nullable = false)
private Membre expediteur;
/**
* Nom de l'expéditeur (dénormalisé pour performance)
*/
@Column(name = "sender_name", nullable = false, length = 255)
private String senderName;
/**
* Avatar de l'expéditeur (dénormalisé)
*/
@Column(name = "sender_avatar", length = 500)
private String senderAvatar;
/**
* Contenu du message
*/
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
/**
* Type de message (INDIVIDUAL, BROADCAST, TARGETED, SYSTEM)
*/
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false, length = 20)
private MessageType type;
@Builder.Default
@Column(name = "type_message", nullable = false, length = 20)
private TypeContenu typeMessage = TypeContenu.TEXTE;
/** Texte du message — null pour les vocaux/images. */
@Column(name = "contenu", columnDefinition = "TEXT")
private String contenu;
/**
* Statut du message (SENT, DELIVERED, READ, FAILED)
* URL du fichier audio (notes vocales) ou image.
* Format : https://storage.lions.dev/chat/{conversationId}/{messageId}.opus
*/
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private MessageStatus status;
@Column(name = "url_fichier", length = 500)
private String urlFichier;
/** Durée en secondes pour les notes vocales. */
@Column(name = "duree_audio")
private Integer dureeAudio;
/**
* Priorité du message (NORMAL, HIGH, URGENT)
* Transcription automatique du vocal — null en V1.
* Sera renseigné par un service Speech-to-Text en V2.
*/
@Enumerated(EnumType.STRING)
@Column(name = "priority", nullable = false, length = 20)
private MessagePriority priority = MessagePriority.NORMAL;
@Column(name = "transcription", columnDefinition = "TEXT")
private String transcription;
/**
* IDs des destinataires (CSV pour targeted messages)
*/
@Column(name = "recipient_ids", length = 2000)
private String recipientIds;
/**
* Rôles destinataires (CSV pour role-based messaging)
*/
@Column(name = "recipient_roles", length = 500)
private String recipientRoles;
/**
* Organisation associée (optionnelle)
*/
/** Message auquel celui-ci répond (threading léger). */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id")
private Organisation organisation;
@JoinColumn(name = "message_parent_id")
private Message messageParent;
/**
* Date de lecture du message
*/
@Column(name = "read_at")
private LocalDateTime readAt;
/** Date de suppression douce (null = message actif). */
@Column(name = "supprime_le")
private LocalDateTime supprimeLe;
/**
* Métadonnées additionnelles (JSON)
*/
@Column(name = "metadata", columnDefinition = "TEXT")
private String metadata;
@PrePersist
@Override
protected void onCreate() {
super.onCreate();
if (typeMessage == null) {
typeMessage = TypeContenu.TEXTE;
}
}
/**
* Pièces jointes (CSV URLs)
*/
@Column(name = "attachments", length = 2000)
private String attachments;
// ── Méthodes métier ───────────────────────────────────────────────────────
/**
* Message édité
*/
@Column(name = "is_edited", nullable = false)
private Boolean isEdited = false;
/** Retourne true si le message a été supprimé par son auteur. */
public boolean estSupprime() {
return supprimeLe != null;
}
/**
* Date d'édition
*/
@Column(name = "edited_at")
private LocalDateTime editedAt;
/** Retourne true si c'est un message texte. */
public boolean estTextuel() {
return TypeContenu.TEXTE.equals(typeMessage);
}
/**
* Message supprimé (soft delete)
*/
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;
/**
* Marque le message comme lu
*/
public void markAsRead() {
this.status = MessageStatus.READ;
this.readAt = LocalDateTime.now();
/** Retourne true si c'est une note vocale. */
public boolean estVocal() {
return TypeContenu.VOCAL.equals(typeMessage);
}
/**
* Marque le message comme édité
* Supprime le message de façon douce.
* Le contenu original est remplacé par un marqueur.
*/
public void markAsEdited() {
this.isEdited = true;
this.editedAt = LocalDateTime.now();
public void supprimer() {
this.supprimeLe = LocalDateTime.now();
this.contenu = "[Message supprimé]";
this.urlFichier = null;
}
}

View File

@@ -5,7 +5,6 @@ import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicLong;
import java.util.ArrayList;
import java.util.List;
import lombok.AllArgsConstructor;
@@ -15,8 +14,8 @@ import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Entité Paiement centralisée pour tous les types de paiements
* Réutilisable pour cotisations, adhésions, événements, aides
* Entité Paiement centralisée pour tous les types de paiements.
* Réutilisable pour cotisations, adhésions, événements, aides.
*
* @author UnionFlow Team
* @version 3.0
@@ -104,7 +103,7 @@ public class Paiement extends BaseEntity {
@JoinColumn(name = "membre_id", nullable = false)
private Membre membre;
/** Objets cibles de ce paiement (Cat.2 — polymorphique) */
/** Objets cibles de ce paiement (polymorphique) */
@JsonIgnore
@OneToMany(mappedBy = "paiement", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default
@@ -115,18 +114,15 @@ public class Paiement extends BaseEntity {
@JoinColumn(name = "transaction_wave_id")
private TransactionWave transactionWave;
private static final AtomicLong REFERENCE_COUNTER =
new AtomicLong(System.currentTimeMillis() % 1000000000000L);
/** Méthode métier pour générer un numéro de référence unique */
/** Génère un numéro de référence unique */
public static String genererNumeroReference() {
return "PAY-"
+ LocalDateTime.now().getYear()
+ "-"
+ String.format("%012d", REFERENCE_COUNTER.incrementAndGet() % 1000000000000L);
+ String.format("%012d", System.currentTimeMillis() % 1000000000000L);
}
/** Méthode métier pour vérifier si le paiement est validé */
/** Vérifie si le paiement est validé */
public boolean isValide() {
return "VALIDE".equals(statutPaiement);
}
@@ -137,12 +133,10 @@ public class Paiement extends BaseEntity {
&& !"ANNULE".equals(statutPaiement);
}
/** Callback JPA avant la persistance */
@PrePersist
protected void onCreate() {
super.onCreate();
if (numeroReference == null
|| numeroReference.isEmpty()) {
if (numeroReference == null || numeroReference.isEmpty()) {
numeroReference = genererNumeroReference();
}
if (statutPaiement == null) {

View File

@@ -1,19 +1,7 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
@@ -24,23 +12,11 @@ import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Table de liaison polymorphique entre un paiement
* et son objet cible.
* Table de liaison polymorphique entre un paiement et son objet cible.
*
* <p>
* Remplace les 4 tables dupliquées
* {@code paiements_cotisations},
* {@code paiements_adhesions},
* {@code paiements_evenements} et
* {@code paiements_aides} par une table unique
* utilisant le pattern
* {@code (type_objet_cible, objet_cible_id)}.
*
* <p>
* Les types d'objet cible sont définis dans le
* domaine {@code OBJET_PAIEMENT} de la table
* {@code types_reference} (ex: COTISATION,
* ADHESION, EVENEMENT, AIDE).
* <p>Remplace les tables dupliquées {@code paiements_cotisations},
* {@code paiements_adhesions}, etc. par une table unique utilisant
* le pattern {@code (type_objet_cible, objet_cible_id)}.
*
* @author UnionFlow Team
* @version 3.0
@@ -48,16 +24,12 @@ import lombok.NoArgsConstructor;
*/
@Entity
@Table(name = "paiements_objets", indexes = {
@Index(name = "idx_po_paiement", columnList = "paiement_id"),
@Index(name = "idx_po_objet", columnList = "type_objet_cible,"
+ " objet_cible_id"),
@Index(name = "idx_po_type", columnList = "type_objet_cible")
@Index(name = "idx_po_paiement", columnList = "paiement_id"),
@Index(name = "idx_po_objet", columnList = "type_objet_cible, objet_cible_id"),
@Index(name = "idx_po_type", columnList = "type_objet_cible")
}, uniqueConstraints = {
@UniqueConstraint(name = "uk_paiement_objet", columnNames = {
"paiement_id",
"type_objet_cible",
"objet_cible_id"
})
@UniqueConstraint(name = "uk_paiement_objet",
columnNames = {"paiement_id", "type_objet_cible", "objet_cible_id"})
})
@Data
@NoArgsConstructor
@@ -66,65 +38,47 @@ import lombok.NoArgsConstructor;
@EqualsAndHashCode(callSuper = true)
public class PaiementObjet extends BaseEntity {
/** Paiement parent. */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "paiement_id", nullable = false)
private Paiement paiement;
/** Paiement parent. */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "paiement_id", nullable = false)
private Paiement paiement;
/**
* Type de l'objet cible (code du domaine
* {@code OBJET_PAIEMENT} dans
* {@code types_reference}).
*
* <p>
* Valeurs attendues : {@code COTISATION},
* {@code ADHESION}, {@code EVENEMENT},
* {@code AIDE}.
*/
@NotBlank
@Size(max = 50)
@Column(name = "type_objet_cible", nullable = false, length = 50)
private String typeObjetCible;
/**
* Type de l'objet cible (ex: COTISATION, ADHESION, EVENEMENT, AIDE).
*/
@NotBlank
@Size(max = 50)
@Column(name = "type_objet_cible", nullable = false, length = 50)
private String typeObjetCible;
/**
* UUID de l'objet cible (cotisation, demande
* d'adhésion, inscription événement, ou demande
* d'aide).
*/
@NotNull
@Column(name = "objet_cible_id", nullable = false)
private UUID objetCibleId;
/** UUID de l'objet cible. */
@NotNull
@Column(name = "objet_cible_id", nullable = false)
private UUID objetCibleId;
/** Montant appliqué à cet objet cible. */
@NotNull
@DecimalMin(value = "0.0", message = "Le montant doit être positif")
@Digits(integer = 12, fraction = 2)
@Column(name = "montant_applique", nullable = false, precision = 14, scale = 2)
private BigDecimal montantApplique;
/** Montant appliqué à cet objet cible. */
@NotNull
@DecimalMin(value = "0.0", message = "Le montant doit être positif")
@Digits(integer = 12, fraction = 2)
@Column(name = "montant_applique", nullable = false, precision = 14, scale = 2)
private BigDecimal montantApplique;
/** Date d'application du paiement. */
@Column(name = "date_application")
private LocalDateTime dateApplication;
/** Date d'application du paiement. */
@Column(name = "date_application")
private LocalDateTime dateApplication;
/** Commentaire sur l'application. */
@Size(max = 500)
@Column(name = "commentaire", length = 500)
private String commentaire;
/** Commentaire sur l'application. */
@Size(max = 500)
@Column(name = "commentaire", length = 500)
private String commentaire;
/**
* Callback JPA avant la persistance.
*
* <p>
* Initialise {@code dateApplication} si non
* renseignée.
*/
@Override
@PrePersist
protected void onCreate() {
super.onCreate();
if (dateApplication == null) {
dateApplication = LocalDateTime.now();
}
@Override
@PrePersist
protected void onCreate() {
super.onCreate();
if (dateApplication == null) {
dateApplication = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,20 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "system_config")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class SystemConfigPersistence extends BaseEntity {
@Column(name = "config_key", unique = true, nullable = false, length = 100)
private String configKey;
@Column(name = "config_value", columnDefinition = "TEXT")
private String configValue;
}

View File

@@ -0,0 +1,171 @@
package dev.lions.unionflow.server.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import lombok.*;
/**
* Versement — acte de régler une cotisation ou de déposer des fonds.
*
* <p>Un versement peut être effectué :
* <ul>
* <li>Via <b>Wave Mobile Money</b> : deep link natif, app Wave sur le même téléphone</li>
* <li>Manuellement : espèces, virement, chèque → statut EN_ATTENTE_VALIDATION</li>
* </ul>
*
* <p>Table DB : {@code paiements} (nom hérité, conservé pour compatibilité Flyway).
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(name = "paiements", indexes = {
@Index(name = "idx_paiement_numero_reference", columnList = "numero_reference", unique = true),
@Index(name = "idx_paiement_membre", columnList = "membre_id"),
@Index(name = "idx_paiement_statut", columnList = "statut_paiement"),
@Index(name = "idx_paiement_methode", columnList = "methode_paiement"),
@Index(name = "idx_paiement_date", columnList = "date_paiement")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class Versement extends BaseEntity {
private static final AtomicLong REFERENCE_COUNTER =
new AtomicLong(System.currentTimeMillis() % 1_000_000_000_000L);
// ── Identité ──────────────────────────────────────────────────────────────
/** Référence unique (ex: VRS-2026-XXXXXXXXXXXX) */
@NotBlank
@Column(name = "numero_reference", unique = true, nullable = false, length = 50)
private String numeroReference;
// ── Montant ───────────────────────────────────────────────────────────────
@NotNull
@DecimalMin(value = "0.0", message = "Le montant doit être positif")
@Digits(integer = 12, fraction = 2)
@Column(name = "montant", nullable = false, precision = 14, scale = 2)
private BigDecimal montant;
@NotBlank
@Pattern(regexp = "^[A-Z]{3}$", message = "Code devise ISO à 3 lettres requis")
@Column(name = "code_devise", nullable = false, length = 3)
private String codeDevise;
// ── Méthode & Statut ──────────────────────────────────────────────────────
/** WAVE | ESPECES | VIREMENT | CHEQUE | AUTRE */
@NotNull
@Column(name = "methode_paiement", nullable = false, length = 50)
private String methodePaiement;
/** EN_ATTENTE | EN_COURS | CONFIRME | ECHEC | EN_ATTENTE_VALIDATION | ANNULE */
@NotNull
@Builder.Default
@Column(name = "statut_paiement", nullable = false, length = 30)
private String statutPaiement = "EN_ATTENTE";
// ── Dates ─────────────────────────────────────────────────────────────────
@Column(name = "date_paiement")
private LocalDateTime datePaiement;
@Column(name = "date_validation")
private LocalDateTime dateValidation;
// ── Validation ────────────────────────────────────────────────────────────
@Column(name = "validateur", length = 255)
private String validateur;
// ── Traçabilité ───────────────────────────────────────────────────────────
/** ID transaction Wave (TCN...) ou référence chèque / bordereau */
@Column(name = "reference_externe", length = 500)
private String referenceExterne;
@Column(name = "url_preuve", length = 1000)
private String urlPreuve;
@Column(name = "commentaire", length = 1000)
private String commentaire;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", length = 500)
private String userAgent;
// ── Téléphone Wave ────────────────────────────────────────────────────────
/**
* Numéro de téléphone Wave utilisé pour ce versement.
* Pré-rempli depuis le profil du membre (même téléphone qu'UnionFlow),
* modifiable à l'étape "Récapitulatif" avant de tapper "Payer".
*/
@Column(name = "numero_telephone", length = 20)
private String numeroTelephone;
// ── Relations ─────────────────────────────────────────────────────────────
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false)
private Membre membre;
@JsonIgnore
@OneToMany(mappedBy = "versement", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default
private List<VersementObjet> versementsObjets = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "transaction_wave_id")
private TransactionWave transactionWave;
// ── Méthodes métier ───────────────────────────────────────────────────────
/** Génère une référence unique : VRS-YYYY-XXXXXXXXXXXX */
public static String genererNumeroReference() {
return "VRS-"
+ LocalDateTime.now().getYear()
+ "-"
+ String.format("%012d", REFERENCE_COUNTER.incrementAndGet() % 1_000_000_000_000L);
}
/** Vrai si le versement est confirmé (Wave) ou validé (manuel) */
public boolean isConfirme() {
return "CONFIRME".equals(statutPaiement) || "VALIDE".equals(statutPaiement);
}
/** Vrai si le versement peut encore être modifié ou annulé */
public boolean peutEtreModifie() {
return !"CONFIRME".equals(statutPaiement)
&& !"VALIDE".equals(statutPaiement)
&& !"ANNULE".equals(statutPaiement);
}
@PrePersist
protected void onCreate() {
super.onCreate();
if (numeroReference == null || numeroReference.isBlank()) {
numeroReference = genererNumeroReference();
}
if (statutPaiement == null) {
statutPaiement = "EN_ATTENTE";
}
if (datePaiement == null) {
datePaiement = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,82 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.*;
/**
* Liaison polymorphique entre un versement et son objet cible.
*
* <p>Remplace les 4 tables dupliquées (paiements_cotisations, paiements_adhesions,
* paiements_evenements, paiements_aides) par une table unique utilisant le pattern
* {@code (typeObjetCible, objetCibleId)}.
*
* <p>Table DB : {@code paiements_objets} (nom hérité, conservé pour compatibilité Flyway).
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Entity
@Table(name = "paiements_objets", indexes = {
@Index(name = "idx_po_paiement", columnList = "paiement_id"),
@Index(name = "idx_po_objet", columnList = "type_objet_cible, objet_cible_id"),
@Index(name = "idx_po_type", columnList = "type_objet_cible")
}, uniqueConstraints = {
@UniqueConstraint(name = "uk_paiement_objet", columnNames = {
"paiement_id", "type_objet_cible", "objet_cible_id"
})
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class VersementObjet extends BaseEntity {
/** Versement parent. */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "paiement_id", nullable = false)
private Versement versement;
/**
* Type de l'objet cible (domaine {@code OBJET_PAIEMENT}).
* Valeurs : COTISATION | ADHESION | EVENEMENT | AIDE.
*/
@NotBlank
@Size(max = 50)
@Column(name = "type_objet_cible", nullable = false, length = 50)
private String typeObjetCible;
/** UUID de l'objet cible (cotisation, adhésion, inscription, aide). */
@NotNull
@Column(name = "objet_cible_id", nullable = false)
private UUID objetCibleId;
/** Montant affecté à cet objet cible. */
@NotNull
@DecimalMin(value = "0.0", message = "Le montant doit être positif")
@Digits(integer = 12, fraction = 2)
@Column(name = "montant_applique", nullable = false, precision = 14, scale = 2)
private BigDecimal montantApplique;
@Column(name = "date_application")
private LocalDateTime dateApplication;
@Size(max = 500)
@Column(name = "commentaire", length = 500)
private String commentaire;
@Override
@PrePersist
protected void onCreate() {
super.onCreate();
if (dateApplication == null) {
dateApplication = LocalDateTime.now();
}
}
}

View File

@@ -83,7 +83,7 @@ public class WebhookWave extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "paiement_id")
private Paiement paiement;
private Versement versement;
/** Méthode métier pour vérifier si le webhook est traité */
public boolean isTraite() {

View File

@@ -86,4 +86,18 @@ public class KafkaEventConsumer {
LOG.errorf(e, "Failed to broadcast contribution event");
}
}
/**
* Consomme les messages de chat (nouveaux messages envoyés dans une conversation).
* Broadcaste l'event en temps réel aux clients WebSocket pour mise à jour instantanée.
*/
@Incoming("chat-messages-in")
public void consumeChatMessages(Record<String, String> record) {
LOG.debugf("Received chat message event: key=%s", record.key());
try {
webSocketBroadcastService.broadcast(record.value());
} catch (Exception e) {
LOG.errorf(e, "Failed to broadcast chat message event");
}
}
}

View File

@@ -1,22 +1,39 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.BackupRecord;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/**
* Repository pour les enregistrements de sauvegarde.
* Étend BaseRepository pour cohérence avec le reste du projet.
*/
@ApplicationScoped
public class BackupRecordRepository implements PanacheRepositoryBase<BackupRecord, UUID> {
public class BackupRecordRepository extends BaseRepository<BackupRecord> {
public BackupRecordRepository() {
super(BackupRecord.class);
}
/**
* Liste tous les enregistrements de sauvegarde triés par date décroissante.
*/
public List<BackupRecord> findAllOrderedByDate() {
return findAll(Sort.by("dateCreation", Sort.Direction.Descending)).list();
}
public void updateStatus(UUID id, String status, Long sizeBytes, LocalDateTime completedAt, String errorMessage) {
/**
* Met à jour le statut d'un enregistrement de sauvegarde.
* Opération transactionnelle — utilisée pour passer de IN_PROGRESS à COMPLETED ou FAILED.
*/
@Transactional
public void updateStatus(UUID id, String status, Long sizeBytes,
LocalDateTime completedAt, String errorMessage) {
update("status = ?1, sizeBytes = ?2, completedAt = ?3, errorMessage = ?4 WHERE id = ?5",
status, sizeBytes, completedAt, errorMessage, id);
}

View File

@@ -0,0 +1,27 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.ContactPolicy;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour les politiques de communication des organisations.
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class ContactPolicyRepository implements PanacheRepositoryBase<ContactPolicy, UUID> {
/**
* Trouve la politique de communication d'une organisation.
* Chaque organisation a exactement une politique.
*/
public Optional<ContactPolicy> findByOrganisationId(UUID organisationId) {
return find("organisation.id = ?1 AND actif = true", organisationId)
.firstResultOptional();
}
}

View File

@@ -0,0 +1,44 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.ConversationParticipant;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour les participants aux conversations.
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class ConversationParticipantRepository
implements PanacheRepositoryBase<ConversationParticipant, UUID> {
/**
* Trouve la participation d'un membre à une conversation.
*/
public Optional<ConversationParticipant> findParticipant(UUID conversationId, UUID membreId) {
return find("conversation.id = ?1 AND membre.id = ?2 AND actif = true",
conversationId, membreId
).firstResultOptional();
}
/**
* Liste tous les participants actifs d'une conversation.
*/
public List<ConversationParticipant> findByConversation(UUID conversationId) {
return find("conversation.id = ?1 AND actif = true", conversationId).list();
}
/**
* Vérifie si un membre est participant à une conversation.
*/
public boolean estParticipant(UUID conversationId, UUID membreId) {
return count("conversation.id = ?1 AND membre.id = ?2 AND actif = true",
conversationId, membreId) > 0;
}
}

View File

@@ -1,72 +1,80 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour Conversation
* Repository pour les conversations de la messagerie.
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class ConversationRepository implements PanacheRepositoryBase<Conversation, UUID> {
/**
* Trouve toutes les conversations d'un membre
* Trouve une conversation par son ID avec Optional.
*/
public List<Conversation> findByParticipant(UUID membreId, boolean includeArchived) {
String query = """
SELECT DISTINCT c FROM Conversation c
JOIN c.participants p
WHERE p.id = :membreId
AND (c.actif IS NULL OR c.actif = true)
""";
if (!includeArchived) {
query += " AND c.isArchived = false";
}
query += " ORDER BY c.updatedAt DESC NULLS LAST, c.dateCreation DESC";
return getEntityManager()
.createQuery(query, Conversation.class)
.setParameter("membreId", membreId)
.getResultList();
public Optional<Conversation> findConversationById(UUID id) {
return find("id", id).firstResultOptional();
}
/**
* Trouve une conversation par ID et vérifie que le membre en fait partie
* Liste toutes les conversations d'un membre (via les participants).
* Triées par date du dernier message décroissante.
*/
public Optional<Conversation> findByIdAndParticipant(UUID conversationId, UUID membreId) {
String query = """
SELECT DISTINCT c FROM Conversation c
JOIN c.participants p
WHERE c.id = :conversationId
AND p.id = :membreId
AND (c.actif IS NULL OR c.actif = true)
""";
return getEntityManager()
.createQuery(query, Conversation.class)
.setParameter("conversationId", conversationId)
.setParameter("membreId", membreId)
.getResultStream()
.findFirst();
public List<Conversation> findByMembreId(UUID membreId) {
return find(
"SELECT DISTINCT c FROM Conversation c " +
"JOIN c.participants p " +
"WHERE p.membre.id = ?1 AND p.actif = true " +
"ORDER BY c.dernierMessageAt DESC NULLS LAST",
membreId
).list();
}
/**
* Trouve les conversations d'une organisation
* Trouve une conversation directe existante entre deux membres dans une organisation.
*/
public List<Conversation> findByOrganisation(UUID organisationId) {
return find("organisation.id = ?1 AND (actif IS NULL OR actif = true) ORDER BY updatedAt DESC NULLS LAST", organisationId)
.list();
public Optional<Conversation> findConversationDirecte(UUID membreAId, UUID membreBId, UUID organisationId) {
return find(
"SELECT DISTINCT c FROM Conversation c " +
"JOIN c.participants p1 " +
"JOIN c.participants p2 " +
"WHERE c.typeConversation = 'DIRECTE' " +
"AND c.organisation.id = ?3 " +
"AND p1.membre.id = ?1 AND p1.actif = true " +
"AND p2.membre.id = ?2 AND p2.actif = true",
membreAId, membreBId, organisationId
).firstResultOptional();
}
/**
* Trouve le canal d'un rôle dans une organisation.
*/
public Optional<Conversation> findCanalRole(UUID organisationId, String roleCible) {
return find(
"organisation.id = ?1 AND roleCible = ?2 AND typeConversation = 'ROLE_CANAL' AND actif = true",
organisationId, roleCible
).firstResultOptional();
}
/**
* Liste les conversations actives d'un membre.
*/
public List<Conversation> findActivesByMembre(UUID membreId) {
return find(
"SELECT DISTINCT c FROM Conversation c " +
"JOIN c.participants p " +
"WHERE p.membre.id = ?1 AND p.actif = true AND c.statut = ?2 " +
"ORDER BY c.dernierMessageAt DESC NULLS LAST",
membreId, StatutConversation.ACTIVE
).list();
}
}

View File

@@ -0,0 +1,51 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.MemberBlock;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour les blocages entre membres.
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class MemberBlockRepository implements PanacheRepositoryBase<MemberBlock, UUID> {
/**
* Vérifie si un membre en a bloqué un autre dans une organisation.
*/
public boolean estBloque(UUID bloqueurId, UUID bloqueId, UUID organisationId) {
return count("bloqueur.id = ?1 AND bloque.id = ?2 AND organisation.id = ?3 AND actif = true",
bloqueurId, bloqueId, organisationId) > 0;
}
/**
* Trouve le blocage entre deux membres dans une organisation.
*/
public Optional<MemberBlock> findBlocage(UUID bloqueurId, UUID bloqueId, UUID organisationId) {
return find("bloqueur.id = ?1 AND bloque.id = ?2 AND organisation.id = ?3 AND actif = true",
bloqueurId, bloqueId, organisationId
).firstResultOptional();
}
/**
* Liste tous les membres bloqués par un membre dans toutes ses organisations.
*/
public List<MemberBlock> findByBloqueur(UUID bloqueurId) {
return find("bloqueur.id = ?1 AND actif = true", bloqueurId).list();
}
/**
* Liste les blocages actifs d'un membre dans une organisation spécifique.
*/
public List<MemberBlock> findByBloqueurEtOrganisation(UUID bloqueurId, UUID organisationId) {
return find("bloqueur.id = ?1 AND organisation.id = ?2 AND actif = true",
bloqueurId, organisationId).list();
}
}

View File

@@ -2,65 +2,77 @@ package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.Message;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import io.quarkus.panache.common.Page;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour Message
* Repository pour les messages de la messagerie.
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class MessageRepository implements PanacheRepositoryBase<Message, UUID> {
/**
* Trouve tous les messages d'une conversation (non supprimés)
* Trouve un message par son ID avec Optional.
*/
public List<Message> findByConversation(UUID conversationId, int limit) {
return find(
"conversation.id = ?1 AND isDeleted = false AND (actif IS NULL OR actif = true) ORDER BY dateCreation DESC",
conversationId
)
.page(0, limit)
.list();
public Optional<Message> findMessageById(UUID id) {
return find("id", id).firstResultOptional();
}
/**
* Compte les messages non lus d'une conversation pour un membre
* Récupère les messages d'une conversation, paginés, du plus récent au plus ancien.
*
* @param conversationId ID de la conversation
* @param page numéro de page (0-based)
* @param size nombre de messages par page
*/
public long countUnreadByConversationAndMember(UUID conversationId, UUID membreId) {
// Pour simplifier, on compte les messages SENT/DELIVERED (pas READ)
// et dont le sender n'est PAS le membre en question
public List<Message> findByConversationPagine(UUID conversationId, int page, int size) {
return find(
"conversation.id = ?1 AND actif = true ORDER BY dateCreation DESC",
conversationId
).page(Page.of(page, size)).list();
}
/**
* Compte les messages non lus dans une conversation pour un membre donné.
* Un message est non lu si sa dateCreation est postérieure au luJusqua du participant.
*/
public long countNonLus(UUID conversationId, UUID membreId) {
return count(
"conversation.id = ?1 AND sender.id != ?2 AND status IN ('SENT', 'DELIVERED') AND isDeleted = false",
conversationId,
membreId
"SELECT COUNT(m) FROM Message m, ConversationParticipant p " +
"WHERE m.conversation.id = ?1 " +
"AND p.conversation.id = ?1 " +
"AND p.membre.id = ?2 " +
"AND m.actif = true " +
"AND (p.luJusqua IS NULL OR m.dateCreation > p.luJusqua) " +
"AND m.expediteur.id <> ?2",
conversationId, membreId
);
}
/**
* Marque tous les messages d'une conversation comme lus pour un membre
* Récupère les messages non supprimés d'une conversation (pour les tests).
*/
public int markAllAsReadByConversationAndMember(UUID conversationId, UUID membreId) {
return update(
"status = 'READ', readAt = CURRENT_TIMESTAMP WHERE conversation.id = ?1 AND sender.id != ?2 AND status != 'READ' AND isDeleted = false",
conversationId,
membreId
);
}
/**
* Trouve le dernier message d'une conversation
*/
public Message findLastByConversation(UUID conversationId) {
public List<Message> findActifsByConversation(UUID conversationId) {
return find(
"conversation.id = ?1 AND isDeleted = false ORDER BY dateCreation DESC",
"conversation.id = ?1 AND actif = true ORDER BY dateCreation ASC",
conversationId
)
.firstResult();
).list();
}
/**
* Trouve le dernier message actif d'une conversation.
*/
public Optional<Message> findDernierMessage(UUID conversationId) {
return find(
"conversation.id = ?1 AND actif = true ORDER BY dateCreation DESC",
conversationId
).firstResultOptional();
}
}

View File

@@ -1,10 +1,7 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.api.enums.paiement.MethodePaiement;
import dev.lions.unionflow.server.api.enums.paiement.StatutPaiement;
import dev.lions.unionflow.server.entity.Paiement;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import java.math.BigDecimal;
@@ -14,7 +11,7 @@ import java.util.Optional;
import java.util.UUID;
/**
* Repository pour l'entité Paiement
* Repository pour l'entité Paiement.
*
* @author UnionFlow Team
* @version 3.0
@@ -23,90 +20,57 @@ import java.util.UUID;
@ApplicationScoped
public class PaiementRepository implements PanacheRepositoryBase<Paiement, UUID> {
/**
* Trouve un paiement par son UUID
*
* @param id UUID du paiement
* @return Paiement ou Optional.empty()
*/
/** Trouve un paiement actif par son UUID. */
public Optional<Paiement> findPaiementById(UUID id) {
return find("id = ?1 AND actif = true", id).firstResultOptional();
}
/**
* Trouve un paiement par son numéro de référence
*
* @param numeroReference Numéro de référence
* @return Paiement ou Optional.empty()
*/
/** Trouve un paiement par son numéro de référence. */
public Optional<Paiement> findByNumeroReference(String numeroReference) {
return find("numeroReference", numeroReference).firstResultOptional();
}
/**
* Trouve tous les paiements d'un membre
*
* @param membreId ID du membre
* @return Liste des paiements
*/
/** Tous les paiements actifs d'un membre, triés par date décroissante. */
public List<Paiement> findByMembreId(UUID membreId) {
return find("membre.id = ?1 AND actif = true", Sort.by("datePaiement", Sort.Direction.Descending), membreId)
return find(
"membre.id = ?1 AND actif = true",
Sort.by("datePaiement", Sort.Direction.Descending),
membreId)
.list();
}
/**
* Trouve les paiements par statut
*
* @param statut Statut du paiement
* @return Liste des paiements
*/
public List<Paiement> findByStatut(StatutPaiement statut) {
return find("statutPaiement = ?1 AND actif = true", Sort.by("datePaiement", Sort.Direction.Descending), statut.name())
/** Paiements actifs par statut (valeur String), triés par date décroissante. */
public List<Paiement> findByStatut(String statut) {
return find(
"statutPaiement = ?1 AND actif = true",
Sort.by("datePaiement", Sort.Direction.Descending),
statut)
.list();
}
/**
* Trouve les paiements par méthode
*
* @param methode Méthode de paiement
* @return Liste des paiements
*/
public List<Paiement> findByMethode(MethodePaiement methode) {
return find("methodePaiement = ?1 AND actif = true", Sort.by("datePaiement", Sort.Direction.Descending), methode.name())
/** Paiements actifs par méthode (valeur String), triés par date décroissante. */
public List<Paiement> findByMethode(String methode) {
return find(
"methodePaiement = ?1 AND actif = true",
Sort.by("datePaiement", Sort.Direction.Descending),
methode)
.list();
}
/**
* Trouve les paiements validés dans une période
*
* @param dateDebut Date de début
* @param dateFin Date de fin
* @return Liste des paiements
*/
/** Paiements validés dans une période, triés par date de validation décroissante. */
public List<Paiement> findValidesParPeriode(LocalDateTime dateDebut, LocalDateTime dateFin) {
return find(
"statutPaiement = ?1 AND dateValidation >= ?2 AND dateValidation <= ?3 AND actif = true",
"statutPaiement = 'VALIDE' AND dateValidation >= ?1 AND dateValidation <= ?2 AND actif = true",
Sort.by("dateValidation", Sort.Direction.Descending),
StatutPaiement.VALIDE.name(),
dateDebut,
dateFin)
.list();
}
/**
* Calcule le montant total des paiements validés dans une période
*
* @param dateDebut Date de début
* @param dateFin Date de fin
* @return Montant total
*/
/** Somme des montants validés sur une période. */
public BigDecimal calculerMontantTotalValides(LocalDateTime dateDebut, LocalDateTime dateFin) {
List<Paiement> paiements = findValidesParPeriode(dateDebut, dateFin);
return paiements.stream()
return findValidesParPeriode(dateDebut, dateFin).stream()
.map(Paiement::getMontant)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}

View File

@@ -0,0 +1,66 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.SystemConfigPersistence;
import io.quarkus.arc.Unremovable;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.util.Optional;
/**
* Repository pour la persistance des paramètres système en base de données.
* Remplace le stockage AtomicReference en RAM pour les clés critiques
* (maintenance_mode, scheduled_maintenance, etc.).
*
* Étend BaseRepository pour cohérence avec le reste du projet et accès
* à EntityManager.
*/
@ApplicationScoped
@Unremovable
public class SystemConfigPersistenceRepository extends BaseRepository<SystemConfigPersistence> {
public SystemConfigPersistenceRepository() {
super(SystemConfigPersistence.class);
}
/**
* Cherche une entrée de configuration par clé.
*/
public Optional<SystemConfigPersistence> findByKey(String key) {
return find("configKey", key).firstResultOptional();
}
/**
* Crée ou met à jour une valeur de configuration.
*/
@Transactional
public void setValue(String key, String value) {
Optional<SystemConfigPersistence> existing = findByKey(key);
if (existing.isPresent()) {
existing.get().setConfigValue(value);
persist(existing.get());
} else {
persist(SystemConfigPersistence.builder()
.configKey(key)
.configValue(value)
.build());
}
}
/**
* Retourne la valeur d'une clé, ou {@code defaultValue} si absente.
*/
public String getValue(String key, String defaultValue) {
return findByKey(key)
.map(SystemConfigPersistence::getConfigValue)
.orElse(defaultValue);
}
/**
* Retourne la valeur booléenne d'une clé, ou {@code defaultValue} si absente.
*/
public boolean getBooleanValue(String key, boolean defaultValue) {
String val = getValue(key, null);
return val != null ? Boolean.parseBoolean(val) : defaultValue;
}
}

View File

@@ -6,6 +6,7 @@ import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.TypedQuery;
import jakarta.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@@ -150,8 +151,10 @@ public class SystemLogRepository extends BaseRepository<SystemLog> {
}
/**
* Supprimer les logs plus anciens qu'une date donnée (rotation)
* Supprimer les logs plus anciens qu'une date donnée (rotation).
* Requiert une transaction active — DELETE via JPQL doit être transactionnel.
*/
@Transactional
public int deleteOlderThan(LocalDateTime threshold) {
return entityManager.createQuery(
"DELETE FROM SystemLog l WHERE l.timestamp < :threshold"

View File

@@ -0,0 +1,79 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.api.enums.paiement.MethodePaiement;
import dev.lions.unionflow.server.api.enums.paiement.StatutPaiement;
import dev.lions.unionflow.server.entity.Versement;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository pour l'entité {@link Versement}.
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class VersementRepository implements PanacheRepositoryBase<Versement, UUID> {
/** Trouve un versement actif par UUID. */
public Optional<Versement> findVersementById(UUID id) {
return find("id = ?1 AND actif = true", id).firstResultOptional();
}
/** Trouve un versement par son numéro de référence. */
public Optional<Versement> findByNumeroReference(String numeroReference) {
return find("numeroReference", numeroReference).firstResultOptional();
}
/** Liste tous les versements actifs d'un membre, les plus récents d'abord. */
public List<Versement> findByMembreId(UUID membreId) {
return find(
"membre.id = ?1 AND actif = true",
Sort.by("datePaiement", Sort.Direction.Descending),
membreId
).list();
}
/** Liste les versements par statut. */
public List<Versement> findByStatut(StatutPaiement statut) {
return find(
"statutPaiement = ?1 AND actif = true",
Sort.by("datePaiement", Sort.Direction.Descending),
statut.name()
).list();
}
/** Liste les versements par méthode. */
public List<Versement> findByMethode(MethodePaiement methode) {
return find(
"methodePaiement = ?1 AND actif = true",
Sort.by("datePaiement", Sort.Direction.Descending),
methode.name()
).list();
}
/** Liste les versements confirmés dans une période donnée. */
public List<Versement> findConfirmesParPeriode(LocalDateTime dateDebut, LocalDateTime dateFin) {
return find(
"statutPaiement IN ('CONFIRME', 'VALIDE') "
+ "AND dateValidation >= ?1 AND dateValidation <= ?2 AND actif = true",
Sort.by("dateValidation", Sort.Direction.Descending),
dateDebut,
dateFin
).list();
}
/** Calcule le montant total des versements confirmés dans une période. */
public BigDecimal calculerMontantTotalConfirmes(LocalDateTime dateDebut, LocalDateTime dateFin) {
return findConfirmesParPeriode(dateDebut, dateFin).stream()
.map(Versement::getMontant)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}

View File

@@ -1,145 +0,0 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.communication.request.CreateConversationRequest;
import dev.lions.unionflow.server.api.dto.communication.response.ConversationResponse;
import dev.lions.unionflow.server.service.ConversationService;
import dev.lions.unionflow.server.service.support.SecuriteHelper;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Resource REST pour la gestion des conversations
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
*/
@Path("/api/conversations")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Communication", description = "Gestion des conversations et messages")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "MEMBRE", "ADMIN_ORGANISATION"})
public class ConversationResource {
private static final Logger LOG = Logger.getLogger(ConversationResource.class);
@Inject
ConversationService conversationService;
@Inject
SecuriteHelper securiteHelper;
/**
* Liste les conversations de l'utilisateur connecté
*/
@GET
@Operation(summary = "Lister mes conversations")
@APIResponse(responseCode = "200", description = "Liste des conversations")
public Response getConversations(
@Parameter(description = "Inclure conversations archivées")
@QueryParam("includeArchived") @DefaultValue("false") boolean includeArchived,
@Parameter(description = "Filtrer par organisation")
@QueryParam("organisationId") String organisationId
) {
UUID membreId = securiteHelper.resolveMembreId();
UUID orgId = organisationId != null ? UUID.fromString(organisationId) : null;
List<ConversationResponse> conversations = conversationService.getConversations(membreId, orgId, includeArchived);
return Response.ok(conversations).build();
}
/**
* Récupère une conversation par ID
*/
@GET
@Path("/{id}")
@Operation(summary = "Récupérer une conversation")
@APIResponse(responseCode = "200", description = "Conversation trouvée")
@APIResponse(responseCode = "404", description = "Conversation non trouvée")
public Response getConversationById(@PathParam("id") UUID conversationId) {
UUID membreId = securiteHelper.resolveMembreId();
ConversationResponse conversation = conversationService.getConversationById(conversationId, membreId);
return Response.ok(conversation).build();
}
/**
* Crée une nouvelle conversation
*/
@POST
@Operation(summary = "Créer une conversation")
@APIResponse(responseCode = "201", description = "Conversation créée")
@APIResponse(responseCode = "400", description = "Données invalides")
public Response createConversation(@Valid CreateConversationRequest request) {
UUID creatorId = securiteHelper.resolveMembreId();
ConversationResponse conversation = conversationService.createConversation(request, creatorId);
return Response.status(Response.Status.CREATED).entity(conversation).build();
}
/**
* Archive une conversation
*/
@PUT
@Path("/{id}/archive")
@Operation(summary = "Archiver/désarchiver une conversation")
@APIResponse(responseCode = "204", description = "Conversation archivée")
public Response archiveConversation(
@PathParam("id") UUID conversationId,
@QueryParam("archive") @DefaultValue("true") boolean archive
) {
UUID membreId = securiteHelper.resolveMembreId();
conversationService.archiveConversation(conversationId, membreId, archive);
return Response.noContent().build();
}
/**
* Marque une conversation comme lue
*/
@PUT
@Path("/{id}/mark-read")
@Operation(summary = "Marquer conversation comme lue")
@APIResponse(responseCode = "204", description = "Marquée comme lue")
public Response markAsRead(@PathParam("id") UUID conversationId) {
UUID membreId = securiteHelper.resolveMembreId();
conversationService.markAsRead(conversationId, membreId);
return Response.noContent().build();
}
/**
* Toggle mute conversation
*/
@PUT
@Path("/{id}/toggle-mute")
@Operation(summary = "Activer/désactiver le son")
@APIResponse(responseCode = "204", description = "Paramètre modifié")
public Response toggleMute(@PathParam("id") UUID conversationId) {
UUID membreId = securiteHelper.resolveMembreId();
conversationService.toggleMute(conversationId, membreId);
return Response.noContent().build();
}
/**
* Toggle pin conversation
*/
@PUT
@Path("/{id}/toggle-pin")
@Operation(summary = "Épingler/désépingler")
@APIResponse(responseCode = "204", description = "Paramètre modifié")
public Response togglePin(@PathParam("id") UUID conversationId) {
UUID membreId = securiteHelper.resolveMembreId();
conversationService.togglePin(conversationId, membreId);
return Response.noContent().build();
}
}

View File

@@ -1,120 +0,0 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.communication.request.SendMessageRequest;
import dev.lions.unionflow.server.api.dto.communication.response.MessageResponse;
import dev.lions.unionflow.server.service.MessageService;
import dev.lions.unionflow.server.service.support.SecuriteHelper;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Resource REST pour la gestion des messages
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
*/
@Path("/api/messages")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Communication", description = "Gestion des conversations et messages")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "MEMBRE", "ADMIN_ORGANISATION"})
public class MessageResource {
private static final Logger LOG = Logger.getLogger(MessageResource.class);
@Inject
MessageService messageService;
@Inject
SecuriteHelper securiteHelper;
/**
* Récupère les messages d'une conversation
*/
@GET
@Operation(summary = "Lister les messages d'une conversation")
@APIResponse(responseCode = "200", description = "Liste des messages")
@APIResponse(responseCode = "404", description = "Conversation non trouvée")
public Response getMessages(
@Parameter(description = "ID de la conversation", required = true)
@QueryParam("conversationId") UUID conversationId,
@Parameter(description = "Nombre maximum de messages")
@QueryParam("limit") @DefaultValue("50") int limit
) {
if (conversationId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "conversationId requis"))
.build();
}
UUID membreId = securiteHelper.resolveMembreId();
List<MessageResponse> messages = messageService.getMessages(conversationId, membreId, limit);
return Response.ok(messages).build();
}
/**
* Envoie un message
*/
@POST
@Operation(summary = "Envoyer un message")
@APIResponse(responseCode = "201", description = "Message envoyé")
@APIResponse(responseCode = "400", description = "Données invalides")
@APIResponse(responseCode = "404", description = "Conversation non trouvée")
public Response sendMessage(@Valid SendMessageRequest request) {
UUID senderId = securiteHelper.resolveMembreId();
MessageResponse message = messageService.sendMessage(request, senderId);
return Response.status(Response.Status.CREATED).entity(message).build();
}
/**
* Édite un message
*/
@PUT
@Path("/{id}")
@Operation(summary = "Éditer un message")
@APIResponse(responseCode = "200", description = "Message édité")
@APIResponse(responseCode = "404", description = "Message non trouvé")
public Response editMessage(
@PathParam("id") UUID messageId,
Map<String, String> body
) {
String newContent = body.get("content");
if (newContent == null || newContent.isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "content requis"))
.build();
}
UUID senderId = securiteHelper.resolveMembreId();
MessageResponse message = messageService.editMessage(messageId, senderId, newContent);
return Response.ok(message).build();
}
/**
* Supprime un message
*/
@DELETE
@Path("/{id}")
@Operation(summary = "Supprimer un message")
@APIResponse(responseCode = "204", description = "Message supprimé")
@APIResponse(responseCode = "404", description = "Message non trouvé")
public Response deleteMessage(@PathParam("id") UUID messageId) {
UUID senderId = securiteHelper.resolveMembreId();
messageService.deleteMessage(messageId, senderId);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,200 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.messagerie.request.BloquerMembreRequest;
import dev.lions.unionflow.server.api.dto.messagerie.request.DemarrerConversationDirecteRequest;
import dev.lions.unionflow.server.api.dto.messagerie.request.DemarrerConversationRoleRequest;
import dev.lions.unionflow.server.api.dto.messagerie.request.EnvoyerMessageRequest;
import dev.lions.unionflow.server.api.dto.messagerie.request.MettreAJourPolitiqueRequest;
import dev.lions.unionflow.server.api.dto.messagerie.response.ContactPolicyResponse;
import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationResponse;
import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationSummaryResponse;
import dev.lions.unionflow.server.api.dto.messagerie.response.MessageResponse;
import dev.lions.unionflow.server.service.MessagingService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
/**
* Resource REST pour la messagerie instantanée.
*
* <p>Endpoints :
* <ul>
* <li>POST /api/messagerie/conversations/directe — démarrer conversation 1-1</li>
* <li>POST /api/messagerie/conversations/role — contacter un rôle officiel</li>
* <li>GET /api/messagerie/conversations — mes conversations</li>
* <li>GET /api/messagerie/conversations/{id} — détail + messages</li>
* <li>DELETE /api/messagerie/conversations/{id} — archiver</li>
* <li>POST /api/messagerie/conversations/{id}/messages — envoyer un message</li>
* <li>GET /api/messagerie/conversations/{id}/messages — historique</li>
* <li>PUT /api/messagerie/conversations/{id}/lire — marquer comme lu</li>
* <li>DELETE /api/messagerie/conversations/{cId}/messages/{mId} — supprimer message</li>
* <li>POST /api/messagerie/blocages — bloquer un membre</li>
* <li>DELETE /api/messagerie/blocages/{membreId} — débloquer</li>
* <li>GET /api/messagerie/blocages — mes blocages</li>
* <li>GET /api/messagerie/politique/{orgId} — politique de communication</li>
* <li>PUT /api/messagerie/politique/{orgId} — mettre à jour (ADMIN)</li>
* </ul>
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Path("/api/messagerie")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"})
@Tag(name = "Messagerie", description = "Messagerie instantanée — conversations, messages, notes vocales")
public class MessagingResource {
private static final Logger LOG = Logger.getLogger(MessagingResource.class);
@Inject
MessagingService messagingService;
// ── Conversations ─────────────────────────────────────────────────────────
@POST
@Path("/conversations/directe")
public Response demarrerConversationDirecte(@Valid DemarrerConversationDirecteRequest request) {
LOG.infof("POST /api/messagerie/conversations/directe → destinataire: %s", request.destinataireId());
ConversationResponse result = messagingService.demarrerConversationDirecte(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
@POST
@Path("/conversations/role")
public Response demarrerConversationRole(@Valid DemarrerConversationRoleRequest request) {
LOG.infof("POST /api/messagerie/conversations/role → rôle: %s, org: %s",
request.roleCible(), request.organisationId());
ConversationResponse result = messagingService.demarrerConversationRole(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
@GET
@Path("/conversations")
public Response getMesConversations() {
LOG.debug("GET /api/messagerie/conversations");
List<ConversationSummaryResponse> result = messagingService.getMesConversations();
return Response.ok(result).build();
}
@GET
@Path("/conversations/{id}")
public Response getConversation(@PathParam("id") UUID id) {
LOG.infof("GET /api/messagerie/conversations/%s", id);
ConversationResponse result = messagingService.getConversation(id);
return Response.ok(result).build();
}
@DELETE
@Path("/conversations/{id}")
public Response archiverConversation(@PathParam("id") UUID id) {
LOG.infof("DELETE /api/messagerie/conversations/%s", id);
ConversationResponse result = messagingService.archiverConversation(id);
return Response.ok(result).build();
}
// ── Messages ──────────────────────────────────────────────────────────────
@POST
@Path("/conversations/{id}/messages")
public Response envoyerMessage(
@PathParam("id") UUID conversationId,
@Valid EnvoyerMessageRequest request) {
LOG.infof("POST /api/messagerie/conversations/%s/messages", conversationId);
MessageResponse result = messagingService.envoyerMessage(conversationId, request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
@GET
@Path("/conversations/{id}/messages")
public Response getMessages(
@PathParam("id") UUID conversationId,
@QueryParam("page") @DefaultValue("0") int page) {
LOG.infof("GET /api/messagerie/conversations/%s/messages?page=%d", conversationId, page);
List<MessageResponse> result = messagingService.getMessages(conversationId, page);
return Response.ok(result).build();
}
@PUT
@Path("/conversations/{id}/lire")
public Response marquerLu(@PathParam("id") UUID conversationId) {
LOG.infof("PUT /api/messagerie/conversations/%s/lire", conversationId);
messagingService.marquerConversationLue(conversationId);
return Response.noContent().build();
}
@DELETE
@Path("/conversations/{conversationId}/messages/{messageId}")
public Response supprimerMessage(
@PathParam("conversationId") UUID conversationId,
@PathParam("messageId") UUID messageId) {
LOG.infof("DELETE /api/messagerie/conversations/%s/messages/%s", conversationId, messageId);
messagingService.supprimerMessage(conversationId, messageId);
return Response.noContent().build();
}
// ── Blocages ──────────────────────────────────────────────────────────────
@POST
@Path("/blocages")
public Response bloquerMembre(@Valid BloquerMembreRequest request) {
LOG.infof("POST /api/messagerie/blocages → bloquer: %s", request.membreABloquerId());
messagingService.bloquerMembre(request);
return Response.noContent().build();
}
@DELETE
@Path("/blocages/{membreId}")
public Response debloquerMembre(
@PathParam("membreId") UUID membreId,
@QueryParam("organisationId") UUID organisationId) {
LOG.infof("DELETE /api/messagerie/blocages/%s", membreId);
messagingService.debloquerMembre(membreId, organisationId);
return Response.noContent().build();
}
@GET
@Path("/blocages")
public Response getMesBlocages() {
LOG.debug("GET /api/messagerie/blocages");
return Response.ok(messagingService.getMesBlocages()).build();
}
// ── Politique de communication ────────────────────────────────────────────
@GET
@Path("/politique/{organisationId}")
public Response getPolitique(@PathParam("organisationId") UUID organisationId) {
LOG.infof("GET /api/messagerie/politique/%s", organisationId);
ContactPolicyResponse result = messagingService.getPolitique(organisationId);
return Response.ok(result).build();
}
@PUT
@Path("/politique/{organisationId}")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
public Response mettreAJourPolitique(
@PathParam("organisationId") UUID organisationId,
@Valid MettreAJourPolitiqueRequest request) {
LOG.infof("PUT /api/messagerie/politique/%s", organisationId);
ContactPolicyResponse result = messagingService.mettreAJourPolitique(organisationId, request);
return Response.ok(result).build();
}
}

View File

@@ -1,6 +1,10 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.paiement.request.CreatePaiementRequest;
import dev.lions.unionflow.server.api.dto.paiement.request.DeclarerPaiementManuelRequest;
import dev.lions.unionflow.server.api.dto.paiement.request.InitierPaiementEnLigneRequest;
import dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementGatewayResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse;
import dev.lions.unionflow.server.service.PaiementService;
@@ -16,193 +20,138 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
/**
* Resource REST pour la gestion des paiements
* Resource REST pour la gestion des paiements (Wave Checkout et paiements manuels).
*
* <p>Endpoints principaux :
* <ul>
* <li>{@code POST /api/paiements/initier-paiement-en-ligne} — démarre le flux Wave QR code</li>
* <li>{@code GET /api/paiements/statut-intention/{intentionId}} — polling du statut Wave</li>
* <li>{@code POST /api/paiements/declarer-manuel} — paiement manuel (espèces/virement)</li>
* <li>{@code GET /api/paiements/mon-historique} — historique du membre connecté</li>
* </ul>
*
* @author UnionFlow Team
* @version 3.0
* @since 2025-01-29
* @since 2026-04-13
*/
@Path("/api/paiements")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" })
@Tag(name = "Paiements", description = "Gestion des paiements : création, validation et suivi")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER"})
@Tag(name = "Paiements", description = "Paiements de cotisations — Wave Checkout et manuel")
public class PaiementResource {
private static final Logger LOG = Logger.getLogger(PaiementResource.class);
private static final Logger LOG = Logger.getLogger(PaiementResource.class);
@Inject
PaiementService paiementService;
@Inject
PaiementService paiementService;
/**
* Crée un nouveau paiement
*
* @param request DTO du paiement à créer
* @return Paiement créé
*/
@POST
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
public Response creerPaiement(@Valid CreatePaiementRequest request) {
LOG.infof("POST /api/paiements - Création paiement: %s", request.numeroReference());
PaiementResponse result = paiementService.creerPaiement(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
// ── Lecture ───────────────────────────────────────────────────────────────
/**
* Valide un paiement
*
* @param id ID du paiement
* @return Paiement validé
*/
@POST
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
@Path("/{id}/valider")
public Response validerPaiement(@PathParam("id") UUID id) {
LOG.infof("POST /api/paiements/%s/valider", id);
PaiementResponse result = paiementService.validerPaiement(id);
return Response.ok(result).build();
}
@GET
@Path("/{id}")
public Response trouverParId(@PathParam("id") UUID id) {
LOG.infof("GET /api/paiements/%s", id);
PaiementResponse result = paiementService.trouverParId(id);
return Response.ok(result).build();
}
/**
* Annule un paiement
*
* @param id ID du paiement
* @return Paiement annulé
*/
@POST
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
@Path("/{id}/annuler")
public Response annulerPaiement(@PathParam("id") UUID id) {
LOG.infof("POST /api/paiements/%s/annuler", id);
PaiementResponse result = paiementService.annulerPaiement(id);
return Response.ok(result).build();
}
@GET
@Path("/reference/{numeroReference}")
public Response trouverParNumeroReference(
@PathParam("numeroReference") String numeroReference) {
LOG.infof("GET /api/paiements/reference/%s", numeroReference);
PaiementResponse result = paiementService.trouverParNumeroReference(numeroReference);
return Response.ok(result).build();
}
/**
* Trouve un paiement par son ID
*
* @param id ID du paiement
* @return Paiement
*/
@GET
@Path("/{id}")
public Response trouverParId(@PathParam("id") UUID id) {
LOG.infof("GET /api/paiements/%s", id);
PaiementResponse result = paiementService.trouverParId(id);
return Response.ok(result).build();
}
@GET
@Path("/membre/{membreId}")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
public Response listerParMembre(@PathParam("membreId") UUID membreId) {
LOG.infof("GET /api/paiements/membre/%s", membreId);
List<PaiementSummaryResponse> result = paiementService.listerParMembre(membreId);
return Response.ok(result).build();
}
/**
* Trouve un paiement par son numéro de référence
*
* @param numeroReference Numéro de référence
* @return Paiement
*/
@GET
@Path("/reference/{numeroReference}")
public Response trouverParNumeroReference(@PathParam("numeroReference") String numeroReference) {
LOG.infof("GET /api/paiements/reference/%s", numeroReference);
PaiementResponse result = paiementService.trouverParNumeroReference(numeroReference);
return Response.ok(result).build();
}
@GET
@Path("/mon-historique")
@RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION"})
public Response getMonHistoriquePaiements(
@QueryParam("limit") @DefaultValue("20") int limit) {
LOG.infof("GET /api/paiements/mon-historique?limit=%d", limit);
List<PaiementSummaryResponse> result = paiementService.getMonHistoriquePaiements(limit);
return Response.ok(result).build();
}
/**
* Liste tous les paiements d'un membre
*
* @param membreId ID du membre
* @return Liste des paiements
*/
@GET
@Path("/membre/{membreId}")
public Response listerParMembre(@PathParam("membreId") UUID membreId) {
LOG.infof("GET /api/paiements/membre/%s", membreId);
List<PaiementSummaryResponse> result = paiementService.listerParMembre(membreId);
return Response.ok(result).build();
}
// ── Administration ────────────────────────────────────────────────────────
/**
* Liste l'historique des paiements du membre connecté (auto-détection).
* Utilisé par la page personnelle "Payer mes Cotisations".
*
* @param limit Nombre maximum de paiements à retourner (défaut : 5)
* @return Liste des derniers paiements
*/
@GET
@Path("/mes-paiements/historique")
@RolesAllowed({ "MEMBRE", "ADMIN", "ADMIN_ORGANISATION" })
public Response getMonHistoriquePaiements(
@QueryParam("limit") @DefaultValue("5") int limit) {
LOG.infof("GET /api/paiements/mes-paiements/historique?limit=%d", limit);
List<PaiementSummaryResponse> result = paiementService.getMonHistoriquePaiements(limit);
return Response.ok(result).build();
}
@POST
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
public Response creerPaiement(@Valid CreatePaiementRequest request) {
LOG.infof("POST /api/paiements — référence: %s", request.numeroReference());
PaiementResponse result = paiementService.creerPaiement(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
/**
* Initie un paiement en ligne via un gateway (Wave, Orange Money, Free Money, Carte).
* Retourne l'URL de redirection vers le gateway.
*
* @param request Données du paiement en ligne
* @return URL de redirection + transaction ID
*/
@POST
@Path("/initier-paiement-en-ligne")
@RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER" })
public Response initierPaiementEnLigne(@Valid dev.lions.unionflow.server.api.dto.paiement.request.InitierPaiementEnLigneRequest request) {
LOG.infof("POST /api/paiements/initier-paiement-en-ligne - cotisation: %s, méthode: %s",
request.cotisationId(), request.methodePaiement());
dev.lions.unionflow.server.api.dto.paiement.response.PaiementGatewayResponse result =
paiementService.initierPaiementEnLigne(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
@POST
@Path("/{id}/valider")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
public Response validerPaiement(@PathParam("id") UUID id) {
LOG.infof("POST /api/paiements/%s/valider", id);
PaiementResponse result = paiementService.validerPaiement(id);
return Response.ok(result).build();
}
/**
* Initie un dépôt sur compte épargne via Wave (même flux que cotisations).
* Retourne wave_launch_url pour ouvrir l'app Wave puis retour deep link.
*/
@POST
@Path("/initier-depot-epargne-en-ligne")
@RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER" })
public Response initierDepotEpargneEnLigne(@Valid dev.lions.unionflow.server.api.dto.paiement.request.InitierDepotEpargneRequest request) {
LOG.infof("POST /api/paiements/initier-depot-epargne-en-ligne - compte: %s, montant: %s",
request.compteId(), request.montant());
dev.lions.unionflow.server.api.dto.paiement.response.PaiementGatewayResponse result =
paiementService.initierDepotEpargneEnLigne(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
@POST
@Path("/{id}/annuler")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"})
public Response annulerPaiement(@PathParam("id") UUID id) {
LOG.infof("POST /api/paiements/%s/annuler", id);
PaiementResponse result = paiementService.annulerPaiement(id);
return Response.ok(result).build();
}
/**
* Polling du statut d'une IntentionPaiement Wave.
* Si Wave a confirmé le paiement, réconcilie automatiquement la cotisation (PAYEE) et retourne COMPLETEE.
* Le client web appelle cet endpoint toutes les 3 secondes pendant l'affichage du QR code.
*
* @param intentionId UUID de l'intention (clientReference retourné par initier-paiement-en-ligne)
* @return Statut courant + waveLaunchUrl (pour re-générer le QR si besoin) + message
*/
@GET
@Path("/statut-intention/{intentionId}")
@RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER" })
public Response getStatutIntention(@PathParam("intentionId") java.util.UUID intentionId) {
LOG.infof("GET /api/paiements/statut-intention/%s", intentionId);
dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse result =
paiementService.verifierStatutIntention(intentionId);
return Response.ok(result).build();
}
// ── Flux Wave Checkout (QR code web) ──────────────────────────────────────
/**
* Déclare un paiement manuel (espèces, virement, chèque).
* Le paiement est créé avec le statut EN_ATTENTE_VALIDATION.
* Le trésorier devra le valider via une page admin.
*
* @param request Données du paiement manuel
* @return Paiement créé (statut EN_ATTENTE_VALIDATION)
*/
@POST
@Path("/declarer-paiement-manuel")
@RolesAllowed({ "MEMBRE", "ADMIN", "ADMIN_ORGANISATION" })
public Response declarerPaiementManuel(@Valid dev.lions.unionflow.server.api.dto.paiement.request.DeclarerPaiementManuelRequest request) {
LOG.infof("POST /api/paiements/declarer-paiement-manuel - cotisation: %s, méthode: %s",
request.cotisationId(), request.methodePaiement());
PaiementResponse result = paiementService.declarerPaiementManuel(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
/**
* Initie un paiement Wave via Checkout QR code.
* Le web encode le {@code waveLaunchUrl} en QR code, l'utilisateur le scanne
* depuis l'app Wave. Après confirmation, Wave redirige vers la success URL.
*/
@POST
@Path("/initier-paiement-en-ligne")
@RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "USER"})
public Response initierPaiementEnLigne(@Valid InitierPaiementEnLigneRequest request) {
LOG.infof("POST /api/paiements/initier-paiement-en-ligne — cotisation: %s, méthode: %s",
request.cotisationId(), request.methodePaiement());
PaiementGatewayResponse result = paiementService.initierPaiementEnLigne(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
/**
* Polling du statut d'une intention de paiement Wave.
* Appelé toutes les 3 secondes par le web pendant que l'utilisateur scanne le QR code.
* Retourne {@code confirme=true} dès que Wave confirme le paiement.
*/
@GET
@Path("/statut-intention/{intentionId}")
@RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "USER"})
public Response getStatutIntention(@PathParam("intentionId") UUID intentionId) {
LOG.infof("GET /api/paiements/statut-intention/%s", intentionId);
IntentionStatutResponse result = paiementService.getStatutIntention(intentionId);
return Response.ok(result).build();
}
// ── Flux manuel ───────────────────────────────────────────────────────────
@POST
@Path("/declarer-manuel")
@RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION"})
public Response declarerPaiementManuel(@Valid DeclarerPaiementManuelRequest request) {
LOG.infof("POST /api/paiements/declarer-manuel — cotisation: %s, méthode: %s",
request.cotisationId(), request.methodePaiement());
PaiementResponse result = paiementService.declarerPaiementManuel(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
}

View File

@@ -17,6 +17,8 @@ import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import java.util.Map;
/**
* REST Resource pour la gestion de la configuration système
*/
@@ -120,4 +122,172 @@ public class SystemResource {
log.info("GET /api/system/metrics");
return systemMetricsService.getSystemMetrics();
}
/**
* Optimiser la base de données
*/
@POST
@Path("/database/optimize")
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
@Operation(summary = "Optimiser la base de données (VACUUM ANALYZE)")
public Response optimizeDatabase() {
log.info("POST /api/system/database/optimize");
return Response.ok(systemConfigService.optimizeDatabase()).build();
}
/**
* Forcer la déconnexion globale
*/
@POST
@Path("/auth/logout-all")
@RolesAllowed({"SUPER_ADMIN"})
@Operation(summary = "Forcer la déconnexion de tous les utilisateurs")
public Response forceGlobalLogout() {
log.info("POST /api/system/auth/logout-all");
return Response.ok(systemConfigService.forceGlobalLogout()).build();
}
/**
* Nettoyer les sessions expirées
*/
@POST
@Path("/sessions/cleanup")
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
@Operation(summary = "Nettoyer les sessions expirées")
public Response cleanupSessions() {
log.info("POST /api/system/sessions/cleanup");
return Response.ok(systemConfigService.cleanupSessions()).build();
}
/**
* Nettoyer les anciens logs
*/
@POST
@Path("/logs/cleanup")
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
@Operation(summary = "Nettoyer les anciens logs selon la politique de rétention")
public Response cleanOldLogs() {
log.info("POST /api/system/logs/cleanup");
return Response.ok(systemConfigService.cleanOldLogs()).build();
}
/**
* Purger les données expirées
*/
@POST
@Path("/data/purge")
@RolesAllowed({"SUPER_ADMIN"})
@Operation(summary = "Purger les données expirées (RGPD)")
public Response purgeExpiredData() {
log.info("POST /api/system/data/purge");
return Response.ok(systemConfigService.purgeExpiredData()).build();
}
/**
* Analyser les performances de la base de données
*/
@POST
@Path("/performance/analyze")
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
@Operation(summary = "Analyser les performances du système")
public Response analyzePerformance() {
log.info("POST /api/system/performance/analyze");
return Response.ok(systemConfigService.analyzePerformance()).build();
}
/**
* Créer une sauvegarde
*/
@POST
@Path("/backup/create")
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
@Operation(summary = "Créer une sauvegarde du système")
public Response createBackup() {
log.info("POST /api/system/backup/create");
return Response.ok(systemConfigService.createBackup()).build();
}
/**
* Planifier une maintenance
*/
@POST
@Path("/maintenance/schedule")
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
@Operation(summary = "Planifier une maintenance")
public Response scheduleMaintenance(@QueryParam("scheduledAt") String scheduledAt, @QueryParam("reason") String reason) {
log.info("POST /api/system/maintenance/schedule");
return Response.ok(systemConfigService.scheduleMaintenance(scheduledAt, reason)).build();
}
/**
* Activer la maintenance d'urgence
*/
@POST
@Path("/maintenance/emergency")
@RolesAllowed({"SUPER_ADMIN"})
@Operation(summary = "Activer le mode maintenance d'urgence")
public Response emergencyMaintenance() {
log.info("POST /api/system/maintenance/emergency");
return Response.ok(systemConfigService.emergencyMaintenance()).build();
}
/**
* Vérifier les mises à jour
*/
@GET
@Path("/updates/check")
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
@Operation(summary = "Vérifier les mises à jour disponibles")
public Response checkUpdates() {
log.info("GET /api/system/updates/check");
return Response.ok(systemConfigService.checkUpdates()).build();
}
/**
* Exporter les logs récents
*/
@GET
@Path("/logs/export")
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
@Operation(summary = "Exporter les logs des dernières 24h")
public Response exportLogs() {
log.info("GET /api/system/logs/export");
return Response.ok(systemConfigService.exportLogs()).build();
}
/**
* Générer un rapport d'utilisation
*/
@GET
@Path("/reports/usage")
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
@Operation(summary = "Générer un rapport d'utilisation du système")
public Response generateUsageReport() {
log.info("GET /api/system/reports/usage");
return Response.ok(systemConfigService.generateUsageReport()).build();
}
/**
* Générer un rapport d'audit
*/
@GET
@Path("/audit/report")
@RolesAllowed({"SUPER_ADMIN", "ADMIN", "MODERATEUR"})
@Operation(summary = "Générer un rapport d'audit")
public Response generateAuditReport() {
log.info("GET /api/system/audit/report");
return Response.ok(systemConfigService.generateAuditReport()).build();
}
/**
* Export RGPD
*/
@POST
@Path("/gdpr/export")
@RolesAllowed({"SUPER_ADMIN"})
@Operation(summary = "Initier un export RGPD des données utilisateurs")
public Response exportGDPRData() {
log.info("POST /api/system/gdpr/export");
return Response.ok(systemConfigService.exportGDPRData()).build();
}
}

View File

@@ -0,0 +1,170 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.versement.request.DeclarerVersementManuelRequest;
import dev.lions.unionflow.server.api.dto.versement.request.InitierDepotEpargneRequest;
import dev.lions.unionflow.server.api.dto.versement.request.InitierVersementWaveRequest;
import dev.lions.unionflow.server.api.dto.versement.response.VersementGatewayResponse;
import dev.lions.unionflow.server.api.dto.versement.response.VersementResponse;
import dev.lions.unionflow.server.api.dto.versement.response.VersementStatutResponse;
import dev.lions.unionflow.server.api.dto.versement.response.VersementSummaryResponse;
import dev.lions.unionflow.server.service.VersementService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
/**
* Resource REST pour la gestion des versements.
*
* <p>Endpoints principaux :
* <ul>
* <li>{@code POST /api/versements/initier-wave} — démarre le flux Wave deep link</li>
* <li>{@code GET /api/versements/statut/{intentionId}} — retour deep link / polling</li>
* <li>{@code POST /api/versements/declarer-manuel} — déclaration espèces/virement/chèque</li>
* <li>{@code GET /api/versements/mes-versements} — historique du membre connecté</li>
* </ul>
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@Path("/api/versements")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER"})
@Tag(name = "Versements", description = "Versements de cotisations — Wave et manuel")
public class VersementResource {
private static final Logger LOG = Logger.getLogger(VersementResource.class);
@Inject
VersementService versementService;
// ── Lecture ───────────────────────────────────────────────────────────────
@GET
@Path("/{id}")
public Response trouverParId(@PathParam("id") UUID id) {
LOG.infof("GET /api/versements/%s", id);
VersementResponse result = versementService.trouverParId(id);
return Response.ok(result).build();
}
@GET
@Path("/reference/{numeroReference}")
public Response trouverParNumeroReference(
@PathParam("numeroReference") String numeroReference) {
LOG.infof("GET /api/versements/reference/%s", numeroReference);
VersementResponse result = versementService.trouverParNumeroReference(numeroReference);
return Response.ok(result).build();
}
@GET
@Path("/membre/{membreId}")
public Response listerParMembre(@PathParam("membreId") UUID membreId) {
LOG.infof("GET /api/versements/membre/%s", membreId);
List<VersementSummaryResponse> result = versementService.listerParMembre(membreId);
return Response.ok(result).build();
}
@GET
@Path("/mes-versements")
@RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION"})
public Response getMesVersements(
@QueryParam("limit") @DefaultValue("20") int limit) {
LOG.infof("GET /api/versements/mes-versements?limit=%d", limit);
List<VersementSummaryResponse> result = versementService.getMesVersements(limit);
return Response.ok(result).build();
}
// ── Validation / Annulation ───────────────────────────────────────────────
@POST
@Path("/{id}/valider")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
public Response validerVersement(@PathParam("id") UUID id) {
LOG.infof("POST /api/versements/%s/valider", id);
VersementResponse result = versementService.validerVersement(id);
return Response.ok(result).build();
}
@POST
@Path("/{id}/annuler")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"})
public Response annulerVersement(@PathParam("id") UUID id) {
LOG.infof("POST /api/versements/%s/annuler", id);
VersementResponse result = versementService.annulerVersement(id);
return Response.ok(result).build();
}
// ── Flux Wave ─────────────────────────────────────────────────────────────
/**
* Initie un versement Wave.
*
* <p>Le mobile appelle cet endpoint puis ouvre le {@code waveLaunchUrl} retourné
* avec {@code url_launcher}. Wave s'ouvre avec le montant et le numéro pré-remplis.
* Après confirmation, Wave redirige vers
* {@code unionflow://payment?result=success&ref={clientReference}}.
*/
@POST
@Path("/initier-wave")
@RolesAllowed({"MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER"})
public Response initierVersementWave(@Valid InitierVersementWaveRequest request) {
LOG.infof("POST /api/versements/initier-wave — cotisation: %s", request.cotisationId());
VersementGatewayResponse result = versementService.initierVersementWave(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
/**
* Retour deep link / polling du statut d'un versement Wave.
*
* <p>Appelé par le mobile au retour du deep link
* {@code unionflow://payment?result=success&ref={intentionId}}
* pour confirmer que le paiement est bien enregistré côté UnionFlow.
* Également utilisé par le web en polling toutes les 3 secondes.
*/
@GET
@Path("/statut/{intentionId}")
@RolesAllowed({"MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER"})
public Response getStatutVersement(@PathParam("intentionId") UUID intentionId) {
LOG.infof("GET /api/versements/statut/%s", intentionId);
VersementStatutResponse result = versementService.verifierStatutVersement(intentionId);
return Response.ok(result).build();
}
// ── Flux manuel ───────────────────────────────────────────────────────────
/**
* Déclare un versement manuel (espèces, virement, chèque).
* Le versement est créé avec le statut EN_ATTENTE_VALIDATION.
* Le trésorier devra le valider via la page admin.
*/
@POST
@Path("/declarer-manuel")
@RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION"})
public Response declarerVersementManuel(@Valid DeclarerVersementManuelRequest request) {
LOG.infof("POST /api/versements/declarer-manuel — cotisation: %s, méthode: %s",
request.cotisationId(), request.methodePaiement());
VersementResponse result = versementService.declarerVersementManuel(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
// ── Dépôt épargne ─────────────────────────────────────────────────────────
@POST
@Path("/initier-depot-epargne")
@RolesAllowed({"MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER"})
public Response initierDepotEpargne(@Valid InitierDepotEpargneRequest request) {
LOG.infof("POST /api/versements/initier-depot-epargne — compte: %s, montant: %s",
request.compteId(), request.montant());
VersementGatewayResponse result = versementService.initierDepotEpargneEnLigne(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
}

View File

@@ -6,7 +6,7 @@ import dev.lions.unionflow.server.api.dto.mutuelle.epargne.TransactionEpargneReq
import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeTransactionEpargne;
import dev.lions.unionflow.server.entity.IntentionPaiement;
import dev.lions.unionflow.server.repository.IntentionPaiementRepository;
import dev.lions.unionflow.server.service.PaiementService;
import dev.lions.unionflow.server.service.VersementService;
import dev.lions.unionflow.server.service.mutuelle.epargne.TransactionEpargneService;
import jakarta.annotation.security.PermitAll;
import jakarta.ws.rs.Produces;
@@ -51,7 +51,7 @@ public class WaveRedirectResource {
TransactionEpargneService transactionEpargneService;
@Inject
PaiementService paiementService;
VersementService versementService;
@GET
@Path("/success")
@@ -172,8 +172,8 @@ public class WaveRedirectResource {
}
}
// Déléguer la complétion cotisations au service
paiementService.completerIntention(intention, null);
// Déléguer la confirmation cotisations au service
versementService.confirmerVersementWave(intention, null);
LOG.infof("Wave: intention %s complétée", ref);
} catch (Exception e) {
LOG.errorf(e, "Wave: erreur applyCompletion ref=%s", ref);

View File

@@ -1,6 +1,7 @@
package dev.lions.unionflow.server.security;
import dev.lions.unionflow.server.service.OrganisationModuleService;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.ws.rs.Priorities;
@@ -44,6 +45,9 @@ public class ModuleAccessFilter implements ContainerRequestFilter {
@Inject
OrganisationModuleService organisationModuleService;
@Inject
SecurityIdentity identity;
@Context
ResourceInfo resourceInfo;
@@ -61,6 +65,11 @@ public class ModuleAccessFilter implements ContainerRequestFilter {
return;
}
// SUPER_ADMIN a accès global à tous les modules sans contexte d'organisation
if (identity.hasRole("SUPER_ADMIN")) {
return;
}
String moduleRequis = annotation.value().toUpperCase();
// 2. Extraire l'organisation active depuis le header

View File

@@ -52,21 +52,11 @@ public class AdminUserService {
}
public List<RoleDTO> getRealmRoles() {
try {
return roleServiceClient.getRealmRoles(DEFAULT_REALM);
} catch (Exception e) {
LOG.warnf("Impossible de récupérer les rôles realm: %s", e.getMessage());
return List.of();
}
return roleServiceClient.getRealmRoles(DEFAULT_REALM);
}
public List<RoleDTO> getUserRoles(String userId) {
try {
return roleServiceClient.getUserRealmRoles(userId, DEFAULT_REALM);
} catch (Exception e) {
LOG.warnf("Impossible de récupérer les rôles de l'utilisateur %s: %s", userId, e.getMessage());
return List.of();
}
return roleServiceClient.getUserRealmRoles(userId, DEFAULT_REALM);
}
/**

View File

@@ -1,208 +0,0 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.communication.request.CreateConversationRequest;
import dev.lions.unionflow.server.api.dto.communication.response.ConversationResponse;
import dev.lions.unionflow.server.api.dto.communication.response.MessageResponse;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Message;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.ConversationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.MessageRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import org.jboss.logging.Logger;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Service de gestion des conversations
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
*/
@ApplicationScoped
public class ConversationService {
private static final Logger LOG = Logger.getLogger(ConversationService.class);
@Inject
ConversationRepository conversationRepository;
@Inject
MessageRepository messageRepository;
@Inject
MembreRepository membreRepository;
@Inject
OrganisationRepository organisationRepository;
/**
* Liste les conversations d'un membre
*/
public List<ConversationResponse> getConversations(UUID membreId, UUID organisationId, boolean includeArchived) {
LOG.infof("Récupération conversations pour membre %s", membreId);
List<Conversation> conversations;
if (organisationId != null) {
conversations = conversationRepository.findByOrganisation(organisationId);
} else {
conversations = conversationRepository.findByParticipant(membreId, includeArchived);
}
return conversations.stream()
.map(c -> convertToResponse(c, membreId))
.collect(Collectors.toList());
}
/**
* Récupère une conversation par ID
*/
public ConversationResponse getConversationById(UUID conversationId, UUID membreId) {
Conversation conversation = conversationRepository.findByIdAndParticipant(conversationId, membreId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée ou accès refusé"));
return convertToResponse(conversation, membreId);
}
/**
* Crée une nouvelle conversation
*/
@Transactional
public ConversationResponse createConversation(CreateConversationRequest request, UUID creatorId) {
LOG.infof("Création conversation: %s (type: %s)", request.name(), request.type());
Conversation conversation = new Conversation();
conversation.setName(request.name());
conversation.setDescription(request.description());
conversation.setType(request.type());
// Ajouter les participants
List<Membre> participants = request.participantIds().stream()
.map(id -> membreRepository.findById(id))
.filter(membre -> membre != null)
.collect(Collectors.toList());
// Ajouter le créateur s'il n'est pas dans la liste
Membre creator = membreRepository.findById(creatorId);
if (creator != null && !participants.contains(creator)) {
participants.add(creator);
}
conversation.setParticipants(participants);
// Organisation
if (request.organisationId() != null) {
Organisation org = organisationRepository.findById(request.organisationId());
conversation.setOrganisation(org);
}
conversation.setUpdatedAt(LocalDateTime.now());
conversationRepository.persist(conversation);
return convertToResponse(conversation, creatorId);
}
/**
* Archive/désarchive une conversation
*/
@Transactional
public void archiveConversation(UUID conversationId, UUID membreId, boolean archive) {
Conversation conversation = conversationRepository.findByIdAndParticipant(conversationId, membreId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée"));
conversation.setIsArchived(archive);
conversation.setUpdatedAt(LocalDateTime.now());
conversationRepository.persist(conversation);
}
/**
* Marque une conversation comme lue
*/
@Transactional
public void markAsRead(UUID conversationId, UUID membreId) {
// Vérifier accès
conversationRepository.findByIdAndParticipant(conversationId, membreId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée"));
messageRepository.markAllAsReadByConversationAndMember(conversationId, membreId);
}
/**
* Toggle mute
*/
@Transactional
public void toggleMute(UUID conversationId, UUID membreId) {
Conversation conversation = conversationRepository.findByIdAndParticipant(conversationId, membreId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée"));
conversation.setIsMuted(!conversation.getIsMuted());
conversation.setUpdatedAt(LocalDateTime.now());
}
/**
* Toggle pin
*/
@Transactional
public void togglePin(UUID conversationId, UUID membreId) {
Conversation conversation = conversationRepository.findByIdAndParticipant(conversationId, membreId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée"));
conversation.setIsPinned(!conversation.getIsPinned());
conversation.setUpdatedAt(LocalDateTime.now());
}
/**
* Convertit Conversation en DTO
*/
private ConversationResponse convertToResponse(Conversation c, UUID currentUserId) {
Message lastMsg = messageRepository.findLastByConversation(c.getId());
long unreadCount = messageRepository.countUnreadByConversationAndMember(c.getId(), currentUserId);
return ConversationResponse.builder()
.id(c.getId())
.name(c.getName())
.description(c.getDescription())
.type(c.getType())
.participantIds(c.getParticipants().stream().map(Membre::getId).collect(Collectors.toList()))
.organisationId(c.getOrganisation() != null ? c.getOrganisation().getId() : null)
.lastMessage(lastMsg != null ? convertMessageToResponse(lastMsg) : null)
.unreadCount((int) unreadCount)
.muted(Boolean.TRUE.equals(c.getIsMuted()))
.pinned(Boolean.TRUE.equals(c.getIsPinned()))
.archived(Boolean.TRUE.equals(c.getIsArchived()))
.createdAt(c.getDateCreation())
.updatedAt(c.getUpdatedAt())
.avatarUrl(c.getAvatarUrl())
.build();
}
/**
* Convertit Message en DTO simple
*/
private MessageResponse convertMessageToResponse(Message m) {
return MessageResponse.builder()
.id(m.getId())
.conversationId(m.getConversation().getId())
.senderId(m.getSender().getId())
.senderName(m.getSenderName())
.senderAvatar(m.getSenderAvatar())
.content(m.getContent())
.type(m.getType())
.status(m.getStatus())
.priority(m.getPriority())
.createdAt(m.getDateCreation())
.edited(Boolean.TRUE.equals(m.getIsEdited()))
.deleted(Boolean.TRUE.equals(m.getIsDeleted()))
.build();
}
}

View File

@@ -23,6 +23,7 @@ import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.TypedQuery;
import jakarta.transaction.Transactional;
import org.jboss.logging.Logger;
import java.math.BigDecimal;
@@ -63,6 +64,7 @@ public class DashboardServiceImpl implements DashboardService {
OrganisationRepository organisationRepository;
@Override
@Transactional(Transactional.TxType.REQUIRED)
public DashboardDataResponse getDashboardData(String organizationId, String userId) {
LOG.infof("Récupération des données dashboard pour org: %s et user: %s", organizationId, userId);
@@ -77,6 +79,7 @@ public class DashboardServiceImpl implements DashboardService {
}
@Override
@Transactional(Transactional.TxType.REQUIRED)
public DashboardStatsResponse getDashboardStats(String organizationId, String userId) {
LOG.infof("Récupération des stats dashboard pour org: %s et user: %s", organizationId, userId);
@@ -171,6 +174,7 @@ public class DashboardServiceImpl implements DashboardService {
}
@Override
@Transactional(Transactional.TxType.REQUIRED)
public List<RecentActivityResponse> getRecentActivities(String organizationId, String userId, int limit) {
LOG.infof("Récupération de %d activités récentes pour org: %s et user: %s", limit, organizationId, userId);
@@ -253,6 +257,7 @@ public class DashboardServiceImpl implements DashboardService {
}
@Override
@Transactional(Transactional.TxType.REQUIRED)
public List<UpcomingEventResponse> getUpcomingEvents(String organizationId, String userId, int limit) {
LOG.infof("Récupération de %d événements à venir pour org: %s et user: %s", limit, organizationId, userId);

View File

@@ -0,0 +1,114 @@
package dev.lions.unionflow.server.service;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* Client HTTP direct vers l'API Admin Keycloak (sans JAX-RS pour éviter les conflits ObjectMapper).
* Pattern identique à KeycloakAdminClientImpl.getAllRealms() — HttpClient Java 11 + ObjectMapper isolé.
*/
@Slf4j
@ApplicationScoped
public class KeycloakAdminHttpClient {
@ConfigProperty(name = "keycloak.admin.url", defaultValue = "http://localhost:8180")
String keycloakUrl;
@ConfigProperty(name = "keycloak.admin.username", defaultValue = "admin")
String adminUsername;
@ConfigProperty(name = "keycloak.admin.password", defaultValue = "admin")
String adminPassword;
@ConfigProperty(name = "keycloak.admin.realm", defaultValue = "unionflow")
String realm;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private final ObjectMapper mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
/**
* Obtenir un token admin depuis le realm master via client_credentials admin-cli
*/
private String getAdminToken() throws Exception {
String body = "client_id=admin-cli"
+ "&username=" + java.net.URLEncoder.encode(adminUsername, java.nio.charset.StandardCharsets.UTF_8)
+ "&password=" + java.net.URLEncoder.encode(adminPassword, java.nio.charset.StandardCharsets.UTF_8)
+ "&grant_type=password";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/realms/master/protocol/openid-connect/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("Échec authentification admin Keycloak (HTTP " + response.statusCode() + "): " + response.body());
}
return mapper.readTree(response.body()).get("access_token").asText();
}
/**
* Révoquer toutes les sessions actives du realm.
* Stratégie : lister tous les users → POST /users/{id}/logout pour chaque.
* @return nombre de sessions révoquées
*/
public int logoutAllSessions() throws Exception {
String token = getAdminToken();
log.info("Token admin Keycloak obtenu — déconnexion de toutes les sessions du realm '{}'", realm);
// Récupérer tous les utilisateurs (max 1000)
HttpRequest usersRequest = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm + "/users?max=1000&enabled=true"))
.header("Authorization", "Bearer " + token)
.GET()
.timeout(Duration.ofSeconds(15))
.build();
HttpResponse<String> usersResponse = httpClient.send(usersRequest, HttpResponse.BodyHandlers.ofString());
if (usersResponse.statusCode() != 200) {
throw new RuntimeException("Impossible de lister les utilisateurs Keycloak (HTTP " + usersResponse.statusCode() + ")");
}
var users = mapper.readTree(usersResponse.body());
int loggedOut = 0;
for (var user : users) {
String userId = user.get("id").asText();
String username = user.has("username") ? user.get("username").asText() : userId;
HttpRequest logoutRequest = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm + "/users/" + userId + "/logout"))
.header("Authorization", "Bearer " + token)
.POST(HttpRequest.BodyPublishers.noBody())
.timeout(Duration.ofSeconds(5))
.build();
HttpResponse<String> logoutResponse = httpClient.send(logoutRequest, HttpResponse.BodyHandlers.ofString());
if (logoutResponse.statusCode() == 204 || logoutResponse.statusCode() == 200) {
loggedOut++;
log.debug("Session révoquée pour l'utilisateur '{}'", username);
} else {
log.warn("Impossible de révoquer la session de '{}' (HTTP {})", username, logoutResponse.statusCode());
}
}
log.info("Déconnexion globale terminée: {}/{} utilisateur(s) déconnecté(s)", loggedOut, users.size());
return loggedOut;
}
}

View File

@@ -1,203 +0,0 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.communication.request.SendMessageRequest;
import dev.lions.unionflow.server.api.dto.communication.response.MessageResponse;
import dev.lions.unionflow.server.api.enums.communication.MessagePriority;
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
import dev.lions.unionflow.server.api.enums.communication.MessageType;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Message;
import dev.lions.unionflow.server.repository.ConversationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.MessageRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import org.jboss.logging.Logger;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Service de gestion des messages
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-16
*/
@ApplicationScoped
public class MessageService {
private static final Logger LOG = Logger.getLogger(MessageService.class);
@Inject
MessageRepository messageRepository;
@Inject
ConversationRepository conversationRepository;
@Inject
MembreRepository membreRepository;
/**
* Récupère les messages d'une conversation
*/
public List<MessageResponse> getMessages(UUID conversationId, UUID membreId, int limit) {
LOG.infof("Récupération messages pour conversation %s (limit: %d)", conversationId, limit);
// Vérifier accès
conversationRepository.findByIdAndParticipant(conversationId, membreId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée ou accès refusé"));
List<Message> messages = messageRepository.findByConversation(conversationId, limit);
return messages.stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
}
/**
* Envoie un message
*/
@Transactional
public MessageResponse sendMessage(SendMessageRequest request, UUID senderId) {
LOG.infof("Envoi message dans conversation %s", request.conversationId());
// Vérifier accès conversation
Conversation conversation = conversationRepository.findByIdAndParticipant(request.conversationId(), senderId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée ou accès refusé"));
Membre sender = membreRepository.findById(senderId);
if (sender == null) {
throw new NotFoundException("Expéditeur non trouvé");
}
Message message = new Message();
message.setConversation(conversation);
message.setSender(sender);
message.setSenderName(sender.getPrenom() + " " + sender.getNom());
message.setSenderAvatar(sender.getPhotoUrl());
message.setContent(request.content());
message.setType(request.type() != null ? request.type() : MessageType.INDIVIDUAL);
message.setStatus(MessageStatus.SENT);
message.setPriority(request.priority() != null ? request.priority() : MessagePriority.NORMAL);
// Destinataires (pour targeted messages)
if (request.recipientIds() != null && !request.recipientIds().isEmpty()) {
message.setRecipientIds(request.recipientIds().stream()
.map(UUID::toString)
.collect(Collectors.joining(",")));
}
// Rôles destinataires
if (request.recipientRoles() != null && !request.recipientRoles().isEmpty()) {
message.setRecipientRoles(String.join(",", request.recipientRoles()));
}
// Pièces jointes
if (request.attachments() != null && !request.attachments().isEmpty()) {
message.setAttachments(String.join(",", request.attachments()));
}
message.setOrganisation(conversation.getOrganisation());
messageRepository.persist(message);
// Mettre à jour la conversation
conversation.setUpdatedAt(LocalDateTime.now());
conversationRepository.persist(conversation);
LOG.infof("Message %s créé avec succès", message.getId());
return convertToResponse(message);
}
/**
* Édite un message
*/
@Transactional
public MessageResponse editMessage(UUID messageId, UUID senderId, String newContent) {
Message message = messageRepository.findById(messageId);
if (message == null) {
throw new NotFoundException("Message non trouvé");
}
if (!message.getSender().getId().equals(senderId)) {
throw new IllegalStateException("Vous ne pouvez éditer que vos propres messages");
}
message.setContent(newContent);
message.markAsEdited();
messageRepository.persist(message);
return convertToResponse(message);
}
/**
* Supprime un message (soft delete)
*/
@Transactional
public void deleteMessage(UUID messageId, UUID senderId) {
Message message = messageRepository.findById(messageId);
if (message == null) {
throw new NotFoundException("Message non trouvé");
}
if (!message.getSender().getId().equals(senderId)) {
throw new IllegalStateException("Vous ne pouvez supprimer que vos propres messages");
}
message.setIsDeleted(true);
message.setContent("[Message supprimé]");
messageRepository.persist(message);
}
/**
* Convertit Message en DTO
*/
private MessageResponse convertToResponse(Message m) {
// Parser recipient IDs
List<UUID> recipientIds = null;
if (m.getRecipientIds() != null && !m.getRecipientIds().isEmpty()) {
recipientIds = List.of(m.getRecipientIds().split(",")).stream()
.map(UUID::fromString)
.collect(Collectors.toList());
}
// Parser roles
List<String> roles = null;
if (m.getRecipientRoles() != null && !m.getRecipientRoles().isEmpty()) {
roles = List.of(m.getRecipientRoles().split(","));
}
// Parser attachments
List<String> attachments = null;
if (m.getAttachments() != null && !m.getAttachments().isEmpty()) {
attachments = List.of(m.getAttachments().split(","));
}
return MessageResponse.builder()
.id(m.getId())
.conversationId(m.getConversation().getId())
.senderId(m.getSender().getId())
.senderName(m.getSenderName())
.senderAvatar(m.getSenderAvatar())
.content(m.getContent())
.type(m.getType())
.status(m.getStatus())
.priority(m.getPriority())
.recipientIds(recipientIds)
.recipientRoles(roles)
.organisationId(m.getOrganisation() != null ? m.getOrganisation().getId() : null)
.createdAt(m.getDateCreation())
.readAt(m.getReadAt())
.attachments(attachments)
.edited(Boolean.TRUE.equals(m.getIsEdited()))
.editedAt(m.getEditedAt())
.deleted(Boolean.TRUE.equals(m.getIsDeleted()))
.build();
}
}

View File

@@ -0,0 +1,653 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.messagerie.request.BloquerMembreRequest;
import dev.lions.unionflow.server.api.dto.messagerie.request.DemarrerConversationDirecteRequest;
import dev.lions.unionflow.server.api.dto.messagerie.request.DemarrerConversationRoleRequest;
import dev.lions.unionflow.server.api.dto.messagerie.request.EnvoyerMessageRequest;
import dev.lions.unionflow.server.api.dto.messagerie.request.MettreAJourPolitiqueRequest;
import dev.lions.unionflow.server.api.dto.messagerie.response.ContactPolicyResponse;
import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationResponse;
import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationSummaryResponse;
import dev.lions.unionflow.server.api.dto.messagerie.response.MessageResponse;
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
import dev.lions.unionflow.server.api.enums.messagerie.TypeContenu;
import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation;
import dev.lions.unionflow.server.api.enums.messagerie.TypePolitiqueCommunication;
import dev.lions.unionflow.server.entity.ContactPolicy;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.ConversationParticipant;
import dev.lions.unionflow.server.entity.MemberBlock;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.MembreOrganisation;
import dev.lions.unionflow.server.entity.Message;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.ContactPolicyRepository;
import dev.lions.unionflow.server.repository.ConversationParticipantRepository;
import dev.lions.unionflow.server.repository.ConversationRepository;
import dev.lions.unionflow.server.repository.MemberBlockRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.MessageRepository;
import dev.lions.unionflow.server.messaging.KafkaEventProducer;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.NotFoundException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import org.jboss.logging.Logger;
/**
* Service métier pour la messagerie instantanée.
*
* <p>Gère les conversations (directes et canaux-rôle), les messages (texte,
* vocal, image), les blocages et les politiques de communication.
*
* <p>Politique par appartenance : deux membres de la même organisation
* peuvent se contacter sans demande d'amitié préalable.
* L'adhésion est la relation de confiance.
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class MessagingService {
private static final Logger LOG = Logger.getLogger(MessagingService.class);
private static final int PAGE_SIZE_DEFAULT = 30;
@Inject ConversationRepository conversationRepository;
@Inject ConversationParticipantRepository participantRepository;
@Inject MessageRepository messageRepository;
@Inject ContactPolicyRepository contactPolicyRepository;
@Inject MemberBlockRepository memberBlockRepository;
@Inject MembreRepository membreRepository;
@Inject MembreOrganisationRepository membreOrganisationRepository;
@Inject KafkaEventProducer kafkaEventProducer;
@Inject io.quarkus.security.identity.SecurityIdentity securityIdentity;
// ── Conversations ─────────────────────────────────────────────────────────
/**
* Démarre ou récupère une conversation directe 1-1.
* Idempotent : si la conversation existe déjà, elle est retournée.
*/
@Transactional
public ConversationResponse demarrerConversationDirecte(DemarrerConversationDirecteRequest request) {
Membre moi = getMembreConnecte();
Membre destinataire = membreRepository.findById(request.destinataireId());
if (destinataire == null) {
throw new NotFoundException("Membre destinataire non trouvé : " + request.destinataireId());
}
UUID orgId = request.organisationId();
verifierAppartenance(moi.getId(), orgId);
verifierAppartenance(request.destinataireId(), orgId);
verifierPolitique(moi.getId(), request.destinataireId(), orgId, false);
// Idempotence : chercher une conversation directe existante
return conversationRepository
.findConversationDirecte(moi.getId(), request.destinataireId(), orgId)
.map(c -> {
// Envoyer le message initial si fourni
if (request.contenuInitial() != null && !request.contenuInitial().isBlank()) {
envoyerMessageDansConversation(c, moi, request.contenuInitial(), TypeContenu.TEXTE, null, null, null);
}
return toConversationResponse(c, moi.getId());
})
.orElseGet(() -> {
Organisation org = getOrganisation(orgId);
Conversation conv = Conversation.builder()
.organisation(org)
.typeConversation(TypeConversation.DIRECTE)
.statut(StatutConversation.ACTIVE)
.build();
conversationRepository.persist(conv);
ajouterParticipant(conv, moi, "INITIATEUR");
ajouterParticipant(conv, destinataire, "PARTICIPANT");
if (request.contenuInitial() != null && !request.contenuInitial().isBlank()) {
envoyerMessageDansConversation(conv, moi, request.contenuInitial(), TypeContenu.TEXTE, null, null, null);
}
LOG.infof("Conversation directe créée: %s ↔ %s dans org %s",
moi.getEmail(), destinataire.getEmail(), orgId);
return toConversationResponse(conv, moi.getId());
});
}
/**
* Démarre ou récupère un canal de rôle.
* Le canal est partagé : tous les membres qui contactent "Le Trésorier"
* aboutissent dans le même canal.
*/
@Transactional
public ConversationResponse demarrerConversationRole(DemarrerConversationRoleRequest request) {
Membre moi = getMembreConnecte();
UUID orgId = request.organisationId();
verifierAppartenance(moi.getId(), orgId);
verifierPolitique(moi.getId(), null, orgId, true);
String roleCible = request.roleCible();
List<Membre> porteurs = trouverPorteursDuRole(orgId, roleCible);
if (porteurs.isEmpty()) {
throw new NotFoundException("Aucun membre avec le rôle " + roleCible + " dans cette organisation");
}
Organisation org = getOrganisation(orgId);
String titreCanal = libelleDuRole(roleCible);
return conversationRepository.findCanalRole(orgId, roleCible)
.map(c -> {
// Ajouter l'initiateur s'il n'est pas encore participant
if (!participantRepository.estParticipant(c.getId(), moi.getId())) {
ajouterParticipant(c, moi, "PARTICIPANT");
}
envoyerMessageDansConversation(c, moi, request.contenuInitial(), TypeContenu.TEXTE, null, null, null);
return toConversationResponse(c, moi.getId());
})
.orElseGet(() -> {
Conversation conv = Conversation.builder()
.organisation(org)
.typeConversation(TypeConversation.ROLE_CANAL)
.roleCible(roleCible)
.titre(titreCanal)
.statut(StatutConversation.ACTIVE)
.build();
conversationRepository.persist(conv);
ajouterParticipant(conv, moi, "INITIATEUR");
porteurs.forEach(p -> ajouterParticipant(conv, p, "MODERATEUR"));
envoyerMessageDansConversation(conv, moi, request.contenuInitial(), TypeContenu.TEXTE, null, null, null);
LOG.infof("Canal rôle créé: %s dans org %s", roleCible, orgId);
return toConversationResponse(conv, moi.getId());
});
}
/**
* Retourne la liste des conversations du membre connecté.
*/
public List<ConversationSummaryResponse> getMesConversations() {
Membre moi = getMembreConnecte();
return conversationRepository.findByMembreId(moi.getId()).stream()
.map(c -> toConversationSummary(c, moi.getId()))
.collect(Collectors.toList());
}
/**
* Retourne le détail d'une conversation (avec les derniers messages).
*/
public ConversationResponse getConversation(UUID conversationId) {
Membre moi = getMembreConnecte();
Conversation conv = conversationRepository.findConversationById(conversationId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée : " + conversationId));
verifierParticipant(conv, moi.getId());
return toConversationResponse(conv, moi.getId());
}
/**
* Archive une conversation.
*/
@Transactional
public ConversationResponse archiverConversation(UUID conversationId) {
Membre moi = getMembreConnecte();
Conversation conv = conversationRepository.findConversationById(conversationId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée : " + conversationId));
verifierParticipant(conv, moi.getId());
conv.archiver();
return toConversationResponse(conv, moi.getId());
}
// ── Messages ──────────────────────────────────────────────────────────────
/**
* Envoie un message dans une conversation existante.
*/
@Transactional
public MessageResponse envoyerMessage(UUID conversationId, EnvoyerMessageRequest request) {
Membre moi = getMembreConnecte();
Conversation conv = conversationRepository.findConversationById(conversationId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée : " + conversationId));
verifierParticipant(conv, moi.getId());
if (!conv.estActive()) {
throw new BadRequestException("Cette conversation est archivée");
}
TypeContenu type = parseTypeContenu(request.typeMessage());
validerContenuMessage(type, request);
Message message = envoyerMessageDansConversation(
conv, moi,
request.contenu(), type,
request.urlFichier(), request.dureeAudio(),
request.messageParentId()
);
return toMessageResponse(message);
}
/**
* Récupère les messages d'une conversation (paginés).
*/
public List<MessageResponse> getMessages(UUID conversationId, int page) {
Membre moi = getMembreConnecte();
Conversation conv = conversationRepository.findConversationById(conversationId)
.orElseThrow(() -> new NotFoundException("Conversation non trouvée : " + conversationId));
verifierParticipant(conv, moi.getId());
return messageRepository.findByConversationPagine(conversationId, page, PAGE_SIZE_DEFAULT)
.stream()
.map(this::toMessageResponse)
.collect(Collectors.toList());
}
/**
* Marque tous les messages d'une conversation comme lus.
*/
@Transactional
public void marquerConversationLue(UUID conversationId) {
Membre moi = getMembreConnecte();
participantRepository.findParticipant(conversationId, moi.getId())
.ifPresent(p -> {
p.marquerLu();
LOG.debugf("Conversation %s marquée lue par %s", conversationId, moi.getEmail());
});
}
/**
* Supprime un message (soft delete — contenu remplacé par "[Message supprimé]").
*/
@Transactional
public void supprimerMessage(UUID conversationId, UUID messageId) {
Membre moi = getMembreConnecte();
Message message = messageRepository.findMessageById(messageId)
.orElseThrow(() -> new NotFoundException("Message non trouvé : " + messageId));
if (!message.getConversation().getId().equals(conversationId)) {
throw new NotFoundException("Message non trouvé dans cette conversation");
}
if (!message.getExpediteur().getId().equals(moi.getId())) {
throw new ForbiddenException("Vous ne pouvez supprimer que vos propres messages");
}
message.supprimer();
}
// ── Blocages ──────────────────────────────────────────────────────────────
/**
* Bloque un membre dans une organisation.
*/
@Transactional
public void bloquerMembre(BloquerMembreRequest request) {
Membre moi = getMembreConnecte();
UUID membreABloquerId = request.membreABloquerId();
UUID orgId = request.organisationId();
if (moi.getId().equals(membreABloquerId)) {
throw new BadRequestException("Vous ne pouvez pas vous bloquer vous-même");
}
Membre aBloquer = membreRepository.findById(membreABloquerId);
if (aBloquer == null) {
throw new NotFoundException("Membre non trouvé : " + membreABloquerId);
}
if (memberBlockRepository.estBloque(moi.getId(), membreABloquerId, orgId)) {
throw new BadRequestException("Ce membre est déjà bloqué");
}
Organisation org = getOrganisation(orgId);
MemberBlock block = MemberBlock.builder()
.bloqueur(moi)
.bloque(aBloquer)
.organisation(org)
.build();
memberBlockRepository.persist(block);
LOG.infof("%s a bloqué %s dans org %s", moi.getEmail(), aBloquer.getEmail(), orgId);
}
/**
* Débloque un membre dans une organisation.
*/
@Transactional
public void debloquerMembre(UUID membreId, UUID organisationId) {
Membre moi = getMembreConnecte();
MemberBlock block = memberBlockRepository.findBlocage(moi.getId(), membreId, organisationId)
.orElseThrow(() -> new NotFoundException("Aucun blocage trouvé pour ce membre"));
block.setActif(false);
}
/**
* Retourne la liste des membres bloqués par le membre connecté.
*/
public List<MemberBlock> getMesBlocages() {
Membre moi = getMembreConnecte();
return memberBlockRepository.findByBloqueur(moi.getId());
}
// ── Politique de communication ────────────────────────────────────────────
/**
* Retourne la politique de communication d'une organisation.
* Crée une politique par défaut si elle n'existe pas encore.
*/
@Transactional
public ContactPolicyResponse getPolitique(UUID organisationId) {
ContactPolicy policy = contactPolicyRepository.findByOrganisationId(organisationId)
.orElseGet(() -> creerPolitiqueParDefaut(organisationId));
return toContactPolicyResponse(policy);
}
/**
* Met à jour la politique de communication.
* Réservé aux ADMIN et ADMIN_ORGANISATION.
*/
@Transactional
public ContactPolicyResponse mettreAJourPolitique(UUID organisationId, MettreAJourPolitiqueRequest request) {
ContactPolicy policy = contactPolicyRepository.findByOrganisationId(organisationId)
.orElseGet(() -> creerPolitiqueParDefaut(organisationId));
if (request.typePolitique() != null) {
policy.setTypePolitique(TypePolitiqueCommunication.valueOf(request.typePolitique()));
}
if (request.autoriserMembreVersMembre() != null) {
policy.setAutoriserMembreVersMembre(request.autoriserMembreVersMembre());
}
if (request.autoriserMembreVersRole() != null) {
policy.setAutoriserMembreVersRole(request.autoriserMembreVersRole());
}
if (request.autoriserNotesVocales() != null) {
policy.setAutoriserNotesVocales(request.autoriserNotesVocales());
}
return toContactPolicyResponse(policy);
}
// ── Méthodes privées ──────────────────────────────────────────────────────
private Message envoyerMessageDansConversation(
Conversation conv, Membre expediteur,
String contenu, TypeContenu type,
String urlFichier, Integer dureeAudio,
UUID messageParentId) {
Message.MessageBuilder builder = Message.builder()
.conversation(conv)
.expediteur(expediteur)
.typeMessage(type)
.contenu(contenu)
.urlFichier(urlFichier)
.dureeAudio(dureeAudio);
if (messageParentId != null) {
messageRepository.findMessageById(messageParentId)
.ifPresent(builder::messageParent);
}
Message message = builder.build();
messageRepository.persist(message);
conv.enregistrerNouveauMessage();
// Notifier via Kafka → WebSocket
try {
java.util.Map<String, Object> data = new java.util.HashMap<>();
data.put("conversationId", conv.getId().toString());
data.put("messageId", message.getId() != null ? message.getId().toString() : "");
data.put("expediteurId", expediteur.getId().toString());
data.put("typeMessage", type.name());
kafkaEventProducer.publishNouveauMessage(conv.getId(), conv.getOrganisation().getId().toString(), data);
} catch (Exception e) {
LOG.warnf("Impossible de publier l'event Kafka pour le message: %s", e.getMessage());
}
return message;
}
private void ajouterParticipant(Conversation conv, Membre membre, String role) {
if (!participantRepository.estParticipant(conv.getId(), membre.getId())) {
ConversationParticipant participant = ConversationParticipant.builder()
.conversation(conv)
.membre(membre)
.roleDansConversation(role)
.notifier(true)
.build();
participantRepository.persist(participant);
}
}
private void verifierAppartenance(UUID membreId, UUID organisationId) {
boolean appartient = membreOrganisationRepository
.count("membre.id = ?1 AND organisation.id = ?2 AND actif = true", membreId, organisationId) > 0;
if (!appartient) {
throw new ForbiddenException("Le membre n'appartient pas à cette organisation");
}
}
private void verifierPolitique(UUID expediteurId, UUID destinataireId, UUID orgId, boolean versRole) {
contactPolicyRepository.findByOrganisationId(orgId).ifPresent(policy -> {
if (versRole && !policy.getAutoriserMembreVersRole()) {
throw new ForbiddenException("La politique de cette organisation n'autorise pas les contacts vers les rôles");
}
if (!versRole && !policy.getAutoriserMembreVersMembre()) {
throw new ForbiddenException("La politique de cette organisation n'autorise pas les contacts entre membres");
}
});
// Vérifier le blocage
if (destinataireId != null && memberBlockRepository.estBloque(destinataireId, expediteurId, orgId)) {
throw new ForbiddenException("Vous ne pouvez pas contacter ce membre");
}
}
private void verifierParticipant(Conversation conv, UUID membreId) {
if (!participantRepository.estParticipant(conv.getId(), membreId)) {
throw new ForbiddenException("Vous n'êtes pas participant à cette conversation");
}
}
private List<Membre> trouverPorteursDuRole(UUID orgId, String role) {
List<MembreOrganisation> membresOrg = membreOrganisationRepository
.find("organisation.id = ?1 AND roleOrg = ?2 AND actif = true", orgId, role)
.list();
return membresOrg.stream()
.map(MembreOrganisation::getMembre)
.collect(Collectors.toList());
}
private ContactPolicy creerPolitiqueParDefaut(UUID organisationId) {
Organisation org = getOrganisation(organisationId);
ContactPolicy policy = ContactPolicy.builder()
.organisation(org)
.typePolitique(TypePolitiqueCommunication.OUVERT)
.autoriserMembreVersMembre(true)
.autoriserMembreVersRole(true)
.autoriserNotesVocales(true)
.build();
contactPolicyRepository.persist(policy);
return policy;
}
private Membre getMembreConnecte() {
String email = securityIdentity.getPrincipal().getName();
return membreRepository.find("email", email).firstResult();
}
private Organisation getOrganisation(UUID orgId) {
return (Organisation) dev.lions.unionflow.server.entity.Organisation.findById(orgId);
}
private TypeContenu parseTypeContenu(String type) {
if (type == null || type.isBlank()) return TypeContenu.TEXTE;
try {
return TypeContenu.valueOf(type.toUpperCase());
} catch (IllegalArgumentException e) {
return TypeContenu.TEXTE;
}
}
private void validerContenuMessage(TypeContenu type, EnvoyerMessageRequest req) {
switch (type) {
case TEXTE:
if (req.contenu() == null || req.contenu().isBlank()) {
throw new BadRequestException("Le contenu est obligatoire pour un message texte");
}
break;
case VOCAL:
if (req.urlFichier() == null || req.urlFichier().isBlank()) {
throw new BadRequestException("L'URL du fichier audio est obligatoire pour une note vocale");
}
if (req.dureeAudio() == null) {
throw new BadRequestException("La durée audio est obligatoire pour une note vocale");
}
break;
case IMAGE:
if (req.urlFichier() == null || req.urlFichier().isBlank()) {
throw new BadRequestException("L'URL de l'image est obligatoire");
}
break;
default:
break;
}
}
private String libelleDuRole(String role) {
return switch (role) {
case "PRESIDENT" -> "Président";
case "TRESORIER" -> "Trésorier";
case "SECRETAIRE" -> "Secrétaire";
case "VICE_PRESIDENT" -> "Vice-Président";
case "ADMIN" -> "Administrateur";
case "ADMIN_ORGANISATION" -> "Administrateur";
default -> role;
};
}
// ── Conversions DTO ───────────────────────────────────────────────────────
private ConversationResponse toConversationResponse(Conversation conv, UUID membreConnecteId) {
List<MessageResponse> msgs = messageRepository
.findByConversationPagine(conv.getId(), 0, PAGE_SIZE_DEFAULT)
.stream().map(this::toMessageResponse).collect(Collectors.toList());
List<ConversationResponse.ParticipantResponse> parts =
participantRepository.findByConversation(conv.getId()).stream()
.map(p -> ConversationResponse.ParticipantResponse.builder()
.membreId(p.getMembre().getId())
.prenom(p.getMembre().getPrenom())
.nom(p.getMembre().getNom())
.roleDansConversation(p.getRoleDansConversation())
.luJusqua(p.getLuJusqua())
.build())
.collect(Collectors.toList());
long nonLus = messageRepository.countNonLus(conv.getId(), membreConnecteId);
return ConversationResponse.builder()
.id(conv.getId())
.typeConversation(conv.getTypeConversation().name())
.titre(resolverTitre(conv, membreConnecteId))
.statut(conv.getStatut().name())
.roleCible(conv.getRoleCible())
.organisationId(conv.getOrganisation().getId())
.organisationNom(conv.getOrganisation().getNom())
.dateCreation(conv.getDateCreation())
.dernierMessageAt(conv.getDernierMessageAt())
.nombreMessages(conv.getNombreMessages())
.participants(parts)
.messages(msgs)
.nonLus(nonLus)
.build();
}
private ConversationSummaryResponse toConversationSummary(Conversation conv, UUID membreConnecteId) {
String apercu = messageRepository.findDernierMessage(conv.getId())
.map(m -> {
if (TypeContenu.VOCAL.equals(m.getTypeMessage())) return "🎤 Note vocale";
if (TypeContenu.IMAGE.equals(m.getTypeMessage())) return "📷 Image";
String c = m.getContenu();
return c != null && c.length() > 100 ? c.substring(0, 97) + "..." : c;
})
.orElse(null);
String dernierType = messageRepository.findDernierMessage(conv.getId())
.map(m -> m.getTypeMessage().name()).orElse(null);
long nonLus = messageRepository.countNonLus(conv.getId(), membreConnecteId);
return ConversationSummaryResponse.builder()
.id(conv.getId())
.typeConversation(conv.getTypeConversation().name())
.titre(resolverTitre(conv, membreConnecteId))
.statut(conv.getStatut().name())
.dernierMessageApercu(apercu)
.dernierMessageType(dernierType)
.dernierMessageAt(conv.getDernierMessageAt())
.nonLus(nonLus)
.organisationId(conv.getOrganisation().getId())
.build();
}
private MessageResponse toMessageResponse(Message message) {
String contenuAffiche = message.estSupprime()
? "[Message supprimé]"
: message.getContenu();
String parentApercu = null;
UUID parentId = null;
if (message.getMessageParent() != null) {
parentId = message.getMessageParent().getId();
String pc = message.getMessageParent().getContenu();
parentApercu = pc != null && pc.length() > 100 ? pc.substring(0, 97) + "..." : pc;
}
return MessageResponse.builder()
.id(message.getId())
.typeMessage(message.getTypeMessage().name())
.contenu(contenuAffiche)
.urlFichier(message.estSupprime() ? null : message.getUrlFichier())
.dureeAudio(message.getDureeAudio())
.supprime(message.estSupprime())
.expediteurId(message.getExpediteur().getId())
.expediteurNom(message.getExpediteur().getNom())
.expediteurPrenom(message.getExpediteur().getPrenom())
.messageParentId(parentId)
.messageParentApercu(parentApercu)
.dateEnvoi(message.getDateCreation())
.build();
}
private ContactPolicyResponse toContactPolicyResponse(ContactPolicy policy) {
return ContactPolicyResponse.builder()
.id(policy.getId())
.organisationId(policy.getOrganisation().getId())
.typePolitique(policy.getTypePolitique().name())
.autoriserMembreVersMembre(Boolean.TRUE.equals(policy.getAutoriserMembreVersMembre()))
.autoriserMembreVersRole(Boolean.TRUE.equals(policy.getAutoriserMembreVersRole()))
.autoriserNotesVocales(Boolean.TRUE.equals(policy.getAutoriserNotesVocales()))
.build();
}
/**
* Résout le titre affiché pour une conversation.
* Pour DIRECTE : "Prénom Nom" de l'autre participant.
* Pour ROLE_CANAL : le titre du canal.
*/
private String resolverTitre(Conversation conv, UUID membreConnecteId) {
if (conv.getTitre() != null) return conv.getTitre();
if (TypeConversation.DIRECTE.equals(conv.getTypeConversation())) {
return participantRepository.findByConversation(conv.getId()).stream()
.filter(p -> !p.getMembre().getId().equals(membreConnecteId))
.findFirst()
.map(p -> p.getMembre().getPrenom() + " " + p.getMembre().getNom())
.orElse("Conversation");
}
return conv.getRoleCible();
}
}

View File

@@ -1,6 +1,5 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest;
import dev.lions.unionflow.server.api.dto.paiement.request.CreatePaiementRequest;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse;
@@ -17,7 +16,6 @@ import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.PaiementRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
import dev.lions.unionflow.server.repository.TypeReferenceRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutException;
import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutSessionResponse;
import jakarta.enterprise.context.ApplicationScoped;
@@ -65,12 +63,6 @@ public class PaiementService {
@Inject
CompteEpargneRepository compteEpargneRepository;
@Inject
MembreOrganisationRepository membreOrganisationRepository;
@Inject
NotificationService notificationService;
@Inject
io.quarkus.security.identity.SecurityIdentity securityIdentity;
@@ -333,11 +325,7 @@ public class PaiementService {
.build();
intentionPaiementRepository.persist(intention);
// Web (sans numéro de téléphone) → page HTML de confirmation ; Mobile → deep link app
boolean isWebContext = request.numeroTelephone() == null || request.numeroTelephone().isBlank();
String successUrl = base + (isWebContext
? "/api/wave-redirect/web-success?ref=" + intention.getId()
: "/api/wave-redirect/success?ref=" + intention.getId());
String successUrl = base + "/api/wave-redirect/success?ref=" + intention.getId();
String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId();
String clientRef = intention.getId().toString();
// XOF : montant entier, pas de décimales (spec Wave)
@@ -397,113 +385,6 @@ public class PaiementService {
.build();
}
/**
* Vérifie le statut d'une IntentionPaiement Wave.
* Si la session Wave est complétée (paiement réussi), réconcilie automatiquement
* la cotisation (marque PAYEE) et met à jour l'intention (COMPLETEE).
* Appelé en polling depuis le web toutes les 3 secondes.
*/
@Transactional
public dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse verifierStatutIntention(UUID intentionId) {
IntentionPaiement intention = intentionPaiementRepository.findById(intentionId);
if (intention == null) {
throw new NotFoundException("IntentionPaiement non trouvée: " + intentionId);
}
// Déjà terminée — retourner immédiatement
if (intention.isCompletee()) {
return buildStatutResponse(intention, "Paiement confirmé !");
}
if (StatutIntentionPaiement.EXPIREE.equals(intention.getStatut())
|| StatutIntentionPaiement.ECHOUEE.equals(intention.getStatut())) {
return buildStatutResponse(intention, "Paiement " + intention.getStatut().name().toLowerCase());
}
// Session expirée côté UnionFlow (30 min)
if (intention.isExpiree()) {
intention.setStatut(StatutIntentionPaiement.EXPIREE);
intentionPaiementRepository.persist(intention);
return buildStatutResponse(intention, "Session expirée, veuillez recommencer");
}
// Vérifier le statut côté Wave si session connue
if (intention.getWaveCheckoutSessionId() != null) {
try {
WaveCheckoutService.WaveSessionStatusResponse waveStatus =
waveCheckoutService.getSession(intention.getWaveCheckoutSessionId());
if (waveStatus.isSucceeded()) {
completerIntention(intention, waveStatus.transactionId);
return buildStatutResponse(intention, "Paiement confirmé !");
} else if (waveStatus.isExpired()) {
intention.setStatut(StatutIntentionPaiement.EXPIREE);
intentionPaiementRepository.persist(intention);
return buildStatutResponse(intention, "Session Wave expirée");
}
} catch (WaveCheckoutService.WaveCheckoutException e) {
LOG.warnf(e, "Impossible de vérifier la session Wave %s — retry au prochain poll",
intention.getWaveCheckoutSessionId());
}
}
return buildStatutResponse(intention, "En attente de confirmation Wave...");
}
/**
* Marque l'IntentionPaiement COMPLETEE et réconcilie les cotisations cibles (PAYEE).
* Utilisé par le polling web ET par WaveRedirectResource lors du redirect success.
*/
@Transactional
public void completerIntention(IntentionPaiement intention, String waveTransactionId) {
if (intention.isCompletee()) return; // idempotent
intention.setStatut(StatutIntentionPaiement.COMPLETEE);
intention.setDateCompletion(java.time.LocalDateTime.now());
if (waveTransactionId != null) intention.setWaveTransactionId(waveTransactionId);
intentionPaiementRepository.persist(intention);
// Réconcilier les cotisations listées dans objetsCibles
String objetsCibles = intention.getObjetsCibles();
if (objetsCibles == null || objetsCibles.isBlank()) return;
try {
com.fasterxml.jackson.databind.JsonNode arr =
new com.fasterxml.jackson.databind.ObjectMapper().readTree(objetsCibles);
if (!arr.isArray()) return;
for (com.fasterxml.jackson.databind.JsonNode node : arr) {
if (!"COTISATION".equals(node.path("type").asText())) continue;
UUID cotisationId = UUID.fromString(node.get("id").asText());
java.math.BigDecimal montant = node.has("montant")
? new java.math.BigDecimal(node.get("montant").asText())
: intention.getMontantTotal();
Cotisation cotisation = paiementRepository.getEntityManager().find(Cotisation.class, cotisationId);
if (cotisation == null) continue;
cotisation.setMontantPaye(montant);
cotisation.setStatut("PAYEE");
cotisation.setDatePaiement(java.time.LocalDateTime.now());
paiementRepository.getEntityManager().merge(cotisation);
LOG.infof("Cotisation %s marquée PAYEE — Wave txn %s", cotisationId, waveTransactionId);
}
} catch (Exception e) {
LOG.errorf(e, "Erreur réconciliation cotisations pour intention %s", intention.getId());
}
}
private dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse buildStatutResponse(
IntentionPaiement intention, String message) {
return dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse.builder()
.intentionId(intention.getId())
.statut(intention.getStatut().name())
.waveLaunchUrl(intention.getWaveLaunchUrl())
.waveCheckoutSessionId(intention.getWaveCheckoutSessionId())
.waveTransactionId(intention.getWaveTransactionId())
.montant(intention.getMontantTotal())
.message(message)
.build();
}
/** Format E.164 pour Wave (ex: 771234567 -> +225771234567). */
private static String toE164(String numeroTelephone) {
if (numeroTelephone == null || numeroTelephone.isBlank()) return null;
@@ -628,21 +509,13 @@ public class PaiementService {
paiementRepository.persist(paiement);
// Notifier l'admin de l'organisation pour validation du paiement manuel
membreOrganisationRepository.findFirstByMembreId(membreConnecte.getId())
.ifPresent(mo -> {
CreateNotificationRequest notif = CreateNotificationRequest.builder()
.typeNotification("VALIDATION_PAIEMENT_REQUIS")
.priorite("HAUTE")
.sujet("Validation paiement manuel requis")
.corps("Le membre " + membreConnecte.getNumeroMembre()
+ " a déclaré un paiement manuel de " + paiement.getMontant()
+ " XOF (réf: " + paiement.getNumeroReference() + ") à valider.")
.organisationId(mo.getOrganisation().getId())
.build();
notificationService.creerNotification(notif);
LOG.infof("Notification de validation envoyée pour l'organisation %s", mo.getOrganisation().getId());
});
// TODO: Créer une notification pour le trésorier
// notificationService.creerNotification(
// "VALIDATION_PAIEMENT_REQUIS",
// "Validation paiement manuel requis",
// "Le membre " + membreConnecte.getNumeroMembre() + " a déclaré un paiement manuel à valider.",
// tresorierIds
// );
LOG.infof("Paiement manuel déclaré avec succès: ID=%s, Référence=%s (EN_ATTENTE_VALIDATION)",
paiement.getId(), paiement.getNumeroReference());
@@ -650,6 +523,69 @@ public class PaiementService {
return convertToResponse(paiement);
}
// ── Polling statut intention ──────────────────────────────────────────────
/**
* Retourne le statut d'une intention de paiement Wave.
* Utilisé par le polling web (QR code) et le deep link mobile.
*/
@Transactional
public dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse getStatutIntention(UUID intentionId) {
IntentionPaiement intention = intentionPaiementRepository.findById(intentionId);
if (intention == null) {
throw new NotFoundException("Intention de paiement non trouvée : " + intentionId);
}
if (intention.isCompletee()) {
return buildIntentionStatutResponse(intention, "Paiement confirmé !");
}
if (StatutIntentionPaiement.EXPIREE.equals(intention.getStatut())
|| StatutIntentionPaiement.ECHOUEE.equals(intention.getStatut())) {
return buildIntentionStatutResponse(intention,
"Paiement " + intention.getStatut().name().toLowerCase());
}
if (intention.isExpiree()) {
intention.setStatut(StatutIntentionPaiement.EXPIREE);
intentionPaiementRepository.persist(intention);
return buildIntentionStatutResponse(intention, "Session expirée, veuillez recommencer");
}
if (intention.getWaveCheckoutSessionId() != null) {
try {
WaveCheckoutService.WaveSessionStatusResponse waveStatus =
waveCheckoutService.getSession(intention.getWaveCheckoutSessionId());
if (waveStatus.isSucceeded()) {
intention.setStatut(StatutIntentionPaiement.COMPLETEE);
intentionPaiementRepository.persist(intention);
return buildIntentionStatutResponse(intention, "Paiement confirmé !");
} else if (waveStatus.isExpired()) {
intention.setStatut(StatutIntentionPaiement.EXPIREE);
intentionPaiementRepository.persist(intention);
return buildIntentionStatutResponse(intention, "Session Wave expirée");
}
} catch (WaveCheckoutException e) {
LOG.warnf(e, "Impossible de vérifier la session Wave %s",
intention.getWaveCheckoutSessionId());
}
}
return buildIntentionStatutResponse(intention, "En attente de confirmation Wave...");
}
private dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse buildIntentionStatutResponse(
IntentionPaiement intention, String message) {
return dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse.builder()
.intentionId(intention.getId())
.statut(intention.getStatut().name())
.confirme(intention.isCompletee())
.waveLaunchUrl(intention.getWaveLaunchUrl())
.waveCheckoutSessionId(intention.getWaveCheckoutSessionId())
.waveTransactionId(intention.getWaveTransactionId())
.montant(intention.getMontantTotal())
.message(message)
.build();
}
// ========================================
// MÉTHODES PRIVÉES
// ========================================
@@ -672,6 +608,10 @@ public class PaiementService {
/** Convertit une entité en Response DTO */
private PaiementResponse convertToResponse(Paiement paiement) {
if (paiement == null) {
return null;
}
PaiementResponse response = new PaiementResponse();
response.setId(paiement.getId());
response.setNumeroReference(paiement.getNumeroReference());

View File

@@ -1,25 +1,47 @@
package dev.lions.unionflow.server.service;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.lions.unionflow.server.api.dto.system.request.UpdateSystemConfigRequest;
import dev.lions.unionflow.server.api.dto.system.response.CacheStatsResponse;
import dev.lions.unionflow.server.api.dto.system.response.SystemConfigResponse;
import dev.lions.unionflow.server.api.dto.system.response.SystemTestResultResponse;
import dev.lions.unionflow.server.entity.BackupRecord;
import dev.lions.unionflow.server.entity.SystemConfigPersistence;
import dev.lions.unionflow.server.entity.SystemLog;
import dev.lions.unionflow.server.repository.BackupRecordRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.SystemConfigPersistenceRepository;
import dev.lions.unionflow.server.repository.SystemLogRepository;
import io.quarkus.cache.CacheManager;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import javax.sql.DataSource;
import java.io.File;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.OperatingSystemMXBean;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.UUID;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Service de gestion de la configuration système
@@ -34,12 +56,42 @@ public class SystemConfigService {
@Inject
DataSource dataSource;
@Inject
SystemLogRepository systemLogRepository;
@Inject
MembreRepository membreRepository;
@Inject
BackupRecordRepository backupRecordRepository;
@Inject
SystemConfigPersistenceRepository systemConfigPersistence;
@Inject
KeycloakAdminHttpClient keycloakAdminHttpClient;
@ConfigProperty(name = "quarkus.application.name", defaultValue = "UnionFlow")
String applicationName;
@ConfigProperty(name = "quarkus.application.version", defaultValue = "1.0.0")
String applicationVersion;
@ConfigProperty(name = "quarkus.datasource.username", defaultValue = "unionflow")
String dbUsername;
@ConfigProperty(name = "quarkus.datasource.password", defaultValue = "changeme")
String dbPassword;
@ConfigProperty(name = "quarkus.datasource.jdbc.url", defaultValue = "jdbc:postgresql://localhost:5432/unionflow")
String jdbcUrl;
@ConfigProperty(name = "unionflow.backup.directory", defaultValue = "/tmp/unionflow-backups")
String backupDirectory;
@ConfigProperty(name = "unionflow.updates.check-url")
Optional<String> updatesCheckUrl;
private final LocalDateTime startTime = LocalDateTime.now();
private final AtomicReference<UpdateSystemConfigRequest> configOverrides = new AtomicReference<>(null);
@@ -61,8 +113,8 @@ public class SystemConfigService {
? overrides.getTimezone() : "UTC")
.defaultLanguage(overrides != null && overrides.getDefaultLanguage() != null
? overrides.getDefaultLanguage() : "fr")
.maintenanceMode(overrides != null && overrides.getMaintenanceMode() != null
? overrides.getMaintenanceMode() : false)
.maintenanceMode(systemConfigPersistence.getBooleanValue("maintenance_mode",
overrides != null && overrides.getMaintenanceMode() != null && overrides.getMaintenanceMode()))
.lastUpdated(LocalDateTime.now())
// Configuration réseau
@@ -143,10 +195,15 @@ public class SystemConfigService {
/**
* Mettre à jour la configuration système
*/
@Transactional
public SystemConfigResponse updateSystemConfig(UpdateSystemConfigRequest request) {
log.info("Mise à jour de la configuration système");
configOverrides.set(request);
log.info("Configuration système mise à jour en mémoire");
// Persister les clés critiques en base
if (request.getMaintenanceMode() != null) {
systemConfigPersistence.setValue("maintenance_mode", String.valueOf(request.getMaintenanceMode()));
}
log.info("Configuration système mise à jour (RAM + DB pour clés critiques)");
return getSystemConfig();
}
@@ -286,6 +343,394 @@ public class SystemConfigService {
}
}
/**
* Optimiser la base de données (VACUUM ANALYZE via connexion directe)
*/
public Map<String, Object> optimizeDatabase() {
long start = System.currentTimeMillis();
try (java.sql.Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(true);
try (Statement stmt = conn.createStatement()) {
stmt.execute("VACUUM ANALYZE");
}
long duration = System.currentTimeMillis() - start;
log.info("VACUUM ANALYZE exécuté en {}ms", duration);
return Map.of("message", "Base de données optimisée en " + duration + "ms", "success", true, "durationMs", duration);
} catch (Exception e) {
log.error("Erreur VACUUM ANALYZE", e);
throw new RuntimeException("Erreur d'optimisation: " + e.getMessage());
}
}
/**
* Forcer la déconnexion globale via l'API Admin Keycloak
*/
@Transactional
public Map<String, Object> forceGlobalLogout() {
log.warn("FORCE_LOGOUT_ALL déclenché par l'administrateur");
try {
int count = keycloakAdminHttpClient.logoutAllSessions();
SystemLog entry = new SystemLog();
entry.setLevel("WARN");
entry.setSource("SECURITY");
entry.setMessage("FORCE_LOGOUT_ALL: " + count + " session(s) Keycloak révoquée(s) par l'administrateur");
entry.setTimestamp(LocalDateTime.now());
systemLogRepository.persist(entry);
return Map.of(
"message", count + " session(s) révoquée(s) dans Keycloak",
"count", (long) count,
"success", true
);
} catch (Exception e) {
log.error("Erreur déconnexion globale Keycloak", e);
throw new RuntimeException("Erreur Keycloak Admin API: " + e.getMessage());
}
}
/**
* Nettoyer les sessions expirées (logs DEBUG > 7 jours)
*/
@Transactional
public Map<String, Object> cleanupSessions() {
LocalDateTime threshold = LocalDateTime.now().minusDays(7);
int deleted = systemLogRepository.deleteOlderThan(threshold);
log.info("Nettoyage sessions: {} entrées supprimées (avant {})", deleted, threshold);
return Map.of("message", deleted + " entrée(s) expirée(s) nettoyée(s)", "count", (long) deleted, "success", true);
}
/**
* Nettoyer les anciens logs selon la rétention configurée
*/
@Transactional
public Map<String, Object> cleanOldLogs() {
UpdateSystemConfigRequest overrides = configOverrides.get();
int retentionDays = (overrides != null && overrides.getLogRetentionDays() != null)
? overrides.getLogRetentionDays() : 30;
LocalDateTime threshold = LocalDateTime.now().minusDays(retentionDays);
int deleted = systemLogRepository.deleteOlderThan(threshold);
log.info("Nettoyage logs: {} entrées supprimées (rétention {} jours)", deleted, retentionDays);
return Map.of("message", deleted + " log(s) supprimé(s) (antérieurs à " + retentionDays + " jours)", "count", (long) deleted, "success", true);
}
/**
* Purger les données expirées (logs WARN/ERROR > 90 jours)
*/
@Transactional
public Map<String, Object> purgeExpiredData() {
LocalDateTime threshold = LocalDateTime.now().minusDays(90);
int deletedLogs = systemLogRepository.deleteOlderThan(threshold);
log.info("Purge données: {} log(s) supprimé(s) (> 90 jours)", deletedLogs);
return Map.of("message", deletedLogs + " enregistrement(s) expiré(s) purgé(s)", "count", (long) deletedLogs, "success", true);
}
/**
* Analyser les performances (statistiques des tables PostgreSQL)
*/
public Map<String, Object> analyzePerformance() {
long start = System.currentTimeMillis();
try (java.sql.Connection conn = dataSource.getConnection()) {
Map<String, Object> stats = new LinkedHashMap<>();
// Taille des tables
try (var stmt = conn.createStatement();
var rs = stmt.executeQuery(
"SELECT relname AS table_name, " +
"pg_size_pretty(pg_total_relation_size(relid)) AS total_size, " +
"n_live_tup AS row_count, " +
"n_dead_tup AS dead_rows " +
"FROM pg_stat_user_tables " +
"ORDER BY pg_total_relation_size(relid) DESC " +
"LIMIT 10")) {
List<Map<String, Object>> tables = new ArrayList<>();
while (rs.next()) {
tables.add(Map.of(
"table", rs.getString("table_name"),
"size", rs.getString("total_size"),
"rows", rs.getLong("row_count"),
"deadRows", rs.getLong("dead_rows")
));
}
stats.put("tables", tables);
}
long duration = System.currentTimeMillis() - start;
stats.put("analysisTimeMs", duration);
stats.put("success", true);
stats.put("message", "Analyse des performances effectuée en " + duration + "ms");
return stats;
} catch (Exception e) {
log.error("Erreur analyse performance", e);
throw new RuntimeException("Erreur d'analyse: " + e.getMessage());
}
}
/**
* Créer une sauvegarde via pg_dump avec enregistrement en base
*/
@Transactional
public Map<String, Object> createBackup() {
String backupId = "BKP-" + java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now());
// Extraire host/port/dbname depuis le JDBC URL
Pattern pattern = Pattern.compile("jdbc:postgresql://([^:/]+):?(\\d+)?/([^?]+)");
Matcher matcher = pattern.matcher(jdbcUrl);
if (!matcher.find()) throw new RuntimeException("URL JDBC invalide: " + jdbcUrl);
String dbHost = matcher.group(1);
String dbPort = matcher.group(2) != null ? matcher.group(2) : "5432";
String dbName = matcher.group(3);
// Préparer le répertoire et le fichier de destination
new File(backupDirectory).mkdirs();
String backupFile = backupDirectory + "/" + backupId + ".sql";
// Enregistrer le backup comme IN_PROGRESS
BackupRecord record = BackupRecord.builder()
.name(backupId)
.description("Sauvegarde manuelle déclenchée depuis les paramètres système")
.type("MANUAL")
.status("IN_PROGRESS")
.includesDatabase(true)
.includesFiles(false)
.includesConfiguration(true)
.filePath(backupFile)
.build();
backupRecordRepository.persist(record);
UUID recordId = record.getId();
try {
ProcessBuilder pb = new ProcessBuilder(
"pg_dump",
"-h", dbHost,
"-p", dbPort,
"-U", dbUsername,
"--no-password",
"-F", "p",
"-f", backupFile,
dbName
);
pb.environment().put("PGPASSWORD", dbPassword);
pb.redirectErrorStream(true);
Process process = pb.start();
String output = new String(process.getInputStream().readAllBytes());
int exitCode = process.waitFor();
if (exitCode != 0) {
backupRecordRepository.updateStatus(recordId, "FAILED", null, LocalDateTime.now(), output);
throw new RuntimeException("pg_dump a échoué (exit " + exitCode + "): " + output);
}
long fileSize = new File(backupFile).length();
backupRecordRepository.updateStatus(recordId, "COMPLETED", fileSize, LocalDateTime.now(), null);
log.info("Sauvegarde créée: {} ({} bytes)", backupFile, fileSize);
return Map.of(
"message", "Sauvegarde " + backupId + " créée (" + formatBytes(fileSize) + ")",
"backupId", backupId,
"filePath", backupFile,
"sizeBytes", fileSize,
"sizeFormatted", formatBytes(fileSize),
"success", true,
"createdAt", LocalDateTime.now().toString()
);
} catch (RuntimeException re) {
throw re;
} catch (Exception e) {
backupRecordRepository.updateStatus(recordId, "FAILED", null, LocalDateTime.now(), e.getMessage());
log.error("Erreur création sauvegarde", e);
throw new RuntimeException("Erreur de sauvegarde: " + e.getMessage());
}
}
/**
* Planifier une maintenance (persistée en base)
*/
@Transactional
public Map<String, Object> scheduleMaintenance(String scheduledAt, String reason) {
String effectiveReason = reason != null && !reason.isBlank() ? reason : "Maintenance de routine";
log.info("Planification maintenance: {} — {}", scheduledAt, effectiveReason);
systemConfigPersistence.setValue("scheduled_maintenance_at", scheduledAt != null ? scheduledAt : "");
systemConfigPersistence.setValue("scheduled_maintenance_reason", effectiveReason);
systemConfigPersistence.setValue("scheduled_maintenance_status", "SCHEDULED");
SystemLog entry = new SystemLog();
entry.setLevel("INFO");
entry.setSource("MAINTENANCE");
entry.setMessage("Maintenance planifiée pour le " + scheduledAt + " : " + effectiveReason);
entry.setTimestamp(LocalDateTime.now());
systemLogRepository.persist(entry);
return Map.of(
"message", "Maintenance planifiée pour le " + scheduledAt + " (persistée en base)",
"success", true,
"scheduledAt", scheduledAt != null ? scheduledAt : "",
"reason", effectiveReason,
"status", "SCHEDULED"
);
}
/**
* Activer la maintenance d'urgence (persistée en base — survit aux redémarrages)
*/
@Transactional
public Map<String, Object> emergencyMaintenance() {
log.warn("EMERGENCY_MAINTENANCE activé");
// Persister en base (survit au redémarrage)
systemConfigPersistence.setValue("maintenance_mode", "true");
systemConfigPersistence.setValue("maintenance_emergency", "true");
// Mettre à jour aussi le cache RAM
UpdateSystemConfigRequest current = configOverrides.get();
if (current == null) current = new UpdateSystemConfigRequest();
current.setMaintenanceMode(true);
configOverrides.set(current);
SystemLog entry = new SystemLog();
entry.setLevel("WARN");
entry.setSource("MAINTENANCE");
entry.setMessage("EMERGENCY_MAINTENANCE: Mode maintenance d'urgence activé et persisté en base");
entry.setTimestamp(LocalDateTime.now());
systemLogRepository.persist(entry);
return Map.of(
"message", "Mode maintenance d'urgence activé — persisté en base, survit aux redémarrages",
"success", true,
"maintenanceMode", true
);
}
/**
* Vérifier les mises à jour disponibles
*/
public Map<String, Object> checkUpdates() {
log.info("Vérification des mises à jour (version actuelle: {})", applicationVersion);
String checkUrl = updatesCheckUrl.orElse("");
if (!checkUrl.isBlank()) {
try {
var httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
var request = HttpRequest.newBuilder()
.uri(URI.create(checkUrl))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
var mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
var json = mapper.readTree(response.body());
String latestVersion = json.has("version") ? json.get("version").asText() : applicationVersion;
boolean updateAvailable = !applicationVersion.equals(latestVersion);
return Map.of(
"currentVersion", applicationVersion,
"latestVersion", latestVersion,
"updateAvailable", updateAvailable,
"message", updateAvailable
? "Mise à jour disponible : v" + latestVersion
: "Système à jour (v" + applicationVersion + ")",
"success", true,
"checkedAt", LocalDateTime.now().toString()
);
}
} catch (Exception e) {
log.warn("Vérification distante impossible ({}): {}", checkUrl, e.getMessage());
}
}
// Aucun endpoint distant configuré — retourner la version réelle honnêtement
return Map.of(
"currentVersion", applicationVersion,
"latestVersion", applicationVersion,
"updateAvailable", false,
"message", "Version actuelle : v" + applicationVersion
+ (checkUrl.isBlank()
? " — vérification distante non configurée (unionflow.updates.check-url)"
: " — vérification distante indisponible"),
"checkConfigured", !checkUrl.isBlank(),
"success", true,
"checkedAt", LocalDateTime.now().toString()
);
}
/**
* Exporter les logs récents (dernières 24h)
*/
public Map<String, Object> exportLogs() {
LocalDateTime since = LocalDateTime.now().minusHours(24);
List<SystemLog> logs = systemLogRepository.findByTimestampBetween(since, LocalDateTime.now());
List<Map<String, Object>> exportedLogs = new ArrayList<>();
for (SystemLog l : logs) {
exportedLogs.add(Map.of(
"level", l.getLevel() != null ? l.getLevel() : "",
"source", l.getSource() != null ? l.getSource() : "",
"message", l.getMessage() != null ? l.getMessage() : "",
"timestamp", l.getTimestamp() != null ? l.getTimestamp().toString() : ""
));
}
log.info("Export logs: {} entrées (24h)", exportedLogs.size());
return Map.of("logs", exportedLogs, "count", (long) exportedLogs.size(), "period", "24h", "success", true, "message", exportedLogs.size() + " log(s) exporté(s)");
}
/**
* Générer un rapport d'utilisation
*/
public Map<String, Object> generateUsageReport() {
log.info("Génération du rapport d'utilisation");
long totalMembers = membreRepository.count();
long activeMembers = membreRepository.count("actif = true");
long totalLogs = systemLogRepository.count();
long errorsLast24h = systemLogRepository.countByLevelLast24h("ERROR");
long warningsLast24h = systemLogRepository.countByLevelLast24h("WARN");
return Map.of(
"totalMembers", totalMembers,
"activeMembers", activeMembers,
"totalLogs", totalLogs,
"errorsLast24h", errorsLast24h,
"warningsLast24h", warningsLast24h,
"generatedAt", LocalDateTime.now().toString(),
"success", true,
"message", "Rapport d'utilisation généré"
);
}
/**
* Générer un rapport d'audit
*/
public Map<String, Object> generateAuditReport() {
log.info("Génération du rapport d'audit");
long totalLogs = systemLogRepository.count();
long errorsLast24h = systemLogRepository.countByLevelLast24h("ERROR");
long warningsLast24h = systemLogRepository.countByLevelLast24h("WARN");
long infoLast24h = systemLogRepository.countByLevelLast24h("INFO");
String reportId = "AUDIT-" + java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now());
return Map.of(
"reportId", reportId,
"totalEvents", totalLogs,
"errorsLast24h", errorsLast24h,
"warningsLast24h", warningsLast24h,
"infoEventsLast24h", infoLast24h,
"generatedAt", LocalDateTime.now().toString(),
"success", true,
"message", "Rapport d'audit " + reportId + " généré"
);
}
/**
* Exporter les données RGPD
*/
public Map<String, Object> exportGDPRData() {
log.info("Export RGPD déclenché");
long totalMembers = membreRepository.count();
String exportId = "RGPD-" + java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now());
return Map.of(
"exportId", exportId,
"totalRecords", totalMembers,
"status", "INITIATED",
"success", true,
"message", "Export RGPD " + exportId + " initié — vous recevrez un email quand il sera prêt",
"estimatedCompletionMinutes", 5
);
}
/**
* Formater les bytes en format lisible
*/

View File

@@ -0,0 +1,558 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest;
import dev.lions.unionflow.server.api.dto.versement.request.DeclarerVersementManuelRequest;
import dev.lions.unionflow.server.api.dto.versement.request.InitierDepotEpargneRequest;
import dev.lions.unionflow.server.api.dto.versement.request.InitierVersementWaveRequest;
import dev.lions.unionflow.server.api.dto.versement.response.VersementGatewayResponse;
import dev.lions.unionflow.server.api.dto.versement.response.VersementResponse;
import dev.lions.unionflow.server.api.dto.versement.response.VersementStatutResponse;
import dev.lions.unionflow.server.api.dto.versement.response.VersementSummaryResponse;
import dev.lions.unionflow.server.api.enums.paiement.StatutIntentionPaiement;
import dev.lions.unionflow.server.api.enums.paiement.TypeObjetIntentionPaiement;
import dev.lions.unionflow.server.entity.Cotisation;
import dev.lions.unionflow.server.entity.IntentionPaiement;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Versement;
import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne;
import dev.lions.unionflow.server.repository.IntentionPaiementRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.TypeReferenceRepository;
import dev.lions.unionflow.server.repository.VersementRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutException;
import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutSessionResponse;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import org.jboss.logging.Logger;
/**
* Service métier pour la gestion des versements.
*
* <p>Un versement est l'acte de régler une cotisation. Deux flux sont supportés :
* <ol>
* <li><b>Wave</b> : deep link natif, app Wave sur le même téléphone, retour via
* {@code unionflow://payment?result=success&ref={intentionId}}</li>
* <li><b>Manuel</b> : déclaration espèces/virement/chèque → validation trésorier</li>
* </ol>
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@ApplicationScoped
public class VersementService {
private static final Logger LOG = Logger.getLogger(VersementService.class);
@Inject VersementRepository versementRepository;
@Inject MembreRepository membreRepository;
@Inject KeycloakService keycloakService;
@Inject TypeReferenceRepository typeReferenceRepository;
@Inject IntentionPaiementRepository intentionPaiementRepository;
@Inject WaveCheckoutService waveCheckoutService;
@Inject CompteEpargneRepository compteEpargneRepository;
@Inject MembreOrganisationRepository membreOrganisationRepository;
@Inject NotificationService notificationService;
@Inject io.quarkus.security.identity.SecurityIdentity securityIdentity;
// ── Lecture ───────────────────────────────────────────────────────────────
public VersementResponse trouverParId(UUID id) {
return versementRepository.findVersementById(id)
.map(this::convertToResponse)
.orElseThrow(() -> new NotFoundException("Versement non trouvé : " + id));
}
public VersementResponse trouverParNumeroReference(String numeroReference) {
return versementRepository.findByNumeroReference(numeroReference)
.map(this::convertToResponse)
.orElseThrow(() -> new NotFoundException("Versement non trouvé : " + numeroReference));
}
public List<VersementSummaryResponse> listerParMembre(UUID membreId) {
return versementRepository.findByMembreId(membreId).stream()
.map(this::convertToSummaryResponse)
.collect(Collectors.toList());
}
public BigDecimal calculerMontantTotalConfirmes(LocalDateTime dateDebut, LocalDateTime dateFin) {
return versementRepository.calculerMontantTotalConfirmes(dateDebut, dateFin);
}
/**
* Historique des versements du membre connecté (auto-détection).
* Retourne uniquement les versements confirmés/validés.
*/
public List<VersementSummaryResponse> getMesVersements(int limit) {
Membre membreConnecte = getMembreConnecte();
LOG.infof("Historique versements pour %s, limit=%d",
membreConnecte.getNumeroMembre(), limit);
List<Versement> versements = versementRepository.getEntityManager()
.createQuery(
"SELECT v FROM Versement v "
+ "WHERE v.membre.id = :membreId "
+ "AND v.statutPaiement IN ('CONFIRME', 'VALIDE') "
+ "ORDER BY v.datePaiement DESC",
Versement.class)
.setParameter("membreId", membreConnecte.getId())
.setMaxResults(limit)
.getResultList();
return versements.stream()
.map(this::convertToSummaryResponse)
.collect(Collectors.toList());
}
// ── Validation / Annulation ───────────────────────────────────────────────
@Transactional
public VersementResponse validerVersement(UUID id) {
Versement versement = versementRepository.findVersementById(id)
.orElseThrow(() -> new NotFoundException("Versement non trouvé : " + id));
if (versement.isConfirme()) {
return convertToResponse(versement);
}
versement.setStatutPaiement("CONFIRME");
versement.setDateValidation(LocalDateTime.now());
versement.setValidateur(keycloakService.getCurrentUserEmail());
versement.setModifiePar(keycloakService.getCurrentUserEmail());
versementRepository.persist(versement);
LOG.infof("Versement validé : %s", id);
return convertToResponse(versement);
}
@Transactional
public VersementResponse annulerVersement(UUID id) {
Versement versement = versementRepository.findVersementById(id)
.orElseThrow(() -> new NotFoundException("Versement non trouvé : " + id));
if (!versement.peutEtreModifie()) {
throw new IllegalStateException("Le versement ne peut plus être annulé (statut finalisé)");
}
versement.setStatutPaiement("ANNULE");
versement.setModifiePar(keycloakService.getCurrentUserEmail());
versementRepository.persist(versement);
LOG.infof("Versement annulé : %s", id);
return convertToResponse(versement);
}
// ── Flux Wave ─────────────────────────────────────────────────────────────
/**
* Initie un versement Wave.
*
* <ol>
* <li>Crée une {@link IntentionPaiement} (hub Wave interne)</li>
* <li>Appelle l'API Wave Checkout → obtient {@code waveLaunchUrl}</li>
* <li>Retourne {@link VersementGatewayResponse} avec {@code waveLaunchUrl}
* pour que {@code url_launcher} ouvre Wave directement</li>
* </ol>
*/
@Transactional
public VersementGatewayResponse initierVersementWave(InitierVersementWaveRequest request) {
Membre membreConnecte = getMembreConnecte();
LOG.infof("Initiation versement Wave — membre %s, cotisation %s",
membreConnecte.getNumeroMembre(), request.cotisationId());
Cotisation cotisation = versementRepository.getEntityManager()
.find(Cotisation.class, request.cotisationId());
if (cotisation == null) {
throw new NotFoundException("Cotisation non trouvée : " + request.cotisationId());
}
if (!cotisation.getMembre().getId().equals(membreConnecte.getId())) {
throw new IllegalArgumentException("Cette cotisation n'appartient pas au membre connecté");
}
String base = waveCheckoutService.getRedirectBaseUrl().replaceAll("/+$", "");
// 1. Créer l'intention (détail interne Wave — non exposé dans l'API publique)
IntentionPaiement intention = IntentionPaiement.builder()
.utilisateur(membreConnecte)
.organisation(cotisation.getOrganisation())
.montantTotal(cotisation.getMontantDu())
.codeDevise(cotisation.getCodeDevise() != null ? cotisation.getCodeDevise() : "XOF")
.typeObjet(TypeObjetIntentionPaiement.COTISATION)
.statut(StatutIntentionPaiement.INITIEE)
.objetsCibles("[{\"type\":\"COTISATION\",\"id\":\"" + cotisation.getId()
+ "\",\"montant\":" + cotisation.getMontantDu() + "}]")
.build();
intentionPaiementRepository.persist(intention);
// 2. URL success → deep link si mobile, page HTML si web
boolean isMobile = request.numeroTelephone() != null && !request.numeroTelephone().isBlank();
String successUrl = base + (isMobile
? "/api/wave-redirect/success?ref=" + intention.getId()
: "/api/wave-redirect/web-success?ref=" + intention.getId());
String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId();
String amountStr = cotisation.getMontantDu()
.setScale(0, java.math.RoundingMode.HALF_UP).toString();
String restrictMobile = toE164(request.numeroTelephone());
// 3. Appel Wave Checkout API
WaveCheckoutSessionResponse session;
try {
session = waveCheckoutService.createSession(
amountStr, "XOF", successUrl, errorUrl,
intention.getId().toString(), restrictMobile);
} catch (WaveCheckoutException e) {
LOG.errorf(e, "Wave Checkout API error : %s", e.getMessage());
intention.setStatut(StatutIntentionPaiement.ECHOUEE);
intentionPaiementRepository.persist(intention);
throw new jakarta.ws.rs.BadRequestException("Wave : " + e.getMessage());
}
intention.setWaveCheckoutSessionId(session.id);
intention.setWaveLaunchUrl(session.waveLaunchUrl);
intention.setStatut(StatutIntentionPaiement.EN_COURS);
intentionPaiementRepository.persist(intention);
cotisation.setIntentionPaiement(intention);
versementRepository.getEntityManager().merge(cotisation);
// 4. Créer le versement en EN_ATTENTE
Versement versement = new Versement();
versement.setNumeroReference("VRS-WAVE-" + intention.getId().toString().substring(0, 8).toUpperCase());
versement.setMontant(cotisation.getMontantDu());
versement.setCodeDevise("XOF");
versement.setMethodePaiement("WAVE");
versement.setStatutPaiement("EN_ATTENTE");
versement.setMembre(membreConnecte);
versement.setReferenceExterne(session.id);
versement.setNumeroTelephone(request.numeroTelephone());
versement.setCommentaire("Versement Wave — session " + session.id);
versement.setCreePar(membreConnecte.getEmail());
versementRepository.persist(versement);
LOG.infof("Versement Wave initié : intention=%s, session=%s, waveLaunchUrl=%s",
intention.getId(), session.id, session.waveLaunchUrl);
return VersementGatewayResponse.builder()
.versementId(versement.getId())
.waveLaunchUrl(session.waveLaunchUrl)
.waveCheckoutSessionId(session.id)
.clientReference(intention.getId().toString())
.montant(cotisation.getMontantDu())
.statut("EN_ATTENTE")
.referenceCotisation(cotisation.getNumeroReference())
.message("Ouvrez Wave pour confirmer le versement, puis vous serez renvoyé dans UnionFlow.")
.build();
}
// ── Flux dépôt épargne ────────────────────────────────────────────────────
@Transactional
public VersementGatewayResponse initierDepotEpargneEnLigne(InitierDepotEpargneRequest request) {
Membre membreConnecte = getMembreConnecte();
CompteEpargne compte = compteEpargneRepository.findByIdOptional(request.compteId())
.orElseThrow(() -> new NotFoundException("Compte épargne non trouvé : " + request.compteId()));
if (!compte.getMembre().getId().equals(membreConnecte.getId())) {
throw new IllegalArgumentException("Ce compte épargne n'appartient pas au membre connecté");
}
String base = waveCheckoutService.getRedirectBaseUrl().replaceAll("/+$", "");
BigDecimal montant = request.montant().setScale(0, java.math.RoundingMode.HALF_UP);
String objetsCibles = "[{\"type\":\"DEPOT_EPARGNE\",\"compteId\":\""
+ request.compteId() + "\",\"montant\":" + montant + "}]";
IntentionPaiement intention = IntentionPaiement.builder()
.utilisateur(membreConnecte)
.organisation(compte.getOrganisation())
.montantTotal(montant)
.codeDevise("XOF")
.typeObjet(TypeObjetIntentionPaiement.DEPOT_EPARGNE)
.statut(StatutIntentionPaiement.INITIEE)
.objetsCibles(objetsCibles)
.build();
intentionPaiementRepository.persist(intention);
String successUrl = base + "/api/wave-redirect/success?ref=" + intention.getId();
String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId();
WaveCheckoutSessionResponse session;
try {
session = waveCheckoutService.createSession(
montant.toString(), "XOF", successUrl, errorUrl,
intention.getId().toString(), toE164(request.numeroTelephone()));
} catch (WaveCheckoutException e) {
LOG.errorf(e, "Wave Checkout (dépôt épargne) : %s", e.getMessage());
intention.setStatut(StatutIntentionPaiement.ECHOUEE);
intentionPaiementRepository.persist(intention);
throw new jakarta.ws.rs.BadRequestException("Wave : " + e.getMessage());
}
intention.setWaveCheckoutSessionId(session.id);
intention.setWaveLaunchUrl(session.waveLaunchUrl);
intention.setStatut(StatutIntentionPaiement.EN_COURS);
intentionPaiementRepository.persist(intention);
LOG.infof("Dépôt épargne Wave initié : intention=%s, compte=%s",
intention.getId(), request.compteId());
return VersementGatewayResponse.builder()
.versementId(intention.getId())
.waveLaunchUrl(session.waveLaunchUrl)
.waveCheckoutSessionId(session.id)
.clientReference(intention.getId().toString())
.montant(montant)
.statut("EN_ATTENTE")
.message("Ouvrez Wave pour confirmer le dépôt, puis vous serez renvoyé dans UnionFlow.")
.build();
}
// ── Polling statut ────────────────────────────────────────────────────────
/**
* Vérifie le statut d'une intention Wave.
* Utilisé par le deep link de retour (mobile) et le polling web.
*/
@Transactional
public VersementStatutResponse verifierStatutVersement(UUID intentionId) {
IntentionPaiement intention = intentionPaiementRepository.findById(intentionId);
if (intention == null) {
throw new NotFoundException("Intention non trouvée : " + intentionId);
}
if (intention.isCompletee()) {
return buildStatutResponse(intention, "Versement confirmé !");
}
if (StatutIntentionPaiement.EXPIREE.equals(intention.getStatut())
|| StatutIntentionPaiement.ECHOUEE.equals(intention.getStatut())) {
return buildStatutResponse(intention,
"Versement " + intention.getStatut().name().toLowerCase());
}
if (intention.isExpiree()) {
intention.setStatut(StatutIntentionPaiement.EXPIREE);
intentionPaiementRepository.persist(intention);
return buildStatutResponse(intention, "Session expirée, veuillez recommencer");
}
if (intention.getWaveCheckoutSessionId() != null) {
try {
WaveCheckoutService.WaveSessionStatusResponse waveStatus =
waveCheckoutService.getSession(intention.getWaveCheckoutSessionId());
if (waveStatus.isSucceeded()) {
confirmerVersementWave(intention, waveStatus.transactionId);
return buildStatutResponse(intention, "Versement confirmé !");
} else if (waveStatus.isExpired()) {
intention.setStatut(StatutIntentionPaiement.EXPIREE);
intentionPaiementRepository.persist(intention);
return buildStatutResponse(intention, "Session Wave expirée");
}
} catch (WaveCheckoutService.WaveCheckoutException e) {
LOG.warnf(e, "Impossible de vérifier la session Wave %s — retry au prochain appel",
intention.getWaveCheckoutSessionId());
}
}
return buildStatutResponse(intention, "En attente de confirmation Wave...");
}
// ── Flux manuel ───────────────────────────────────────────────────────────
@Transactional
public VersementResponse declarerVersementManuel(DeclarerVersementManuelRequest request) {
Membre membreConnecte = getMembreConnecte();
LOG.infof("Déclaration versement manuel — membre %s, cotisation %s, méthode %s",
membreConnecte.getNumeroMembre(), request.cotisationId(), request.methodePaiement());
Cotisation cotisation = versementRepository.getEntityManager()
.createQuery("SELECT c FROM Cotisation c WHERE c.id = :id", Cotisation.class)
.setParameter("id", request.cotisationId())
.getResultList().stream().findFirst()
.orElseThrow(() -> new NotFoundException("Cotisation non trouvée : " + request.cotisationId()));
if (!cotisation.getMembre().getId().equals(membreConnecte.getId())) {
throw new IllegalArgumentException("Cette cotisation n'appartient pas au membre connecté");
}
Versement versement = new Versement();
versement.setNumeroReference("VRS-MAN-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase());
versement.setMontant(cotisation.getMontantDu());
versement.setCodeDevise("XOF");
versement.setMethodePaiement(request.methodePaiement());
versement.setStatutPaiement("EN_ATTENTE_VALIDATION");
versement.setMembre(membreConnecte);
versement.setReferenceExterne(request.reference());
versement.setCommentaire(request.commentaire());
versement.setDatePaiement(LocalDateTime.now());
versement.setCreePar(membreConnecte.getEmail());
versementRepository.persist(versement);
// Notifier le trésorier
membreOrganisationRepository.findFirstByMembreId(membreConnecte.getId())
.ifPresent(mo -> {
CreateNotificationRequest notif = CreateNotificationRequest.builder()
.typeNotification("VALIDATION_VERSEMENT_REQUIS")
.priorite("HAUTE")
.sujet("Validation versement manuel requis")
.corps("Le membre " + membreConnecte.getNumeroMembre()
+ " a déclaré un versement manuel de " + versement.getMontant()
+ " XOF (réf: " + versement.getNumeroReference() + ") à valider.")
.organisationId(mo.getOrganisation().getId())
.build();
notificationService.creerNotification(notif);
});
LOG.infof("Versement manuel déclaré : %s (EN_ATTENTE_VALIDATION)", versement.getNumeroReference());
return convertToResponse(versement);
}
// ── Réconciliation Wave (appelé par WaveRedirectResource) ─────────────────
/**
* Marque l'intention COMPLETEE et met à jour les cotisations cibles.
* Idempotent : si déjà complétée, retourne sans effet.
*/
@Transactional
public void confirmerVersementWave(IntentionPaiement intention, String waveTransactionId) {
if (intention.isCompletee()) return;
intention.setStatut(StatutIntentionPaiement.COMPLETEE);
intention.setDateCompletion(LocalDateTime.now());
if (waveTransactionId != null) {
intention.setWaveTransactionId(waveTransactionId);
}
intentionPaiementRepository.persist(intention);
String objetsCibles = intention.getObjetsCibles();
if (objetsCibles == null || objetsCibles.isBlank()) return;
try {
com.fasterxml.jackson.databind.JsonNode arr =
new com.fasterxml.jackson.databind.ObjectMapper().readTree(objetsCibles);
if (!arr.isArray()) return;
for (com.fasterxml.jackson.databind.JsonNode node : arr) {
if (!"COTISATION".equals(node.path("type").asText())) continue;
UUID cotisationId = UUID.fromString(node.get("id").asText());
BigDecimal montant = node.has("montant")
? new BigDecimal(node.get("montant").asText())
: intention.getMontantTotal();
Cotisation cotisation = versementRepository.getEntityManager()
.find(Cotisation.class, cotisationId);
if (cotisation == null) continue;
cotisation.setMontantPaye(montant);
cotisation.setStatut("PAYEE");
cotisation.setDatePaiement(LocalDateTime.now());
versementRepository.getEntityManager().merge(cotisation);
LOG.infof("Cotisation %s marquée PAYEE — Wave txn %s",
cotisationId, waveTransactionId);
}
} catch (Exception e) {
LOG.errorf(e, "Erreur réconciliation cotisations pour intention %s", intention.getId());
}
}
// ── Méthodes privées ──────────────────────────────────────────────────────
private Membre getMembreConnecte() {
String email = securityIdentity.getPrincipal().getName();
return membreRepository.findByEmail(email)
.orElseThrow(() -> new NotFoundException(
"Membre non trouvé pour l'email : " + email));
}
private VersementResponse convertToResponse(Versement v) {
VersementResponse r = new VersementResponse();
r.setId(v.getId());
r.setNumeroReference(v.getNumeroReference());
r.setMontant(v.getMontant());
r.setCodeDevise(v.getCodeDevise());
r.setMethodePaiement(v.getMethodePaiement());
r.setStatutPaiement(v.getStatutPaiement());
r.setDatePaiement(v.getDatePaiement());
r.setDateValidation(v.getDateValidation());
r.setValidateur(v.getValidateur());
r.setReferenceExterne(v.getReferenceExterne());
r.setUrlPreuve(v.getUrlPreuve());
r.setCommentaire(v.getCommentaire());
r.setNumeroTelephone(v.getNumeroTelephone());
if (v.getMembre() != null) {
r.setMembreId(v.getMembre().getId());
}
if (v.getTransactionWave() != null) {
r.setTransactionWaveId(v.getTransactionWave().getId());
}
r.setDateCreation(v.getDateCreation());
r.setDateModification(v.getDateModification());
r.setActif(v.getActif());
enrichirLibelles(v, r);
return r;
}
private VersementSummaryResponse convertToSummaryResponse(Versement v) {
if (v == null) return null;
return new VersementSummaryResponse(
v.getId(),
v.getNumeroReference(),
v.getMontant(),
v.getCodeDevise(),
resolveLibelle("METHODE_PAIEMENT", v.getMethodePaiement()),
v.getStatutPaiement(),
resolveLibelle("STATUT_PAIEMENT", v.getStatutPaiement()),
resolveSeverity("STATUT_PAIEMENT", v.getStatutPaiement()),
v.getDatePaiement());
}
private void enrichirLibelles(Versement v, VersementResponse r) {
r.setMethodePaiementLibelle(resolveLibelle("METHODE_PAIEMENT", v.getMethodePaiement()));
r.setStatutPaiementLibelle(resolveLibelle("STATUT_PAIEMENT", v.getStatutPaiement()));
r.setStatutPaiementSeverity(resolveSeverity("STATUT_PAIEMENT", v.getStatutPaiement()));
}
private String resolveLibelle(String domaine, String code) {
if (code == null) return null;
return typeReferenceRepository.findByDomaineAndCode(domaine, code)
.map(dev.lions.unionflow.server.entity.TypeReference::getLibelle)
.orElse(code);
}
private String resolveSeverity(String domaine, String code) {
if (code == null) return null;
return typeReferenceRepository.findByDomaineAndCode(domaine, code)
.map(dev.lions.unionflow.server.entity.TypeReference::getSeverity)
.orElse("info");
}
private VersementStatutResponse buildStatutResponse(IntentionPaiement intention, String message) {
return VersementStatutResponse.builder()
.intentionId(intention.getId())
.statut(intention.getStatut().name())
.confirme(intention.isCompletee())
.waveLaunchUrl(intention.getWaveLaunchUrl())
.waveCheckoutSessionId(intention.getWaveCheckoutSessionId())
.waveTransactionId(intention.getWaveTransactionId())
.montant(intention.getMontantTotal())
.message(message)
.build();
}
/** Format E.164 pour Wave : 771234567 → +221771234567 */
static String toE164(String numeroTelephone) {
if (numeroTelephone == null || numeroTelephone.isBlank()) return null;
String digits = numeroTelephone.replaceAll("\\D", "");
if (digits.length() == 9 && (digits.startsWith("7") || digits.startsWith("0"))) {
return "+221" + (digits.startsWith("0") ? digits.substring(1) : digits);
}
if (digits.length() >= 9 && digits.startsWith("221")) return "+" + digits;
return numeroTelephone.startsWith("+") ? numeroTelephone : "+" + digits;
}
}

View File

@@ -9,6 +9,19 @@ quarkus.application.version=1.0.0
# Backup configuration
unionflow.backup.directory=${BACKUP_DIR:/tmp/unionflow-backups}
# Keycloak Admin API (pour la déconnexion globale des sessions)
keycloak.admin.url=${KEYCLOAK_URL:http://localhost:8180}
keycloak.admin.username=${KEYCLOAK_ADMIN_USERNAME:admin}
keycloak.admin.password=${KEYCLOAK_ADMIN_PASSWORD:admin}
keycloak.admin.realm=${KEYCLOAK_REALM:unionflow}
# Vérification des mises à jour disponibles (propriété optionnelle — Optional<String> côté Java)
# Absent par défaut : le check distant est désactivé, la version courante est retournée honnêtement.
# Pour activer : définir UNIONFLOW_UPDATES_CHECK_URL (convention MicroProfile env var) ou
# ajouter la ligne suivante dans application-prod.properties :
# unionflow.updates.check-url=https://releases.lions.dev/unionflow/latest.json
# L'endpoint doit retourner un JSON {"version": "x.y.z"}
# Jackson — sérialisation des dates en ISO string (pas en tableau [year, month, day])
quarkus.jackson.write-dates-as-timestamps=false
quarkus.jackson.serialization-inclusion=non_null
@@ -172,3 +185,15 @@ mp.messaging.incoming.contributions-events-in.topic=unionflow.contributions.even
mp.messaging.incoming.contributions-events-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.contributions-events-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.contributions-events-in.group.id=unionflow-websocket-server
# Chat Messages — Messagerie instantanée
mp.messaging.outgoing.chat-messages-out.connector=smallrye-kafka
mp.messaging.outgoing.chat-messages-out.topic=unionflow.chat.messages
mp.messaging.outgoing.chat-messages-out.value.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.chat-messages-out.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.incoming.chat-messages-in.connector=smallrye-kafka
mp.messaging.incoming.chat-messages-in.topic=unionflow.chat.messages
mp.messaging.incoming.chat-messages-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.chat-messages-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.chat-messages-in.group.id=unionflow-websocket-server

View File

@@ -0,0 +1,13 @@
-- V25 : Rendre numero_transaction nullable dans la table paiements
--
-- Problème : la colonne numero_transaction est définie NOT NULL dans V1,
-- mais l'entité Paiement ne la mappe pas. Un paiement Wave créé en état
-- EN_ATTENTE n'a pas encore de numéro de transaction (celui-ci arrive via
-- le callback Wave après complétion). La contrainte NOT NULL empêche tout
-- INSERT et provoque un 500.
--
-- La colonne transaction_wave_id stocke déjà le session ID Wave ;
-- numero_transaction est distinct (ID de transaction finalisée chez Wave).
ALTER TABLE paiements
ALTER COLUMN numero_transaction DROP NOT NULL;

View File

@@ -0,0 +1,16 @@
-- V26 : Correction des colonnes legacy NOT NULL non mappées dans l'entité Paiement
--
-- Contexte : La table paiements a été créée par V1 avec des colonnes NOT NULL
-- (statut, type_paiement). Hibernate en mode "update" a ensuite ajouté les colonnes
-- métier réelles (statut_paiement, methode_paiement...) sans supprimer les anciennes.
-- Résultat : l'entité Paiement ne mappe pas ces colonnes V1, et tout INSERT échoue.
--
-- Solutions :
-- - type_paiement : équivalent fonctionnel de methode_paiement → nullable
-- - statut : remplacé par statut_paiement dans l'entité → nullable
ALTER TABLE paiements
ALTER COLUMN type_paiement DROP NOT NULL;
ALTER TABLE paiements
ALTER COLUMN statut DROP NOT NULL;

View File

@@ -0,0 +1,10 @@
-- V27 : Ajoute la colonne numero_telephone dans la table paiements (versements)
--
-- Contexte : La refonte conceptuelle remplace "Paiement" par "Versement".
-- L'application Wave est installée sur le même téléphone qu'UnionFlow ;
-- le numéro de téléphone du membre est envoyé automatiquement depuis son profil
-- afin de pré-remplir le formulaire Wave (deep link natif).
-- Ce champ mémorise le numéro utilisé lors du versement Wave.
ALTER TABLE paiements
ADD COLUMN IF NOT EXISTS numero_telephone VARCHAR(20);

View File

@@ -0,0 +1,237 @@
-- ============================================================================
-- V28 — Messagerie temps réel entre membres
--
-- Crée les tables nécessaires pour la messagerie instantanée :
-- - contact_policies : politique de communication par organisation
-- - member_blocks : blocages unilatéraux entre membres
-- - conversations : fil de discussion (directe ou canal-rôle)
-- - conversation_participants : membres d'une conversation
-- - messages : messages texte, vocal ou image
--
-- Les notes vocales sont stockées comme messages de type VOCAL avec
-- url_fichier + duree_audio. Aucune transcription en V1.
--
-- Auteur : UnionFlow Team
-- Version : 4.0
-- Date : 2026-04-13
-- ============================================================================
-- ── Politique de communication par organisation ───────────────────────────────
CREATE TABLE IF NOT EXISTS contact_policies (
id UUID NOT NULL PRIMARY KEY,
date_creation TIMESTAMP NOT NULL,
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT,
actif BOOLEAN NOT NULL DEFAULT TRUE,
organisation_id UUID NOT NULL REFERENCES organisations(id),
type_politique VARCHAR(30) NOT NULL DEFAULT 'OUVERT',
autoriser_membre_vers_membre BOOLEAN NOT NULL DEFAULT TRUE,
autoriser_membre_vers_role BOOLEAN NOT NULL DEFAULT TRUE,
autoriser_notes_vocales BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT uk_contact_policy_org UNIQUE (organisation_id)
);
-- ── Blocages unilatéraux ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS member_blocks (
id UUID NOT NULL PRIMARY KEY,
date_creation TIMESTAMP NOT NULL,
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT,
actif BOOLEAN NOT NULL DEFAULT TRUE,
bloqueur_id UUID NOT NULL REFERENCES utilisateurs(id),
bloque_id UUID NOT NULL REFERENCES utilisateurs(id),
organisation_id UUID NOT NULL REFERENCES organisations(id),
CONSTRAINT uk_member_block UNIQUE (bloqueur_id, bloque_id, organisation_id),
CONSTRAINT chk_block_self CHECK (bloqueur_id <> bloque_id)
);
-- ── Conversations ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS conversations (
id UUID NOT NULL PRIMARY KEY,
date_creation TIMESTAMP NOT NULL,
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT,
actif BOOLEAN NOT NULL DEFAULT TRUE,
organisation_id UUID NOT NULL REFERENCES organisations(id),
-- DIRECTE | ROLE_CANAL | GROUPE
type_conversation VARCHAR(30) NOT NULL,
-- Pour ROLE_CANAL : PRESIDENT, TRESORIER, SECRETAIRE, etc.
role_cible VARCHAR(50),
-- Titre affiché (nom du rôle ou du groupe)
titre VARCHAR(200),
-- ACTIVE | ARCHIVEE
statut VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
dernier_message_at TIMESTAMP,
nombre_messages INTEGER NOT NULL DEFAULT 0
);
-- ── Participants aux conversations ────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS conversation_participants (
id UUID NOT NULL PRIMARY KEY,
date_creation TIMESTAMP NOT NULL,
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT,
actif BOOLEAN NOT NULL DEFAULT TRUE,
conversation_id UUID NOT NULL REFERENCES conversations(id),
membre_id UUID NOT NULL REFERENCES utilisateurs(id),
-- INITIATEUR | PARTICIPANT | MODERATEUR
role_dans_conversation VARCHAR(50) DEFAULT 'PARTICIPANT',
-- Dernier message lu par ce participant
lu_jusqu_a TIMESTAMP,
notifier BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT uk_conv_participant UNIQUE (conversation_id, membre_id)
);
-- ── Messages ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS messages (
id UUID NOT NULL PRIMARY KEY,
date_creation TIMESTAMP NOT NULL,
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT,
actif BOOLEAN NOT NULL DEFAULT TRUE,
conversation_id UUID NOT NULL REFERENCES conversations(id),
expediteur_id UUID NOT NULL REFERENCES utilisateurs(id),
-- TEXTE | VOCAL | IMAGE | SYSTEME
type_message VARCHAR(20) NOT NULL DEFAULT 'TEXTE',
-- Contenu textuel (null pour les vocaux/images)
contenu TEXT,
-- URL du fichier audio ou image (stocké sur object storage)
url_fichier VARCHAR(500),
-- Durée en secondes pour les notes vocales
duree_audio INTEGER,
-- Transcription automatique V2 (null en V1)
transcription TEXT,
-- Réponse à un message (threading léger)
message_parent_id UUID REFERENCES messages(id),
-- Suppression douce (null = non supprimé)
supprime_le TIMESTAMP
);
-- ── Ajout colonnes v4 manquantes sur tables existantes ───────────────────────
-- (les tables ont pu être créées par une migration antérieure avec le schéma v1)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'conversations' AND column_name = 'type_conversation') THEN
ALTER TABLE conversations ADD COLUMN type_conversation VARCHAR(30) NOT NULL DEFAULT 'DIRECTE';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'conversations' AND column_name = 'role_cible') THEN
ALTER TABLE conversations ADD COLUMN role_cible VARCHAR(50);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'conversations' AND column_name = 'titre') THEN
ALTER TABLE conversations ADD COLUMN titre VARCHAR(200);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'conversations' AND column_name = 'statut') THEN
ALTER TABLE conversations ADD COLUMN statut VARCHAR(20) NOT NULL DEFAULT 'ACTIVE';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'conversations' AND column_name = 'dernier_message_at') THEN
ALTER TABLE conversations ADD COLUMN dernier_message_at TIMESTAMP;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'conversations' AND column_name = 'nombre_messages') THEN
ALTER TABLE conversations ADD COLUMN nombre_messages INTEGER NOT NULL DEFAULT 0;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'conversations' AND column_name = 'organisation_id') THEN
ALTER TABLE conversations ADD COLUMN organisation_id UUID REFERENCES organisations(id);
END IF;
-- Messages : colonnes FK v4 (nécessaires pour les index)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'messages' AND column_name = 'conversation_id') THEN
ALTER TABLE messages ADD COLUMN conversation_id UUID REFERENCES conversations(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'messages' AND column_name = 'expediteur_id') THEN
ALTER TABLE messages ADD COLUMN expediteur_id UUID REFERENCES utilisateurs(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'messages' AND column_name = 'message_parent_id') THEN
ALTER TABLE messages ADD COLUMN message_parent_id UUID REFERENCES messages(id);
END IF;
-- Messages : colonnes métier v4
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'messages' AND column_name = 'type_message') THEN
ALTER TABLE messages ADD COLUMN type_message VARCHAR(20) NOT NULL DEFAULT 'TEXTE';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'messages' AND column_name = 'contenu') THEN
ALTER TABLE messages ADD COLUMN contenu TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'messages' AND column_name = 'url_fichier') THEN
ALTER TABLE messages ADD COLUMN url_fichier VARCHAR(500);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'messages' AND column_name = 'duree_audio') THEN
ALTER TABLE messages ADD COLUMN duree_audio INTEGER;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'messages' AND column_name = 'transcription') THEN
ALTER TABLE messages ADD COLUMN transcription TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'messages' AND column_name = 'supprime_le') THEN
ALTER TABLE messages ADD COLUMN supprime_le TIMESTAMP;
END IF;
-- ConversationParticipants : colonnes FK v4
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'conversation_participants' AND column_name = 'conversation_id') THEN
ALTER TABLE conversation_participants ADD COLUMN conversation_id UUID REFERENCES conversations(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'conversation_participants' AND column_name = 'membre_id') THEN
ALTER TABLE conversation_participants ADD COLUMN membre_id UUID REFERENCES utilisateurs(id);
END IF;
-- ConversationParticipants : colonnes métier v4
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'conversation_participants' AND column_name = 'role_dans_conversation') THEN
ALTER TABLE conversation_participants ADD COLUMN role_dans_conversation VARCHAR(50) DEFAULT 'PARTICIPANT';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'conversation_participants' AND column_name = 'lu_jusqu_a') THEN
ALTER TABLE conversation_participants ADD COLUMN lu_jusqu_a TIMESTAMP;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'conversation_participants' AND column_name = 'notifier') THEN
ALTER TABLE conversation_participants ADD COLUMN notifier BOOLEAN NOT NULL DEFAULT TRUE;
END IF;
END $$;
-- ── Index de performance ──────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_conversations_organisation ON conversations(organisation_id);
CREATE INDEX IF NOT EXISTS idx_conversations_statut ON conversations(statut);
CREATE INDEX IF NOT EXISTS idx_conversations_dernier_msg ON conversations(dernier_message_at DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_conv_part_conversation ON conversation_participants(conversation_id);
CREATE INDEX IF NOT EXISTS idx_conv_part_membre ON conversation_participants(membre_id);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_messages_expediteur ON messages(expediteur_id);
CREATE INDEX IF NOT EXISTS idx_messages_date_creation ON messages(date_creation DESC);
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(message_parent_id);
CREATE INDEX IF NOT EXISTS idx_contact_policies_org ON contact_policies(organisation_id);
CREATE INDEX IF NOT EXISTS idx_member_blocks_bloqueur ON member_blocks(bloqueur_id);
CREATE INDEX IF NOT EXISTS idx_member_blocks_bloque ON member_blocks(bloque_id, organisation_id);

View File

@@ -0,0 +1,26 @@
-- V29: Table de persistance de la configuration système (maintenance_mode, scheduled_maintenance, etc.)
-- Remplace le stockage en RAM (AtomicReference) pour les paramètres critiques.
CREATE TABLE IF NOT EXISTS system_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT TRUE,
config_key VARCHAR(100) NOT NULL,
config_value TEXT,
CONSTRAINT uk_system_config_key UNIQUE (config_key)
);
CREATE INDEX IF NOT EXISTS idx_system_config_key ON system_config (config_key);
-- Valeurs initiales
INSERT INTO system_config (config_key, config_value, cree_par) VALUES
('maintenance_mode', 'false', 'SYSTEM'),
('maintenance_emergency', 'false', 'SYSTEM'),
('scheduled_maintenance_at', NULL, 'SYSTEM'),
('scheduled_maintenance_reason', NULL, 'SYSTEM'),
('scheduled_maintenance_status', 'NONE', 'SYSTEM')
ON CONFLICT (config_key) DO NOTHING;

View File

@@ -0,0 +1,109 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.messagerie.TypePolitiqueCommunication;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("ContactPolicy")
class ContactPolicyTest {
private static Organisation newOrg() {
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
org.setNom("Org Test");
return org;
}
@Test
@DisplayName("getters/setters — tous les champs")
void gettersSetters() {
ContactPolicy policy = new ContactPolicy();
Organisation org = newOrg();
policy.setOrganisation(org);
policy.setTypePolitique(TypePolitiqueCommunication.BUREAU_SEULEMENT);
policy.setAutoriserMembreVersMembre(false);
policy.setAutoriserMembreVersRole(true);
policy.setAutoriserNotesVocales(false);
assertThat(policy.getOrganisation()).isEqualTo(org);
assertThat(policy.getTypePolitique()).isEqualTo(TypePolitiqueCommunication.BUREAU_SEULEMENT);
assertThat(policy.getAutoriserMembreVersMembre()).isFalse();
assertThat(policy.getAutoriserMembreVersRole()).isTrue();
assertThat(policy.getAutoriserNotesVocales()).isFalse();
}
@Test
@DisplayName("onCreate — valeurs par défaut : OUVERT, tout à true")
void onCreate_defaults() {
ContactPolicy policy = new ContactPolicy();
policy.setOrganisation(newOrg());
policy.onCreate();
assertThat(policy.getTypePolitique()).isEqualTo(TypePolitiqueCommunication.OUVERT);
assertThat(policy.getAutoriserMembreVersMembre()).isTrue();
assertThat(policy.getAutoriserMembreVersRole()).isTrue();
assertThat(policy.getAutoriserNotesVocales()).isTrue();
assertThat(policy.getDateCreation()).isNotNull();
assertThat(policy.getActif()).isTrue();
}
@Test
@DisplayName("onCreate — ne remplace pas les valeurs existantes")
void onCreate_preservesExisting() {
ContactPolicy policy = new ContactPolicy();
policy.setOrganisation(newOrg());
policy.setTypePolitique(TypePolitiqueCommunication.GROUPES_INTERNES);
policy.setAutoriserNotesVocales(false);
policy.onCreate();
assertThat(policy.getTypePolitique()).isEqualTo(TypePolitiqueCommunication.GROUPES_INTERNES);
assertThat(policy.getAutoriserNotesVocales()).isFalse();
}
@Test
@DisplayName("builder — tous les champs")
void builder_allFields() {
Organisation org = newOrg();
ContactPolicy policy = ContactPolicy.builder()
.organisation(org)
.typePolitique(TypePolitiqueCommunication.OUVERT)
.autoriserMembreVersMembre(true)
.autoriserMembreVersRole(true)
.autoriserNotesVocales(true)
.build();
assertThat(policy.getOrganisation()).isEqualTo(org);
assertThat(policy.getTypePolitique()).isEqualTo(TypePolitiqueCommunication.OUVERT);
assertThat(policy.getAutoriserNotesVocales()).isTrue();
}
@Test
@DisplayName("equals et hashCode")
void equalsHashCode() {
UUID id = UUID.randomUUID();
Organisation org = newOrg();
ContactPolicy a = ContactPolicy.builder().organisation(org)
.typePolitique(TypePolitiqueCommunication.OUVERT).build();
a.setId(id);
ContactPolicy b = ContactPolicy.builder().organisation(org)
.typePolitique(TypePolitiqueCommunication.OUVERT).build();
b.setId(id);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString non null")
void toString_nonNull() {
ContactPolicy policy = ContactPolicy.builder().organisation(newOrg()).build();
assertThat(policy.toString()).isNotNull().isNotEmpty();
}
}

View File

@@ -0,0 +1,128 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("ConversationParticipant")
class ConversationParticipantTest {
private static Membre newMembre() {
Membre m = new Membre();
m.setId(UUID.randomUUID());
m.setNumeroMembre("M1");
m.setPrenom("Alpha");
m.setNom("Diallo");
m.setEmail("alpha@test.com");
m.setDateNaissance(LocalDate.now());
return m;
}
private static Conversation newConversation() {
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
org.setNom("Org Test");
Conversation c = new Conversation();
c.setId(UUID.randomUUID());
c.setOrganisation(org);
c.setTypeConversation(TypeConversation.DIRECTE);
c.setStatut(StatutConversation.ACTIVE);
c.setNombreMessages(0);
return c;
}
@Test
@DisplayName("getters/setters")
void gettersSetters() {
ConversationParticipant p = new ConversationParticipant();
Conversation conv = newConversation();
Membre membre = newMembre();
p.setConversation(conv);
p.setMembre(membre);
p.setRoleDansConversation("INITIATEUR");
p.setNotifier(true);
assertThat(p.getConversation()).isEqualTo(conv);
assertThat(p.getMembre()).isEqualTo(membre);
assertThat(p.getRoleDansConversation()).isEqualTo("INITIATEUR");
assertThat(p.getNotifier()).isTrue();
}
@Test
@DisplayName("estInitiateur — INITIATEUR → true")
void estInitiateur_true() {
ConversationParticipant p = buildMinimal("INITIATEUR");
assertThat(p.estInitiateur()).isTrue();
}
@Test
@DisplayName("estInitiateur — PARTICIPANT → false")
void estInitiateur_false() {
ConversationParticipant p = buildMinimal("PARTICIPANT");
assertThat(p.estInitiateur()).isFalse();
}
@Test
@DisplayName("marquerLu — luJusqua mis à jour")
void marquerLu() {
ConversationParticipant p = buildMinimal("PARTICIPANT");
p.setLuJusqua(null);
p.marquerLu();
assertThat(p.getLuJusqua()).isNotNull();
}
@Test
@DisplayName("marquerLu — luJusqua remplace l'ancien")
void marquerLu_replaceOld() {
ConversationParticipant p = buildMinimal("PARTICIPANT");
LocalDateTime old = LocalDateTime.now().minusDays(1);
p.setLuJusqua(old);
p.marquerLu();
assertThat(p.getLuJusqua()).isAfter(old);
}
@Test
@DisplayName("builder — valeurs par défaut")
void builder_defaults() {
ConversationParticipant p = ConversationParticipant.builder()
.conversation(newConversation())
.membre(newMembre())
.build();
assertThat(p.getRoleDansConversation()).isEqualTo("PARTICIPANT");
assertThat(p.getNotifier()).isTrue();
}
@Test
@DisplayName("equals et hashCode")
void equalsHashCode() {
UUID id = UUID.randomUUID();
ConversationParticipant a = buildMinimal("PARTICIPANT");
a.setId(id);
ConversationParticipant b = buildMinimal("PARTICIPANT");
b.setId(id);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString non null")
void toString_nonNull() {
assertThat(buildMinimal("PARTICIPANT").toString()).isNotNull().isNotEmpty();
}
private ConversationParticipant buildMinimal(String role) {
ConversationParticipant p = new ConversationParticipant();
p.setConversation(newConversation());
p.setMembre(newMembre());
p.setRoleDansConversation(role);
p.setNotifier(true);
return p;
}
}

View File

@@ -1,95 +1,166 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.communication.ConversationType;
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
@DisplayName("Conversation")
class ConversationTest {
private static Membre newMembre() {
Membre m = new Membre();
m.setId(UUID.randomUUID());
m.setNumeroMembre("M1");
m.setPrenom("Alpha");
m.setNom("Diallo");
m.setEmail("alpha@test.com");
m.setDateNaissance(LocalDate.now());
return m;
}
private static Organisation newOrg() {
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
org.setNom("Tontine Test");
return org;
}
@Test
@DisplayName("getters/setters de base")
@DisplayName("getters/setters — tous les champs")
void gettersSetters() {
Conversation c = new Conversation();
c.setName("Groupe Test");
c.setDescription("Description groupe");
c.setType(ConversationType.GROUP);
c.setIsMuted(false);
c.setIsPinned(true);
c.setIsArchived(false);
Conversation conv = new Conversation();
Organisation org = newOrg();
conv.setOrganisation(org);
conv.setTypeConversation(TypeConversation.DIRECTE);
conv.setStatut(StatutConversation.ACTIVE);
conv.setTitre("Discussion");
conv.setNombreMessages(5);
assertThat(c.getName()).isEqualTo("Groupe Test");
assertThat(c.getDescription()).isEqualTo("Description groupe");
assertThat(c.getType()).isEqualTo(ConversationType.GROUP);
assertThat(c.getIsMuted()).isFalse();
assertThat(c.getIsPinned()).isTrue();
assertThat(c.getIsArchived()).isFalse();
assertThat(conv.getOrganisation()).isEqualTo(org);
assertThat(conv.getTypeConversation()).isEqualTo(TypeConversation.DIRECTE);
assertThat(conv.getStatut()).isEqualTo(StatutConversation.ACTIVE);
assertThat(conv.getTitre()).isEqualTo("Discussion");
assertThat(conv.getNombreMessages()).isEqualTo(5);
}
@Test
@DisplayName("onUpdate (PreUpdate) - met à jour updatedAt via réflexion")
void onUpdate_setsUpdatedAt() throws Exception {
Conversation c = new Conversation();
assertThat(c.getUpdatedAt()).isNull();
Method onUpdate = Conversation.class.getDeclaredMethod("onUpdate");
onUpdate.setAccessible(true);
LocalDateTime before = LocalDateTime.now().minusSeconds(1);
onUpdate.invoke(c);
LocalDateTime after = LocalDateTime.now().plusSeconds(1);
assertThat(c.getUpdatedAt()).isNotNull();
assertThat(c.getUpdatedAt()).isAfter(before);
assertThat(c.getUpdatedAt()).isBefore(after);
@DisplayName("estActive — ACTIVE → true")
void estActive_active() {
Conversation conv = buildMinimal();
conv.setStatut(StatutConversation.ACTIVE);
assertThat(conv.estActive()).isTrue();
}
@Test
@DisplayName("onUpdate appelé deux fois met à jour updatedAt à chaque fois")
void onUpdate_calledTwice_updatesEachTime() throws Exception {
Conversation c = new Conversation();
Method onUpdate = Conversation.class.getDeclaredMethod("onUpdate");
onUpdate.setAccessible(true);
onUpdate.invoke(c);
LocalDateTime first = c.getUpdatedAt();
// petit délai pour différencier les timestamps
Thread.sleep(5);
onUpdate.invoke(c);
LocalDateTime second = c.getUpdatedAt();
assertThat(second).isAfterOrEqualTo(first);
@DisplayName("estActive — ARCHIVEE → false")
void estActive_archivee() {
Conversation conv = buildMinimal();
conv.setStatut(StatutConversation.ARCHIVEE);
assertThat(conv.estActive()).isFalse();
}
@Test
@DisplayName("participants initialisé à liste vide")
@DisplayName("archiver — passe le statut à ARCHIVEE")
void archiver() {
Conversation conv = buildMinimal();
conv.setStatut(StatutConversation.ACTIVE);
conv.archiver();
assertThat(conv.getStatut()).isEqualTo(StatutConversation.ARCHIVEE);
assertThat(conv.estActive()).isFalse();
}
@Test
@DisplayName("enregistrerNouveauMessage — incrémente le compteur et met à jour la date")
void enregistrerNouveauMessage() {
Conversation conv = buildMinimal();
conv.setNombreMessages(3);
conv.enregistrerNouveauMessage();
assertThat(conv.getNombreMessages()).isEqualTo(4);
assertThat(conv.getDernierMessageAt()).isNotNull();
}
@Test
@DisplayName("enregistrerNouveauMessage — part de null")
void enregistrerNouveauMessage_partDeNull() {
Conversation conv = buildMinimal();
conv.setNombreMessages(null);
conv.enregistrerNouveauMessage();
assertThat(conv.getNombreMessages()).isEqualTo(1);
}
@Test
@DisplayName("onCreate — initialise statut et nombreMessages si null")
void onCreate_initDefaults() {
Conversation conv = new Conversation();
conv.setOrganisation(newOrg());
conv.setTypeConversation(TypeConversation.DIRECTE);
conv.onCreate();
assertThat(conv.getStatut()).isEqualTo(StatutConversation.ACTIVE);
assertThat(conv.getNombreMessages()).isEqualTo(0);
assertThat(conv.getDateCreation()).isNotNull();
assertThat(conv.getActif()).isTrue();
}
@Test
@DisplayName("ROLE_CANAL — roleCible renseigné")
void roleCanalType() {
Conversation conv = buildMinimal();
conv.setTypeConversation(TypeConversation.ROLE_CANAL);
conv.setRoleCible("TRESORIER");
conv.setTitre("Trésorier");
assertThat(conv.getTypeConversation()).isEqualTo(TypeConversation.ROLE_CANAL);
assertThat(conv.getRoleCible()).isEqualTo("TRESORIER");
}
@Test
@DisplayName("participants initialisé à liste vide (builder default)")
void participants_initializedEmpty() {
Conversation c = new Conversation();
assertThat(c.getParticipants()).isNotNull().isEmpty();
Conversation conv = Conversation.builder()
.organisation(newOrg())
.typeConversation(TypeConversation.DIRECTE)
.build();
assertThat(conv.getParticipants()).isNotNull().isEmpty();
assertThat(conv.getMessages()).isNotNull().isEmpty();
}
@Test
@DisplayName("messages initialisé à liste vide")
void messages_initializedEmpty() {
Conversation c = new Conversation();
assertThat(c.getMessages()).isNotNull().isEmpty();
@DisplayName("equals et hashCode")
void equalsHashCode() {
UUID id = UUID.randomUUID();
Organisation org = newOrg();
Conversation a = buildMinimal();
a.setId(id);
a.setOrganisation(org);
Conversation b = buildMinimal();
b.setId(id);
b.setOrganisation(org);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("isMuted et isPinned et isArchived défaut false")
void defaultFlags_areFalse() {
Conversation c = new Conversation();
assertThat(c.getIsMuted()).isFalse();
assertThat(c.getIsPinned()).isFalse();
assertThat(c.getIsArchived()).isFalse();
@DisplayName("toString non null")
void toString_nonNull() {
assertThat(buildMinimal().toString()).isNotNull().isNotEmpty();
}
private Conversation buildMinimal() {
Conversation conv = new Conversation();
conv.setOrganisation(newOrg());
conv.setTypeConversation(TypeConversation.DIRECTE);
conv.setStatut(StatutConversation.ACTIVE);
conv.setNombreMessages(0);
return conv;
}
}

View File

@@ -4,7 +4,8 @@ import dev.lions.unionflow.server.api.enums.membre.StatutMembre;
import dev.lions.unionflow.server.api.enums.solidarite.StatutAide;
import dev.lions.unionflow.server.api.enums.solidarite.TypeAide;
import dev.lions.unionflow.server.api.enums.wave.StatutWebhook;
import dev.lions.unionflow.server.api.enums.communication.ConversationType;
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@@ -134,38 +135,85 @@ class EntityCoverageTest {
}
}
// ─── Conversation — onUpdate ─────────────────────────────────────────────
// ─── Conversation v4 ────────────────────────────────────────────────────
@Nested
@DisplayName("Conversation — onUpdate() non couvert")
@DisplayName("Conversation v4 — méthodes métier")
class ConversationCoverage {
@Test
@DisplayName("Conversation getters/setters de base")
@DisplayName("Conversation getters/setters v4")
void gettersSetters() {
Conversation c = new Conversation();
c.setName("Chat Test");
c.setDescription("Description");
c.setType(ConversationType.GROUP);
c.setIsMuted(false);
c.setIsPinned(true);
c.setIsArchived(false);
c.setUpdatedAt(LocalDateTime.now());
c.setTypeConversation(TypeConversation.DIRECTE);
c.setRoleCible("TRESORIER");
c.setTitre("Canal Trésorier");
c.setStatut(StatutConversation.ACTIVE);
c.setDernierMessageAt(LocalDateTime.now());
c.setNombreMessages(3);
assertThat(c.getName()).isEqualTo("Chat Test");
assertThat(c.getType()).isEqualTo(ConversationType.GROUP);
assertThat(c.getIsPinned()).isTrue();
assertThat(c.getTypeConversation()).isEqualTo(TypeConversation.DIRECTE);
assertThat(c.getRoleCible()).isEqualTo("TRESORIER");
assertThat(c.getTitre()).isEqualTo("Canal Trésorier");
assertThat(c.getStatut()).isEqualTo(StatutConversation.ACTIVE);
assertThat(c.getNombreMessages()).isEqualTo(3);
}
@Test
@DisplayName("onUpdate() met à jour updatedAt")
void onUpdate_setsUpdatedAt() {
@DisplayName("estActive() — ACTIVE → true, ARCHIVEE → false")
void estActive() {
Conversation active = new Conversation();
active.setStatut(StatutConversation.ACTIVE);
assertThat(active.estActive()).isTrue();
Conversation archivee = new Conversation();
archivee.setStatut(StatutConversation.ARCHIVEE);
assertThat(archivee.estActive()).isFalse();
}
@Test
@DisplayName("archiver() — passe à ARCHIVEE")
void archiver() {
Conversation c = new Conversation();
c.setName("Chat");
c.setType(ConversationType.INDIVIDUAL);
assertThat(c.getUpdatedAt()).isNull();
c.onUpdate();
assertThat(c.getUpdatedAt()).isNotNull();
c.setStatut(StatutConversation.ACTIVE);
c.archiver();
assertThat(c.getStatut()).isEqualTo(StatutConversation.ARCHIVEE);
}
@Test
@DisplayName("enregistrerNouveauMessage() — incrémente compteur et met à jour horodatage")
void enregistrerNouveauMessage() {
Conversation c = new Conversation();
c.setNombreMessages(2);
LocalDateTime avant = LocalDateTime.now().minusSeconds(1);
c.enregistrerNouveauMessage();
assertThat(c.getNombreMessages()).isEqualTo(3);
assertThat(c.getDernierMessageAt()).isNotNull().isAfter(avant);
}
@Test
@DisplayName("enregistrerNouveauMessage() — nombreMessages null → passe à 1")
void enregistrerNouveauMessage_nullCount() {
Conversation c = new Conversation();
c.setNombreMessages(null);
c.enregistrerNouveauMessage();
assertThat(c.getNombreMessages()).isEqualTo(1);
}
@Test
@DisplayName("onCreate() — statut null → initialisé à ACTIVE")
void onCreate_statut() {
Conversation c = new Conversation();
c.setStatut(null);
c.setTypeConversation(TypeConversation.ROLE_CANAL);
c.setId(java.util.UUID.randomUUID());
c.setDateCreation(LocalDateTime.now());
c.setActif(true);
c.setVersion(0L);
c.onCreate();
assertThat(c.getStatut()).isEqualTo(StatutConversation.ACTIVE);
}
}

View File

@@ -0,0 +1,94 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("MemberBlock")
class MemberBlockTest {
private static Membre newMembre(String email) {
Membre m = new Membre();
m.setId(UUID.randomUUID());
m.setNumeroMembre("M-" + UUID.randomUUID().toString().substring(0, 4));
m.setPrenom("Test");
m.setNom("User");
m.setEmail(email);
m.setDateNaissance(LocalDate.now());
return m;
}
private static Organisation newOrg() {
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
org.setNom("Org Test");
return org;
}
@Test
@DisplayName("getters/setters")
void gettersSetters() {
Membre bloqueur = newMembre("bloqueur@test.com");
Membre bloque = newMembre("bloque@test.com");
Organisation org = newOrg();
MemberBlock block = new MemberBlock();
block.setBloqueur(bloqueur);
block.setBloque(bloque);
block.setOrganisation(org);
assertThat(block.getBloqueur()).isEqualTo(bloqueur);
assertThat(block.getBloque()).isEqualTo(bloque);
assertThat(block.getOrganisation()).isEqualTo(org);
}
@Test
@DisplayName("builder — tous les champs")
void builder_allFields() {
Membre bloqueur = newMembre("a@test.com");
Membre bloque = newMembre("b@test.com");
Organisation org = newOrg();
MemberBlock block = MemberBlock.builder()
.bloqueur(bloqueur)
.bloque(bloque)
.organisation(org)
.build();
assertThat(block.getBloqueur()).isEqualTo(bloqueur);
assertThat(block.getBloque()).isEqualTo(bloque);
assertThat(block.getOrganisation()).isEqualTo(org);
}
@Test
@DisplayName("equals et hashCode")
void equalsHashCode() {
UUID id = UUID.randomUUID();
Membre bloqueur = newMembre("a@test.com");
Membre bloque = newMembre("b@test.com");
Organisation org = newOrg();
MemberBlock a = MemberBlock.builder().bloqueur(bloqueur).bloque(bloque).organisation(org).build();
a.setId(id);
MemberBlock b = MemberBlock.builder().bloqueur(bloqueur).bloque(bloque).organisation(org).build();
b.setId(id);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString non null")
void toString_nonNull() {
MemberBlock block = MemberBlock.builder()
.bloqueur(newMembre("a@test.com"))
.bloque(newMembre("b@test.com"))
.organisation(newOrg())
.build();
assertThat(block.toString()).isNotNull().isNotEmpty();
}
}

View File

@@ -1,37 +1,173 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
import dev.lions.unionflow.server.api.enums.messagerie.TypeContenu;
import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation;
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Message")
class MessageTest {
@Test
@DisplayName("markAsRead sets status to READ and sets readAt")
void markAsRead_setsStatusAndReadAt() {
Message m = new Message();
m.setStatus(MessageStatus.SENT);
assertThat(m.getReadAt()).isNull();
private static Membre newMembre() {
Membre m = new Membre();
m.setId(UUID.randomUUID());
m.setNumeroMembre("M1");
m.setPrenom("Alpha");
m.setNom("Diallo");
m.setEmail("alpha@test.com");
m.setDateNaissance(LocalDate.now());
return m;
}
m.markAsRead();
assertThat(m.getStatus()).isEqualTo(MessageStatus.READ);
assertThat(m.getReadAt()).isNotNull();
private static Conversation newConversation() {
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
org.setNom("Org Test");
Conversation c = new Conversation();
c.setId(UUID.randomUUID());
c.setOrganisation(org);
c.setTypeConversation(TypeConversation.DIRECTE);
c.setStatut(StatutConversation.ACTIVE);
c.setNombreMessages(0);
return c;
}
@Test
@DisplayName("markAsEdited sets isEdited true and sets editedAt")
void markAsEdited_setsIsEditedAndEditedAt() {
Message m = new Message();
assertThat(m.getIsEdited()).isFalse();
assertThat(m.getEditedAt()).isNull();
@DisplayName("getters/setters — message texte")
void gettersSetters_texte() {
Message msg = new Message();
Conversation conv = newConversation();
Membre expediteur = newMembre();
msg.setConversation(conv);
msg.setExpediteur(expediteur);
msg.setTypeMessage(TypeContenu.TEXTE);
msg.setContenu("Bonjour tout le monde !");
m.markAsEdited();
assertThat(msg.getConversation()).isEqualTo(conv);
assertThat(msg.getExpediteur()).isEqualTo(expediteur);
assertThat(msg.getTypeMessage()).isEqualTo(TypeContenu.TEXTE);
assertThat(msg.getContenu()).isEqualTo("Bonjour tout le monde !");
}
assertThat(m.getIsEdited()).isTrue();
assertThat(m.getEditedAt()).isNotNull();
@Test
@DisplayName("getters/setters — note vocale")
void gettersSetters_vocal() {
Message msg = new Message();
msg.setConversation(newConversation());
msg.setExpediteur(newMembre());
msg.setTypeMessage(TypeContenu.VOCAL);
msg.setUrlFichier("https://storage.example.com/audio.opus");
msg.setDureeAudio(45);
assertThat(msg.getTypeMessage()).isEqualTo(TypeContenu.VOCAL);
assertThat(msg.getUrlFichier()).isEqualTo("https://storage.example.com/audio.opus");
assertThat(msg.getDureeAudio()).isEqualTo(45);
}
@Test
@DisplayName("estTextuel — TEXTE → true")
void estTextuel_true() {
Message msg = buildMinimal(TypeContenu.TEXTE);
assertThat(msg.estTextuel()).isTrue();
assertThat(msg.estVocal()).isFalse();
}
@Test
@DisplayName("estVocal — VOCAL → true")
void estVocal_true() {
Message msg = buildMinimal(TypeContenu.VOCAL);
assertThat(msg.estVocal()).isTrue();
assertThat(msg.estTextuel()).isFalse();
}
@Test
@DisplayName("estSupprime — null → false")
void estSupprime_nullFalse() {
Message msg = buildMinimal(TypeContenu.TEXTE);
msg.setSupprimeLe(null);
assertThat(msg.estSupprime()).isFalse();
}
@Test
@DisplayName("supprimer — contenu remplacé par marqueur, urlFichier null")
void supprimer() {
Message msg = buildMinimal(TypeContenu.TEXTE);
msg.setContenu("Message secret");
msg.setUrlFichier("https://example.com/file");
msg.supprimer();
assertThat(msg.estSupprime()).isTrue();
assertThat(msg.getContenu()).isEqualTo("[Message supprimé]");
assertThat(msg.getUrlFichier()).isNull();
assertThat(msg.getSupprimeLe()).isNotNull();
}
@Test
@DisplayName("onCreate — initialise typeMessage si null")
void onCreate_initDefaults() {
Message msg = new Message();
msg.setConversation(newConversation());
msg.setExpediteur(newMembre());
msg.setContenu("Test");
msg.onCreate();
assertThat(msg.getTypeMessage()).isEqualTo(TypeContenu.TEXTE);
assertThat(msg.getDateCreation()).isNotNull();
assertThat(msg.getActif()).isTrue();
}
@Test
@DisplayName("onCreate — préserve typeMessage existant")
void onCreate_preservesType() {
Message msg = new Message();
msg.setConversation(newConversation());
msg.setExpediteur(newMembre());
msg.setTypeMessage(TypeContenu.VOCAL);
msg.setUrlFichier("url");
msg.setDureeAudio(30);
msg.onCreate();
assertThat(msg.getTypeMessage()).isEqualTo(TypeContenu.VOCAL);
}
@Test
@DisplayName("equals et hashCode")
void equalsHashCode() {
UUID id = UUID.randomUUID();
Message a = buildMinimal(TypeContenu.TEXTE);
a.setId(id);
Message b = buildMinimal(TypeContenu.TEXTE);
b.setId(id);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString non null")
void toString_nonNull() {
assertThat(buildMinimal(TypeContenu.TEXTE).toString()).isNotNull().isNotEmpty();
}
private Message buildMinimal(TypeContenu type) {
Message msg = new Message();
msg.setConversation(newConversation());
msg.setExpediteur(newMembre());
msg.setTypeMessage(type);
if (TypeContenu.TEXTE.equals(type)) {
msg.setContenu("Texte test");
} else if (TypeContenu.VOCAL.equals(type)) {
msg.setUrlFichier("https://example.com/audio.opus");
msg.setDureeAudio(30);
}
return msg;
}
}

View File

@@ -1,109 +0,0 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("PaiementObjet")
class PaiementObjetTest {
private static Paiement newPaiement() {
Membre m = new Membre();
m.setId(UUID.randomUUID());
m.setNumeroMembre("M1");
m.setPrenom("A");
m.setNom("B");
m.setEmail("a@test.com");
m.setDateNaissance(java.time.LocalDate.now());
Paiement p = new Paiement();
p.setId(UUID.randomUUID());
p.setNumeroReference("PAY-1");
p.setMontant(BigDecimal.TEN);
p.setCodeDevise("XOF");
p.setMethodePaiement("WAVE");
p.setMembre(m);
return p;
}
@Test
@DisplayName("getters/setters")
void gettersSetters() {
PaiementObjet po = new PaiementObjet();
po.setPaiement(newPaiement());
po.setTypeObjetCible("COTISATION");
po.setObjetCibleId(UUID.randomUUID());
po.setMontantApplique(new BigDecimal("5000.00"));
po.setDateApplication(LocalDateTime.now());
po.setCommentaire("Cotisation janvier");
assertThat(po.getTypeObjetCible()).isEqualTo("COTISATION");
assertThat(po.getMontantApplique()).isEqualByComparingTo("5000.00");
assertThat(po.getCommentaire()).isEqualTo("Cotisation janvier");
}
@Test
@DisplayName("equals et hashCode")
void equalsHashCode() {
UUID id = UUID.randomUUID();
UUID objId = UUID.randomUUID();
Paiement p = newPaiement();
PaiementObjet a = new PaiementObjet();
a.setId(id);
a.setPaiement(p);
a.setTypeObjetCible("COTISATION");
a.setObjetCibleId(objId);
a.setMontantApplique(BigDecimal.ONE);
PaiementObjet b = new PaiementObjet();
b.setId(id);
b.setPaiement(p);
b.setTypeObjetCible("COTISATION");
b.setObjetCibleId(objId);
b.setMontantApplique(BigDecimal.ONE);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString non null")
void toString_nonNull() {
PaiementObjet po = new PaiementObjet();
po.setPaiement(newPaiement());
po.setTypeObjetCible("COTISATION");
po.setObjetCibleId(UUID.randomUUID());
po.setMontantApplique(BigDecimal.ONE);
assertThat(po.toString()).isNotNull().isNotEmpty();
}
@Test
@DisplayName("onCreate initialise dateApplication si null")
void onCreate_setsDateApplicationWhenNull() {
PaiementObjet po = new PaiementObjet();
po.setPaiement(newPaiement());
po.setTypeObjetCible("COTISATION");
po.setObjetCibleId(UUID.randomUUID());
po.setMontantApplique(BigDecimal.ONE);
// dateApplication est null
po.onCreate();
assertThat(po.getDateApplication()).isNotNull();
assertThat(po.getActif()).isTrue();
}
@Test
@DisplayName("onCreate ne remplace pas une dateApplication existante")
void onCreate_doesNotOverrideDateApplication() {
LocalDateTime fixed = LocalDateTime.of(2026, 1, 1, 0, 0);
PaiementObjet po = new PaiementObjet();
po.setDateApplication(fixed);
po.onCreate();
assertThat(po.getDateApplication()).isEqualTo(fixed);
}
}

View File

@@ -1,101 +0,0 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Paiement")
class PaiementTest {
private static Membre newMembre() {
Membre m = new Membre();
m.setId(UUID.randomUUID());
m.setNumeroMembre("M1");
m.setPrenom("A");
m.setNom("B");
m.setEmail("a@test.com");
m.setDateNaissance(java.time.LocalDate.now());
return m;
}
@Test
@DisplayName("getters/setters")
void gettersSetters() {
Paiement p = new Paiement();
p.setNumeroReference("PAY-2025-001");
p.setMontant(new BigDecimal("10000.00"));
p.setCodeDevise("XOF");
p.setMethodePaiement("WAVE");
p.setStatutPaiement("VALIDE");
p.setDatePaiement(LocalDateTime.now());
p.setMembre(newMembre());
assertThat(p.getNumeroReference()).isEqualTo("PAY-2025-001");
assertThat(p.getMontant()).isEqualByComparingTo("10000.00");
assertThat(p.getCodeDevise()).isEqualTo("XOF");
assertThat(p.getStatutPaiement()).isEqualTo("VALIDE");
}
@Test
@DisplayName("genererNumeroReference")
void genererNumeroReference() {
String ref = Paiement.genererNumeroReference();
assertThat(ref).startsWith("PAY-").isNotNull();
}
@Test
@DisplayName("isValide et peutEtreModifie")
void isValide_peutEtreModifie() {
Paiement p = new Paiement();
p.setNumeroReference("X");
p.setMontant(BigDecimal.ONE);
p.setCodeDevise("XOF");
p.setMethodePaiement("WAVE");
p.setMembre(newMembre());
p.setStatutPaiement("VALIDE");
assertThat(p.isValide()).isTrue();
assertThat(p.peutEtreModifie()).isFalse();
p.setStatutPaiement("EN_ATTENTE");
assertThat(p.peutEtreModifie()).isTrue();
}
@Test
@DisplayName("equals et hashCode")
void equalsHashCode() {
UUID id = UUID.randomUUID();
Membre m = newMembre();
Paiement a = new Paiement();
a.setId(id);
a.setNumeroReference("REF-1");
a.setMontant(BigDecimal.ONE);
a.setCodeDevise("XOF");
a.setMethodePaiement("WAVE");
a.setMembre(m);
Paiement b = new Paiement();
b.setId(id);
b.setNumeroReference("REF-1");
b.setMontant(BigDecimal.ONE);
b.setCodeDevise("XOF");
b.setMethodePaiement("WAVE");
b.setMembre(m);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString non null")
void toString_nonNull() {
Paiement p = new Paiement();
p.setNumeroReference("X");
p.setMontant(BigDecimal.ONE);
p.setCodeDevise("XOF");
p.setMethodePaiement("WAVE");
p.setMembre(newMembre());
assertThat(p.toString()).isNotNull().isNotEmpty();
}
}

View File

@@ -0,0 +1,108 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("VersementObjet")
class VersementObjetTest {
private static Versement newVersement() {
Membre m = new Membre();
m.setId(UUID.randomUUID());
m.setNumeroMembre("M1");
m.setPrenom("A");
m.setNom("B");
m.setEmail("a@test.com");
m.setDateNaissance(java.time.LocalDate.now());
Versement v = new Versement();
v.setId(UUID.randomUUID());
v.setNumeroReference("VRS-2026-001");
v.setMontant(BigDecimal.TEN);
v.setCodeDevise("XOF");
v.setMethodePaiement("WAVE");
v.setMembre(m);
return v;
}
@Test
@DisplayName("getters/setters")
void gettersSetters() {
VersementObjet vo = new VersementObjet();
vo.setVersement(newVersement());
vo.setTypeObjetCible("COTISATION");
vo.setObjetCibleId(UUID.randomUUID());
vo.setMontantApplique(new BigDecimal("5000.00"));
vo.setDateApplication(LocalDateTime.now());
vo.setCommentaire("Cotisation janvier");
assertThat(vo.getTypeObjetCible()).isEqualTo("COTISATION");
assertThat(vo.getMontantApplique()).isEqualByComparingTo("5000.00");
assertThat(vo.getCommentaire()).isEqualTo("Cotisation janvier");
}
@Test
@DisplayName("equals et hashCode")
void equalsHashCode() {
UUID id = UUID.randomUUID();
UUID objId = UUID.randomUUID();
Versement v = newVersement();
VersementObjet a = new VersementObjet();
a.setId(id);
a.setVersement(v);
a.setTypeObjetCible("COTISATION");
a.setObjetCibleId(objId);
a.setMontantApplique(BigDecimal.ONE);
VersementObjet b = new VersementObjet();
b.setId(id);
b.setVersement(v);
b.setTypeObjetCible("COTISATION");
b.setObjetCibleId(objId);
b.setMontantApplique(BigDecimal.ONE);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString non null")
void toString_nonNull() {
VersementObjet vo = new VersementObjet();
vo.setVersement(newVersement());
vo.setTypeObjetCible("COTISATION");
vo.setObjetCibleId(UUID.randomUUID());
vo.setMontantApplique(BigDecimal.ONE);
assertThat(vo.toString()).isNotNull().isNotEmpty();
}
@Test
@DisplayName("onCreate initialise dateApplication si null")
void onCreate_setsDateApplicationWhenNull() {
VersementObjet vo = new VersementObjet();
vo.setVersement(newVersement());
vo.setTypeObjetCible("COTISATION");
vo.setObjetCibleId(UUID.randomUUID());
vo.setMontantApplique(BigDecimal.ONE);
vo.onCreate();
assertThat(vo.getDateApplication()).isNotNull();
assertThat(vo.getActif()).isTrue();
}
@Test
@DisplayName("onCreate ne remplace pas une dateApplication existante")
void onCreate_doesNotOverrideDateApplication() {
LocalDateTime fixed = LocalDateTime.of(2026, 1, 1, 0, 0);
VersementObjet vo = new VersementObjet();
vo.setDateApplication(fixed);
vo.onCreate();
assertThat(vo.getDateApplication()).isEqualTo(fixed);
}
}

View File

@@ -0,0 +1,160 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Versement")
class VersementTest {
private static Membre newMembre() {
Membre m = new Membre();
m.setId(UUID.randomUUID());
m.setNumeroMembre("M1");
m.setPrenom("A");
m.setNom("B");
m.setEmail("a@test.com");
m.setDateNaissance(java.time.LocalDate.now());
return m;
}
@Test
@DisplayName("getters/setters")
void gettersSetters() {
Versement v = new Versement();
v.setNumeroReference("VRS-2026-001");
v.setMontant(new BigDecimal("10000.00"));
v.setCodeDevise("XOF");
v.setMethodePaiement("WAVE");
v.setStatutPaiement("CONFIRME");
v.setDatePaiement(LocalDateTime.now());
v.setNumeroTelephone("771234567");
v.setMembre(newMembre());
assertThat(v.getNumeroReference()).isEqualTo("VRS-2026-001");
assertThat(v.getMontant()).isEqualByComparingTo("10000.00");
assertThat(v.getCodeDevise()).isEqualTo("XOF");
assertThat(v.getStatutPaiement()).isEqualTo("CONFIRME");
assertThat(v.getNumeroTelephone()).isEqualTo("771234567");
}
@Test
@DisplayName("genererNumeroReference commence par VRS-")
void genererNumeroReference_startsWithVRS() {
String ref = Versement.genererNumeroReference();
assertThat(ref).startsWith("VRS-").isNotNull();
}
@Test
@DisplayName("genererNumeroReference retourne des références uniques")
void genererNumeroReference_unique() {
String ref1 = Versement.genererNumeroReference();
String ref2 = Versement.genererNumeroReference();
assertThat(ref1).isNotEqualTo(ref2);
}
@Test
@DisplayName("isConfirme — statut CONFIRME")
void isConfirme_statutCONFIRME() {
Versement v = buildMinimal();
v.setStatutPaiement("CONFIRME");
assertThat(v.isConfirme()).isTrue();
assertThat(v.peutEtreModifie()).isFalse();
}
@Test
@DisplayName("isConfirme — statut VALIDE (compatibilité)")
void isConfirme_statutVALIDE() {
Versement v = buildMinimal();
v.setStatutPaiement("VALIDE");
assertThat(v.isConfirme()).isTrue();
assertThat(v.peutEtreModifie()).isFalse();
}
@Test
@DisplayName("isConfirme — statut EN_ATTENTE")
void isConfirme_statutEnAttente() {
Versement v = buildMinimal();
v.setStatutPaiement("EN_ATTENTE");
assertThat(v.isConfirme()).isFalse();
assertThat(v.peutEtreModifie()).isTrue();
}
@Test
@DisplayName("peutEtreModifie — statut ANNULE")
void peutEtreModifie_false_whenANNULE() {
Versement v = buildMinimal();
v.setStatutPaiement("ANNULE");
assertThat(v.peutEtreModifie()).isFalse();
}
@Test
@DisplayName("peutEtreModifie — statut EN_ATTENTE_VALIDATION")
void peutEtreModifie_true_whenEnAttenteValidation() {
Versement v = buildMinimal();
v.setStatutPaiement("EN_ATTENTE_VALIDATION");
assertThat(v.peutEtreModifie()).isTrue();
}
@Test
@DisplayName("onCreate initialise reference, statut et datePaiement si null")
void onCreate_initDefaults() {
Versement v = new Versement();
v.setMontant(BigDecimal.TEN);
v.setCodeDevise("XOF");
v.setMethodePaiement("WAVE");
v.setMembre(newMembre());
v.onCreate();
assertThat(v.getNumeroReference()).startsWith("VRS-");
assertThat(v.getStatutPaiement()).isEqualTo("EN_ATTENTE");
assertThat(v.getDatePaiement()).isNotNull();
}
@Test
@DisplayName("onCreate conserve la référence existante")
void onCreate_preservesExistingReference() {
Versement v = buildMinimal();
v.setNumeroReference("VRS-CUSTOM-001");
v.onCreate();
assertThat(v.getNumeroReference()).isEqualTo("VRS-CUSTOM-001");
}
@Test
@DisplayName("equals et hashCode")
void equalsHashCode() {
UUID id = UUID.randomUUID();
Membre m = newMembre();
Versement a = buildMinimal();
a.setId(id);
a.setMembre(m);
Versement b = buildMinimal();
b.setId(id);
b.setMembre(m);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString non null")
void toString_nonNull() {
assertThat(buildMinimal().toString()).isNotNull().isNotEmpty();
}
private Versement buildMinimal() {
Versement v = new Versement();
v.setNumeroReference("VRS-2026-TEST");
v.setMontant(BigDecimal.ONE);
v.setCodeDevise("XOF");
v.setMethodePaiement("WAVE");
v.setStatutPaiement("EN_ATTENTE");
v.setMembre(newMembre());
return v;
}
}

View File

@@ -0,0 +1,43 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.ContactPolicy;
import io.quarkus.test.TestTransaction;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@QuarkusTest
@DisplayName("ContactPolicyRepository")
class ContactPolicyRepositoryTest {
@Inject
ContactPolicyRepository contactPolicyRepository;
@Test
@TestTransaction
@DisplayName("findByOrganisationId retourne empty pour organisation inexistante")
void findByOrganisationId_inexistant_returnsEmpty() {
Optional<ContactPolicy> opt = contactPolicyRepository.findByOrganisationId(UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("listAll retourne liste non nulle")
void listAll_returnsNonNull() {
assertThat(contactPolicyRepository.listAll()).isNotNull();
}
@Test
@TestTransaction
@DisplayName("count retourne >= 0")
void count_returnsNonNegative() {
assertThat(contactPolicyRepository.count()).isGreaterThanOrEqualTo(0);
}
}

View File

@@ -0,0 +1,63 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.ConversationParticipant;
import io.quarkus.test.TestTransaction;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@QuarkusTest
@DisplayName("ConversationParticipantRepository")
class ConversationParticipantRepositoryTest {
@Inject
ConversationParticipantRepository conversationParticipantRepository;
@Test
@TestTransaction
@DisplayName("findParticipant retourne empty pour conversation inexistante")
void findParticipant_returnsEmpty() {
Optional<ConversationParticipant> opt =
conversationParticipantRepository.findParticipant(UUID.randomUUID(), UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("findByConversation retourne liste vide pour conversation inexistante")
void findByConversation_returnsEmpty() {
List<ConversationParticipant> list =
conversationParticipantRepository.findByConversation(UUID.randomUUID());
assertThat(list).isNotNull().isEmpty();
}
@Test
@TestTransaction
@DisplayName("estParticipant retourne false pour conversation inexistante")
void estParticipant_returnsFalse() {
boolean result = conversationParticipantRepository.estParticipant(
UUID.randomUUID(), UUID.randomUUID());
assertThat(result).isFalse();
}
@Test
@TestTransaction
@DisplayName("count retourne >= 0")
void count_returnsNonNegative() {
assertThat(conversationParticipantRepository.count()).isGreaterThanOrEqualTo(0);
}
@Test
@TestTransaction
@DisplayName("listAll retourne liste non nulle")
void listAll_returnsNonNull() {
assertThat(conversationParticipantRepository.listAll()).isNotNull();
}
}

View File

@@ -1,149 +1,85 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.api.enums.communication.ConversationType;
import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.Organisation;
import io.quarkus.test.TestTransaction;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests d'intégration pour {@link ConversationRepository}.
* Couvre les 3 méthodes : findByParticipant, findByIdAndParticipant, findByOrganisation.
*/
@QuarkusTest
@DisplayName("ConversationRepository")
class ConversationRepositoryTest {
@Inject
ConversationRepository conversationRepository;
@Inject
OrganisationRepository organisationRepository;
private Organisation createOrganisation() {
Organisation o = new Organisation();
o.setNom("Org Conversation");
o.setTypeOrganisation("ASSOCIATION");
o.setStatut("ACTIVE");
o.setEmail("conv-" + UUID.randomUUID() + "@test.com");
o.setActif(true);
o.setDateCreation(LocalDateTime.now());
organisationRepository.persist(o);
return o;
}
private Conversation createConversation(String name, Organisation org) {
Conversation c = new Conversation();
c.setName(name);
c.setType(ConversationType.GROUP);
c.setOrganisation(org);
c.setActif(true);
c.setDateCreation(LocalDateTime.now());
conversationRepository.persist(c);
return c;
}
// =========================================================================
// findByParticipant — branches avec includeArchived=true et false
// =========================================================================
@Test
@TestTransaction
@DisplayName("findByParticipant avec includeArchived=true retourne liste vide si aucune conversation")
void findByParticipant_noConversations_returnsEmptyList() {
UUID randomMembre = UUID.randomUUID();
List<Conversation> result = conversationRepository.findByParticipant(randomMembre, true);
assertThat(result).isNotNull();
assertThat(result).isEmpty();
@DisplayName("findById retourne null pour UUID inexistant")
void findById_inexistant_returnsNull() {
assertThat(conversationRepository.findById(UUID.randomUUID())).isNull();
}
@Test
@TestTransaction
@DisplayName("findByParticipant avec includeArchived=false retourne liste vide si aucune conversation")
void findByParticipant_excludeArchived_returnsEmptyList() {
UUID randomMembre = UUID.randomUUID();
List<Conversation> result = conversationRepository.findByParticipant(randomMembre, false);
assertThat(result).isNotNull();
assertThat(result).isEmpty();
}
// =========================================================================
// findByIdAndParticipant
// =========================================================================
@Test
@TestTransaction
@DisplayName("findByIdAndParticipant retourne empty pour ID et membreId inconnus")
void findByIdAndParticipant_unknownIds_returnsEmpty() {
Optional<Conversation> result = conversationRepository.findByIdAndParticipant(
UUID.randomUUID(), UUID.randomUUID());
assertThat(result).isEmpty();
@DisplayName("findConversationById retourne empty pour UUID inexistant")
void findConversationById_inexistant_returnsEmpty() {
Optional<Conversation> opt = conversationRepository.findConversationById(UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("findByIdAndParticipant retourne empty pour conversationId inexistant")
void findByIdAndParticipant_nonExistentConversation_returnsEmpty() {
UUID nonExistentId = UUID.randomUUID();
UUID membreId = UUID.randomUUID();
Optional<Conversation> result = conversationRepository.findByIdAndParticipant(
nonExistentId, membreId);
assertThat(result).isEmpty();
}
// =========================================================================
// findByOrganisation
// =========================================================================
@Test
@TestTransaction
@DisplayName("findByOrganisation retourne liste vide si organisation sans conversation")
void findByOrganisation_noConversations_returnsEmptyList() {
Organisation org = createOrganisation();
List<Conversation> result = conversationRepository.findByOrganisation(org.getId());
assertThat(result).isNotNull();
assertThat(result).isEmpty();
@DisplayName("findByMembreId retourne liste non nulle pour membre inexistant")
void findByMembreId_inconnu_returnsEmpty() {
List<Conversation> list = conversationRepository.findByMembreId(UUID.randomUUID());
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("findByOrganisation retourne les conversations de l'organisation persistées")
void findByOrganisation_withConversations_returnsList() {
Organisation org = createOrganisation();
createConversation("Conv1-" + UUID.randomUUID(), org);
createConversation("Conv2-" + UUID.randomUUID(), org);
List<Conversation> result = conversationRepository.findByOrganisation(org.getId());
assertThat(result).isNotNull();
assertThat(result).hasSize(2);
@DisplayName("findConversationDirecte retourne empty si aucune conversation")
void findConversationDirecte_inconnu_returnsEmpty() {
Optional<Conversation> opt = conversationRepository.findConversationDirecte(
UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("findByOrganisation retourne liste vide pour organisationId inexistant")
void findByOrganisation_unknownOrganisationId_returnsEmptyList() {
List<Conversation> result = conversationRepository.findByOrganisation(UUID.randomUUID());
@DisplayName("findCanalRole retourne empty si aucun canal")
void findCanalRole_inconnu_returnsEmpty() {
Optional<Conversation> opt = conversationRepository.findCanalRole(UUID.randomUUID(), "TRESORIER");
assertThat(opt).isEmpty();
}
assertThat(result).isNotNull();
assertThat(result).isEmpty();
@Test
@TestTransaction
@DisplayName("findActivesByMembre retourne liste non nulle")
void findActivesByMembre_returnsNonNull() {
List<Conversation> list = conversationRepository.findActivesByMembre(UUID.randomUUID());
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("count retourne un nombre >= 0")
void count_returnsNonNegative() {
assertThat(conversationRepository.count()).isGreaterThanOrEqualTo(0);
}
@Test
@TestTransaction
@DisplayName("listAll retourne une liste non nulle")
void listAll_returnsNonNull() {
assertThat(conversationRepository.listAll()).isNotNull();
}
}

View File

@@ -0,0 +1,64 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.MemberBlock;
import io.quarkus.test.TestTransaction;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@QuarkusTest
@DisplayName("MemberBlockRepository")
class MemberBlockRepositoryTest {
@Inject
MemberBlockRepository memberBlockRepository;
@Test
@TestTransaction
@DisplayName("estBloque retourne false si aucun blocage")
void estBloque_returns_false() {
boolean result = memberBlockRepository.estBloque(
UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID());
assertThat(result).isFalse();
}
@Test
@TestTransaction
@DisplayName("findBlocage retourne empty si aucun blocage")
void findBlocage_returnsEmpty() {
Optional<MemberBlock> opt = memberBlockRepository.findBlocage(
UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("findByBloqueur retourne liste vide pour membre inexistant")
void findByBloqueur_returnsEmpty() {
List<MemberBlock> list = memberBlockRepository.findByBloqueur(UUID.randomUUID());
assertThat(list).isNotNull().isEmpty();
}
@Test
@TestTransaction
@DisplayName("findByBloqueurEtOrganisation retourne liste vide")
void findByBloqueurEtOrganisation_returnsEmpty() {
List<MemberBlock> list = memberBlockRepository.findByBloqueurEtOrganisation(
UUID.randomUUID(), UUID.randomUUID());
assertThat(list).isNotNull().isEmpty();
}
@Test
@TestTransaction
@DisplayName("count retourne >= 0")
void count_returnsNonNegative() {
assertThat(memberBlockRepository.count()).isGreaterThanOrEqualTo(0);
}
}

View File

@@ -1,256 +1,69 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.api.enums.communication.ConversationType;
import dev.lions.unionflow.server.api.enums.communication.MessagePriority;
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
import dev.lions.unionflow.server.api.enums.communication.MessageType;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Message;
import io.quarkus.test.TestTransaction;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@QuarkusTest
@DisplayName("MessageRepository — tests des méthodes de requête")
@DisplayName("MessageRepository")
class MessageRepositoryTest {
@Inject
MessageRepository messageRepository;
@Inject
EntityManager em;
// ─── Helpers ─────────────────────────────────────────────────────────────
private Membre persistMembre() {
Membre m = new Membre();
m.setNumeroMembre("MSG-MEM-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase());
m.setPrenom("Jean");
m.setNom("Messagerie");
m.setEmail("msg." + UUID.randomUUID() + "@test.com");
m.setDateNaissance(LocalDate.of(1990, 1, 1));
m.setActif(true);
em.persist(m);
em.flush();
return m;
}
private Conversation persistConversation() {
Conversation conv = new Conversation();
conv.setName("Conv Test " + UUID.randomUUID().toString().substring(0, 8));
conv.setType(ConversationType.GROUP);
conv.setActif(true);
em.persist(conv);
em.flush();
return conv;
}
private Message persistMessage(Conversation conv, Membre sender, MessageStatus status) {
Message msg = new Message();
msg.setConversation(conv);
msg.setSender(sender);
msg.setSenderName(sender.getPrenom() + " " + sender.getNom());
msg.setContent("Contenu test " + UUID.randomUUID());
msg.setType(MessageType.INDIVIDUAL);
msg.setStatus(status);
msg.setPriority(MessagePriority.NORMAL);
msg.setIsEdited(false);
msg.setIsDeleted(false);
msg.setActif(true);
em.persist(msg);
em.flush();
return msg;
}
// ─── Tests ───────────────────────────────────────────────────────────────
@Test
@TestTransaction
@DisplayName("findByConversation retourne les messages actifs non supprimés")
void findByConversation_retourneMessagesActifs() {
Membre sender = persistMembre();
Conversation conv = persistConversation();
persistMessage(conv, sender, MessageStatus.SENT);
persistMessage(conv, sender, MessageStatus.READ);
List<Message> messages = messageRepository.findByConversation(conv.getId(), 10);
assertThat(messages).isNotNull();
assertThat(messages).hasSizeGreaterThanOrEqualTo(2);
messages.forEach(m -> assertThat(m.getIsDeleted()).isFalse());
@DisplayName("findMessageById retourne empty pour UUID inexistant")
void findMessageById_inexistant_returnsEmpty() {
Optional<Message> opt = messageRepository.findMessageById(UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("findByConversation respecte la limite de pagination")
void findByConversation_respecteLimite() {
Membre sender = persistMembre();
Conversation conv = persistConversation();
for (int i = 0; i < 5; i++) {
persistMessage(conv, sender, MessageStatus.SENT);
}
List<Message> messages = messageRepository.findByConversation(conv.getId(), 3);
assertThat(messages).hasSizeLessThanOrEqualTo(3);
@DisplayName("findByConversationPagine retourne liste vide pour conversation inexistante")
void findByConversationPagine_returnsEmpty() {
List<Message> list = messageRepository.findByConversationPagine(UUID.randomUUID(), 0, 20);
assertThat(list).isNotNull().isEmpty();
}
@Test
@TestTransaction
@DisplayName("findByConversation retourne liste vide pour conversation sans messages")
void findByConversation_conversationVide_retourneListe() {
Conversation conv = persistConversation();
List<Message> messages = messageRepository.findByConversation(conv.getId(), 10);
assertThat(messages).isNotNull();
assertThat(messages).isEmpty();
@DisplayName("countNonLus retourne >= 0 pour conversation inexistante")
void countNonLus_returnsNonNegative() {
long count = messageRepository.countNonLus(UUID.randomUUID(), UUID.randomUUID());
assertThat(count).isGreaterThanOrEqualTo(0);
}
@Test
@TestTransaction
@DisplayName("findByConversation exclut les messages supprimés")
void findByConversation_exclutMessagesSupprimés() {
Membre sender = persistMembre();
Conversation conv = persistConversation();
// Message supprimé
Message msgSupprime = persistMessage(conv, sender, MessageStatus.SENT);
msgSupprime.setIsDeleted(true);
em.flush();
List<Message> messages = messageRepository.findByConversation(conv.getId(), 10);
assertThat(messages).noneMatch(m -> m.getId().equals(msgSupprime.getId()));
@DisplayName("findActifsByConversation retourne liste vide pour conversation inexistante")
void findActifsByConversation_returnsEmpty() {
List<Message> list = messageRepository.findActifsByConversation(UUID.randomUUID());
assertThat(list).isNotNull().isEmpty();
}
@Test
@TestTransaction
@DisplayName("countUnreadByConversationAndMember compte les messages SENT et DELIVERED d'autres membres")
void countUnreadByConversationAndMember_compteMsgNonLusAutresMembres() {
Membre sender = persistMembre();
Membre reader = persistMembre();
Conversation conv = persistConversation();
persistMessage(conv, sender, MessageStatus.SENT);
persistMessage(conv, sender, MessageStatus.DELIVERED);
persistMessage(conv, sender, MessageStatus.READ); // déjà lu → exclu
long count = messageRepository.countUnreadByConversationAndMember(conv.getId(), reader.getId());
assertThat(count).isGreaterThanOrEqualTo(2);
@DisplayName("findDernierMessage retourne empty pour conversation inexistante")
void findDernierMessage_returnsEmpty() {
Optional<Message> opt = messageRepository.findDernierMessage(UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("countUnreadByConversationAndMember exclut les messages du membre lui-même")
void countUnreadByConversationAndMember_excluMessagesPropresMembre() {
Membre sender = persistMembre();
Conversation conv = persistConversation();
persistMessage(conv, sender, MessageStatus.SENT);
// Le sender lui-même : ses propres messages ne sont pas comptés comme non lus
long count = messageRepository.countUnreadByConversationAndMember(conv.getId(), sender.getId());
assertThat(count).isEqualTo(0);
}
@Test
@TestTransaction
@DisplayName("countUnreadByConversationAndMember retourne 0 pour conversation vide")
void countUnreadByConversationAndMember_conversationVide_retourneZero() {
Conversation conv = persistConversation();
UUID membreId = UUID.randomUUID();
long count = messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId);
assertThat(count).isEqualTo(0);
}
@Test
@TestTransaction
@DisplayName("markAllAsReadByConversationAndMember marque les messages SENT et DELIVERED en READ")
void markAllAsReadByConversationAndMember_marqueLesMessagesEnRead() {
Membre sender = persistMembre();
Membre reader = persistMembre();
Conversation conv = persistConversation();
persistMessage(conv, sender, MessageStatus.SENT);
persistMessage(conv, sender, MessageStatus.DELIVERED);
int updated = messageRepository.markAllAsReadByConversationAndMember(conv.getId(), reader.getId());
assertThat(updated).isGreaterThanOrEqualTo(2);
}
@Test
@TestTransaction
@DisplayName("markAllAsReadByConversationAndMember ne touche pas les messages déjà READ")
void markAllAsReadByConversationAndMember_neTouchePasMsgDejaRead() {
Membre sender = persistMembre();
Membre reader = persistMembre();
Conversation conv = persistConversation();
persistMessage(conv, sender, MessageStatus.READ); // déjà lu
int updated = messageRepository.markAllAsReadByConversationAndMember(conv.getId(), reader.getId());
assertThat(updated).isEqualTo(0);
}
@Test
@TestTransaction
@DisplayName("findLastByConversation retourne le dernier message")
void findLastByConversation_retourneDernierMessage() {
Membre sender = persistMembre();
Conversation conv = persistConversation();
// Set explicit dateCreation to guarantee ordering (PrePersist skips if non-null)
Message premierMsg = new Message();
premierMsg.setConversation(conv);
premierMsg.setSender(sender);
premierMsg.setSenderName(sender.getPrenom() + " " + sender.getNom());
premierMsg.setContent("Premier message");
premierMsg.setType(dev.lions.unionflow.server.api.enums.communication.MessageType.INDIVIDUAL);
premierMsg.setStatus(MessageStatus.SENT);
premierMsg.setPriority(dev.lions.unionflow.server.api.enums.communication.MessagePriority.NORMAL);
premierMsg.setIsEdited(false);
premierMsg.setIsDeleted(false);
premierMsg.setActif(true);
premierMsg.setDateCreation(java.time.LocalDateTime.now().minusSeconds(10));
em.persist(premierMsg);
em.flush();
Message dernierMsg = persistMessage(conv, sender, MessageStatus.DELIVERED);
Message last = messageRepository.findLastByConversation(conv.getId());
assertThat(last).isNotNull();
assertThat(last.getId()).isEqualTo(dernierMsg.getId());
}
@Test
@TestTransaction
@DisplayName("findLastByConversation retourne null pour conversation vide")
void findLastByConversation_conversationVide_retourneNull() {
Conversation conv = persistConversation();
Message last = messageRepository.findLastByConversation(conv.getId());
assertThat(last).isNull();
}
@Test
@TestTransaction
@DisplayName("findLastByConversation retourne null pour conversation inexistante")
void findLastByConversation_conversationInexistante_retourneNull() {
Message last = messageRepository.findLastByConversation(UUID.randomUUID());
assertThat(last).isNull();
@DisplayName("count retourne un nombre >= 0")
void count_returnsNonNegative() {
assertThat(messageRepository.count()).isGreaterThanOrEqualTo(0);
}
}

View File

@@ -1,107 +0,0 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.api.enums.paiement.MethodePaiement;
import dev.lions.unionflow.server.api.enums.paiement.StatutPaiement;
import dev.lions.unionflow.server.entity.Paiement;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.TestTransaction;
import jakarta.inject.Inject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@QuarkusTest
class PaiementRepositoryTest {
@Inject
PaiementRepository paiementRepository;
@Test
@TestTransaction
@DisplayName("findById retourne null pour UUID inexistant")
void findById_inexistant_returnsNull() {
assertThat(paiementRepository.findById(UUID.randomUUID())).isNull();
}
@Test
@TestTransaction
@DisplayName("findPaiementById retourne empty pour UUID inexistant")
void findPaiementById_inexistant_returnsEmpty() {
Optional<Paiement> opt = paiementRepository.findPaiementById(UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("findByNumeroReference retourne empty pour référence inexistante")
void findByNumeroReference_inexistant_returnsEmpty() {
Optional<Paiement> opt = paiementRepository.findByNumeroReference("REF-" + UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("listAll retourne une liste")
void listAll_returnsList() {
List<Paiement> list = paiementRepository.listAll();
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("count retourne un nombre >= 0")
void count_returnsNonNegative() {
assertThat(paiementRepository.count()).isGreaterThanOrEqualTo(0L);
}
@Test
@TestTransaction
@DisplayName("findByStatut retourne une liste (vide si aucun paiement avec ce statut)")
void findByStatut_returnsEmptyList() {
List<Paiement> list = paiementRepository.findByStatut(StatutPaiement.EN_ATTENTE);
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("findByMethode retourne une liste (vide si aucun paiement avec cette méthode)")
void findByMethode_returnsEmptyList() {
List<Paiement> list = paiementRepository.findByMethode(MethodePaiement.VIREMENT_BANCAIRE);
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("findValidesParPeriode retourne une liste pour une période donnée")
void findValidesParPeriode_returnsEmptyList() {
LocalDateTime debut = LocalDateTime.now().minusDays(30);
LocalDateTime fin = LocalDateTime.now();
List<Paiement> list = paiementRepository.findValidesParPeriode(debut, fin);
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("calculerMontantTotalValides retourne ZERO si aucun paiement validé")
void calculerMontantTotalValides_returnsZero() {
LocalDateTime debut = LocalDateTime.now().minusDays(1);
LocalDateTime fin = LocalDateTime.now();
BigDecimal total = paiementRepository.calculerMontantTotalValides(debut, fin);
assertThat(total).isGreaterThanOrEqualTo(BigDecimal.ZERO);
}
@Test
@TestTransaction
@DisplayName("findByMembreId retourne une liste (vide si aucun paiement pour ce membre)")
void findByMembreId_returnsEmptyList() {
List<Paiement> list = paiementRepository.findByMembreId(UUID.randomUUID());
assertThat(list).isNotNull();
}
}

View File

@@ -0,0 +1,109 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.api.enums.paiement.MethodePaiement;
import dev.lions.unionflow.server.api.enums.paiement.StatutPaiement;
import dev.lions.unionflow.server.entity.Versement;
import io.quarkus.test.TestTransaction;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@QuarkusTest
@DisplayName("VersementRepository")
class VersementRepositoryTest {
@Inject
VersementRepository versementRepository;
@Test
@TestTransaction
@DisplayName("findById retourne null pour UUID inexistant")
void findById_inexistant_returnsNull() {
assertThat(versementRepository.findById(UUID.randomUUID())).isNull();
}
@Test
@TestTransaction
@DisplayName("findVersementById retourne empty pour UUID inexistant")
void findVersementById_inexistant_returnsEmpty() {
Optional<Versement> opt = versementRepository.findVersementById(UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("findByNumeroReference retourne empty pour référence inexistante")
void findByNumeroReference_inexistant_returnsEmpty() {
Optional<Versement> opt = versementRepository.findByNumeroReference("VRS-" + UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("listAll retourne une liste")
void listAll_returnsList() {
List<Versement> list = versementRepository.listAll();
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("count retourne un nombre >= 0")
void count_returnsNonNegative() {
assertThat(versementRepository.count()).isGreaterThanOrEqualTo(0);
}
@Test
@TestTransaction
@DisplayName("findByMembreId retourne liste vide pour membre inexistant")
void findByMembreId_inconnu_returnsEmpty() {
List<Versement> list = versementRepository.findByMembreId(UUID.randomUUID());
assertThat(list).isNotNull().isEmpty();
}
@Test
@TestTransaction
@DisplayName("findByStatut retourne liste non nulle")
void findByStatut_returnsNonNull() {
List<Versement> list = versementRepository.findByStatut(StatutPaiement.EN_ATTENTE);
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("findByMethode retourne liste non nulle")
void findByMethode_returnsNonNull() {
List<Versement> list = versementRepository.findByMethode(MethodePaiement.WAVE_MOBILE_MONEY);
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("findConfirmesParPeriode retourne liste non nulle")
void findConfirmesParPeriode_returnsNonNull() {
LocalDateTime debut = LocalDateTime.now().minusDays(30);
LocalDateTime fin = LocalDateTime.now();
List<Versement> list = versementRepository.findConfirmesParPeriode(debut, fin);
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("calculerMontantTotalConfirmes retourne ZERO si aucun versement")
void calculerMontantTotalConfirmes_returnsZeroWhenEmpty() {
LocalDateTime debut = LocalDateTime.now().minusSeconds(1);
LocalDateTime fin = LocalDateTime.now().plusSeconds(1);
BigDecimal total = versementRepository.calculerMontantTotalConfirmes(debut, fin);
// Peut être > 0 selon les données de test, mais ne doit pas être null
assertThat(total).isNotNull().isGreaterThanOrEqualTo(BigDecimal.ZERO);
}
}

View File

@@ -1,377 +0,0 @@
package dev.lions.unionflow.server.resource;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.notNullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
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.communication.request.CreateConversationRequest;
import dev.lions.unionflow.server.api.dto.communication.response.ConversationResponse;
import dev.lions.unionflow.server.api.enums.communication.ConversationType;
import dev.lions.unionflow.server.service.ConversationService;
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.NotFoundException;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Tests d'intégration REST pour ConversationResource.
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-21
*/
@QuarkusTest
class ConversationResourceTest {
private static final String BASE_PATH = "/api/conversations";
private static final String MEMBRE_ID = "00000000-0000-0000-0000-000000000010";
private static final String CONVERSATION_ID = "00000000-0000-0000-0000-000000000011";
private static final String ORG_ID = "00000000-0000-0000-0000-000000000012";
@InjectMock
ConversationService conversationService;
@InjectMock
SecuriteHelper securiteHelper;
@BeforeEach
void setup() {
when(securiteHelper.resolveMembreId()).thenReturn(UUID.fromString(MEMBRE_ID));
}
private ConversationResponse buildConversationResponse() {
return ConversationResponse.builder()
.id(UUID.fromString(CONVERSATION_ID))
.name("Discussion générale")
.description("Conversation test")
.type(ConversationType.GROUP)
.participantIds(List.of(UUID.fromString(MEMBRE_ID)))
.unreadCount(0)
.muted(false)
.pinned(false)
.archived(false)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
}
// -------------------------------------------------------------------------
// GET /api/conversations
// -------------------------------------------------------------------------
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("getConversations retourne 200 avec liste vide")
void getConversations_returnsEmptyList_200() {
when(conversationService.getConversations(any(), any(), anyBoolean()))
.thenReturn(Collections.emptyList());
given()
.when()
.get(BASE_PATH)
.then()
.statusCode(200)
.contentType(ContentType.JSON)
.body("$", notNullValue());
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("getConversations avec includeArchived=true retourne 200")
void getConversations_withIncludeArchived_returns200() {
when(conversationService.getConversations(any(), any(), eq(true)))
.thenReturn(List.of(buildConversationResponse()));
given()
.queryParam("includeArchived", true)
.when()
.get(BASE_PATH)
.then()
.statusCode(200)
.contentType(ContentType.JSON);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("getConversations avec organisationId filtre par organisation")
void getConversations_withOrganisationId_returns200() {
when(conversationService.getConversations(any(), eq(UUID.fromString(ORG_ID)), anyBoolean()))
.thenReturn(List.of(buildConversationResponse()));
given()
.queryParam("organisationId", ORG_ID)
.when()
.get(BASE_PATH)
.then()
.statusCode(200)
.contentType(ContentType.JSON);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("getConversations retourne 200 avec liste non vide")
void getConversations_withResults_returns200() {
when(conversationService.getConversations(any(), any(), anyBoolean()))
.thenReturn(List.of(buildConversationResponse()));
given()
.when()
.get(BASE_PATH)
.then()
.statusCode(200)
.contentType(ContentType.JSON);
}
// -------------------------------------------------------------------------
// GET /api/conversations/{id}
// -------------------------------------------------------------------------
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("getConversationById retourne 200 quand conversation trouvée")
void getConversationById_found_returns200() {
when(conversationService.getConversationById(eq(UUID.fromString(CONVERSATION_ID)), any()))
.thenReturn(buildConversationResponse());
given()
.when()
.get(BASE_PATH + "/{id}", CONVERSATION_ID)
.then()
.statusCode(200)
.contentType(ContentType.JSON)
.body("id", notNullValue());
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("getConversationById retourne 404 quand conversation non trouvée")
void getConversationById_notFound_returns404() {
when(conversationService.getConversationById(any(), any()))
.thenThrow(new NotFoundException("Conversation non trouvée ou accès refusé"));
given()
.when()
.get(BASE_PATH + "/{id}", UUID.randomUUID().toString())
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// POST /api/conversations
// -------------------------------------------------------------------------
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("createConversation retourne 201 avec body valide")
void createConversation_validRequest_returns201() {
when(conversationService.createConversation(any(CreateConversationRequest.class), any()))
.thenReturn(buildConversationResponse());
String body = """
{
"name": "Nouveau groupe",
"description": "Description du groupe",
"type": "GROUP",
"participantIds": ["%s"]
}
""".formatted(MEMBRE_ID);
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH)
.then()
.statusCode(201)
.contentType(ContentType.JSON);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("createConversation avec body invalide (sans name) retourne 400")
void createConversation_invalidRequest_returns400() {
String body = """
{
"description": "Sans nom",
"type": "GROUP",
"participantIds": ["%s"]
}
""".formatted(MEMBRE_ID);
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH)
.then()
.statusCode(400);
}
// -------------------------------------------------------------------------
// PUT /api/conversations/{id}/archive
// -------------------------------------------------------------------------
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("archiveConversation retourne 204 quand succès")
void archiveConversation_success_returns204() {
doNothing().when(conversationService).archiveConversation(any(), any(), anyBoolean());
given()
.when()
.put(BASE_PATH + "/{id}/archive", CONVERSATION_ID)
.then()
.statusCode(204);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("archiveConversation avec archive=false retourne 204")
void archiveConversation_unarchive_returns204() {
doNothing().when(conversationService).archiveConversation(any(), any(), eq(false));
given()
.queryParam("archive", false)
.when()
.put(BASE_PATH + "/{id}/archive", CONVERSATION_ID)
.then()
.statusCode(204);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("archiveConversation non trouvée retourne 404")
void archiveConversation_notFound_returns404() {
doThrow(new NotFoundException("Conversation non trouvée"))
.when(conversationService).archiveConversation(any(), any(), anyBoolean());
given()
.when()
.put(BASE_PATH + "/{id}/archive", UUID.randomUUID().toString())
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// PUT /api/conversations/{id}/mark-read
// -------------------------------------------------------------------------
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("markAsRead retourne 204 quand succès")
void markAsRead_success_returns204() {
doNothing().when(conversationService).markAsRead(any(), any());
given()
.when()
.put(BASE_PATH + "/{id}/mark-read", CONVERSATION_ID)
.then()
.statusCode(204);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("markAsRead conversation non trouvée retourne 404")
void markAsRead_notFound_returns404() {
doThrow(new NotFoundException("Conversation non trouvée"))
.when(conversationService).markAsRead(any(), any());
given()
.when()
.put(BASE_PATH + "/{id}/mark-read", UUID.randomUUID().toString())
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// PUT /api/conversations/{id}/toggle-mute
// -------------------------------------------------------------------------
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("toggleMute retourne 204 quand succès")
void toggleMute_success_returns204() {
doNothing().when(conversationService).toggleMute(any(), any());
given()
.when()
.put(BASE_PATH + "/{id}/toggle-mute", CONVERSATION_ID)
.then()
.statusCode(204);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("toggleMute conversation non trouvée retourne 404")
void toggleMute_notFound_returns404() {
doThrow(new NotFoundException("Conversation non trouvée"))
.when(conversationService).toggleMute(any(), any());
given()
.when()
.put(BASE_PATH + "/{id}/toggle-mute", UUID.randomUUID().toString())
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// PUT /api/conversations/{id}/toggle-pin
// -------------------------------------------------------------------------
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("togglePin retourne 204 quand succès")
void togglePin_success_returns204() {
doNothing().when(conversationService).togglePin(any(), any());
given()
.when()
.put(BASE_PATH + "/{id}/toggle-pin", CONVERSATION_ID)
.then()
.statusCode(204);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("togglePin conversation non trouvée retourne 404")
void togglePin_notFound_returns404() {
doThrow(new NotFoundException("Conversation non trouvée"))
.when(conversationService).togglePin(any(), any());
given()
.when()
.put(BASE_PATH + "/{id}/toggle-pin", UUID.randomUUID().toString())
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// Sécurité — non authentifié
// -------------------------------------------------------------------------
@Test
@DisplayName("getConversations sans authentification retourne 401")
void getConversations_unauthenticated_returns401() {
given()
.when()
.get(BASE_PATH)
.then()
.statusCode(401);
}
}

View File

@@ -1,374 +0,0 @@
package dev.lions.unionflow.server.resource;
import static io.restassured.RestAssured.given;
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.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
import dev.lions.unionflow.server.api.dto.communication.request.SendMessageRequest;
import dev.lions.unionflow.server.api.dto.communication.response.MessageResponse;
import dev.lions.unionflow.server.api.enums.communication.MessagePriority;
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
import dev.lions.unionflow.server.api.enums.communication.MessageType;
import dev.lions.unionflow.server.service.MessageService;
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.NotFoundException;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Tests d'intégration REST pour MessageResource.
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-21
*/
@QuarkusTest
class MessageResourceTest {
private static final String BASE_PATH = "/api/messages";
private static final String MEMBRE_ID = "00000000-0000-0000-0000-000000000020";
private static final String MESSAGE_ID = "00000000-0000-0000-0000-000000000021";
private static final String CONVERSATION_ID = "00000000-0000-0000-0000-000000000022";
@InjectMock
MessageService messageService;
@InjectMock
SecuriteHelper securiteHelper;
@BeforeEach
void setup() {
when(securiteHelper.resolveMembreId()).thenReturn(UUID.fromString(MEMBRE_ID));
}
private MessageResponse buildMessageResponse() {
return MessageResponse.builder()
.id(UUID.fromString(MESSAGE_ID))
.conversationId(UUID.fromString(CONVERSATION_ID))
.senderId(UUID.fromString(MEMBRE_ID))
.senderName("Alice Martin")
.content("Bonjour !")
.type(MessageType.INDIVIDUAL)
.status(MessageStatus.SENT)
.priority(MessagePriority.NORMAL)
.edited(false)
.deleted(false)
.createdAt(LocalDateTime.now())
.build();
}
// -------------------------------------------------------------------------
// GET /api/messages
// -------------------------------------------------------------------------
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("getMessages sans conversationId retourne 400")
void getMessages_missingConversationId_returns400() {
given()
.when()
.get(BASE_PATH)
.then()
.statusCode(400);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("getMessages avec conversationId valide retourne 200")
void getMessages_withConversationId_returns200() {
when(messageService.getMessages(eq(UUID.fromString(CONVERSATION_ID)), any(), anyInt()))
.thenReturn(List.of(buildMessageResponse()));
given()
.queryParam("conversationId", CONVERSATION_ID)
.when()
.get(BASE_PATH)
.then()
.statusCode(200)
.contentType(ContentType.JSON)
.body("$", notNullValue());
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("getMessages avec conversationId valide retourne liste vide")
void getMessages_emptyConversation_returns200() {
when(messageService.getMessages(any(), any(), anyInt()))
.thenReturn(Collections.emptyList());
given()
.queryParam("conversationId", CONVERSATION_ID)
.when()
.get(BASE_PATH)
.then()
.statusCode(200)
.contentType(ContentType.JSON);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("getMessages avec limit personnalisé retourne 200")
void getMessages_withLimit_returns200() {
when(messageService.getMessages(any(), any(), eq(10)))
.thenReturn(List.of(buildMessageResponse()));
given()
.queryParam("conversationId", CONVERSATION_ID)
.queryParam("limit", 10)
.when()
.get(BASE_PATH)
.then()
.statusCode(200)
.contentType(ContentType.JSON);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("getMessages conversation non trouvée retourne 404")
void getMessages_conversationNotFound_returns404() {
when(messageService.getMessages(any(), any(), anyInt()))
.thenThrow(new NotFoundException("Conversation non trouvée ou accès refusé"));
given()
.queryParam("conversationId", CONVERSATION_ID)
.when()
.get(BASE_PATH)
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// POST /api/messages
// -------------------------------------------------------------------------
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("sendMessage avec body valide retourne 201")
void sendMessage_validRequest_returns201() {
when(messageService.sendMessage(any(SendMessageRequest.class), any()))
.thenReturn(buildMessageResponse());
String body = """
{
"conversationId": "%s",
"content": "Bonjour à tous !"
}
""".formatted(CONVERSATION_ID);
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH)
.then()
.statusCode(201)
.contentType(ContentType.JSON)
.body("id", notNullValue());
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("sendMessage sans conversationId retourne 400")
void sendMessage_missingConversationId_returns400() {
String body = """
{
"content": "Message sans conversation"
}
""";
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH)
.then()
.statusCode(400);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("sendMessage sans contenu retourne 400")
void sendMessage_missingContent_returns400() {
String body = """
{
"conversationId": "%s"
}
""".formatted(CONVERSATION_ID);
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH)
.then()
.statusCode(400);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("sendMessage conversation non trouvée retourne 404")
void sendMessage_conversationNotFound_returns404() {
when(messageService.sendMessage(any(), any()))
.thenThrow(new NotFoundException("Conversation non trouvée ou accès refusé"));
String body = """
{
"conversationId": "%s",
"content": "Test"
}
""".formatted(CONVERSATION_ID);
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH)
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// PUT /api/messages/{id}
// -------------------------------------------------------------------------
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("editMessage avec body valide retourne 200")
void editMessage_validRequest_returns200() {
when(messageService.editMessage(any(), any(), anyString()))
.thenReturn(buildMessageResponse());
given()
.contentType(ContentType.JSON)
.body("{\"content\": \"Message modifié\"}")
.when()
.put(BASE_PATH + "/{id}", MESSAGE_ID)
.then()
.statusCode(200)
.contentType(ContentType.JSON);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("editMessage sans contenu retourne 400")
void editMessage_missingContent_returns400() {
given()
.contentType(ContentType.JSON)
.body("{}")
.when()
.put(BASE_PATH + "/{id}", MESSAGE_ID)
.then()
.statusCode(400);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("editMessage avec contenu vide retourne 400")
void editMessage_emptyContent_returns400() {
given()
.contentType(ContentType.JSON)
.body("{\"content\": \"\"}")
.when()
.put(BASE_PATH + "/{id}", MESSAGE_ID)
.then()
.statusCode(400);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("editMessage message non trouvé retourne 404")
void editMessage_notFound_returns404() {
when(messageService.editMessage(any(), any(), anyString()))
.thenThrow(new NotFoundException("Message non trouvé"));
given()
.contentType(ContentType.JSON)
.body("{\"content\": \"Contenu modifié\"}")
.when()
.put(BASE_PATH + "/{id}", UUID.randomUUID().toString())
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// DELETE /api/messages/{id}
// -------------------------------------------------------------------------
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("deleteMessage retourne 204 quand succès")
void deleteMessage_success_returns204() {
doNothing().when(messageService).deleteMessage(any(), any());
given()
.when()
.delete(BASE_PATH + "/{id}", MESSAGE_ID)
.then()
.statusCode(204);
}
@Test
@TestSecurity(user = "alice@test.com", roles = {"MEMBRE"})
@DisplayName("deleteMessage message non trouvé retourne 404")
void deleteMessage_notFound_returns404() {
doThrow(new NotFoundException("Message non trouvé"))
.when(messageService).deleteMessage(any(), any());
given()
.when()
.delete(BASE_PATH + "/{id}", UUID.randomUUID().toString())
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// Sécurité — non authentifié
// -------------------------------------------------------------------------
@Test
@DisplayName("getMessages sans authentification retourne 401")
void getMessages_unauthenticated_returns401() {
given()
.queryParam("conversationId", CONVERSATION_ID)
.when()
.get(BASE_PATH)
.then()
.statusCode(401);
}
@Test
@DisplayName("sendMessage sans authentification retourne 401")
void sendMessage_unauthenticated_returns401() {
String body = """
{
"conversationId": "%s",
"content": "Message non auth"
}
""".formatted(CONVERSATION_ID);
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH)
.then()
.statusCode(401);
}
}

View File

@@ -0,0 +1,439 @@
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.Mockito.doNothing;
import static org.mockito.Mockito.when;
import dev.lions.unionflow.server.api.dto.messagerie.response.ContactPolicyResponse;
import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationResponse;
import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationSummaryResponse;
import dev.lions.unionflow.server.api.dto.messagerie.response.MessageResponse;
import dev.lions.unionflow.server.service.MessagingService;
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.NotFoundException;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* Tests d'intégration REST pour {@link MessagingResource}.
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@QuarkusTest
@DisplayName("MessagingResource")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class MessagingResourceTest {
private static final String BASE = "/api/messagerie";
private static final String CONV_ID = "00000000-0000-0000-0000-000000000001";
private static final String MSG_ID = "00000000-0000-0000-0000-000000000002";
private static final String MEMBRE_ID = "00000000-0000-0000-0000-000000000003";
private static final String ORG_ID = "00000000-0000-0000-0000-000000000004";
@InjectMock
MessagingService messagingService;
// ── POST /conversations/directe ───────────────────────────────────────────
@Test
@Order(1)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("demarrerConversationDirecte — requête valide → 201")
void demarrerConversationDirecte_valid_returns201() {
ConversationResponse conv = ConversationResponse.builder()
.id(UUID.fromString(CONV_ID))
.typeConversation("DIRECTE")
.statut("ACTIVE")
.build();
when(messagingService.demarrerConversationDirecte(any())).thenReturn(conv);
given()
.contentType(ContentType.JSON)
.body("{\"destinataireId\":\"" + MEMBRE_ID + "\",\"organisationId\":\"" + ORG_ID + "\"}")
.when().post(BASE + "/conversations/directe")
.then().statusCode(201)
.body("typeConversation", equalTo("DIRECTE"))
.body("statut", equalTo("ACTIVE"));
}
@Test
@Order(2)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("demarrerConversationDirecte — destinataireId manquant → 400")
void demarrerConversationDirecte_missingDestinataire_returns400() {
given()
.contentType(ContentType.JSON)
.body("{\"organisationId\":\"" + ORG_ID + "\"}")
.when().post(BASE + "/conversations/directe")
.then().statusCode(400);
}
// ── POST /conversations/role ──────────────────────────────────────────────
@Test
@Order(3)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("demarrerConversationRole — requête valide → 201")
void demarrerConversationRole_valid_returns201() {
ConversationResponse conv = ConversationResponse.builder()
.id(UUID.fromString(CONV_ID))
.typeConversation("ROLE_CANAL")
.roleCible("TRESORIER")
.build();
when(messagingService.demarrerConversationRole(any())).thenReturn(conv);
given()
.contentType(ContentType.JSON)
.body("{\"organisationId\":\"" + ORG_ID + "\","
+ "\"roleCible\":\"TRESORIER\","
+ "\"contenuInitial\":\"Bonjour\"}")
.when().post(BASE + "/conversations/role")
.then().statusCode(201)
.body("typeConversation", equalTo("ROLE_CANAL"))
.body("roleCible", equalTo("TRESORIER"));
}
@Test
@Order(4)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("demarrerConversationRole — rôle invalide → 400")
void demarrerConversationRole_invalidRole_returns400() {
given()
.contentType(ContentType.JSON)
.body("{\"organisationId\":\"" + ORG_ID + "\","
+ "\"roleCible\":\"DIRECTEUR\","
+ "\"contenuInitial\":\"Bonjour\"}")
.when().post(BASE + "/conversations/role")
.then().statusCode(400);
}
// ── GET /conversations ────────────────────────────────────────────────────
@Test
@Order(5)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("getMesConversations — liste vide → 200")
void getMesConversations_empty_returns200() {
when(messagingService.getMesConversations()).thenReturn(Collections.emptyList());
given()
.when().get(BASE + "/conversations")
.then().statusCode(200);
}
@Test
@Order(6)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("getMesConversations — liste non vide → 200")
void getMesConversations_nonEmpty_returns200() {
ConversationSummaryResponse summary = ConversationSummaryResponse.builder()
.id(UUID.fromString(CONV_ID))
.typeConversation("DIRECTE")
.titre("Alice Dupont")
.build();
when(messagingService.getMesConversations()).thenReturn(List.of(summary));
given()
.when().get(BASE + "/conversations")
.then().statusCode(200)
.body("[0].titre", equalTo("Alice Dupont"));
}
// ── GET /conversations/{id} ───────────────────────────────────────────────
@Test
@Order(7)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("getConversation — trouvée → 200")
void getConversation_found_returns200() {
ConversationResponse conv = ConversationResponse.builder()
.id(UUID.fromString(CONV_ID))
.typeConversation("DIRECTE")
.titre("Bob Martin")
.build();
when(messagingService.getConversation(any(UUID.class))).thenReturn(conv);
given()
.when().get(BASE + "/conversations/" + CONV_ID)
.then().statusCode(200)
.body("titre", equalTo("Bob Martin"));
}
@Test
@Order(8)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("getConversation — non trouvée → 404")
void getConversation_notFound_returns404() {
when(messagingService.getConversation(any(UUID.class)))
.thenThrow(new NotFoundException("conversation non trouvée"));
given()
.when().get(BASE + "/conversations/" + CONV_ID)
.then().statusCode(404);
}
// ── DELETE /conversations/{id} ────────────────────────────────────────────
@Test
@Order(9)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("archiverConversation → 200")
void archiverConversation_returns200() {
ConversationResponse conv = ConversationResponse.builder()
.id(UUID.fromString(CONV_ID))
.statut("ARCHIVEE")
.build();
when(messagingService.archiverConversation(any(UUID.class))).thenReturn(conv);
given()
.when().delete(BASE + "/conversations/" + CONV_ID)
.then().statusCode(200)
.body("statut", equalTo("ARCHIVEE"));
}
// ── POST /conversations/{id}/messages ─────────────────────────────────────
@Test
@Order(10)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("envoyerMessage — message texte → 201")
void envoyerMessage_texte_returns201() {
MessageResponse msg = MessageResponse.builder()
.id(UUID.fromString(MSG_ID))
.typeMessage("TEXTE")
.contenu("Bonjour !")
.build();
when(messagingService.envoyerMessage(any(UUID.class), any())).thenReturn(msg);
given()
.contentType(ContentType.JSON)
.body("{\"typeMessage\":\"TEXTE\",\"contenu\":\"Bonjour !\"}")
.when().post(BASE + "/conversations/" + CONV_ID + "/messages")
.then().statusCode(201)
.body("typeMessage", equalTo("TEXTE"))
.body("contenu", equalTo("Bonjour !"));
}
@Test
@Order(11)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("envoyerMessage — type invalide → 400")
void envoyerMessage_invalidType_returns400() {
given()
.contentType(ContentType.JSON)
.body("{\"typeMessage\":\"VIDEO\",\"contenu\":\"test\"}")
.when().post(BASE + "/conversations/" + CONV_ID + "/messages")
.then().statusCode(400);
}
// ── GET /conversations/{id}/messages ──────────────────────────────────────
@Test
@Order(12)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("getMessages — page 0 → 200")
void getMessages_page0_returns200() {
when(messagingService.getMessages(any(UUID.class), anyInt()))
.thenReturn(Collections.emptyList());
given()
.when().get(BASE + "/conversations/" + CONV_ID + "/messages")
.then().statusCode(200);
}
@Test
@Order(13)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("getMessages — page custom → 200")
void getMessages_customPage_returns200() {
when(messagingService.getMessages(any(UUID.class), anyInt()))
.thenReturn(Collections.emptyList());
given()
.queryParam("page", 2)
.when().get(BASE + "/conversations/" + CONV_ID + "/messages")
.then().statusCode(200);
}
// ── PUT /conversations/{id}/lire ──────────────────────────────────────────
@Test
@Order(14)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("marquerLu → 204")
void marquerLu_returns204() {
doNothing().when(messagingService).marquerConversationLue(any(UUID.class));
given()
.when().put(BASE + "/conversations/" + CONV_ID + "/lire")
.then().statusCode(204);
}
// ── DELETE /conversations/{cId}/messages/{mId} ────────────────────────────
@Test
@Order(15)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("supprimerMessage → 204")
void supprimerMessage_returns204() {
doNothing().when(messagingService).supprimerMessage(any(UUID.class), any(UUID.class));
given()
.when().delete(BASE + "/conversations/" + CONV_ID + "/messages/" + MSG_ID)
.then().statusCode(204);
}
@Test
@Order(16)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("supprimerMessage — non trouvé → 404")
void supprimerMessage_notFound_returns404() {
org.mockito.Mockito.doThrow(new NotFoundException("message introuvable"))
.when(messagingService).supprimerMessage(any(UUID.class), any(UUID.class));
given()
.when().delete(BASE + "/conversations/" + CONV_ID + "/messages/" + MSG_ID)
.then().statusCode(404);
}
// ── POST /blocages ────────────────────────────────────────────────────────
@Test
@Order(17)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("bloquerMembre — requête valide → 204")
void bloquerMembre_valid_returns204() {
doNothing().when(messagingService).bloquerMembre(any());
given()
.contentType(ContentType.JSON)
.body("{\"membreABloquerId\":\"" + MEMBRE_ID + "\","
+ "\"organisationId\":\"" + ORG_ID + "\"}")
.when().post(BASE + "/blocages")
.then().statusCode(204);
}
@Test
@Order(18)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("bloquerMembre — membreABloquerId manquant → 400")
void bloquerMembre_missingId_returns400() {
given()
.contentType(ContentType.JSON)
.body("{\"organisationId\":\"" + ORG_ID + "\"}")
.when().post(BASE + "/blocages")
.then().statusCode(400);
}
// ── DELETE /blocages/{membreId} ───────────────────────────────────────────
@Test
@Order(19)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("debloquerMembre → 204")
void debloquerMembre_returns204() {
doNothing().when(messagingService).debloquerMembre(any(UUID.class), any());
given()
.queryParam("organisationId", ORG_ID)
.when().delete(BASE + "/blocages/" + MEMBRE_ID)
.then().statusCode(204);
}
// ── GET /blocages ─────────────────────────────────────────────────────────
@Test
@Order(20)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("getMesBlocages → 200")
void getMesBlocages_returns200() {
when(messagingService.getMesBlocages()).thenReturn(Collections.emptyList());
given()
.when().get(BASE + "/blocages")
.then().statusCode(200);
}
// ── GET /politique/{organisationId} ───────────────────────────────────────
@Test
@Order(21)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("getPolitique → 200")
void getPolitique_returns200() {
ContactPolicyResponse policy = ContactPolicyResponse.builder()
.id(UUID.randomUUID())
.organisationId(UUID.fromString(ORG_ID))
.typePolitique("OUVERT")
.autoriserMembreVersMembre(true)
.autoriserMembreVersRole(true)
.autoriserNotesVocales(true)
.build();
when(messagingService.getPolitique(any(UUID.class))).thenReturn(policy);
given()
.when().get(BASE + "/politique/" + ORG_ID)
.then().statusCode(200)
.body("typePolitique", equalTo("OUVERT"))
.body("autoriserNotesVocales", equalTo(true));
}
// ── PUT /politique/{organisationId} ───────────────────────────────────────
@Test
@Order(22)
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
@DisplayName("mettreAJourPolitique — ADMIN → 200")
void mettreAJourPolitique_admin_returns200() {
ContactPolicyResponse updated = ContactPolicyResponse.builder()
.typePolitique("BUREAU_SEULEMENT")
.build();
when(messagingService.mettreAJourPolitique(any(UUID.class), any())).thenReturn(updated);
given()
.contentType(ContentType.JSON)
.body("{\"typePolitique\":\"BUREAU_SEULEMENT\"}")
.when().put(BASE + "/politique/" + ORG_ID)
.then().statusCode(200)
.body("typePolitique", equalTo("BUREAU_SEULEMENT"));
}
@Test
@Order(23)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
@DisplayName("mettreAJourPolitique — MEMBRE → 403")
void mettreAJourPolitique_membre_returns403() {
given()
.contentType(ContentType.JSON)
.body("{\"typePolitique\":\"BUREAU_SEULEMENT\"}")
.when().put(BASE + "/politique/" + ORG_ID)
.then().statusCode(403);
}
@Test
@Order(24)
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
@DisplayName("mettreAJourPolitique — politique invalide → 400")
void mettreAJourPolitique_invalidPolicy_returns400() {
given()
.contentType(ContentType.JSON)
.body("{\"typePolitique\":\"LIBRE\"}")
.when().put(BASE + "/politique/" + ORG_ID)
.then().statusCode(400);
}
}

View File

@@ -1,459 +0,0 @@
package dev.lions.unionflow.server.resource;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.anyOf;
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.Mockito.when;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementGatewayResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse;
import dev.lions.unionflow.server.service.PaiementService;
import 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.NotFoundException;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* Tests d'intégration REST pour PaiementResource.
*
* @author UnionFlow Team
* @version 2.0
* @since 2026-03-21
*/
@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class PaiementResourceTest {
private static final String BASE_PATH = "/api/paiements";
private static final String PAIEMENT_ID = "00000000-0000-0000-0000-000000000010";
private static final String MEMBRE_ID = "00000000-0000-0000-0000-000000000020";
@InjectMock
PaiementService paiementService;
// -------------------------------------------------------------------------
// GET /api/paiements/{id}
// -------------------------------------------------------------------------
@Test
@Order(1)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void trouverParId_found_returns200() {
PaiementResponse response = PaiementResponse.builder()
.numeroReference("PAY-001")
.build();
when(paiementService.trouverParId(any(UUID.class))).thenReturn(response);
given()
.pathParam("id", PAIEMENT_ID)
.when()
.get(BASE_PATH + "/{id}")
.then()
.statusCode(200)
.contentType(ContentType.JSON)
.body("$", notNullValue());
}
@Test
@Order(2)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void trouverParId_notFound_returns404() {
when(paiementService.trouverParId(any(UUID.class)))
.thenThrow(new NotFoundException("Paiement non trouvé"));
given()
.pathParam("id", UUID.randomUUID())
.when()
.get(BASE_PATH + "/{id}")
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// GET /api/paiements/reference/{numeroReference}
// -------------------------------------------------------------------------
@Test
@Order(3)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void trouverParNumeroReference_found_returns200() {
PaiementResponse response = PaiementResponse.builder()
.numeroReference("PAY-REF-001")
.build();
when(paiementService.trouverParNumeroReference(anyString())).thenReturn(response);
given()
.pathParam("numeroReference", "PAY-REF-001")
.when()
.get(BASE_PATH + "/reference/{numeroReference}")
.then()
.statusCode(200)
.contentType(ContentType.JSON);
}
@Test
@Order(4)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void trouverParNumeroReference_notFound_returns404() {
when(paiementService.trouverParNumeroReference(anyString()))
.thenThrow(new NotFoundException("Référence non trouvée"));
given()
.pathParam("numeroReference", "PAY-INEXISTANT-99999")
.when()
.get(BASE_PATH + "/reference/{numeroReference}")
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// GET /api/paiements/membre/{membreId}
// -------------------------------------------------------------------------
@Test
@Order(5)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void listerParMembre_returns200() {
when(paiementService.listerParMembre(any(UUID.class)))
.thenReturn(Collections.emptyList());
given()
.pathParam("membreId", MEMBRE_ID)
.when()
.get(BASE_PATH + "/membre/{membreId}")
.then()
.statusCode(200)
.body("$", notNullValue());
}
// -------------------------------------------------------------------------
// GET /api/paiements/mes-paiements/historique
// -------------------------------------------------------------------------
@Test
@Order(6)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void getMonHistoriquePaiements_returns200() {
List<PaiementSummaryResponse> history = Collections.emptyList();
when(paiementService.getMonHistoriquePaiements(anyInt())).thenReturn(history);
given()
.queryParam("limit", 5)
.when()
.get(BASE_PATH + "/mes-paiements/historique")
.then()
.statusCode(200)
.body("$", notNullValue());
}
@Test
@Order(7)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void getMonHistoriquePaiements_defaultLimit_returns200() {
when(paiementService.getMonHistoriquePaiements(anyInt())).thenReturn(Collections.emptyList());
given()
.when()
.get(BASE_PATH + "/mes-paiements/historique")
.then()
.statusCode(200);
}
// -------------------------------------------------------------------------
// POST /api/paiements
// -------------------------------------------------------------------------
@Test
@Order(8)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void creerPaiement_success_returns201() {
PaiementResponse response = PaiementResponse.builder()
.numeroReference("PAY-NEW-001")
.build();
when(paiementService.creerPaiement(any())).thenReturn(response);
String body = String.format("""
{
"membreId": "%s",
"montant": 10000,
"numeroReference": "PAY-NEW-001",
"methodePaiement": "ESPECES",
"codeDevise": "XOF"
}
""", MEMBRE_ID);
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH)
.then()
.statusCode(anyOf(equalTo(201), equalTo(400)));
}
@Test
@Order(9)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void creerPaiement_serverError_returns500() {
when(paiementService.creerPaiement(any()))
.thenThrow(new RuntimeException("db error"));
String body = String.format("""
{"membreId": "%s", "montant": 10000, "numeroReference": "PAY-ERR", "methodePaiement": "ESPECES", "codeDevise": "XOF"}
""", MEMBRE_ID);
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH)
.then()
.statusCode(anyOf(equalTo(500), equalTo(400)));
}
// -------------------------------------------------------------------------
// POST /api/paiements/{id}/valider
// -------------------------------------------------------------------------
@Test
@Order(10)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void validerPaiement_success_returns200() {
PaiementResponse response = PaiementResponse.builder()
.numeroReference("PAY-001")
.build();
when(paiementService.validerPaiement(any(UUID.class))).thenReturn(response);
given()
.pathParam("id", PAIEMENT_ID)
.contentType(ContentType.JSON)
.when()
.post(BASE_PATH + "/{id}/valider")
.then()
.statusCode(200)
.contentType(ContentType.JSON);
}
@Test
@Order(11)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void validerPaiement_notFound_returns404() {
when(paiementService.validerPaiement(any(UUID.class)))
.thenThrow(new NotFoundException("Paiement non trouvé"));
given()
.pathParam("id", UUID.randomUUID())
.contentType(ContentType.JSON)
.when()
.post(BASE_PATH + "/{id}/valider")
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// POST /api/paiements/{id}/annuler
// -------------------------------------------------------------------------
@Test
@Order(12)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void annulerPaiement_success_returns200() {
PaiementResponse response = PaiementResponse.builder()
.numeroReference("PAY-001")
.build();
when(paiementService.annulerPaiement(any(UUID.class))).thenReturn(response);
given()
.pathParam("id", PAIEMENT_ID)
.contentType(ContentType.JSON)
.when()
.post(BASE_PATH + "/{id}/annuler")
.then()
.statusCode(200)
.contentType(ContentType.JSON);
}
@Test
@Order(13)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void annulerPaiement_notFound_returns404() {
when(paiementService.annulerPaiement(any(UUID.class)))
.thenThrow(new NotFoundException("Paiement non trouvé"));
given()
.pathParam("id", UUID.randomUUID())
.contentType(ContentType.JSON)
.when()
.post(BASE_PATH + "/{id}/annuler")
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// POST /api/paiements/initier-paiement-en-ligne
// -------------------------------------------------------------------------
@Test
@Order(14)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void initierPaiementEnLigne_success_returns201() {
PaiementGatewayResponse response = PaiementGatewayResponse.builder()
.transactionId(UUID.randomUUID())
.redirectUrl("https://wave.example.com/pay/TXN-001")
.build();
when(paiementService.initierPaiementEnLigne(any())).thenReturn(response);
String body = String.format("""
{
"cotisationId": "%s",
"methodePaiement": "WAVE",
"numeroTelephone": "771234567"
}
""", UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH + "/initier-paiement-en-ligne")
.then()
.statusCode(anyOf(equalTo(201), equalTo(400)));
}
@Test
@Order(15)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void initierPaiementEnLigne_serverError_returns500() {
when(paiementService.initierPaiementEnLigne(any()))
.thenThrow(new RuntimeException("gateway error"));
String body = String.format("""
{"cotisationId": "%s", "methodePaiement": "WAVE", "numeroTelephone": "771234567"}
""", UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH + "/initier-paiement-en-ligne")
.then()
.statusCode(anyOf(equalTo(500), equalTo(400)));
}
// -------------------------------------------------------------------------
// POST /api/paiements/initier-depot-epargne-en-ligne
// -------------------------------------------------------------------------
@Test
@Order(16)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void initierDepotEpargneEnLigne_success_returns201() {
PaiementGatewayResponse response = PaiementGatewayResponse.builder()
.transactionId(UUID.randomUUID())
.redirectUrl("https://wave.example.com/pay/DEPOT-001")
.build();
when(paiementService.initierDepotEpargneEnLigne(any())).thenReturn(response);
String body = String.format("""
{
"compteId": "%s",
"montant": 10000,
"numeroTelephone": "771234567"
}
""", UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH + "/initier-depot-epargne-en-ligne")
.then()
.statusCode(anyOf(equalTo(201), equalTo(400)));
}
@Test
@Order(17)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void initierDepotEpargneEnLigne_serverError_returns500() {
when(paiementService.initierDepotEpargneEnLigne(any()))
.thenThrow(new RuntimeException("epargne error"));
String body = String.format("""
{"compteId": "%s", "montant": 10000, "numeroTelephone": "771234567"}
""", UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH + "/initier-depot-epargne-en-ligne")
.then()
.statusCode(anyOf(equalTo(500), equalTo(400)));
}
// -------------------------------------------------------------------------
// POST /api/paiements/declarer-paiement-manuel
// -------------------------------------------------------------------------
@Test
@Order(18)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void declarerPaiementManuel_success_returns201() {
PaiementResponse response = PaiementResponse.builder()
.numeroReference("MANUEL-001")
.build();
when(paiementService.declarerPaiementManuel(any())).thenReturn(response);
String body = String.format("""
{
"cotisationId": "%s",
"methodePaiement": "ESPECES",
"montant": 5000,
"dateDeclaration": "2026-03-21",
"commentaire": "Paiement remis en main propre"
}
""", UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH + "/declarer-paiement-manuel")
.then()
.statusCode(201)
.contentType(ContentType.JSON);
}
@Test
@Order(19)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void declarerPaiementManuel_serverError_returns500() {
when(paiementService.declarerPaiementManuel(any()))
.thenThrow(new RuntimeException("declaration error"));
String body = String.format("""
{"cotisationId": "%s", "methodePaiement": "ESPECES", "montant": 5000}
""", UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH + "/declarer-paiement-manuel")
.then()
.statusCode(500);
}
}

View File

@@ -0,0 +1,317 @@
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.Mockito.when;
import dev.lions.unionflow.server.api.dto.versement.response.VersementGatewayResponse;
import dev.lions.unionflow.server.api.dto.versement.response.VersementResponse;
import dev.lions.unionflow.server.api.dto.versement.response.VersementStatutResponse;
import dev.lions.unionflow.server.api.dto.versement.response.VersementSummaryResponse;
import dev.lions.unionflow.server.service.VersementService;
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.NotFoundException;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* Tests d'intégration REST pour {@link VersementResource}.
*
* @author UnionFlow Team
* @version 4.0
* @since 2026-04-13
*/
@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class VersementResourceTest {
private static final String BASE = "/api/versements";
private static final String VERSEMENT_ID = "00000000-0000-0000-0000-000000000010";
private static final String MEMBRE_ID = "00000000-0000-0000-0000-000000000020";
private static final String INTENTION_ID = "00000000-0000-0000-0000-000000000030";
@InjectMock
VersementService versementService;
// ── GET /{id} ─────────────────────────────────────────────────────────────
@Test
@Order(1)
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
void trouverParId_found_returns200() {
VersementResponse response = VersementResponse.builder()
.numeroReference("VRS-2026-001")
.build();
when(versementService.trouverParId(any(UUID.class))).thenReturn(response);
given()
.when().get(BASE + "/" + VERSEMENT_ID)
.then().statusCode(200)
.body("numeroReference", equalTo("VRS-2026-001"));
}
@Test
@Order(2)
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
void trouverParId_notFound_returns404() {
when(versementService.trouverParId(any(UUID.class)))
.thenThrow(new NotFoundException("non trouvé"));
given()
.when().get(BASE + "/" + VERSEMENT_ID)
.then().statusCode(404);
}
// ── GET /reference/{ref} ──────────────────────────────────────────────────
@Test
@Order(3)
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
void trouverParReference_found_returns200() {
VersementResponse response = VersementResponse.builder()
.numeroReference("VRS-2026-001")
.build();
when(versementService.trouverParNumeroReference(anyString())).thenReturn(response);
given()
.when().get(BASE + "/reference/VRS-2026-001")
.then().statusCode(200);
}
// ── GET /membre/{id} ──────────────────────────────────────────────────────
@Test
@Order(4)
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
void listerParMembre_returns200() {
when(versementService.listerParMembre(any(UUID.class)))
.thenReturn(List.of(new VersementSummaryResponse()));
given()
.when().get(BASE + "/membre/" + MEMBRE_ID)
.then().statusCode(200);
}
// ── GET /mes-versements ────────────────────────────────────────────────────
@Test
@Order(5)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void getMesVersements_returns200() {
when(versementService.getMesVersements(anyInt()))
.thenReturn(Collections.emptyList());
given()
.when().get(BASE + "/mes-versements")
.then().statusCode(200);
}
@Test
@Order(6)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void getMesVersements_customLimit_returns200() {
when(versementService.getMesVersements(10))
.thenReturn(Collections.emptyList());
given()
.queryParam("limit", 10)
.when().get(BASE + "/mes-versements")
.then().statusCode(200);
}
// ── POST /{id}/valider ────────────────────────────────────────────────────
@Test
@Order(7)
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
void validerVersement_returns200() {
VersementResponse response = VersementResponse.builder()
.statutPaiement("CONFIRME")
.build();
when(versementService.validerVersement(any(UUID.class))).thenReturn(response);
given()
.contentType(ContentType.JSON)
.when().post(BASE + "/" + VERSEMENT_ID + "/valider")
.then().statusCode(200)
.body("statutPaiement", equalTo("CONFIRME"));
}
@Test
@Order(8)
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
void validerVersement_notFound_returns404() {
when(versementService.validerVersement(any(UUID.class)))
.thenThrow(new NotFoundException("non trouvé"));
given()
.contentType(ContentType.JSON)
.when().post(BASE + "/" + VERSEMENT_ID + "/valider")
.then().statusCode(404);
}
// ── POST /{id}/annuler ────────────────────────────────────────────────────
@Test
@Order(9)
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
void annulerVersement_returns200() {
VersementResponse response = VersementResponse.builder()
.statutPaiement("ANNULE")
.build();
when(versementService.annulerVersement(any(UUID.class))).thenReturn(response);
given()
.contentType(ContentType.JSON)
.when().post(BASE + "/" + VERSEMENT_ID + "/annuler")
.then().statusCode(200);
}
// ── POST /initier-wave ────────────────────────────────────────────────────
@Test
@Order(10)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierWave_validRequest_returns201() {
VersementGatewayResponse gateway = VersementGatewayResponse.builder()
.versementId(UUID.fromString(VERSEMENT_ID))
.waveLaunchUrl("wave://checkout/abc123")
.waveCheckoutSessionId("cos-abc")
.clientReference(INTENTION_ID)
.montant(new BigDecimal("5000"))
.statut("EN_ATTENTE")
.build();
when(versementService.initierVersementWave(any())).thenReturn(gateway);
given()
.contentType(ContentType.JSON)
.body("{\"cotisationId\":\"" + UUID.randomUUID() + "\",\"numeroTelephone\":\"771234567\"}")
.when().post(BASE + "/initier-wave")
.then().statusCode(201)
.body("waveLaunchUrl", equalTo("wave://checkout/abc123"))
.body("waveCheckoutSessionId", equalTo("cos-abc"));
}
@Test
@Order(11)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierWave_missingCotisationId_returns400() {
given()
.contentType(ContentType.JSON)
.body("{\"numeroTelephone\":\"771234567\"}")
.when().post(BASE + "/initier-wave")
.then().statusCode(400);
}
// ── GET /statut/{intentionId} ─────────────────────────────────────────────
@Test
@Order(12)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void getStatutVersement_completee_returns200() {
VersementStatutResponse statut = VersementStatutResponse.builder()
.intentionId(UUID.fromString(INTENTION_ID))
.statut("COMPLETEE")
.confirme(true)
.message("Versement confirmé !")
.build();
when(versementService.verifierStatutVersement(any(UUID.class))).thenReturn(statut);
given()
.when().get(BASE + "/statut/" + INTENTION_ID)
.then().statusCode(200)
.body("confirme", equalTo(true))
.body("statut", equalTo("COMPLETEE"));
}
@Test
@Order(13)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void getStatutVersement_notFound_returns404() {
when(versementService.verifierStatutVersement(any(UUID.class)))
.thenThrow(new NotFoundException("non trouvé"));
given()
.when().get(BASE + "/statut/" + INTENTION_ID)
.then().statusCode(404);
}
// ── POST /declarer-manuel ─────────────────────────────────────────────────
@Test
@Order(14)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void declarerManuel_validRequest_returns201() {
VersementResponse response = VersementResponse.builder()
.statutPaiement("EN_ATTENTE_VALIDATION")
.methodePaiement("ESPECES")
.build();
when(versementService.declarerVersementManuel(any())).thenReturn(response);
given()
.contentType(ContentType.JSON)
.body("{\"cotisationId\":\"" + UUID.randomUUID()
+ "\",\"methodePaiement\":\"ESPECES\"}")
.when().post(BASE + "/declarer-manuel")
.then().statusCode(201)
.body("statutPaiement", equalTo("EN_ATTENTE_VALIDATION"));
}
@Test
@Order(15)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void declarerManuel_methodInvalide_returns400() {
given()
.contentType(ContentType.JSON)
.body("{\"cotisationId\":\"" + UUID.randomUUID()
+ "\",\"methodePaiement\":\"PAYPAL\"}")
.when().post(BASE + "/declarer-manuel")
.then().statusCode(400);
}
// ── POST /initier-depot-epargne ───────────────────────────────────────────
@Test
@Order(16)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierDepotEpargne_validRequest_returns201() {
VersementGatewayResponse gateway = VersementGatewayResponse.builder()
.waveLaunchUrl("wave://checkout/epargne")
.statut("EN_ATTENTE")
.montant(new BigDecimal("10000"))
.build();
when(versementService.initierDepotEpargneEnLigne(any())).thenReturn(gateway);
given()
.contentType(ContentType.JSON)
.body("{\"compteId\":\"" + UUID.randomUUID()
+ "\",\"montant\":10000,\"numeroTelephone\":\"771234567\"}")
.when().post(BASE + "/initier-depot-epargne")
.then().statusCode(201)
.body("waveLaunchUrl", notNullValue());
}
@Test
@Order(17)
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierDepotEpargne_montantManquant_returns400() {
given()
.contentType(ContentType.JSON)
.body("{\"compteId\":\"" + UUID.randomUUID() + "\"}")
.when().post(BASE + "/initier-depot-epargne")
.then().statusCode(400);
}
}

View File

@@ -1,562 +0,0 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.communication.request.CreateConversationRequest;
import dev.lions.unionflow.server.api.dto.communication.response.ConversationResponse;
import dev.lions.unionflow.server.api.enums.communication.ConversationType;
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
import dev.lions.unionflow.server.api.enums.communication.MessageType;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Message;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.ConversationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.MessageRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.mockito.InjectSpy;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.ws.rs.NotFoundException;
import org.junit.jupiter.api.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* Tests unitaires pour ConversationService
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-20
*/
@QuarkusTest
@TestMethodOrder(MethodOrderer.DisplayName.class)
class ConversationServiceTest {
@Inject
ConversationService conversationService;
@InjectMock
ConversationRepository conversationRepository;
@InjectMock
MessageRepository messageRepository;
@InjectSpy
MembreRepository membreRepository;
@InjectSpy
OrganisationRepository organisationRepository;
@InjectMock
EntityManager entityManager;
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private Conversation mockConversation() {
Conversation c = new Conversation();
c.setId(UUID.randomUUID());
c.setName("Test Conv");
c.setIsMuted(false);
c.setIsPinned(false);
c.setIsArchived(false);
c.setParticipants(new ArrayList<>());
return c;
}
private Message mockMessage(Conversation conv) {
Message msg = new Message();
msg.setId(UUID.randomUUID());
msg.setConversation(conv);
Membre sender = new Membre();
sender.setId(UUID.randomUUID());
msg.setSender(sender);
msg.setSenderName("Test Sender");
msg.setContent("Hello");
msg.setType(MessageType.INDIVIDUAL);
msg.setStatus(MessageStatus.SENT);
msg.setIsEdited(false);
msg.setIsDeleted(false);
return msg;
}
// -------------------------------------------------------------------------
// getConversations
// -------------------------------------------------------------------------
@Test
@DisplayName("getConversations_withOrgId_callsByOrganisation")
void getConversations_withOrgId_callsByOrganisation() {
UUID membreId = UUID.randomUUID();
UUID orgId = UUID.randomUUID();
Conversation conv = mockConversation();
when(conversationRepository.findByOrganisation(orgId)).thenReturn(List.of(conv));
when(messageRepository.findLastByConversation(conv.getId())).thenReturn(null);
when(messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId)).thenReturn(0L);
List<ConversationResponse> result = conversationService.getConversations(membreId, orgId, false);
assertThat(result).hasSize(1);
verify(conversationRepository).findByOrganisation(orgId);
verify(conversationRepository, never()).findByParticipant(any(), anyBoolean());
}
@Test
@DisplayName("getConversations_withoutOrgId_callsByParticipant")
void getConversations_withoutOrgId_callsByParticipant() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
when(conversationRepository.findByParticipant(membreId, false)).thenReturn(List.of(conv));
when(messageRepository.findLastByConversation(conv.getId())).thenReturn(null);
when(messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId)).thenReturn(0L);
List<ConversationResponse> result = conversationService.getConversations(membreId, null, false);
assertThat(result).hasSize(1);
verify(conversationRepository).findByParticipant(membreId, false);
verify(conversationRepository, never()).findByOrganisation(any());
}
@Test
@DisplayName("getConversations_includesLastMessageAndUnread")
void getConversations_includesLastMessageAndUnread() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
Message lastMsg = mockMessage(conv);
when(conversationRepository.findByParticipant(membreId, true)).thenReturn(List.of(conv));
when(messageRepository.findLastByConversation(conv.getId())).thenReturn(lastMsg);
when(messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId)).thenReturn(3L);
List<ConversationResponse> result = conversationService.getConversations(membreId, null, true);
assertThat(result).hasSize(1);
ConversationResponse response = result.get(0);
assertThat(response.getLastMessage()).isNotNull();
assertThat(response.getLastMessage().getContent()).isEqualTo("Hello");
assertThat(response.getUnreadCount()).isEqualTo(3);
}
// -------------------------------------------------------------------------
// getConversationById
// -------------------------------------------------------------------------
@Test
@DisplayName("getConversationById_notFound_throwsNotFound")
void getConversationById_notFound_throwsNotFound() {
UUID convId = UUID.randomUUID();
UUID membreId = UUID.randomUUID();
when(conversationRepository.findByIdAndParticipant(convId, membreId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> conversationService.getConversationById(convId, membreId))
.isInstanceOf(NotFoundException.class)
.hasMessageContaining("Conversation non trouvée");
}
@Test
@DisplayName("getConversationById_found_returnsResponse")
void getConversationById_found_returnsResponse() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
when(messageRepository.findLastByConversation(conv.getId())).thenReturn(null);
when(messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId)).thenReturn(0L);
ConversationResponse response = conversationService.getConversationById(conv.getId(), membreId);
assertThat(response).isNotNull();
assertThat(response.getId()).isEqualTo(conv.getId());
assertThat(response.getName()).isEqualTo("Test Conv");
}
// -------------------------------------------------------------------------
// createConversation
// -------------------------------------------------------------------------
@Test
@DisplayName("createConversation_withoutOrg_success")
void createConversation_withoutOrg_success() {
UUID creatorId = UUID.randomUUID();
Membre creator = new Membre();
creator.setId(creatorId);
CreateConversationRequest request = CreateConversationRequest.builder()
.name("New Conv")
.description("desc")
.type(ConversationType.GROUP)
.participantIds(new ArrayList<>())
.organisationId(null)
.build();
when(entityManager.find(Membre.class, creatorId)).thenReturn(creator);
when(messageRepository.findLastByConversation(any())).thenReturn(null);
when(messageRepository.countUnreadByConversationAndMember(any(), eq(creatorId))).thenReturn(0L);
ConversationResponse response = conversationService.createConversation(request, creatorId);
assertThat(response).isNotNull();
assertThat(response.getName()).isEqualTo("New Conv");
verify(conversationRepository).persist(any(Conversation.class));
verify(entityManager, never()).find(eq(Organisation.class), any());
}
@Test
@DisplayName("createConversation_withOrg_setsOrganisation")
void createConversation_withOrg_setsOrganisation() {
UUID creatorId = UUID.randomUUID();
UUID orgId = UUID.randomUUID();
Membre creator = new Membre();
creator.setId(creatorId);
Organisation org = new Organisation();
org.setId(orgId);
CreateConversationRequest request = CreateConversationRequest.builder()
.name("Org Conv")
.description(null)
.type(ConversationType.BROADCAST)
.participantIds(new ArrayList<>())
.organisationId(orgId)
.build();
when(entityManager.find(Membre.class, creatorId)).thenReturn(creator);
when(entityManager.find(Organisation.class, orgId)).thenReturn(org);
when(messageRepository.findLastByConversation(any())).thenReturn(null);
when(messageRepository.countUnreadByConversationAndMember(any(), eq(creatorId))).thenReturn(0L);
ConversationResponse response = conversationService.createConversation(request, creatorId);
assertThat(response).isNotNull();
assertThat(response.getOrganisationId()).isEqualTo(orgId);
verify(entityManager).find(Organisation.class, orgId);
}
@Test
@DisplayName("createConversation_creatorNotInList_addsCreator")
void createConversation_creatorNotInList_addsCreator() {
UUID creatorId = UUID.randomUUID();
UUID participant1Id = UUID.randomUUID();
Membre creator = new Membre();
creator.setId(creatorId);
Membre participant1 = new Membre();
participant1.setId(participant1Id);
CreateConversationRequest request = CreateConversationRequest.builder()
.name("Conv")
.description(null)
.type(ConversationType.INDIVIDUAL)
.participantIds(new ArrayList<>(List.of(participant1Id)))
.organisationId(null)
.build();
when(entityManager.find(Membre.class, participant1Id)).thenReturn(participant1);
when(entityManager.find(Membre.class, creatorId)).thenReturn(creator);
when(messageRepository.findLastByConversation(any())).thenReturn(null);
when(messageRepository.countUnreadByConversationAndMember(any(), eq(creatorId))).thenReturn(0L);
ConversationResponse response = conversationService.createConversation(request, creatorId);
// Creator + participant1 = 2 participants
assertThat(response.getParticipantIds()).hasSize(2);
assertThat(response.getParticipantIds()).contains(creatorId, participant1Id);
}
@Test
@DisplayName("createConversation_creatorAlreadyInList_doesNotDuplicate")
void createConversation_creatorAlreadyInList_doesNotDuplicate() {
UUID creatorId = UUID.randomUUID();
Membre creator = new Membre();
creator.setId(creatorId);
CreateConversationRequest request = CreateConversationRequest.builder()
.name("Conv")
.description(null)
.type(ConversationType.INDIVIDUAL)
.participantIds(new ArrayList<>(List.of(creatorId)))
.organisationId(null)
.build();
// findById(creatorId) appelé 2 fois: une pour le participant, une pour le créateur
when(entityManager.find(Membre.class, creatorId)).thenReturn(creator);
when(messageRepository.findLastByConversation(any())).thenReturn(null);
when(messageRepository.countUnreadByConversationAndMember(any(), eq(creatorId))).thenReturn(0L);
ConversationResponse response = conversationService.createConversation(request, creatorId);
// Le créateur ne doit pas être dupliqué
assertThat(response.getParticipantIds()).hasSize(1);
assertThat(response.getParticipantIds()).containsExactly(creatorId);
}
@Test
@DisplayName("createConversation_participantNotFound_filtersNull")
void createConversation_participantNotFound_filtersNull() {
UUID creatorId = UUID.randomUUID();
UUID unknownId = UUID.randomUUID();
Membre creator = new Membre();
creator.setId(creatorId);
CreateConversationRequest request = CreateConversationRequest.builder()
.name("Conv")
.description(null)
.type(ConversationType.GROUP)
.participantIds(new ArrayList<>(List.of(unknownId)))
.organisationId(null)
.build();
when(entityManager.find(Membre.class, creatorId)).thenReturn(creator);
when(messageRepository.findLastByConversation(any())).thenReturn(null);
when(messageRepository.countUnreadByConversationAndMember(any(), eq(creatorId))).thenReturn(0L);
ConversationResponse response = conversationService.createConversation(request, creatorId);
// unknownId est filtré, seul le créateur reste
assertThat(response.getParticipantIds()).hasSize(1);
assertThat(response.getParticipantIds()).containsExactly(creatorId);
}
@Test
@DisplayName("createConversation_creatorNotFound_doesNotAddCreator (creator == null → L97 false)")
void createConversation_creatorNull_doesNotAddCreator() {
UUID creatorId = UUID.randomUUID();
CreateConversationRequest request = CreateConversationRequest.builder()
.name("No Creator Conv")
.description(null)
.type(ConversationType.GROUP)
.participantIds(new ArrayList<>())
.organisationId(null)
.build();
// creator == null → condition L97: creator != null = false → pas d'ajout du créateur
when(entityManager.find(Membre.class, creatorId)).thenReturn(null);
when(messageRepository.findLastByConversation(any())).thenReturn(null);
when(messageRepository.countUnreadByConversationAndMember(any(), eq(creatorId))).thenReturn(0L);
ConversationResponse response = conversationService.createConversation(request, creatorId);
assertThat(response).isNotNull();
// Aucun participant car creator introuvable et liste vide
assertThat(response.getParticipantIds()).isEmpty();
}
// -------------------------------------------------------------------------
// archiveConversation
// -------------------------------------------------------------------------
@Test
@DisplayName("archiveConversation_notFound_throwsNotFound")
void archiveConversation_notFound_throwsNotFound() {
UUID convId = UUID.randomUUID();
UUID membreId = UUID.randomUUID();
when(conversationRepository.findByIdAndParticipant(convId, membreId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> conversationService.archiveConversation(convId, membreId, true))
.isInstanceOf(NotFoundException.class)
.hasMessageContaining("Conversation non trouvée");
}
@Test
@DisplayName("archiveConversation_archive_setsTrue")
void archiveConversation_archive_setsTrue() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
conv.setIsArchived(false);
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
conversationService.archiveConversation(conv.getId(), membreId, true);
assertThat(conv.getIsArchived()).isTrue();
verify(conversationRepository).persist(conv);
}
@Test
@DisplayName("archiveConversation_unarchive_setsFalse")
void archiveConversation_unarchive_setsFalse() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
conv.setIsArchived(true);
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
conversationService.archiveConversation(conv.getId(), membreId, false);
assertThat(conv.getIsArchived()).isFalse();
verify(conversationRepository).persist(conv);
}
// -------------------------------------------------------------------------
// markAsRead
// -------------------------------------------------------------------------
@Test
@DisplayName("markAsRead_notFound_throwsNotFound")
void markAsRead_notFound_throwsNotFound() {
UUID convId = UUID.randomUUID();
UUID membreId = UUID.randomUUID();
when(conversationRepository.findByIdAndParticipant(convId, membreId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> conversationService.markAsRead(convId, membreId))
.isInstanceOf(NotFoundException.class)
.hasMessageContaining("Conversation non trouvée");
}
@Test
@DisplayName("markAsRead_success_callsRepo")
void markAsRead_success_callsRepo() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
when(messageRepository.markAllAsReadByConversationAndMember(conv.getId(), membreId)).thenReturn(5);
conversationService.markAsRead(conv.getId(), membreId);
verify(messageRepository).markAllAsReadByConversationAndMember(conv.getId(), membreId);
}
// -------------------------------------------------------------------------
// toggleMute
// -------------------------------------------------------------------------
@Test
@DisplayName("toggleMute_notFound_throwsNotFound")
void toggleMute_notFound_throwsNotFound() {
UUID convId = UUID.randomUUID();
UUID membreId = UUID.randomUUID();
when(conversationRepository.findByIdAndParticipant(convId, membreId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> conversationService.toggleMute(convId, membreId))
.isInstanceOf(NotFoundException.class)
.hasMessageContaining("Conversation non trouvée");
}
@Test
@DisplayName("toggleMute_false_setsTrue")
void toggleMute_false_setsTrue() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
conv.setIsMuted(false);
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
conversationService.toggleMute(conv.getId(), membreId);
assertThat(conv.getIsMuted()).isTrue();
}
@Test
@DisplayName("toggleMute_true_setsFalse")
void toggleMute_true_setsFalse() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
conv.setIsMuted(true);
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
conversationService.toggleMute(conv.getId(), membreId);
assertThat(conv.getIsMuted()).isFalse();
}
// -------------------------------------------------------------------------
// togglePin
// -------------------------------------------------------------------------
@Test
@DisplayName("togglePin_notFound_throwsNotFound")
void togglePin_notFound_throwsNotFound() {
UUID convId = UUID.randomUUID();
UUID membreId = UUID.randomUUID();
when(conversationRepository.findByIdAndParticipant(convId, membreId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> conversationService.togglePin(convId, membreId))
.isInstanceOf(NotFoundException.class)
.hasMessageContaining("Conversation non trouvée");
}
@Test
@DisplayName("togglePin_false_setsTrue")
void togglePin_false_setsTrue() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
conv.setIsPinned(false);
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
conversationService.togglePin(conv.getId(), membreId);
assertThat(conv.getIsPinned()).isTrue();
}
@Test
@DisplayName("togglePin_true_setsFalse")
void togglePin_true_setsFalse() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
conv.setIsPinned(true);
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
conversationService.togglePin(conv.getId(), membreId);
assertThat(conv.getIsPinned()).isFalse();
}
// -------------------------------------------------------------------------
// convertToResponse (via getConversationById)
// -------------------------------------------------------------------------
@Test
@DisplayName("convertToResponse_withLastMessage_includesMessage")
void convertToResponse_withLastMessage_includesMessage() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
Message lastMsg = mockMessage(conv);
lastMsg.setContent("Dernier message");
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
when(messageRepository.findLastByConversation(conv.getId())).thenReturn(lastMsg);
when(messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId)).thenReturn(0L);
ConversationResponse response = conversationService.getConversationById(conv.getId(), membreId);
assertThat(response.getLastMessage()).isNotNull();
assertThat(response.getLastMessage().getContent()).isEqualTo("Dernier message");
assertThat(response.getLastMessage().getId()).isEqualTo(lastMsg.getId());
assertThat(response.getLastMessage().getSenderId()).isEqualTo(lastMsg.getSender().getId());
}
@Test
@DisplayName("convertToResponse_noLastMessage_nullLastMessage")
void convertToResponse_noLastMessage_nullLastMessage() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
when(messageRepository.findLastByConversation(conv.getId())).thenReturn(null);
when(messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId)).thenReturn(0L);
ConversationResponse response = conversationService.getConversationById(conv.getId(), membreId);
assertThat(response.getLastMessage()).isNull();
}
}

View File

@@ -674,14 +674,19 @@ class MembreServiceTest {
doReturn(100L).when(membreRepository).count();
doReturn(80L).when(membreRepository).countActifs();
doReturn(10L).when(membreRepository).countNouveauxMembres(any());
doReturn(5L).when(organisationService).rechercherOrganisationsCount("");
Map<String, Object> stats = membreService.obtenirStatistiquesAvancees();
assertThat(stats).containsKey("totalMembres");
assertThat(stats).containsKey("total"); // alias mobile
assertThat(stats).containsKey("totalOrganisations");
assertThat(stats).containsKey("membresActifs");
assertThat(stats).containsKey("membresInactifs");
assertThat(stats).containsKey("tauxActivite");
assertThat(stats.get("totalMembres")).isEqualTo(100L);
assertThat(stats.get("total")).isEqualTo(100L);
assertThat(stats.get("totalOrganisations")).isEqualTo(5L);
assertThat(stats.get("membresActifs")).isEqualTo(80L);
assertThat(stats.get("membresInactifs")).isEqualTo(20L);
assertThat((Double) stats.get("tauxActivite")).isEqualTo(80.0);
@@ -693,10 +698,12 @@ class MembreServiceTest {
doReturn(0L).when(membreRepository).count();
doReturn(0L).when(membreRepository).countActifs();
doReturn(0L).when(membreRepository).countNouveauxMembres(any());
doReturn(0L).when(organisationService).rechercherOrganisationsCount("");
Map<String, Object> stats = membreService.obtenirStatistiquesAvancees();
assertThat((Double) stats.get("tauxActivite")).isEqualTo(0.0);
assertThat(stats.get("totalOrganisations")).isEqualTo(0L);
}
// =========================================================================

View File

@@ -1,621 +0,0 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.communication.request.SendMessageRequest;
import dev.lions.unionflow.server.api.dto.communication.response.MessageResponse;
import dev.lions.unionflow.server.api.enums.communication.MessagePriority;
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
import dev.lions.unionflow.server.api.enums.communication.MessageType;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Message;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.ConversationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.MessageRepository;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.mockito.InjectSpy;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.ws.rs.NotFoundException;
import org.junit.jupiter.api.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* Tests unitaires pour MessageService
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-20
*/
@QuarkusTest
@TestMethodOrder(MethodOrderer.DisplayName.class)
class MessageServiceTest {
@Inject
MessageService messageService;
@InjectSpy
MessageRepository messageRepository;
@InjectMock
ConversationRepository conversationRepository;
@InjectSpy
MembreRepository membreRepository;
@InjectMock
EntityManager entityManager;
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private Conversation mockConversation() {
Conversation c = new Conversation();
c.setId(UUID.randomUUID());
c.setParticipants(new ArrayList<>());
return c;
}
private Membre mockMembre(UUID id) {
Membre m = new Membre();
m.setId(id);
m.setPrenom("Jean");
m.setNom("Dupont");
return m;
}
private Message mockMessage(UUID senderId) {
Message msg = new Message();
msg.setId(UUID.randomUUID());
Conversation conv = mockConversation();
msg.setConversation(conv);
Membre sender = mockMembre(senderId);
msg.setSender(sender);
msg.setSenderName("Jean Dupont");
msg.setContent("Hello");
msg.setType(MessageType.INDIVIDUAL);
msg.setStatus(MessageStatus.SENT);
msg.setPriority(MessagePriority.NORMAL);
msg.setIsEdited(false);
msg.setIsDeleted(false);
return msg;
}
// -------------------------------------------------------------------------
// getMessages
// -------------------------------------------------------------------------
@Test
@DisplayName("getMessages_conversationNotFound_throwsNotFound")
void getMessages_conversationNotFound_throwsNotFound() {
UUID convId = UUID.randomUUID();
UUID membreId = UUID.randomUUID();
when(conversationRepository.findByIdAndParticipant(convId, membreId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> messageService.getMessages(convId, membreId, 20))
.isInstanceOf(NotFoundException.class)
.hasMessageContaining("Conversation non trouvée");
}
@Test
@DisplayName("getMessages_found_returnsList")
void getMessages_found_returnsList() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
Message msg = mockMessage(UUID.randomUUID());
msg.setConversation(conv);
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
assertThat(result).hasSize(1);
assertThat(result.get(0).getContent()).isEqualTo("Hello");
}
// -------------------------------------------------------------------------
// sendMessage
// -------------------------------------------------------------------------
@Test
@DisplayName("sendMessage_conversationNotFound_throwsNotFound")
void sendMessage_conversationNotFound_throwsNotFound() {
UUID senderId = UUID.randomUUID();
UUID convId = UUID.randomUUID();
SendMessageRequest request = SendMessageRequest.builder()
.conversationId(convId)
.content("test")
.build();
when(conversationRepository.findByIdAndParticipant(convId, senderId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> messageService.sendMessage(request, senderId))
.isInstanceOf(NotFoundException.class)
.hasMessageContaining("Conversation non trouvée");
}
@Test
@DisplayName("sendMessage_senderNotFound_throwsNotFound")
void sendMessage_senderNotFound_throwsNotFound() {
UUID senderId = UUID.randomUUID();
Conversation conv = mockConversation();
SendMessageRequest request = SendMessageRequest.builder()
.conversationId(conv.getId())
.content("test")
.build();
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
// entityManager.find returns null by default → findById(senderId) returns null → service throws
assertThatThrownBy(() -> messageService.sendMessage(request, senderId))
.isInstanceOf(NotFoundException.class)
.hasMessageContaining("Expéditeur non trouvé");
}
@Test
@DisplayName("sendMessage_withDefaultTypeAndPriority_success")
void sendMessage_withDefaultTypeAndPriority_success() {
UUID senderId = UUID.randomUUID();
Conversation conv = mockConversation();
Membre sender = mockMembre(senderId);
SendMessageRequest request = SendMessageRequest.builder()
.conversationId(conv.getId())
.content("Bonjour")
.type(null)
.priority(null)
.recipientIds(null)
.recipientRoles(null)
.attachments(null)
.build();
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
when(entityManager.find(Membre.class, senderId)).thenReturn(sender);
MessageResponse response = messageService.sendMessage(request, senderId);
assertThat(response).isNotNull();
assertThat(response.getContent()).isEqualTo("Bonjour");
assertThat(response.getType()).isEqualTo(MessageType.INDIVIDUAL);
assertThat(response.getPriority()).isEqualTo(MessagePriority.NORMAL);
assertThat(response.getSenderName()).isEqualTo("Jean Dupont");
verify(messageRepository).persist(any(Message.class));
verify(conversationRepository).persist(conv);
}
@Test
@DisplayName("sendMessage_withExplicitType_usesType")
void sendMessage_withExplicitType_usesType() {
UUID senderId = UUID.randomUUID();
Conversation conv = mockConversation();
Membre sender = mockMembre(senderId);
SendMessageRequest request = SendMessageRequest.builder()
.conversationId(conv.getId())
.content("Broadcast!")
.type(MessageType.BROADCAST)
.priority(MessagePriority.HIGH)
.recipientIds(null)
.recipientRoles(null)
.attachments(null)
.build();
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
when(entityManager.find(Membre.class, senderId)).thenReturn(sender);
MessageResponse response = messageService.sendMessage(request, senderId);
assertThat(response.getType()).isEqualTo(MessageType.BROADCAST);
assertThat(response.getPriority()).isEqualTo(MessagePriority.HIGH);
}
@Test
@DisplayName("sendMessage_withRecipientIds_setsCSV")
void sendMessage_withRecipientIds_setsCSV() {
UUID senderId = UUID.randomUUID();
UUID recipient1 = UUID.randomUUID();
UUID recipient2 = UUID.randomUUID();
Conversation conv = mockConversation();
Membre sender = mockMembre(senderId);
SendMessageRequest request = SendMessageRequest.builder()
.conversationId(conv.getId())
.content("Targeted")
.type(MessageType.TARGETED)
.priority(null)
.recipientIds(List.of(recipient1, recipient2))
.recipientRoles(null)
.attachments(null)
.build();
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
when(entityManager.find(Membre.class, senderId)).thenReturn(sender);
MessageResponse response = messageService.sendMessage(request, senderId);
assertThat(response.getRecipientIds()).isNotNull();
assertThat(response.getRecipientIds()).containsExactlyInAnyOrder(recipient1, recipient2);
}
@Test
@DisplayName("sendMessage_withRecipientRoles_setsCSV")
void sendMessage_withRecipientRoles_setsCSV() {
UUID senderId = UUID.randomUUID();
Conversation conv = mockConversation();
Membre sender = mockMembre(senderId);
SendMessageRequest request = SendMessageRequest.builder()
.conversationId(conv.getId())
.content("Role msg")
.type(null)
.priority(null)
.recipientIds(null)
.recipientRoles(List.of("ADMIN", "TRESORIER"))
.attachments(null)
.build();
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
when(entityManager.find(Membre.class, senderId)).thenReturn(sender);
MessageResponse response = messageService.sendMessage(request, senderId);
assertThat(response.getRecipientRoles()).isNotNull();
assertThat(response.getRecipientRoles()).containsExactly("ADMIN", "TRESORIER");
}
@Test
@DisplayName("sendMessage_withAttachments_setsCSV")
void sendMessage_withAttachments_setsCSV() {
UUID senderId = UUID.randomUUID();
Conversation conv = mockConversation();
Membre sender = mockMembre(senderId);
SendMessageRequest request = SendMessageRequest.builder()
.conversationId(conv.getId())
.content("Msg avec PJ")
.type(null)
.priority(null)
.recipientIds(null)
.recipientRoles(null)
.attachments(List.of("https://cdn.example.com/doc1.pdf", "https://cdn.example.com/img1.png"))
.build();
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
when(entityManager.find(Membre.class, senderId)).thenReturn(sender);
MessageResponse response = messageService.sendMessage(request, senderId);
assertThat(response.getAttachments()).isNotNull();
assertThat(response.getAttachments()).containsExactly(
"https://cdn.example.com/doc1.pdf",
"https://cdn.example.com/img1.png"
);
}
@Test
@DisplayName("sendMessage_noRecipientsNoRolesNoAttachments_noCSV")
void sendMessage_noRecipientsNoRolesNoAttachments_noCSV() {
UUID senderId = UUID.randomUUID();
Conversation conv = mockConversation();
Membre sender = mockMembre(senderId);
SendMessageRequest request = SendMessageRequest.builder()
.conversationId(conv.getId())
.content("Simple")
.type(null)
.priority(null)
.recipientIds(new ArrayList<>())
.recipientRoles(new ArrayList<>())
.attachments(new ArrayList<>())
.build();
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
when(entityManager.find(Membre.class, senderId)).thenReturn(sender);
MessageResponse response = messageService.sendMessage(request, senderId);
assertThat(response.getRecipientIds()).isNull();
assertThat(response.getRecipientRoles()).isNull();
assertThat(response.getAttachments()).isNull();
}
// -------------------------------------------------------------------------
// editMessage
// -------------------------------------------------------------------------
@Test
@DisplayName("editMessage_notFound_throwsNotFound")
void editMessage_notFound_throwsNotFound() {
UUID messageId = UUID.randomUUID();
UUID senderId = UUID.randomUUID();
// entityManager.find returns null by default → findById(messageId) returns null → service throws
assertThatThrownBy(() -> messageService.editMessage(messageId, senderId, "nouveau contenu"))
.isInstanceOf(NotFoundException.class)
.hasMessageContaining("Message non trouvé");
}
@Test
@DisplayName("editMessage_wrongSender_throwsIllegalState")
void editMessage_wrongSender_throwsIllegalState() {
UUID realSenderId = UUID.randomUUID();
UUID wrongSenderId = UUID.randomUUID();
Message msg = mockMessage(realSenderId);
when(entityManager.find(Message.class, msg.getId())).thenReturn(msg);
assertThatThrownBy(() -> messageService.editMessage(msg.getId(), wrongSenderId, "contenu modifié"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("propres messages");
}
@Test
@DisplayName("editMessage_success_updatesContent")
void editMessage_success_updatesContent() {
UUID senderId = UUID.randomUUID();
Message msg = mockMessage(senderId);
msg.setIsEdited(false);
when(entityManager.find(Message.class, msg.getId())).thenReturn(msg);
MessageResponse response = messageService.editMessage(msg.getId(), senderId, "Contenu édité");
assertThat(msg.getContent()).isEqualTo("Contenu édité");
assertThat(msg.getIsEdited()).isTrue();
assertThat(msg.getEditedAt()).isNotNull();
verify(messageRepository).persist(msg);
assertThat(response.getContent()).isEqualTo("Contenu édité");
assertThat(response.isEdited()).isTrue();
}
// -------------------------------------------------------------------------
// deleteMessage
// -------------------------------------------------------------------------
@Test
@DisplayName("deleteMessage_notFound_throwsNotFound")
void deleteMessage_notFound_throwsNotFound() {
UUID messageId = UUID.randomUUID();
UUID senderId = UUID.randomUUID();
// entityManager.find returns null by default → findById(messageId) returns null → service throws
assertThatThrownBy(() -> messageService.deleteMessage(messageId, senderId))
.isInstanceOf(NotFoundException.class)
.hasMessageContaining("Message non trouvé");
}
@Test
@DisplayName("deleteMessage_wrongSender_throwsIllegalState")
void deleteMessage_wrongSender_throwsIllegalState() {
UUID realSenderId = UUID.randomUUID();
UUID wrongSenderId = UUID.randomUUID();
Message msg = mockMessage(realSenderId);
when(entityManager.find(Message.class, msg.getId())).thenReturn(msg);
assertThatThrownBy(() -> messageService.deleteMessage(msg.getId(), wrongSenderId))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("propres messages");
}
@Test
@DisplayName("deleteMessage_success_softDeletes")
void deleteMessage_success_softDeletes() {
UUID senderId = UUID.randomUUID();
Message msg = mockMessage(senderId);
msg.setIsDeleted(false);
msg.setContent("Contenu original");
when(entityManager.find(Message.class, msg.getId())).thenReturn(msg);
messageService.deleteMessage(msg.getId(), senderId);
assertThat(msg.getIsDeleted()).isTrue();
assertThat(msg.getContent()).isEqualTo("[Message supprimé]");
verify(messageRepository).persist(msg);
}
// -------------------------------------------------------------------------
// convertToResponse (via getMessages)
// -------------------------------------------------------------------------
@Test
@DisplayName("convertToResponse_withRecipientIds_parsesCsv")
void convertToResponse_withRecipientIds_parsesCsv() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
UUID r1 = UUID.randomUUID();
UUID r2 = UUID.randomUUID();
Message msg = mockMessage(UUID.randomUUID());
msg.setConversation(conv);
msg.setRecipientIds(r1 + "," + r2);
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
assertThat(result).hasSize(1);
assertThat(result.get(0).getRecipientIds()).containsExactlyInAnyOrder(r1, r2);
}
@Test
@DisplayName("convertToResponse_withRecipientRoles_parsesCsv")
void convertToResponse_withRecipientRoles_parsesCsv() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
Message msg = mockMessage(UUID.randomUUID());
msg.setConversation(conv);
msg.setRecipientRoles("ADMIN,SECRETAIRE");
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
assertThat(result.get(0).getRecipientRoles()).containsExactly("ADMIN", "SECRETAIRE");
}
@Test
@DisplayName("convertToResponse_withAttachments_parsesCsv")
void convertToResponse_withAttachments_parsesCsv() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
Message msg = mockMessage(UUID.randomUUID());
msg.setConversation(conv);
msg.setAttachments("https://cdn.example.com/a.pdf,https://cdn.example.com/b.png");
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
assertThat(result.get(0).getAttachments()).containsExactly(
"https://cdn.example.com/a.pdf",
"https://cdn.example.com/b.png"
);
}
@Test
@DisplayName("convertToResponse_noRecipients_nullFields")
void convertToResponse_noRecipients_nullFields() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
Message msg = mockMessage(UUID.randomUUID());
msg.setConversation(conv);
// recipientIds, recipientRoles et attachments sont null par défaut
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
MessageResponse response = result.get(0);
assertThat(response.getRecipientIds()).isNull();
assertThat(response.getRecipientRoles()).isNull();
assertThat(response.getAttachments()).isNull();
}
@Test
@DisplayName("convertToResponse_withOrganisation_setsOrgId")
void convertToResponse_withOrganisation_setsOrgId() {
UUID membreId = UUID.randomUUID();
UUID orgId = UUID.randomUUID();
Conversation conv = mockConversation();
Organisation org = new Organisation();
org.setId(orgId);
conv.setOrganisation(org);
Message msg = mockMessage(UUID.randomUUID());
msg.setConversation(conv);
msg.setOrganisation(org);
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
assertThat(result.get(0).getOrganisationId()).isEqualTo(orgId);
}
@Test
@DisplayName("convertToResponse_noOrganisation_nullOrgId")
void convertToResponse_noOrganisation_nullOrgId() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
// organisation est null
Message msg = mockMessage(UUID.randomUUID());
msg.setConversation(conv);
msg.setOrganisation(null);
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
assertThat(result.get(0).getOrganisationId()).isNull();
}
// -------------------------------------------------------------------------
// convertToResponse — branches isEmpty() (non-null mais vide)
// L163: recipientIds != null && !isEmpty() → false (empty string → isEmpty = true)
// L172: recipientRoles != null && !isEmpty() → false
// L178: attachments != null && !isEmpty() → false
// -------------------------------------------------------------------------
@Test
@DisplayName("convertToResponse_recipientIdsEmptyString_returnsNullRecipientIds (L163 false)")
void convertToResponse_recipientIdsEmptyString_returnsNullRecipientIds() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
Message msg = mockMessage(UUID.randomUUID());
msg.setConversation(conv);
// non-null mais vide → L163: isEmpty() = true → condition false → recipientIds = null
msg.setRecipientIds("");
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
assertThat(result.get(0).getRecipientIds()).isNull();
}
@Test
@DisplayName("convertToResponse_recipientRolesEmptyString_returnsNullRoles (L172 false)")
void convertToResponse_recipientRolesEmptyString_returnsNullRoles() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
Message msg = mockMessage(UUID.randomUUID());
msg.setConversation(conv);
msg.setRecipientRoles(""); // non-null mais vide → L172 false → roles = null
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
assertThat(result.get(0).getRecipientRoles()).isNull();
}
@Test
@DisplayName("convertToResponse_attachmentsEmptyString_returnsNullAttachments (L178 false)")
void convertToResponse_attachmentsEmptyString_returnsNullAttachments() {
UUID membreId = UUID.randomUUID();
Conversation conv = mockConversation();
Message msg = mockMessage(UUID.randomUUID());
msg.setConversation(conv);
msg.setAttachments(""); // non-null mais vide → L178 false → attachments = null
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
assertThat(result.get(0).getAttachments()).isNull();
}
}

View File

@@ -0,0 +1,509 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.messagerie.request.BloquerMembreRequest;
import dev.lions.unionflow.server.api.dto.messagerie.request.DemarrerConversationDirecteRequest;
import dev.lions.unionflow.server.api.dto.messagerie.request.DemarrerConversationRoleRequest;
import dev.lions.unionflow.server.api.dto.messagerie.request.EnvoyerMessageRequest;
import dev.lions.unionflow.server.api.dto.messagerie.request.MettreAJourPolitiqueRequest;
import dev.lions.unionflow.server.api.dto.messagerie.response.ContactPolicyResponse;
import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationResponse;
import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationSummaryResponse;
import dev.lions.unionflow.server.api.dto.messagerie.response.MessageResponse;
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
import dev.lions.unionflow.server.api.enums.messagerie.TypeContenu;
import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation;
import dev.lions.unionflow.server.api.enums.messagerie.TypePolitiqueCommunication;
import dev.lions.unionflow.server.entity.ContactPolicy;
import dev.lions.unionflow.server.entity.Conversation;
import dev.lions.unionflow.server.entity.ConversationParticipant;
import dev.lions.unionflow.server.entity.MemberBlock;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Message;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.ContactPolicyRepository;
import dev.lions.unionflow.server.repository.ConversationParticipantRepository;
import dev.lions.unionflow.server.repository.ConversationRepository;
import dev.lions.unionflow.server.repository.MemberBlockRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.MessageRepository;
import dev.lions.unionflow.server.messaging.KafkaEventProducer;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.NotFoundException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Tests unitaires du MessagingService avec mocks.
*/
@QuarkusTest
@DisplayName("MessagingService")
class MessagingServiceTest {
@Inject
MessagingService messagingService;
@InjectMock ConversationRepository conversationRepository;
@InjectMock ConversationParticipantRepository participantRepository;
@InjectMock MessageRepository messageRepository;
@InjectMock ContactPolicyRepository contactPolicyRepository;
@InjectMock MemberBlockRepository memberBlockRepository;
@InjectMock MembreRepository membreRepository;
@InjectMock MembreOrganisationRepository membreOrganisationRepository;
@InjectMock KafkaEventProducer kafkaEventProducer;
@InjectMock SecurityIdentity securityIdentity;
private static final UUID MEMBRE_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
private static final UUID DESTINATAIRE_ID = UUID.fromString("00000000-0000-0000-0000-000000000002");
private static final UUID ORG_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
private static final UUID CONV_ID = UUID.fromString("00000000-0000-0000-0000-000000000004");
private static final UUID MESSAGE_ID = UUID.fromString("00000000-0000-0000-0000-000000000005");
@BeforeEach
void setUp() {
// Mock du membre connecté
io.quarkus.security.runtime.QuarkusPrincipal principal =
new io.quarkus.security.runtime.QuarkusPrincipal("membre@test.com");
when(securityIdentity.getPrincipal()).thenReturn(principal);
Membre moi = newMembre(MEMBRE_ID, "membre@test.com", "Alpha", "Diallo");
when(membreRepository.find("email", "membre@test.com"))
.thenReturn(io.quarkus.hibernate.orm.panache.PanacheQuery.class.cast(
mockSingleResultQuery(moi)));
}
// ── getMesConversations ───────────────────────────────────────────────────
@Test
@DisplayName("getMesConversations — retourne liste vide")
void getMesConversations_returnsEmpty() {
when(conversationRepository.findByMembreId(MEMBRE_ID))
.thenReturn(Collections.emptyList());
List<ConversationSummaryResponse> result = messagingService.getMesConversations();
assertThat(result).isNotNull().isEmpty();
}
@Test
@DisplayName("getMesConversations — retourne la liste des conversations")
void getMesConversations_returnsList() {
Conversation conv = buildConversation(TypeConversation.DIRECTE);
when(conversationRepository.findByMembreId(MEMBRE_ID)).thenReturn(List.of(conv));
when(messageRepository.findDernierMessage(CONV_ID)).thenReturn(Optional.empty());
when(messageRepository.countNonLus(CONV_ID, MEMBRE_ID)).thenReturn(0L);
when(participantRepository.findByConversation(CONV_ID)).thenReturn(Collections.emptyList());
List<ConversationSummaryResponse> result = messagingService.getMesConversations();
assertThat(result).hasSize(1);
assertThat(result.get(0).getTypeConversation()).isEqualTo("DIRECTE");
}
// ── getConversation ───────────────────────────────────────────────────────
@Test
@DisplayName("getConversation — notFound → NotFoundException")
void getConversation_notFound() {
when(conversationRepository.findConversationById(CONV_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> messagingService.getConversation(CONV_ID))
.isInstanceOf(NotFoundException.class);
}
@Test
@DisplayName("getConversation — non participant → ForbiddenException")
void getConversation_nonParticipant() {
Conversation conv = buildConversation(TypeConversation.DIRECTE);
when(conversationRepository.findConversationById(CONV_ID)).thenReturn(Optional.of(conv));
when(participantRepository.estParticipant(CONV_ID, MEMBRE_ID)).thenReturn(false);
assertThatThrownBy(() -> messagingService.getConversation(CONV_ID))
.isInstanceOf(ForbiddenException.class);
}
@Test
@DisplayName("getConversation — success → retourne ConversationResponse")
void getConversation_success() {
Conversation conv = buildConversation(TypeConversation.DIRECTE);
when(conversationRepository.findConversationById(CONV_ID)).thenReturn(Optional.of(conv));
when(participantRepository.estParticipant(CONV_ID, MEMBRE_ID)).thenReturn(true);
when(participantRepository.findByConversation(CONV_ID)).thenReturn(Collections.emptyList());
when(messageRepository.findByConversationPagine(eq(CONV_ID), eq(0), anyInt()))
.thenReturn(Collections.emptyList());
when(messageRepository.countNonLus(CONV_ID, MEMBRE_ID)).thenReturn(2L);
ConversationResponse result = messagingService.getConversation(CONV_ID);
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(CONV_ID);
assertThat(result.getNonLus()).isEqualTo(2L);
}
// ── archiverConversation ──────────────────────────────────────────────────
@Test
@DisplayName("archiverConversation — success → statut ARCHIVEE")
void archiverConversation_success() {
Conversation conv = buildConversation(TypeConversation.DIRECTE);
when(conversationRepository.findConversationById(CONV_ID)).thenReturn(Optional.of(conv));
when(participantRepository.estParticipant(CONV_ID, MEMBRE_ID)).thenReturn(true);
when(participantRepository.findByConversation(CONV_ID)).thenReturn(Collections.emptyList());
when(messageRepository.findByConversationPagine(eq(CONV_ID), eq(0), anyInt()))
.thenReturn(Collections.emptyList());
when(messageRepository.countNonLus(CONV_ID, MEMBRE_ID)).thenReturn(0L);
ConversationResponse result = messagingService.archiverConversation(CONV_ID);
assertThat(result.getStatut()).isEqualTo("ARCHIVEE");
}
// ── envoyerMessage ────────────────────────────────────────────────────────
@Test
@DisplayName("envoyerMessage — conversation non trouvée → NotFoundException")
void envoyerMessage_notFound() {
when(conversationRepository.findConversationById(CONV_ID)).thenReturn(Optional.empty());
EnvoyerMessageRequest req = EnvoyerMessageRequest.builder()
.typeMessage("TEXTE").contenu("Bonjour").build();
assertThatThrownBy(() -> messagingService.envoyerMessage(CONV_ID, req))
.isInstanceOf(NotFoundException.class);
}
@Test
@DisplayName("envoyerMessage — non participant → ForbiddenException")
void envoyerMessage_nonParticipant() {
Conversation conv = buildConversation(TypeConversation.DIRECTE);
when(conversationRepository.findConversationById(CONV_ID)).thenReturn(Optional.of(conv));
when(participantRepository.estParticipant(CONV_ID, MEMBRE_ID)).thenReturn(false);
EnvoyerMessageRequest req = EnvoyerMessageRequest.builder()
.typeMessage("TEXTE").contenu("Bonjour").build();
assertThatThrownBy(() -> messagingService.envoyerMessage(CONV_ID, req))
.isInstanceOf(ForbiddenException.class);
}
@Test
@DisplayName("envoyerMessage — conversation archivée → BadRequestException")
void envoyerMessage_archived() {
Conversation conv = buildConversation(TypeConversation.DIRECTE);
conv.setStatut(StatutConversation.ARCHIVEE);
when(conversationRepository.findConversationById(CONV_ID)).thenReturn(Optional.of(conv));
when(participantRepository.estParticipant(CONV_ID, MEMBRE_ID)).thenReturn(true);
EnvoyerMessageRequest req = EnvoyerMessageRequest.builder()
.typeMessage("TEXTE").contenu("Bonjour").build();
assertThatThrownBy(() -> messagingService.envoyerMessage(CONV_ID, req))
.isInstanceOf(BadRequestException.class);
}
@Test
@DisplayName("envoyerMessage — TEXTE sans contenu → BadRequestException")
void envoyerMessage_texteSansContenu() {
Conversation conv = buildConversation(TypeConversation.DIRECTE);
when(conversationRepository.findConversationById(CONV_ID)).thenReturn(Optional.of(conv));
when(participantRepository.estParticipant(CONV_ID, MEMBRE_ID)).thenReturn(true);
EnvoyerMessageRequest req = EnvoyerMessageRequest.builder()
.typeMessage("TEXTE").contenu("").build();
assertThatThrownBy(() -> messagingService.envoyerMessage(CONV_ID, req))
.isInstanceOf(BadRequestException.class);
}
@Test
@DisplayName("envoyerMessage — VOCAL sans urlFichier → BadRequestException")
void envoyerMessage_vocalSansUrl() {
Conversation conv = buildConversation(TypeConversation.DIRECTE);
when(conversationRepository.findConversationById(CONV_ID)).thenReturn(Optional.of(conv));
when(participantRepository.estParticipant(CONV_ID, MEMBRE_ID)).thenReturn(true);
EnvoyerMessageRequest req = EnvoyerMessageRequest.builder()
.typeMessage("VOCAL").dureeAudio(30).build();
assertThatThrownBy(() -> messagingService.envoyerMessage(CONV_ID, req))
.isInstanceOf(BadRequestException.class);
}
@Test
@DisplayName("envoyerMessage — VOCAL sans dureeAudio → BadRequestException")
void envoyerMessage_vocalSansDuree() {
Conversation conv = buildConversation(TypeConversation.DIRECTE);
when(conversationRepository.findConversationById(CONV_ID)).thenReturn(Optional.of(conv));
when(participantRepository.estParticipant(CONV_ID, MEMBRE_ID)).thenReturn(true);
EnvoyerMessageRequest req = EnvoyerMessageRequest.builder()
.typeMessage("VOCAL").urlFichier("https://example.com/audio.opus").build();
assertThatThrownBy(() -> messagingService.envoyerMessage(CONV_ID, req))
.isInstanceOf(BadRequestException.class);
}
// ── getMessages ───────────────────────────────────────────────────────────
@Test
@DisplayName("getMessages — success → retourne liste de messages")
void getMessages_success() {
Conversation conv = buildConversation(TypeConversation.DIRECTE);
Message msg = buildMessage(conv);
when(conversationRepository.findConversationById(CONV_ID)).thenReturn(Optional.of(conv));
when(participantRepository.estParticipant(CONV_ID, MEMBRE_ID)).thenReturn(true);
when(messageRepository.findByConversationPagine(eq(CONV_ID), eq(0), anyInt()))
.thenReturn(List.of(msg));
List<MessageResponse> result = messagingService.getMessages(CONV_ID, 0);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTypeMessage()).isEqualTo("TEXTE");
}
// ── marquerConversationLue ────────────────────────────────────────────────
@Test
@DisplayName("marquerConversationLue — participant trouvé → luJusqua mis à jour")
void marquerConversationLue_success() {
ConversationParticipant participant = new ConversationParticipant();
participant.setMembre(newMembre(MEMBRE_ID, "membre@test.com", "Alpha", "Diallo"));
when(participantRepository.findParticipant(CONV_ID, MEMBRE_ID))
.thenReturn(Optional.of(participant));
messagingService.marquerConversationLue(CONV_ID);
assertThat(participant.getLuJusqua()).isNotNull();
}
@Test
@DisplayName("marquerConversationLue — participant absent → no-op")
void marquerConversationLue_noParticipant() {
when(participantRepository.findParticipant(CONV_ID, MEMBRE_ID))
.thenReturn(Optional.empty());
// ne lève pas d'exception
messagingService.marquerConversationLue(CONV_ID);
}
// ── supprimerMessage ──────────────────────────────────────────────────────
@Test
@DisplayName("supprimerMessage — message non trouvé → NotFoundException")
void supprimerMessage_notFound() {
when(messageRepository.findMessageById(MESSAGE_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> messagingService.supprimerMessage(CONV_ID, MESSAGE_ID))
.isInstanceOf(NotFoundException.class);
}
@Test
@DisplayName("supprimerMessage — message dans autre conversation → NotFoundException")
void supprimerMessage_wrongConversation() {
Conversation autreConv = buildConversation(TypeConversation.DIRECTE);
autreConv.setId(UUID.randomUUID()); // ID différent de CONV_ID
Message msg = buildMessage(autreConv);
when(messageRepository.findMessageById(MESSAGE_ID)).thenReturn(Optional.of(msg));
assertThatThrownBy(() -> messagingService.supprimerMessage(CONV_ID, MESSAGE_ID))
.isInstanceOf(NotFoundException.class);
}
@Test
@DisplayName("supprimerMessage — pas l'auteur → ForbiddenException")
void supprimerMessage_notAuthor() {
Conversation conv = buildConversation(TypeConversation.DIRECTE);
Membre autreExpedireur = newMembre(DESTINATAIRE_ID, "other@test.com", "Beta", "Koné");
Message msg = new Message();
msg.setId(MESSAGE_ID);
msg.setConversation(conv);
msg.setExpediteur(autreExpedireur);
msg.setTypeMessage(TypeContenu.TEXTE);
msg.setContenu("Message d'un autre");
when(messageRepository.findMessageById(MESSAGE_ID)).thenReturn(Optional.of(msg));
assertThatThrownBy(() -> messagingService.supprimerMessage(CONV_ID, MESSAGE_ID))
.isInstanceOf(ForbiddenException.class);
}
// ── bloquerMembre ─────────────────────────────────────────────────────────
@Test
@DisplayName("bloquerMembre — soi-même → BadRequestException")
void bloquerMembre_soiMeme() {
BloquerMembreRequest req = BloquerMembreRequest.builder()
.membreABloquerId(MEMBRE_ID)
.organisationId(ORG_ID)
.build();
assertThatThrownBy(() -> messagingService.bloquerMembre(req))
.isInstanceOf(BadRequestException.class);
}
@Test
@DisplayName("bloquerMembre — membre introuvable → NotFoundException")
void bloquerMembre_destinataireInconnu() {
when(membreRepository.findById(DESTINATAIRE_ID)).thenReturn(null);
BloquerMembreRequest req = BloquerMembreRequest.builder()
.membreABloquerId(DESTINATAIRE_ID)
.organisationId(ORG_ID)
.build();
assertThatThrownBy(() -> messagingService.bloquerMembre(req))
.isInstanceOf(NotFoundException.class);
}
@Test
@DisplayName("bloquerMembre — déjà bloqué → BadRequestException")
void bloquerMembre_dejaBloque() {
Membre aBloquer = newMembre(DESTINATAIRE_ID, "dest@test.com", "Beta", "Koné");
when(membreRepository.findById(DESTINATAIRE_ID)).thenReturn(aBloquer);
when(memberBlockRepository.estBloque(MEMBRE_ID, DESTINATAIRE_ID, ORG_ID)).thenReturn(true);
BloquerMembreRequest req = BloquerMembreRequest.builder()
.membreABloquerId(DESTINATAIRE_ID)
.organisationId(ORG_ID)
.build();
assertThatThrownBy(() -> messagingService.bloquerMembre(req))
.isInstanceOf(BadRequestException.class);
}
// ── debloquerMembre ───────────────────────────────────────────────────────
@Test
@DisplayName("debloquerMembre — blocage non trouvé → NotFoundException")
void debloquerMembre_notFound() {
when(memberBlockRepository.findBlocage(MEMBRE_ID, DESTINATAIRE_ID, ORG_ID))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> messagingService.debloquerMembre(DESTINATAIRE_ID, ORG_ID))
.isInstanceOf(NotFoundException.class);
}
// ── getPolitique ──────────────────────────────────────────────────────────
@Test
@DisplayName("getPolitique — politique existante → retourne la politique")
void getPolitique_existing() {
Organisation org = newOrg();
ContactPolicy policy = ContactPolicy.builder()
.organisation(org)
.typePolitique(TypePolitiqueCommunication.BUREAU_SEULEMENT)
.autoriserMembreVersMembre(false)
.autoriserMembreVersRole(true)
.autoriserNotesVocales(true)
.build();
policy.setId(UUID.randomUUID());
when(contactPolicyRepository.findByOrganisationId(ORG_ID)).thenReturn(Optional.of(policy));
ContactPolicyResponse result = messagingService.getPolitique(ORG_ID);
assertThat(result.getTypePolitique()).isEqualTo("BUREAU_SEULEMENT");
assertThat(result.isAutoriserMembreVersMembre()).isFalse();
}
// ── mettreAJourPolitique ──────────────────────────────────────────────────
@Test
@DisplayName("mettreAJourPolitique — change typePolitique")
void mettreAJourPolitique_changeType() {
Organisation org = newOrg();
ContactPolicy policy = ContactPolicy.builder()
.organisation(org)
.typePolitique(TypePolitiqueCommunication.OUVERT)
.autoriserMembreVersMembre(true)
.autoriserMembreVersRole(true)
.autoriserNotesVocales(true)
.build();
policy.setId(UUID.randomUUID());
when(contactPolicyRepository.findByOrganisationId(ORG_ID)).thenReturn(Optional.of(policy));
MettreAJourPolitiqueRequest req = MettreAJourPolitiqueRequest.builder()
.typePolitique("BUREAU_SEULEMENT")
.autoriserMembreVersMembre(false)
.build();
ContactPolicyResponse result = messagingService.mettreAJourPolitique(ORG_ID, req);
assertThat(result.getTypePolitique()).isEqualTo("BUREAU_SEULEMENT");
assertThat(result.isAutoriserMembreVersMembre()).isFalse();
}
// ── Helpers ───────────────────────────────────────────────────────────────
private Membre newMembre(UUID id, String email, String prenom, String nom) {
Membre m = new Membre();
m.setId(id);
m.setNumeroMembre("M-" + id.toString().substring(0, 4));
m.setPrenom(prenom);
m.setNom(nom);
m.setEmail(email);
m.setDateNaissance(LocalDate.now());
return m;
}
private Organisation newOrg() {
Organisation org = new Organisation();
org.setId(ORG_ID);
org.setNom("Tontine Test");
return org;
}
private Conversation buildConversation(TypeConversation type) {
Conversation conv = new Conversation();
conv.setId(CONV_ID);
conv.setOrganisation(newOrg());
conv.setTypeConversation(type);
conv.setStatut(StatutConversation.ACTIVE);
conv.setNombreMessages(0);
return conv;
}
private Message buildMessage(Conversation conv) {
Membre expediteur = newMembre(MEMBRE_ID, "membre@test.com", "Alpha", "Diallo");
Message msg = new Message();
msg.setId(MESSAGE_ID);
msg.setConversation(conv);
msg.setExpediteur(expediteur);
msg.setTypeMessage(TypeContenu.TEXTE);
msg.setContenu("Bonjour !");
msg.setDateCreation(LocalDateTime.now());
return msg;
}
@SuppressWarnings("unchecked")
private <T> io.quarkus.hibernate.orm.panache.PanacheQuery<T> mockSingleResultQuery(T entity) {
io.quarkus.hibernate.orm.panache.PanacheQuery<T> query =
org.mockito.Mockito.mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class);
when(query.firstResult()).thenReturn(entity);
return query;
}
}

View File

@@ -0,0 +1,755 @@
package dev.lions.unionflow.server.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import dev.lions.unionflow.server.api.dto.versement.request.DeclarerVersementManuelRequest;
import dev.lions.unionflow.server.api.dto.versement.request.InitierDepotEpargneRequest;
import dev.lions.unionflow.server.api.dto.versement.request.InitierVersementWaveRequest;
import dev.lions.unionflow.server.api.dto.versement.response.VersementGatewayResponse;
import dev.lions.unionflow.server.api.dto.versement.response.VersementResponse;
import dev.lions.unionflow.server.api.dto.versement.response.VersementStatutResponse;
import dev.lions.unionflow.server.api.dto.versement.response.VersementSummaryResponse;
import dev.lions.unionflow.server.api.enums.paiement.StatutIntentionPaiement;
import dev.lions.unionflow.server.entity.Cotisation;
import dev.lions.unionflow.server.entity.IntentionPaiement;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.Versement;
import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne;
import dev.lions.unionflow.server.repository.IntentionPaiementRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.TypeReferenceRepository;
import dev.lions.unionflow.server.repository.VersementRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutException;
import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutSessionResponse;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import jakarta.ws.rs.NotFoundException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Tests unitaires pour {@link VersementService}.
*
* Couverture : trouverParId, trouverParNumeroReference, listerParMembre,
* calculerMontantTotalConfirmes, getMesVersements, validerVersement,
* annulerVersement, initierVersementWave (succès + Wave error + cotisation
* inconnue + cotisation d'un autre membre), declarerVersementManuel,
* verifierStatutVersement (complété / expiré / en attente),
* confirmerVersementWave (idempotence), toE164 (toutes branches).
*/
@QuarkusTest
@DisplayName("VersementService")
class VersementServiceTest {
@Inject
VersementService versementService;
@InjectMock VersementRepository versementRepository;
@InjectMock MembreRepository membreRepository;
@InjectMock KeycloakService keycloakService;
@InjectMock TypeReferenceRepository typeReferenceRepository;
@InjectMock IntentionPaiementRepository intentionPaiementRepository;
@InjectMock WaveCheckoutService waveCheckoutService;
@InjectMock CompteEpargneRepository compteEpargneRepository;
@InjectMock MembreOrganisationRepository membreOrganisationRepository;
@InjectMock NotificationService notificationService;
private Membre testMembre;
private Membre autreMembre;
private Organisation testOrg;
private Cotisation testCotisation;
private Versement testVersement;
private CompteEpargne testCompte;
private EntityManager mockEm;
@BeforeEach
void setUp() {
testOrg = new Organisation();
testOrg.setId(UUID.randomUUID());
testOrg.setNom("Org Test");
testMembre = new Membre();
testMembre.setId(UUID.randomUUID());
testMembre.setNumeroMembre("M001");
testMembre.setEmail("membre@test.com");
testMembre.setPrenom("Jean");
testMembre.setNom("Dupont");
testMembre.setDateNaissance(LocalDate.of(1990, 1, 1));
autreMembre = new Membre();
autreMembre.setId(UUID.randomUUID());
autreMembre.setNumeroMembre("M002");
autreMembre.setEmail("autre@test.com");
autreMembre.setDateNaissance(LocalDate.of(1985, 5, 15));
testCotisation = new Cotisation();
testCotisation.setId(UUID.randomUUID());
testCotisation.setNumeroReference("COT-2026-001");
testCotisation.setMembre(testMembre);
testCotisation.setOrganisation(testOrg);
testCotisation.setMontantDu(new BigDecimal("5000"));
testCotisation.setCodeDevise("XOF");
testCotisation.setStatut("EN_ATTENTE");
testCotisation.setDateEcheance(LocalDate.now().plusDays(30));
testCotisation.setTypeCotisation("MENSUELLE");
testCotisation.setLibelle("Cotisation janvier 2026");
testCotisation.setAnnee(2026);
testVersement = new Versement();
testVersement.setId(UUID.randomUUID());
testVersement.setNumeroReference("VRS-2026-001");
testVersement.setMontant(new BigDecimal("5000"));
testVersement.setCodeDevise("XOF");
testVersement.setMethodePaiement("WAVE");
testVersement.setStatutPaiement("EN_ATTENTE");
testVersement.setMembre(testMembre);
testVersement.setDatePaiement(LocalDateTime.now());
testCompte = new CompteEpargne();
testCompte.setId(UUID.randomUUID());
testCompte.setMembre(testMembre);
testCompte.setOrganisation(testOrg);
mockEm = mock(EntityManager.class);
when(versementRepository.getEntityManager()).thenReturn(mockEm);
when(typeReferenceRepository.findByDomaineAndCode(anyString(), anyString()))
.thenReturn(Optional.empty());
when(keycloakService.getCurrentUserEmail()).thenReturn(testMembre.getEmail());
}
// ── trouverParId ──────────────────────────────────────────────────────────
@Test
@DisplayName("trouverParId — trouvé → retourne response")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void trouverParId_found() {
when(versementRepository.findVersementById(testVersement.getId()))
.thenReturn(Optional.of(testVersement));
VersementResponse r = versementService.trouverParId(testVersement.getId());
assertThat(r.getNumeroReference()).isEqualTo("VRS-2026-001");
}
@Test
@DisplayName("trouverParId — non trouvé → NotFoundException")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void trouverParId_notFound() {
when(versementRepository.findVersementById(any())).thenReturn(Optional.empty());
assertThatThrownBy(() -> versementService.trouverParId(UUID.randomUUID()))
.isInstanceOf(NotFoundException.class);
}
// ── trouverParNumeroReference ─────────────────────────────────────────────
@Test
@DisplayName("trouverParNumeroReference — trouvé → response")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void trouverParNumeroReference_found() {
when(versementRepository.findByNumeroReference("VRS-2026-001"))
.thenReturn(Optional.of(testVersement));
VersementResponse r = versementService.trouverParNumeroReference("VRS-2026-001");
assertThat(r).isNotNull();
}
@Test
@DisplayName("trouverParNumeroReference — non trouvé → NotFoundException")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void trouverParNumeroReference_notFound() {
when(versementRepository.findByNumeroReference(anyString())).thenReturn(Optional.empty());
assertThatThrownBy(() -> versementService.trouverParNumeroReference("VRS-INCONNU"))
.isInstanceOf(NotFoundException.class);
}
// ── listerParMembre ───────────────────────────────────────────────────────
@Test
@DisplayName("listerParMembre — retourne liste non nulle")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void listerParMembre_returnsList() {
when(versementRepository.findByMembreId(testMembre.getId()))
.thenReturn(List.of(testVersement));
List<VersementSummaryResponse> list = versementService.listerParMembre(testMembre.getId());
assertThat(list).hasSize(1);
}
// ── calculerMontantTotalConfirmes ─────────────────────────────────────────
@Test
@DisplayName("calculerMontantTotalConfirmes — délègue au repository")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void calculerMontantTotalConfirmes_delegates() {
LocalDateTime debut = LocalDateTime.now().minusDays(30);
LocalDateTime fin = LocalDateTime.now();
when(versementRepository.calculerMontantTotalConfirmes(debut, fin))
.thenReturn(new BigDecimal("15000"));
BigDecimal total = versementService.calculerMontantTotalConfirmes(debut, fin);
assertThat(total).isEqualByComparingTo("15000");
}
// ── getMesVersements ──────────────────────────────────────────────────────
@Test
@DisplayName("getMesVersements — retourne historique du membre connecté")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void getMesVersements_returnsHistory() {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
@SuppressWarnings("unchecked")
TypedQuery<Versement> query = mock(TypedQuery.class);
doReturn(query).when(mockEm).createQuery(anyString(), any());
when(query.setParameter(anyString(), any())).thenReturn(query);
when(query.setMaxResults(anyInt())).thenReturn(query);
when(query.getResultList()).thenReturn(List.of(testVersement));
List<VersementSummaryResponse> list = versementService.getMesVersements(5);
assertThat(list).hasSize(1);
}
@Test
@DisplayName("getMesVersements — membre non trouvé → NotFoundException")
@TestSecurity(user = "inconnu@test.com", roles = {"MEMBRE"})
void getMesVersements_memberNotFound() {
when(membreRepository.findByEmail(anyString())).thenReturn(Optional.empty());
assertThatThrownBy(() -> versementService.getMesVersements(5))
.isInstanceOf(NotFoundException.class);
}
// ── validerVersement ──────────────────────────────────────────────────────
@Test
@DisplayName("validerVersement — passe à CONFIRME")
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
void validerVersement_setsConfirme() {
when(versementRepository.findVersementById(testVersement.getId()))
.thenReturn(Optional.of(testVersement));
VersementResponse r = versementService.validerVersement(testVersement.getId());
assertThat(r.getStatutPaiement()).isEqualTo("CONFIRME");
}
@Test
@DisplayName("validerVersement — déjà confirmé → idempotent")
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
void validerVersement_alreadyConfirme_idempotent() {
testVersement.setStatutPaiement("CONFIRME");
when(versementRepository.findVersementById(testVersement.getId()))
.thenReturn(Optional.of(testVersement));
VersementResponse r = versementService.validerVersement(testVersement.getId());
assertThat(r.getStatutPaiement()).isEqualTo("CONFIRME");
}
@Test
@DisplayName("validerVersement — non trouvé → NotFoundException")
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
void validerVersement_notFound() {
when(versementRepository.findVersementById(any())).thenReturn(Optional.empty());
assertThatThrownBy(() -> versementService.validerVersement(UUID.randomUUID()))
.isInstanceOf(NotFoundException.class);
}
// ── annulerVersement ──────────────────────────────────────────────────────
@Test
@DisplayName("annulerVersement — passe à ANNULE")
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
void annulerVersement_setsAnnule() {
when(versementRepository.findVersementById(testVersement.getId()))
.thenReturn(Optional.of(testVersement));
VersementResponse r = versementService.annulerVersement(testVersement.getId());
assertThat(r.getStatutPaiement()).isEqualTo("ANNULE");
}
@Test
@DisplayName("annulerVersement — déjà ANNULE → IllegalStateException")
@TestSecurity(user = "admin@test.com", roles = {"ADMIN"})
void annulerVersement_alreadyAnnule() {
testVersement.setStatutPaiement("ANNULE");
when(versementRepository.findVersementById(testVersement.getId()))
.thenReturn(Optional.of(testVersement));
assertThatThrownBy(() -> versementService.annulerVersement(testVersement.getId()))
.isInstanceOf(IllegalStateException.class);
}
// ── initierVersementWave ──────────────────────────────────────────────────
@Test
@DisplayName("initierVersementWave — succès Wave → retourne waveLaunchUrl")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierVersementWave_success() throws WaveCheckoutException {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
when(mockEm.find(Cotisation.class, testCotisation.getId()))
.thenReturn(testCotisation);
when(waveCheckoutService.getRedirectBaseUrl()).thenReturn("https://api.test.com");
WaveCheckoutSessionResponse session = new WaveCheckoutSessionResponse("cos-abc123", "wave://checkout/abc123");
when(waveCheckoutService.createSession(
anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
.thenReturn(session);
doAnswer(inv -> null).when(intentionPaiementRepository).persist(any(IntentionPaiement.class));
doAnswer(inv -> null).when(versementRepository).persist(any(Versement.class));
when(mockEm.merge(any())).thenReturn(testCotisation);
InitierVersementWaveRequest request = InitierVersementWaveRequest.builder()
.cotisationId(testCotisation.getId())
.numeroTelephone("771234567")
.build();
VersementGatewayResponse r = versementService.initierVersementWave(request);
assertThat(r.getWaveLaunchUrl()).isEqualTo("wave://checkout/abc123");
assertThat(r.getWaveCheckoutSessionId()).isEqualTo("cos-abc123");
assertThat(r.getMontant()).isEqualByComparingTo("5000");
assertThat(r.getClientReference()).isNotNull();
}
@Test
@DisplayName("initierVersementWave — cotisation inconnue → NotFoundException")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierVersementWave_cotisationInconnue() {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
when(mockEm.find(Cotisation.class, any())).thenReturn(null);
InitierVersementWaveRequest request = InitierVersementWaveRequest.builder()
.cotisationId(UUID.randomUUID())
.numeroTelephone("771234567")
.build();
assertThatThrownBy(() -> versementService.initierVersementWave(request))
.isInstanceOf(NotFoundException.class);
}
@Test
@DisplayName("initierVersementWave — cotisation d'un autre membre → IllegalArgumentException")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierVersementWave_cotisationAutreMembre() {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
testCotisation.setMembre(autreMembre);
when(mockEm.find(Cotisation.class, testCotisation.getId()))
.thenReturn(testCotisation);
InitierVersementWaveRequest request = InitierVersementWaveRequest.builder()
.cotisationId(testCotisation.getId())
.numeroTelephone("771234567")
.build();
assertThatThrownBy(() -> versementService.initierVersementWave(request))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("initierVersementWave — Wave API error → BadRequestException")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierVersementWave_waveApiError() throws WaveCheckoutException {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
when(mockEm.find(Cotisation.class, testCotisation.getId()))
.thenReturn(testCotisation);
when(waveCheckoutService.getRedirectBaseUrl()).thenReturn("https://api.test.com");
doAnswer(inv -> null).when(intentionPaiementRepository).persist(any(IntentionPaiement.class));
doThrow(new WaveCheckoutException("Wave down"))
.when(waveCheckoutService).createSession(
anyString(), anyString(), anyString(), anyString(), anyString(), anyString());
InitierVersementWaveRequest request = InitierVersementWaveRequest.builder()
.cotisationId(testCotisation.getId())
.numeroTelephone("771234567")
.build();
assertThatThrownBy(() -> versementService.initierVersementWave(request))
.isInstanceOf(jakarta.ws.rs.BadRequestException.class);
}
@Test
@DisplayName("initierVersementWave — sans numéro téléphone (web QR) → successUrl web")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierVersementWave_noPhone_webContext() throws WaveCheckoutException {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
when(mockEm.find(Cotisation.class, testCotisation.getId()))
.thenReturn(testCotisation);
when(waveCheckoutService.getRedirectBaseUrl()).thenReturn("https://api.test.com");
WaveCheckoutSessionResponse session = new WaveCheckoutSessionResponse("cos-web", "wave://checkout/web");
when(waveCheckoutService.createSession(
anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
.thenReturn(session);
doAnswer(inv -> null).when(intentionPaiementRepository).persist(any(IntentionPaiement.class));
doAnswer(inv -> null).when(versementRepository).persist(any(Versement.class));
when(mockEm.merge(any())).thenReturn(testCotisation);
InitierVersementWaveRequest request = InitierVersementWaveRequest.builder()
.cotisationId(testCotisation.getId())
.build(); // pas de numéro → web
VersementGatewayResponse r = versementService.initierVersementWave(request);
assertThat(r.getWaveLaunchUrl()).isEqualTo("wave://checkout/web");
}
// ── initierDepotEpargneEnLigne ────────────────────────────────────────────
@Test
@DisplayName("initierDepotEpargneEnLigne — succès Wave")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierDepotEpargne_success() throws WaveCheckoutException {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
when(compteEpargneRepository.findByIdOptional(testCompte.getId()))
.thenReturn(Optional.of(testCompte));
when(waveCheckoutService.getRedirectBaseUrl()).thenReturn("https://api.test.com");
WaveCheckoutSessionResponse session = new WaveCheckoutSessionResponse("cos-epargne", "wave://checkout/epargne");
when(waveCheckoutService.createSession(
anyString(), anyString(), anyString(), anyString(), anyString(), anyString()))
.thenReturn(session);
doAnswer(inv -> null).when(intentionPaiementRepository).persist(any(IntentionPaiement.class));
InitierDepotEpargneRequest request = InitierDepotEpargneRequest.builder()
.compteId(testCompte.getId())
.montant(new BigDecimal("10000"))
.numeroTelephone("771234567")
.build();
VersementGatewayResponse r = versementService.initierDepotEpargneEnLigne(request);
assertThat(r.getWaveLaunchUrl()).isEqualTo("wave://checkout/epargne");
assertThat(r.getMontant()).isEqualByComparingTo("10000");
}
@Test
@DisplayName("initierDepotEpargneEnLigne — compte inconnu → NotFoundException")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierDepotEpargne_compteInconnu() {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
when(compteEpargneRepository.findByIdOptional(any())).thenReturn(Optional.empty());
assertThatThrownBy(() -> versementService.initierDepotEpargneEnLigne(
InitierDepotEpargneRequest.builder()
.compteId(UUID.randomUUID())
.montant(BigDecimal.TEN)
.build()))
.isInstanceOf(NotFoundException.class);
}
@Test
@DisplayName("initierDepotEpargneEnLigne — compte d'un autre membre → IllegalArgumentException")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierDepotEpargne_compteAutreMembre() {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
testCompte.setMembre(autreMembre);
when(compteEpargneRepository.findByIdOptional(testCompte.getId()))
.thenReturn(Optional.of(testCompte));
assertThatThrownBy(() -> versementService.initierDepotEpargneEnLigne(
InitierDepotEpargneRequest.builder()
.compteId(testCompte.getId())
.montant(BigDecimal.TEN)
.build()))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("initierDepotEpargneEnLigne — Wave error → BadRequestException")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void initierDepotEpargne_waveError() throws WaveCheckoutException {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
when(compteEpargneRepository.findByIdOptional(testCompte.getId()))
.thenReturn(Optional.of(testCompte));
when(waveCheckoutService.getRedirectBaseUrl()).thenReturn("https://api.test.com");
doAnswer(inv -> null).when(intentionPaiementRepository).persist(any(IntentionPaiement.class));
doThrow(new WaveCheckoutException("Wave down"))
.when(waveCheckoutService).createSession(
anyString(), anyString(), anyString(), anyString(), anyString(), anyString());
assertThatThrownBy(() -> versementService.initierDepotEpargneEnLigne(
InitierDepotEpargneRequest.builder()
.compteId(testCompte.getId())
.montant(BigDecimal.TEN)
.build()))
.isInstanceOf(jakarta.ws.rs.BadRequestException.class);
}
// ── declarerVersementManuel ───────────────────────────────────────────────
@Test
@DisplayName("declarerVersementManuel — créé EN_ATTENTE_VALIDATION")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void declarerVersementManuel_success() {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
@SuppressWarnings("unchecked")
TypedQuery<Cotisation> query = mock(TypedQuery.class);
doReturn(query).when(mockEm).createQuery(anyString(), any());
when(query.setParameter(anyString(), any())).thenReturn(query);
when(query.getResultList()).thenReturn(List.of(testCotisation));
doAnswer(inv -> null).when(versementRepository).persist(any(Versement.class));
when(membreOrganisationRepository.findFirstByMembreId(any()))
.thenReturn(Optional.empty());
DeclarerVersementManuelRequest request = DeclarerVersementManuelRequest.builder()
.cotisationId(testCotisation.getId())
.methodePaiement("ESPECES")
.commentaire("Remis en main propre")
.build();
VersementResponse r = versementService.declarerVersementManuel(request);
assertThat(r.getStatutPaiement()).isEqualTo("EN_ATTENTE_VALIDATION");
assertThat(r.getMethodePaiement()).isEqualTo("ESPECES");
}
@Test
@DisplayName("declarerVersementManuel — cotisation inconnue → NotFoundException")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void declarerVersementManuel_cotisationInconnue() {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
@SuppressWarnings("unchecked")
TypedQuery<Cotisation> query = mock(TypedQuery.class);
doReturn(query).when(mockEm).createQuery(anyString(), any());
when(query.setParameter(anyString(), any())).thenReturn(query);
when(query.getResultList()).thenReturn(Collections.emptyList());
assertThatThrownBy(() -> versementService.declarerVersementManuel(
DeclarerVersementManuelRequest.builder()
.cotisationId(UUID.randomUUID())
.methodePaiement("ESPECES")
.build()))
.isInstanceOf(NotFoundException.class);
}
@Test
@DisplayName("declarerVersementManuel — cotisation d'un autre membre → IllegalArgumentException")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void declarerVersementManuel_cotisationAutreMembre() {
when(membreRepository.findByEmail("membre@test.com"))
.thenReturn(Optional.of(testMembre));
testCotisation.setMembre(autreMembre);
@SuppressWarnings("unchecked")
TypedQuery<Cotisation> query = mock(TypedQuery.class);
doReturn(query).when(mockEm).createQuery(anyString(), any());
when(query.setParameter(anyString(), any())).thenReturn(query);
when(query.getResultList()).thenReturn(List.of(testCotisation));
assertThatThrownBy(() -> versementService.declarerVersementManuel(
DeclarerVersementManuelRequest.builder()
.cotisationId(testCotisation.getId())
.methodePaiement("ESPECES")
.build()))
.isInstanceOf(IllegalArgumentException.class);
}
// ── verifierStatutVersement ───────────────────────────────────────────────
@Test
@DisplayName("verifierStatutVersement — intention inconnue → NotFoundException")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void verifierStatutVersement_notFound() {
when(intentionPaiementRepository.findById(any())).thenReturn(null);
assertThatThrownBy(() -> versementService.verifierStatutVersement(UUID.randomUUID()))
.isInstanceOf(NotFoundException.class);
}
@Test
@DisplayName("verifierStatutVersement — déjà COMPLETEE → confirme=true")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void verifierStatutVersement_completee() {
IntentionPaiement intention = buildIntention(StatutIntentionPaiement.COMPLETEE);
when(intentionPaiementRepository.findById(intention.getId()))
.thenReturn(intention);
VersementStatutResponse r = versementService.verifierStatutVersement(intention.getId());
assertThat(r.isConfirme()).isTrue();
assertThat(r.getMessage()).contains("confirmé");
}
@Test
@DisplayName("verifierStatutVersement — EXPIREE → confirme=false")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void verifierStatutVersement_expiree() {
IntentionPaiement intention = buildIntention(StatutIntentionPaiement.EXPIREE);
when(intentionPaiementRepository.findById(intention.getId()))
.thenReturn(intention);
VersementStatutResponse r = versementService.verifierStatutVersement(intention.getId());
assertThat(r.isConfirme()).isFalse();
}
@Test
@DisplayName("verifierStatutVersement — ECHOUEE → confirme=false")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void verifierStatutVersement_echouee() {
IntentionPaiement intention = buildIntention(StatutIntentionPaiement.ECHOUEE);
when(intentionPaiementRepository.findById(intention.getId()))
.thenReturn(intention);
VersementStatutResponse r = versementService.verifierStatutVersement(intention.getId());
assertThat(r.isConfirme()).isFalse();
}
@Test
@DisplayName("verifierStatutVersement — session expirée localement → EXPIREE")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void verifierStatutVersement_localExpiry() {
IntentionPaiement intention = buildIntention(StatutIntentionPaiement.EN_COURS);
intention.setDateExpiration(LocalDateTime.now().minusMinutes(5));
when(intentionPaiementRepository.findById(intention.getId()))
.thenReturn(intention);
VersementStatutResponse r = versementService.verifierStatutVersement(intention.getId());
assertThat(r.getStatut()).isEqualTo("EXPIREE");
}
@Test
@DisplayName("verifierStatutVersement — EN_COURS sans session Wave → en attente")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void verifierStatutVersement_enCoursNoSession() {
IntentionPaiement intention = buildIntention(StatutIntentionPaiement.EN_COURS);
intention.setDateExpiration(LocalDateTime.now().plusMinutes(25));
when(intentionPaiementRepository.findById(intention.getId()))
.thenReturn(intention);
VersementStatutResponse r = versementService.verifierStatutVersement(intention.getId());
assertThat(r.isConfirme()).isFalse();
assertThat(r.getMessage()).containsIgnoringCase("attente");
}
// ── confirmerVersementWave ────────────────────────────────────────────────
@Test
@DisplayName("confirmerVersementWave — déjà COMPLETEE → idempotent")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void confirmerVersementWave_alreadyCompletee_idempotent() {
IntentionPaiement intention = buildIntention(StatutIntentionPaiement.COMPLETEE);
// Ne doit pas appeler persist
versementService.confirmerVersementWave(intention, null);
// Pas d'exception = idempotence OK
}
@Test
@DisplayName("confirmerVersementWave — objetsCibles null → passe sans erreur")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void confirmerVersementWave_nullObjetsCibles() {
IntentionPaiement intention = buildIntention(StatutIntentionPaiement.EN_COURS);
intention.setObjetsCibles(null);
doAnswer(inv -> null).when(intentionPaiementRepository).persist(any(IntentionPaiement.class));
versementService.confirmerVersementWave(intention, "TCN-123");
assertThat(intention.getStatut()).isEqualTo(StatutIntentionPaiement.COMPLETEE);
}
@Test
@DisplayName("confirmerVersementWave — JSON cotisation → mise à jour cotisation")
@TestSecurity(user = "membre@test.com", roles = {"MEMBRE"})
void confirmerVersementWave_reconcilesCotisation() {
UUID cotisationId = testCotisation.getId();
IntentionPaiement intention = buildIntention(StatutIntentionPaiement.EN_COURS);
intention.setObjetsCibles(
"[{\"type\":\"COTISATION\",\"id\":\"" + cotisationId + "\",\"montant\":5000}]");
doAnswer(inv -> null).when(intentionPaiementRepository).persist(any(IntentionPaiement.class));
when(mockEm.find(Cotisation.class, cotisationId)).thenReturn(testCotisation);
when(mockEm.merge(any())).thenReturn(testCotisation);
versementService.confirmerVersementWave(intention, "TCN-ABC");
assertThat(testCotisation.getStatut()).isEqualTo("PAYEE");
assertThat(testCotisation.getMontantPaye()).isEqualByComparingTo("5000");
}
// ── toE164 ────────────────────────────────────────────────────────────────
@Test
@DisplayName("toE164 — null → null")
void toE164_null() {
assertThat(VersementService.toE164(null)).isNull();
}
@Test
@DisplayName("toE164 — vide → null")
void toE164_blank() {
assertThat(VersementService.toE164(" ")).isNull();
}
@Test
@DisplayName("toE164 — 9 chiffres commençant par 7 → +221 prefix")
void toE164_9digits_7prefix() {
assertThat(VersementService.toE164("771234567")).isEqualTo("+221771234567");
}
@Test
@DisplayName("toE164 — 9 chiffres commençant par 0 → +221 + supprime 0")
void toE164_9digits_0prefix() {
assertThat(VersementService.toE164("071234567")).isEqualTo("+22171234567");
}
@Test
@DisplayName("toE164 — déjà avec indicatif 221 → ajoute +")
void toE164_with221prefix() {
assertThat(VersementService.toE164("221771234567")).isEqualTo("+221771234567");
}
@Test
@DisplayName("toE164 — déjà au format E.164 (+221...) → conservé")
void toE164_alreadyE164() {
assertThat(VersementService.toE164("+221771234567")).isEqualTo("+221771234567");
}
@Test
@DisplayName("toE164 — format non reconnu → + + chiffres")
void toE164_unknown() {
assertThat(VersementService.toE164("0033612345678")).startsWith("+");
}
// ── helpers ───────────────────────────────────────────────────────────────
private IntentionPaiement buildIntention(StatutIntentionPaiement statut) {
IntentionPaiement i = new IntentionPaiement();
i.setId(UUID.randomUUID());
i.setStatut(statut);
i.setMontantTotal(new BigDecimal("5000"));
i.setCodeDevise("XOF");
i.setUtilisateur(testMembre);
i.setDateExpiration(LocalDateTime.now().plusMinutes(30));
return i;
}
}

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
https://jakarta.ee/xml/ns/jakartaee/beans_4_0.xsd"
version="4.0"
bean-discovery-mode="annotated">
</beans>

View File

@@ -1,63 +0,0 @@
# ============================================================================
# UnionFlow Server — Profil PROD
# Chargé automatiquement quand le profil "prod" est actif
# Surcharge application.properties — sans préfixes %prod.
# ============================================================================
# Base de données PostgreSQL — Production (variables d'environnement obligatoires)
quarkus.datasource.username=${DB_USERNAME}
quarkus.datasource.password=${DB_PASSWORD}
quarkus.datasource.jdbc.url=${DB_URL}
quarkus.datasource.jdbc.min-size=5
quarkus.datasource.jdbc.max-size=20
quarkus.datasource.jdbc.acquisition-timeout=5
quarkus.datasource.jdbc.idle-removal-interval=PT2M
quarkus.datasource.jdbc.max-lifetime=PT30M
# Hibernate — Validate uniquement (Flyway gère le schéma)
quarkus.hibernate-orm.database.generation=validate
quarkus.hibernate-orm.statistics=false
# CORS — strict en production
quarkus.http.cors.origins=${CORS_ORIGINS:https://unionflow.lions.dev,https://security.lions.dev}
quarkus.http.cors.access-control-allow-credentials=true
# WebSocket — public (auth gérée dans le handshake)
quarkus.http.auth.permission.websocket.paths=/ws/*
quarkus.http.auth.permission.websocket.policy=permit
# Keycloak / OIDC — Production
quarkus.oidc.tenant-enabled=true
quarkus.oidc.auth-server-url=${KEYCLOAK_AUTH_SERVER_URL:https://security.lions.dev/realms/unionflow}
quarkus.oidc.client-id=unionflow-server
quarkus.oidc.credentials.secret=${KEYCLOAK_CLIENT_SECRET}
quarkus.oidc.tls.verification=required
# OpenAPI — serveur prod
quarkus.smallrye-openapi.servers=https://api.lions.dev/unionflow
quarkus.smallrye-openapi.oidc-open-id-connect-url=${quarkus.oidc.auth-server-url}/.well-known/openid-configuration
# Swagger UI — désactivé en production
quarkus.swagger-ui.always-include=false
# Logging — fichier en production
quarkus.log.file.enable=true
quarkus.log.file.path=/var/log/unionflow/server.log
quarkus.log.file.rotation.max-file-size=10M
quarkus.log.file.rotation.max-backup-index=5
quarkus.log.category."org.jboss.resteasy".level=WARN
# REST Client lions-user-manager
quarkus.rest-client.lions-user-manager-api.url=${LIONS_USER_MANAGER_URL:http://lions-user-manager:8081}
# Wave Money — Production
wave.environment=production
# Email — Production
quarkus.mailer.from=${MAIL_FROM:noreply@unionflow.lions.dev}
quarkus.mailer.host=${MAIL_HOST:smtp.lions.dev}
quarkus.mailer.port=${MAIL_PORT:587}
quarkus.mailer.username=${MAIL_USERNAME:}
quarkus.mailer.password=${MAIL_PASSWORD:}
quarkus.mailer.start-tls=REQUIRED
quarkus.mailer.ssl=false

View File

@@ -1,48 +0,0 @@
# Configuration UnionFlow Server - Profil Test
# Ce fichier est chargé automatiquement quand le profil 'test' est actif
# Configuration Base de données H2 pour tests
quarkus.datasource.db-kind=h2
quarkus.datasource.username=sa
quarkus.datasource.password=
quarkus.datasource.jdbc.url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;NON_KEYWORDS=MONTH,YEAR
# Configuration Hibernate pour tests
quarkus.hibernate-orm.database.generation=update
# Désactiver complètement l'exécution des scripts SQL au démarrage
quarkus.hibernate-orm.sql-load-script=no-file
# Empêcher Hibernate d'exécuter les scripts SQL automatiquement
# Note: Ne pas définir quarkus.hibernate-orm.sql-load-script car une chaîne vide peut causer des problèmes
# Configuration Flyway pour tests (désactivé complètement)
quarkus.flyway.migrate-at-start=false
quarkus.flyway.enabled=false
quarkus.flyway.baseline-on-migrate=false
# Note: Ne pas définir quarkus.flyway.locations car une chaîne vide cause une erreur de configuration
# Configuration Keycloak pour tests (désactivé)
quarkus.oidc.tenant-enabled=false
quarkus.keycloak.policy-enforcer.enable=false
# Configuration HTTP pour tests
quarkus.http.port=0
quarkus.http.test-port=0
# Wave — mock pour tests
wave.mock.enabled=true
wave.api.key=test-wave-api-key-for-unit-tests
wave.api.secret=test-wave-api-secret-for-unit-tests
wave.redirect.base.url=http://localhost:8080
# Kafka — in-memory connector pour les tests (pas de broker Kafka requis)
mp.messaging.outgoing.finance-approvals-out.connector=smallrye-in-memory
mp.messaging.outgoing.dashboard-stats-out.connector=smallrye-in-memory
mp.messaging.outgoing.notifications-out.connector=smallrye-in-memory
mp.messaging.outgoing.members-events-out.connector=smallrye-in-memory
mp.messaging.outgoing.contributions-events-out.connector=smallrye-in-memory
mp.messaging.incoming.finance-approvals-in.connector=smallrye-in-memory
mp.messaging.incoming.dashboard-stats-in.connector=smallrye-in-memory
mp.messaging.incoming.notifications-in.connector=smallrye-in-memory
mp.messaging.incoming.members-events-in.connector=smallrye-in-memory
mp.messaging.incoming.contributions-events-in.connector=smallrye-in-memory

View File

@@ -1,166 +0,0 @@
# ============================================================================
# UnionFlow Server — Configuration commune (tous profils)
# Chargée en premier, les fichiers application-{profil}.properties surchargent
# ============================================================================
quarkus.application.name=unionflow-server
quarkus.application.version=1.0.0
# Configuration HTTP
quarkus.http.port=8085
quarkus.http.host=0.0.0.0
quarkus.http.limits.max-body-size=10M
quarkus.http.limits.max-header-size=16K
# Configuration Datasource — db-kind est une propriété build-time (commune à tous profils)
# Les valeurs réelles sont surchargées par application-dev.properties et application-prod.properties
quarkus.datasource.db-kind=postgresql
quarkus.datasource.username=${DB_USERNAME:unionflow}
quarkus.datasource.password=${DB_PASSWORD:changeme}
quarkus.datasource.jdbc.url=${DB_URL:jdbc:postgresql://localhost:5432/unionflow}
# Configuration CORS
quarkus.http.cors=true
quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS
quarkus.http.cors.headers=Content-Type,Authorization
# Chemins publics
quarkus.http.auth.permission.public.paths=/health,/q/*,/favicon.ico,/auth/callback,/auth/*
quarkus.http.auth.permission.public.policy=permit
# Configuration Hibernate — base commune
quarkus.hibernate-orm.database.generation=update
quarkus.hibernate-orm.log.sql=false
quarkus.hibernate-orm.jdbc.timezone=UTC
quarkus.hibernate-orm.metrics.enabled=false
# Configuration Flyway — base commune
quarkus.flyway.migrate-at-start=true
quarkus.flyway.baseline-on-migrate=true
quarkus.flyway.baseline-version=0
# Configuration Keycloak OIDC — base commune
quarkus.oidc.application-type=service
quarkus.oidc.roles.role-claim-path=realm_access/roles
# Keycloak Policy Enforcer (PERMISSIVE — sécurité gérée par @RolesAllowed)
quarkus.keycloak.policy-enforcer.enable=false
quarkus.keycloak.policy-enforcer.lazy-load-paths=true
quarkus.keycloak.policy-enforcer.enforcement-mode=PERMISSIVE
# Configuration OpenAPI
quarkus.smallrye-openapi.info-title=UnionFlow Server API
quarkus.smallrye-openapi.info-version=1.0.0
quarkus.smallrye-openapi.info-description=API REST pour la gestion d'union avec authentification Keycloak
quarkus.smallrye-openapi.security-scheme=oidc
quarkus.smallrye-openapi.security-scheme-name=Keycloak
quarkus.smallrye-openapi.security-scheme-description=Authentification Bearer JWT via Keycloak
# Swagger UI
quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.path=/swagger-ui
quarkus.swagger-ui.doc-expansion=list
quarkus.swagger-ui.filter=true
quarkus.swagger-ui.deep-linking=true
quarkus.swagger-ui.operations-sorter=alpha
quarkus.swagger-ui.tags-sorter=alpha
# Health
quarkus.smallrye-health.root-path=/health
# Logging — base commune
quarkus.log.console.enable=true
quarkus.log.console.level=INFO
quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{2.}] (%t) %s%e%n
quarkus.log.category."dev.lions.unionflow".level=INFO
quarkus.log.category."org.hibernate".level=WARN
quarkus.log.category."io.quarkus".level=INFO
# Arc / MapStruct
quarkus.arc.remove-unused-beans=false
quarkus.arc.unremovable-types=dev.lions.unionflow.server.mapper.**
# Jandex
quarkus.index-dependency.unionflow-server-api.group-id=dev.lions.unionflow
quarkus.index-dependency.unionflow-server-api.artifact-id=unionflow-server-api
# REST Client lions-user-manager
quarkus.rest-client.lions-user-manager-api.url=${LIONS_USER_MANAGER_URL:http://localhost:8081}
# Wave Money — Checkout API (https://docs.wave.com/checkout)
# Test : WAVE_API_KEY vide ou absent + wave.mock.enabled=true pour mocker Wave
wave.api.key=${WAVE_API_KEY: }
wave.api.secret=${WAVE_API_SECRET: }
wave.api.base.url=${WAVE_API_BASE_URL:https://api.wave.com/v1}
wave.environment=${WAVE_ENVIRONMENT:sandbox}
wave.webhook.secret=${WAVE_WEBHOOK_SECRET: }
# URLs de redirection (https en prod). Défaut dev: http://localhost:8080
wave.redirect.base.url=${WAVE_REDIRECT_BASE_URL:http://localhost:8080}
# Mock Wave (tests) : true = pas d'appel API, validation simulée. Si api.key vide, mock auto.
wave.mock.enabled=${WAVE_MOCK_ENABLED:false}
# Schéma deep link pour le retour vers l'app mobile (ex: unionflow)
wave.deep.link.scheme=${WAVE_DEEP_LINK_SCHEME:unionflow}
# ============================================================================
# Kafka Event Streaming Configuration
# ============================================================================
# Kafka Bootstrap Servers
kafka.bootstrap.servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
# Producer Channels (Outgoing)
mp.messaging.outgoing.finance-approvals-out.connector=smallrye-kafka
mp.messaging.outgoing.finance-approvals-out.topic=unionflow.finance.approvals
mp.messaging.outgoing.finance-approvals-out.value.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.finance-approvals-out.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.dashboard-stats-out.connector=smallrye-kafka
mp.messaging.outgoing.dashboard-stats-out.topic=unionflow.dashboard.stats
mp.messaging.outgoing.dashboard-stats-out.value.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.dashboard-stats-out.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.notifications-out.connector=smallrye-kafka
mp.messaging.outgoing.notifications-out.topic=unionflow.notifications.user
mp.messaging.outgoing.notifications-out.value.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.notifications-out.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.members-events-out.connector=smallrye-kafka
mp.messaging.outgoing.members-events-out.topic=unionflow.members.events
mp.messaging.outgoing.members-events-out.value.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.members-events-out.key.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.contributions-events-out.connector=smallrye-kafka
mp.messaging.outgoing.contributions-events-out.topic=unionflow.contributions.events
mp.messaging.outgoing.contributions-events-out.value.serializer=org.apache.kafka.common.serialization.StringSerializer
mp.messaging.outgoing.contributions-events-out.key.serializer=org.apache.kafka.common.serialization.StringSerializer
# Consumer Channels (Incoming)
mp.messaging.incoming.finance-approvals-in.connector=smallrye-kafka
mp.messaging.incoming.finance-approvals-in.topic=unionflow.finance.approvals
mp.messaging.incoming.finance-approvals-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.finance-approvals-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.finance-approvals-in.group.id=unionflow-websocket-server
mp.messaging.incoming.dashboard-stats-in.connector=smallrye-kafka
mp.messaging.incoming.dashboard-stats-in.topic=unionflow.dashboard.stats
mp.messaging.incoming.dashboard-stats-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.dashboard-stats-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.dashboard-stats-in.group.id=unionflow-websocket-server
mp.messaging.incoming.notifications-in.connector=smallrye-kafka
mp.messaging.incoming.notifications-in.topic=unionflow.notifications.user
mp.messaging.incoming.notifications-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.notifications-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.notifications-in.group.id=unionflow-websocket-server
mp.messaging.incoming.members-events-in.connector=smallrye-kafka
mp.messaging.incoming.members-events-in.topic=unionflow.members.events
mp.messaging.incoming.members-events-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.members-events-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.members-events-in.group.id=unionflow-websocket-server
mp.messaging.incoming.contributions-events-in.connector=smallrye-kafka
mp.messaging.incoming.contributions-events-in.topic=unionflow.contributions.events
mp.messaging.incoming.contributions-events-in.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.contributions-events-in.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.incoming.contributions-events-in.group.id=unionflow-websocket-server

Some files were not shown because too many files have changed in this diff Show More