diff --git a/src/main/java/dev/lions/unionflow/server/entity/ContactPolicy.java b/src/main/java/dev/lions/unionflow/server/entity/ContactPolicy.java new file mode 100644 index 0000000..33d5ac9 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/ContactPolicy.java @@ -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. + * + *

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. + * + *

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; + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/Conversation.java b/src/main/java/dev/lions/unionflow/server/entity/Conversation.java index 01a7300..cba0ab8 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/Conversation.java +++ b/src/main/java/dev/lions/unionflow/server/entity/Conversation.java @@ -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. + * + *

Deux types sont supportés en V1 : + *

+ * + *

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 participants = new ArrayList<>(); + @Builder.Default + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private List 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 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(); } } diff --git a/src/main/java/dev/lions/unionflow/server/entity/ConversationParticipant.java b/src/main/java/dev/lions/unionflow/server/entity/ConversationParticipant.java new file mode 100644 index 0000000..f160e86 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/ConversationParticipant.java @@ -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. + * + *

Stocke l'état de lecture individuel ({@code luJusqua}) et + * les préférences de notification du participant. + * + *

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); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/MemberBlock.java b/src/main/java/dev/lions/unionflow/server/entity/MemberBlock.java new file mode 100644 index 0000000..b1eea21 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/MemberBlock.java @@ -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. + * + *

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). + * + *

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; +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/Message.java b/src/main/java/dev/lions/unionflow/server/entity/Message.java index 3991018..e283000 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/Message.java +++ b/src/main/java/dev/lions/unionflow/server/entity/Message.java @@ -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. + * + *

Supporte trois types de contenu : + *

+ * + *

La suppression est douce : {@code supprimeLe} est renseigné au lieu de + * supprimer la ligne. Le contenu devient {@code "[Message supprimé]"}. + * + *

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; } } diff --git a/src/main/java/dev/lions/unionflow/server/repository/ContactPolicyRepository.java b/src/main/java/dev/lions/unionflow/server/repository/ContactPolicyRepository.java new file mode 100644 index 0000000..5051e6c --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/repository/ContactPolicyRepository.java @@ -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 { + + /** + * Trouve la politique de communication d'une organisation. + * Chaque organisation a exactement une politique. + */ + public Optional findByOrganisationId(UUID organisationId) { + return find("organisation.id = ?1 AND actif = true", organisationId) + .firstResultOptional(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/repository/ConversationParticipantRepository.java b/src/main/java/dev/lions/unionflow/server/repository/ConversationParticipantRepository.java new file mode 100644 index 0000000..fe9f736 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/repository/ConversationParticipantRepository.java @@ -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 { + + /** + * Trouve la participation d'un membre à une conversation. + */ + public Optional 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 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; + } +} diff --git a/src/main/java/dev/lions/unionflow/server/repository/ConversationRepository.java b/src/main/java/dev/lions/unionflow/server/repository/ConversationRepository.java index 616cef9..4c5341d 100644 --- a/src/main/java/dev/lions/unionflow/server/repository/ConversationRepository.java +++ b/src/main/java/dev/lions/unionflow/server/repository/ConversationRepository.java @@ -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 { /** - * Trouve toutes les conversations d'un membre + * Trouve une conversation par son ID avec Optional. */ - public List 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 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 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 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 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 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 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 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(); } } diff --git a/src/main/java/dev/lions/unionflow/server/repository/MemberBlockRepository.java b/src/main/java/dev/lions/unionflow/server/repository/MemberBlockRepository.java new file mode 100644 index 0000000..70f92ae --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/repository/MemberBlockRepository.java @@ -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 { + + /** + * 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 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 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 findByBloqueurEtOrganisation(UUID bloqueurId, UUID organisationId) { + return find("bloqueur.id = ?1 AND organisation.id = ?2 AND actif = true", + bloqueurId, organisationId).list(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/repository/MessageRepository.java b/src/main/java/dev/lions/unionflow/server/repository/MessageRepository.java index 7adf34d..75e0f72 100644 --- a/src/main/java/dev/lions/unionflow/server/repository/MessageRepository.java +++ b/src/main/java/dev/lions/unionflow/server/repository/MessageRepository.java @@ -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 { /** - * Trouve tous les messages d'une conversation (non supprimés) + * Trouve un message par son ID avec Optional. */ - public List 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 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 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 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 findDernierMessage(UUID conversationId) { + return find( + "conversation.id = ?1 AND actif = true ORDER BY dateCreation DESC", + conversationId + ).firstResultOptional(); } } diff --git a/src/main/java/dev/lions/unionflow/server/resource/ConversationResource.java b/src/main/java/dev/lions/unionflow/server/resource/ConversationResource.java deleted file mode 100644 index afdba5c..0000000 --- a/src/main/java/dev/lions/unionflow/server/resource/ConversationResource.java +++ /dev/null @@ -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 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(); - } -} diff --git a/src/main/java/dev/lions/unionflow/server/resource/MessageResource.java b/src/main/java/dev/lions/unionflow/server/resource/MessageResource.java deleted file mode 100644 index 4b311ca..0000000 --- a/src/main/java/dev/lions/unionflow/server/resource/MessageResource.java +++ /dev/null @@ -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 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 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(); - } -} diff --git a/src/main/java/dev/lions/unionflow/server/resource/MessagingResource.java b/src/main/java/dev/lions/unionflow/server/resource/MessagingResource.java new file mode 100644 index 0000000..51e9597 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/resource/MessagingResource.java @@ -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. + * + *

Endpoints : + *

+ * + * @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 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 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(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/ConversationService.java b/src/main/java/dev/lions/unionflow/server/service/ConversationService.java deleted file mode 100644 index ed45840..0000000 --- a/src/main/java/dev/lions/unionflow/server/service/ConversationService.java +++ /dev/null @@ -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 getConversations(UUID membreId, UUID organisationId, boolean includeArchived) { - LOG.infof("Récupération conversations pour membre %s", membreId); - - List 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 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(); - } -} diff --git a/src/main/java/dev/lions/unionflow/server/service/MessageService.java b/src/main/java/dev/lions/unionflow/server/service/MessageService.java deleted file mode 100644 index c8f6546..0000000 --- a/src/main/java/dev/lions/unionflow/server/service/MessageService.java +++ /dev/null @@ -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 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 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 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 roles = null; - if (m.getRecipientRoles() != null && !m.getRecipientRoles().isEmpty()) { - roles = List.of(m.getRecipientRoles().split(",")); - } - - // Parser attachments - List 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(); - } -} diff --git a/src/main/java/dev/lions/unionflow/server/service/MessagingService.java b/src/main/java/dev/lions/unionflow/server/service/MessagingService.java new file mode 100644 index 0000000..db954c7 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/MessagingService.java @@ -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. + * + *

Gère les conversations (directes et canaux-rôle), les messages (texte, + * vocal, image), les blocages et les politiques de communication. + * + *

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 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 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 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 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 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 trouverPorteursDuRole(UUID orgId, String role) { + List 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 msgs = messageRepository + .findByConversationPagine(conv.getId(), 0, PAGE_SIZE_DEFAULT) + .stream().map(this::toMessageResponse).collect(Collectors.toList()); + + List 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(); + } +} diff --git a/src/main/resources/db/migration/V28__Create_Messagerie_Tables.sql b/src/main/resources/db/migration/V28__Create_Messagerie_Tables.sql new file mode 100644 index 0000000..6906df3 --- /dev/null +++ b/src/main/resources/db/migration/V28__Create_Messagerie_Tables.sql @@ -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); diff --git a/src/test/java/dev/lions/unionflow/server/entity/ConversationTest.java b/src/test/java/dev/lions/unionflow/server/entity/ConversationTest.java index c294a7d..ce0794b 100644 --- a/src/test/java/dev/lions/unionflow/server/entity/ConversationTest.java +++ b/src/test/java/dev/lions/unionflow/server/entity/ConversationTest.java @@ -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; } } diff --git a/src/test/java/dev/lions/unionflow/server/entity/MessageTest.java b/src/test/java/dev/lions/unionflow/server/entity/MessageTest.java index de8480d..0fd405a 100644 --- a/src/test/java/dev/lions/unionflow/server/entity/MessageTest.java +++ b/src/test/java/dev/lions/unionflow/server/entity/MessageTest.java @@ -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; } } diff --git a/src/test/java/dev/lions/unionflow/server/repository/ConversationRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/ConversationRepositoryTest.java index ce619cb..8294ed5 100644 --- a/src/test/java/dev/lions/unionflow/server/repository/ConversationRepositoryTest.java +++ b/src/test/java/dev/lions/unionflow/server/repository/ConversationRepositoryTest.java @@ -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 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 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 result = conversationRepository.findByIdAndParticipant( - UUID.randomUUID(), UUID.randomUUID()); - - assertThat(result).isEmpty(); + @DisplayName("findConversationById retourne empty pour UUID inexistant") + void findConversationById_inexistant_returnsEmpty() { + Optional 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 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 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 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 result = conversationRepository.findByOrganisation(org.getId()); - - assertThat(result).isNotNull(); - assertThat(result).hasSize(2); + @DisplayName("findConversationDirecte retourne empty si aucune conversation") + void findConversationDirecte_inconnu_returnsEmpty() { + Optional 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 result = conversationRepository.findByOrganisation(UUID.randomUUID()); + @DisplayName("findCanalRole retourne empty si aucun canal") + void findCanalRole_inconnu_returnsEmpty() { + Optional 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 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(); } } diff --git a/src/test/java/dev/lions/unionflow/server/repository/MessageRepositoryTest.java b/src/test/java/dev/lions/unionflow/server/repository/MessageRepositoryTest.java index 29e8bda..6148889 100644 --- a/src/test/java/dev/lions/unionflow/server/repository/MessageRepositoryTest.java +++ b/src/test/java/dev/lions/unionflow/server/repository/MessageRepositoryTest.java @@ -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 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 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 messages = messageRepository.findByConversation(conv.getId(), 3); - - assertThat(messages).hasSizeLessThanOrEqualTo(3); + @DisplayName("findByConversationPagine retourne liste vide pour conversation inexistante") + void findByConversationPagine_returnsEmpty() { + List 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 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 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 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 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); } } diff --git a/src/test/java/dev/lions/unionflow/server/resource/ConversationResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/ConversationResourceTest.java deleted file mode 100644 index bc92dff..0000000 --- a/src/test/java/dev/lions/unionflow/server/resource/ConversationResourceTest.java +++ /dev/null @@ -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); - } -} diff --git a/src/test/java/dev/lions/unionflow/server/resource/MessageResourceTest.java b/src/test/java/dev/lions/unionflow/server/resource/MessageResourceTest.java deleted file mode 100644 index d5a2793..0000000 --- a/src/test/java/dev/lions/unionflow/server/resource/MessageResourceTest.java +++ /dev/null @@ -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); - } -} diff --git a/src/test/java/dev/lions/unionflow/server/service/ConversationServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/ConversationServiceTest.java deleted file mode 100644 index fe1d93d..0000000 --- a/src/test/java/dev/lions/unionflow/server/service/ConversationServiceTest.java +++ /dev/null @@ -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 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 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 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(); - } -} diff --git a/src/test/java/dev/lions/unionflow/server/service/MessageServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/MessageServiceTest.java deleted file mode 100644 index c934f91..0000000 --- a/src/test/java/dev/lions/unionflow/server/service/MessageServiceTest.java +++ /dev/null @@ -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 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 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 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 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 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 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 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 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 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 result = messageService.getMessages(conv.getId(), membreId, 20); - - assertThat(result.get(0).getAttachments()).isNull(); - } -}