feat(messaging): module messagerie unifié avec contact policies + member blocks
Refactor complet : fusion de Conversation + Message en un module Messaging unique avec ContactPolicy (règles qui-peut-parler-à-qui) et MemberBlock (blocages utilisateur). - Migration V28 : tables conversations/conversation_participants/messages/ contact_policies/member_blocks - Nouvelles entités : ContactPolicy, ConversationParticipant, MemberBlock (Conversation/Message mises à jour avec relations) - Nouvelles repositories : ContactPolicyRepository, ConversationParticipantRepository, MemberBlockRepository - MessagingResource (nouveau) remplace ConversationResource + MessageResource - MessagingService (nouveau) remplace ConversationService + MessageService avec vérifications appartenance org + policies + blocages avant envoi - Anciens fichiers Conversation/Message Resource/Service/Tests supprimés
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package dev.lions.unionflow.server.entity;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.messagerie.TypePolitiqueCommunication;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Politique de communication d'une organisation.
|
||||
*
|
||||
* <p>Chaque organisation possède exactement une politique, créée automatiquement
|
||||
* lors de la création de l'organisation avec les valeurs par défaut.
|
||||
* L'administrateur peut la modifier via l'API.
|
||||
*
|
||||
* <p>Table : {@code contact_policies}
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 4.0
|
||||
* @since 2026-04-13
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "contact_policies",
|
||||
indexes = {
|
||||
@Index(name = "idx_contact_policies_org", columnList = "organisation_id")
|
||||
},
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_contact_policy_org", columnNames = "organisation_id")
|
||||
}
|
||||
)
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ContactPolicy extends BaseEntity {
|
||||
|
||||
@NotNull
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "organisation_id", nullable = false)
|
||||
private Organisation organisation;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Builder.Default
|
||||
@Column(name = "type_politique", nullable = false, length = 30)
|
||||
private TypePolitiqueCommunication typePolitique = TypePolitiqueCommunication.OUVERT;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "autoriser_membre_vers_membre", nullable = false)
|
||||
private Boolean autoriserMembreVersMembre = Boolean.TRUE;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "autoriser_membre_vers_role", nullable = false)
|
||||
private Boolean autoriserMembreVersRole = Boolean.TRUE;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "autoriser_notes_vocales", nullable = false)
|
||||
private Boolean autoriserNotesVocales = Boolean.TRUE;
|
||||
|
||||
@PrePersist
|
||||
@Override
|
||||
protected void onCreate() {
|
||||
super.onCreate();
|
||||
if (typePolitique == null) {
|
||||
typePolitique = TypePolitiqueCommunication.OUVERT;
|
||||
}
|
||||
if (autoriserMembreVersMembre == null) {
|
||||
autoriserMembreVersMembre = Boolean.TRUE;
|
||||
}
|
||||
if (autoriserMembreVersRole == null) {
|
||||
autoriserMembreVersRole = Boolean.TRUE;
|
||||
}
|
||||
if (autoriserNotesVocales == null) {
|
||||
autoriserNotesVocales = Boolean.TRUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +1,129 @@
|
||||
package dev.lions.unionflow.server.entity;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.communication.ConversationType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
|
||||
import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation;
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Entité Conversation pour le système de messagerie UnionFlow.
|
||||
* Représente un fil de discussion entre membres.
|
||||
* Fil de discussion entre membres d'une organisation.
|
||||
*
|
||||
* <p>Deux types sont supportés en V1 :
|
||||
* <ul>
|
||||
* <li>{@link TypeConversation#DIRECTE} — 1-1 entre deux membres</li>
|
||||
* <li>{@link TypeConversation#ROLE_CANAL} — membre vers un rôle officiel
|
||||
* (PRESIDENT, TRESORIER, SECRETAIRE…). Tous les porteurs du rôle répondent.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Table : {@code conversations}
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 1.0
|
||||
* @since 2026-03-16
|
||||
* @version 4.0
|
||||
* @since 2026-04-13
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "conversations", indexes = {
|
||||
@Index(name = "idx_conversation_organisation", columnList = "organisation_id"),
|
||||
@Index(name = "idx_conversation_type", columnList = "type"),
|
||||
@Index(name = "idx_conversation_archived", columnList = "is_archived"),
|
||||
@Index(name = "idx_conversation_created", columnList = "date_creation")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@Table(
|
||||
name = "conversations",
|
||||
indexes = {
|
||||
@Index(name = "idx_conversations_organisation", columnList = "organisation_id"),
|
||||
@Index(name = "idx_conversations_statut", columnList = "statut"),
|
||||
@Index(name = "idx_conversations_dernier_msg", columnList = "dernier_message_at")
|
||||
}
|
||||
)
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Conversation extends BaseEntity {
|
||||
|
||||
/**
|
||||
* Nom de la conversation
|
||||
*/
|
||||
@Column(name = "name", nullable = false, length = 255)
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* Description optionnelle
|
||||
*/
|
||||
@Column(name = "description", length = 1000)
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* Type de conversation (INDIVIDUAL, GROUP, BROADCAST, ANNOUNCEMENT)
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "type", nullable = false, length = 20)
|
||||
private ConversationType type;
|
||||
|
||||
/**
|
||||
* Organisation associée (optionnelle)
|
||||
*/
|
||||
@NotNull
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "organisation_id")
|
||||
@JoinColumn(name = "organisation_id", nullable = false)
|
||||
private Organisation organisation;
|
||||
|
||||
/**
|
||||
* URL de l'avatar de la conversation
|
||||
*/
|
||||
@Column(name = "avatar_url", length = 500)
|
||||
private String avatarUrl;
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "type_conversation", nullable = false, length = 30)
|
||||
private TypeConversation typeConversation;
|
||||
|
||||
/**
|
||||
* Conversation muette
|
||||
* Rôle cible pour les ROLE_CANAL (ex : "TRESORIER", "PRESIDENT").
|
||||
* Null pour les conversations DIRECTE.
|
||||
*/
|
||||
@Column(name = "is_muted", nullable = false)
|
||||
private Boolean isMuted = false;
|
||||
@Column(name = "role_cible", length = 50)
|
||||
private String roleCible;
|
||||
|
||||
/**
|
||||
* Conversation épinglée
|
||||
*/
|
||||
@Column(name = "is_pinned", nullable = false)
|
||||
private Boolean isPinned = false;
|
||||
/** Titre affiché (nom du rôle ou du groupe, null pour DIRECTE). */
|
||||
@Column(name = "titre", length = 200)
|
||||
private String titre;
|
||||
|
||||
/**
|
||||
* Conversation archivée
|
||||
*/
|
||||
@Column(name = "is_archived", nullable = false)
|
||||
private Boolean isArchived = false;
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Builder.Default
|
||||
@Column(name = "statut", nullable = false, length = 20)
|
||||
private StatutConversation statut = StatutConversation.ACTIVE;
|
||||
|
||||
/**
|
||||
* Métadonnées additionnelles (JSON)
|
||||
*/
|
||||
@Column(name = "metadata", columnDefinition = "TEXT")
|
||||
private String metadata;
|
||||
@Column(name = "dernier_message_at")
|
||||
private LocalDateTime dernierMessageAt;
|
||||
|
||||
/**
|
||||
* Date de dernière mise à jour
|
||||
*/
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
@Builder.Default
|
||||
@Column(name = "nombre_messages", nullable = false)
|
||||
private Integer nombreMessages = 0;
|
||||
|
||||
/**
|
||||
* Participants de la conversation (many-to-many)
|
||||
*/
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(
|
||||
name = "conversation_participants",
|
||||
joinColumns = @JoinColumn(name = "conversation_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "membre_id")
|
||||
)
|
||||
private List<Membre> participants = new ArrayList<>();
|
||||
@Builder.Default
|
||||
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
private List<ConversationParticipant> participants = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Messages de la conversation (one-to-many)
|
||||
*/
|
||||
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@Builder.Default
|
||||
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
private List<Message> messages = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Met à jour le timestamp
|
||||
*/
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
@PrePersist
|
||||
@Override
|
||||
protected void onCreate() {
|
||||
super.onCreate();
|
||||
if (statut == null) {
|
||||
statut = StatutConversation.ACTIVE;
|
||||
}
|
||||
if (nombreMessages == null) {
|
||||
nombreMessages = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Méthodes métier ───────────────────────────────────────────────────────
|
||||
|
||||
/** Retourne true si la conversation accepte encore de nouveaux messages. */
|
||||
public boolean estActive() {
|
||||
return StatutConversation.ACTIVE.equals(statut);
|
||||
}
|
||||
|
||||
/** Archive la conversation — plus aucun message n'est accepté. */
|
||||
public void archiver() {
|
||||
this.statut = StatutConversation.ARCHIVEE;
|
||||
}
|
||||
|
||||
/** Incrémente le compteur et met à jour l'horodatage du dernier message. */
|
||||
public void enregistrerNouveauMessage() {
|
||||
this.nombreMessages = (this.nombreMessages == null ? 0 : this.nombreMessages) + 1;
|
||||
this.dernierMessageAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package dev.lions.unionflow.server.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Participation d'un membre à une conversation.
|
||||
*
|
||||
* <p>Stocke l'état de lecture individuel ({@code luJusqua}) et
|
||||
* les préférences de notification du participant.
|
||||
*
|
||||
* <p>Table : {@code conversation_participants}
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 4.0
|
||||
* @since 2026-04-13
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "conversation_participants",
|
||||
indexes = {
|
||||
@Index(name = "idx_conv_part_conversation", columnList = "conversation_id"),
|
||||
@Index(name = "idx_conv_part_membre", columnList = "membre_id")
|
||||
},
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_conv_participant",
|
||||
columnNames = {"conversation_id", "membre_id"})
|
||||
}
|
||||
)
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ConversationParticipant extends BaseEntity {
|
||||
|
||||
@NotNull
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "conversation_id", nullable = false)
|
||||
private Conversation conversation;
|
||||
|
||||
@NotNull
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "membre_id", nullable = false)
|
||||
private Membre membre;
|
||||
|
||||
/**
|
||||
* Rôle de ce participant dans la conversation.
|
||||
* Ex : INITIATEUR, PARTICIPANT, MODERATEUR.
|
||||
*/
|
||||
@Builder.Default
|
||||
@Column(name = "role_dans_conversation", length = 50)
|
||||
private String roleDansConversation = "PARTICIPANT";
|
||||
|
||||
/**
|
||||
* Horodatage du dernier message lu.
|
||||
* Permet de calculer le nombre de messages non lus.
|
||||
*/
|
||||
@Column(name = "lu_jusqu_a")
|
||||
private LocalDateTime luJusqua;
|
||||
|
||||
/** Si false, ce participant ne reçoit plus de notifications pour cette conversation. */
|
||||
@Builder.Default
|
||||
@Column(name = "notifier", nullable = false)
|
||||
private Boolean notifier = Boolean.TRUE;
|
||||
|
||||
// ── Méthodes métier ───────────────────────────────────────────────────────
|
||||
|
||||
/** Marque tous les messages jusqu'à maintenant comme lus. */
|
||||
public void marquerLu() {
|
||||
this.luJusqua = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/** Retourne true si ce participant est l'initiateur de la conversation. */
|
||||
public boolean estInitiateur() {
|
||||
return "INITIATEUR".equals(roleDansConversation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package dev.lions.unionflow.server.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Blocage unilatéral entre deux membres au sein d'une organisation.
|
||||
*
|
||||
* <p>Un membre bloqué ne peut plus envoyer de messages au bloqueur.
|
||||
* Le blocage est limité à une organisation (un membre bloqué dans l'asso X
|
||||
* peut encore écrire dans la tontine Y).
|
||||
*
|
||||
* <p>Table : {@code member_blocks}
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 4.0
|
||||
* @since 2026-04-13
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "member_blocks",
|
||||
indexes = {
|
||||
@Index(name = "idx_member_blocks_bloqueur", columnList = "bloqueur_id"),
|
||||
@Index(name = "idx_member_blocks_bloque", columnList = "bloque_id, organisation_id")
|
||||
},
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_member_block",
|
||||
columnNames = {"bloqueur_id", "bloque_id", "organisation_id"})
|
||||
}
|
||||
)
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class MemberBlock extends BaseEntity {
|
||||
|
||||
/** Membre qui effectue le blocage */
|
||||
@NotNull
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "bloqueur_id", nullable = false)
|
||||
private Membre bloqueur;
|
||||
|
||||
/** Membre qui est bloqué */
|
||||
@NotNull
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "bloque_id", nullable = false)
|
||||
private Membre bloque;
|
||||
|
||||
/** Organisation dans laquelle le blocage est actif */
|
||||
@NotNull
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "organisation_id", nullable = false)
|
||||
private Organisation organisation;
|
||||
}
|
||||
@@ -1,156 +1,140 @@
|
||||
package dev.lions.unionflow.server.entity;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessagePriority;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessageType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.messagerie.TypeContenu;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Entité Message pour le système de messagerie UnionFlow.
|
||||
* Représente un message individuel dans une conversation.
|
||||
* Message envoyé dans une conversation.
|
||||
*
|
||||
* <p>Supporte trois types de contenu :
|
||||
* <ul>
|
||||
* <li>{@link TypeContenu#TEXTE} — message texte classique</li>
|
||||
* <li>{@link TypeContenu#VOCAL} — note vocale (Opus/AAC), stockée sur object storage.
|
||||
* Champs {@code urlFichier} + {@code dureeAudio} obligatoires.</li>
|
||||
* <li>{@link TypeContenu#IMAGE} — image JPEG/PNG. Champ {@code urlFichier} obligatoire.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>La suppression est douce : {@code supprimeLe} est renseigné au lieu de
|
||||
* supprimer la ligne. Le contenu devient {@code "[Message supprimé]"}.
|
||||
*
|
||||
* <p>Table : {@code messages}
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 1.0
|
||||
* @since 2026-03-16
|
||||
* @version 4.0
|
||||
* @since 2026-04-13
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "messages", indexes = {
|
||||
@Index(name = "idx_message_conversation", columnList = "conversation_id"),
|
||||
@Index(name = "idx_message_sender", columnList = "sender_id"),
|
||||
@Index(name = "idx_message_organisation", columnList = "organisation_id"),
|
||||
@Index(name = "idx_message_status", columnList = "status"),
|
||||
@Index(name = "idx_message_created", columnList = "date_creation"),
|
||||
@Index(name = "idx_message_deleted", columnList = "is_deleted")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@Table(
|
||||
name = "messages",
|
||||
indexes = {
|
||||
@Index(name = "idx_messages_conversation", columnList = "conversation_id"),
|
||||
@Index(name = "idx_messages_expediteur", columnList = "expediteur_id"),
|
||||
@Index(name = "idx_messages_date_creation", columnList = "date_creation"),
|
||||
@Index(name = "idx_messages_parent", columnList = "message_parent_id")
|
||||
}
|
||||
)
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class Message extends BaseEntity {
|
||||
|
||||
/**
|
||||
* Conversation parente
|
||||
*/
|
||||
@NotNull
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "conversation_id", nullable = false)
|
||||
private Conversation conversation;
|
||||
|
||||
/**
|
||||
* Expéditeur du message
|
||||
*/
|
||||
@NotNull
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "sender_id", nullable = false)
|
||||
private Membre sender;
|
||||
@JoinColumn(name = "expediteur_id", nullable = false)
|
||||
private Membre expediteur;
|
||||
|
||||
/**
|
||||
* Nom de l'expéditeur (dénormalisé pour performance)
|
||||
*/
|
||||
@Column(name = "sender_name", nullable = false, length = 255)
|
||||
private String senderName;
|
||||
|
||||
/**
|
||||
* Avatar de l'expéditeur (dénormalisé)
|
||||
*/
|
||||
@Column(name = "sender_avatar", length = 500)
|
||||
private String senderAvatar;
|
||||
|
||||
/**
|
||||
* Contenu du message
|
||||
*/
|
||||
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* Type de message (INDIVIDUAL, BROADCAST, TARGETED, SYSTEM)
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "type", nullable = false, length = 20)
|
||||
private MessageType type;
|
||||
@Builder.Default
|
||||
@Column(name = "type_message", nullable = false, length = 20)
|
||||
private TypeContenu typeMessage = TypeContenu.TEXTE;
|
||||
|
||||
/** Texte du message — null pour les vocaux/images. */
|
||||
@Column(name = "contenu", columnDefinition = "TEXT")
|
||||
private String contenu;
|
||||
|
||||
/**
|
||||
* Statut du message (SENT, DELIVERED, READ, FAILED)
|
||||
* URL du fichier audio (notes vocales) ou image.
|
||||
* Format : https://storage.lions.dev/chat/{conversationId}/{messageId}.opus
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private MessageStatus status;
|
||||
@Column(name = "url_fichier", length = 500)
|
||||
private String urlFichier;
|
||||
|
||||
/** Durée en secondes pour les notes vocales. */
|
||||
@Column(name = "duree_audio")
|
||||
private Integer dureeAudio;
|
||||
|
||||
/**
|
||||
* Priorité du message (NORMAL, HIGH, URGENT)
|
||||
* Transcription automatique du vocal — null en V1.
|
||||
* Sera renseigné par un service Speech-to-Text en V2.
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "priority", nullable = false, length = 20)
|
||||
private MessagePriority priority = MessagePriority.NORMAL;
|
||||
@Column(name = "transcription", columnDefinition = "TEXT")
|
||||
private String transcription;
|
||||
|
||||
/**
|
||||
* IDs des destinataires (CSV pour targeted messages)
|
||||
*/
|
||||
@Column(name = "recipient_ids", length = 2000)
|
||||
private String recipientIds;
|
||||
|
||||
/**
|
||||
* Rôles destinataires (CSV pour role-based messaging)
|
||||
*/
|
||||
@Column(name = "recipient_roles", length = 500)
|
||||
private String recipientRoles;
|
||||
|
||||
/**
|
||||
* Organisation associée (optionnelle)
|
||||
*/
|
||||
/** Message auquel celui-ci répond (threading léger). */
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "organisation_id")
|
||||
private Organisation organisation;
|
||||
@JoinColumn(name = "message_parent_id")
|
||||
private Message messageParent;
|
||||
|
||||
/**
|
||||
* Date de lecture du message
|
||||
*/
|
||||
@Column(name = "read_at")
|
||||
private LocalDateTime readAt;
|
||||
/** Date de suppression douce (null = message actif). */
|
||||
@Column(name = "supprime_le")
|
||||
private LocalDateTime supprimeLe;
|
||||
|
||||
/**
|
||||
* Métadonnées additionnelles (JSON)
|
||||
*/
|
||||
@Column(name = "metadata", columnDefinition = "TEXT")
|
||||
private String metadata;
|
||||
@PrePersist
|
||||
@Override
|
||||
protected void onCreate() {
|
||||
super.onCreate();
|
||||
if (typeMessage == null) {
|
||||
typeMessage = TypeContenu.TEXTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pièces jointes (CSV URLs)
|
||||
*/
|
||||
@Column(name = "attachments", length = 2000)
|
||||
private String attachments;
|
||||
// ── Méthodes métier ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Message édité
|
||||
*/
|
||||
@Column(name = "is_edited", nullable = false)
|
||||
private Boolean isEdited = false;
|
||||
/** Retourne true si le message a été supprimé par son auteur. */
|
||||
public boolean estSupprime() {
|
||||
return supprimeLe != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date d'édition
|
||||
*/
|
||||
@Column(name = "edited_at")
|
||||
private LocalDateTime editedAt;
|
||||
/** Retourne true si c'est un message texte. */
|
||||
public boolean estTextuel() {
|
||||
return TypeContenu.TEXTE.equals(typeMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Message supprimé (soft delete)
|
||||
*/
|
||||
@Column(name = "is_deleted", nullable = false)
|
||||
private Boolean isDeleted = false;
|
||||
|
||||
/**
|
||||
* Marque le message comme lu
|
||||
*/
|
||||
public void markAsRead() {
|
||||
this.status = MessageStatus.READ;
|
||||
this.readAt = LocalDateTime.now();
|
||||
/** Retourne true si c'est une note vocale. */
|
||||
public boolean estVocal() {
|
||||
return TypeContenu.VOCAL.equals(typeMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque le message comme édité
|
||||
* Supprime le message de façon douce.
|
||||
* Le contenu original est remplacé par un marqueur.
|
||||
*/
|
||||
public void markAsEdited() {
|
||||
this.isEdited = true;
|
||||
this.editedAt = LocalDateTime.now();
|
||||
public void supprimer() {
|
||||
this.supprimeLe = LocalDateTime.now();
|
||||
this.contenu = "[Message supprimé]";
|
||||
this.urlFichier = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package dev.lions.unionflow.server.repository;
|
||||
|
||||
import dev.lions.unionflow.server.entity.ContactPolicy;
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository pour les politiques de communication des organisations.
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 4.0
|
||||
* @since 2026-04-13
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class ContactPolicyRepository implements PanacheRepositoryBase<ContactPolicy, UUID> {
|
||||
|
||||
/**
|
||||
* Trouve la politique de communication d'une organisation.
|
||||
* Chaque organisation a exactement une politique.
|
||||
*/
|
||||
public Optional<ContactPolicy> findByOrganisationId(UUID organisationId) {
|
||||
return find("organisation.id = ?1 AND actif = true", organisationId)
|
||||
.firstResultOptional();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package dev.lions.unionflow.server.repository;
|
||||
|
||||
import dev.lions.unionflow.server.entity.ConversationParticipant;
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository pour les participants aux conversations.
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 4.0
|
||||
* @since 2026-04-13
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class ConversationParticipantRepository
|
||||
implements PanacheRepositoryBase<ConversationParticipant, UUID> {
|
||||
|
||||
/**
|
||||
* Trouve la participation d'un membre à une conversation.
|
||||
*/
|
||||
public Optional<ConversationParticipant> findParticipant(UUID conversationId, UUID membreId) {
|
||||
return find("conversation.id = ?1 AND membre.id = ?2 AND actif = true",
|
||||
conversationId, membreId
|
||||
).firstResultOptional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les participants actifs d'une conversation.
|
||||
*/
|
||||
public List<ConversationParticipant> findByConversation(UUID conversationId) {
|
||||
return find("conversation.id = ?1 AND actif = true", conversationId).list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un membre est participant à une conversation.
|
||||
*/
|
||||
public boolean estParticipant(UUID conversationId, UUID membreId) {
|
||||
return count("conversation.id = ?1 AND membre.id = ?2 AND actif = true",
|
||||
conversationId, membreId) > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,80 @@
|
||||
package dev.lions.unionflow.server.repository;
|
||||
|
||||
import dev.lions.unionflow.server.entity.Conversation;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository pour Conversation
|
||||
* Repository pour les conversations de la messagerie.
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 1.0
|
||||
* @since 2026-03-16
|
||||
* @version 4.0
|
||||
* @since 2026-04-13
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class ConversationRepository implements PanacheRepositoryBase<Conversation, UUID> {
|
||||
|
||||
/**
|
||||
* Trouve toutes les conversations d'un membre
|
||||
* Trouve une conversation par son ID avec Optional.
|
||||
*/
|
||||
public List<Conversation> findByParticipant(UUID membreId, boolean includeArchived) {
|
||||
String query = """
|
||||
SELECT DISTINCT c FROM Conversation c
|
||||
JOIN c.participants p
|
||||
WHERE p.id = :membreId
|
||||
AND (c.actif IS NULL OR c.actif = true)
|
||||
""";
|
||||
|
||||
if (!includeArchived) {
|
||||
query += " AND c.isArchived = false";
|
||||
}
|
||||
|
||||
query += " ORDER BY c.updatedAt DESC NULLS LAST, c.dateCreation DESC";
|
||||
|
||||
return getEntityManager()
|
||||
.createQuery(query, Conversation.class)
|
||||
.setParameter("membreId", membreId)
|
||||
.getResultList();
|
||||
public Optional<Conversation> findConversationById(UUID id) {
|
||||
return find("id", id).firstResultOptional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve une conversation par ID et vérifie que le membre en fait partie
|
||||
* Liste toutes les conversations d'un membre (via les participants).
|
||||
* Triées par date du dernier message décroissante.
|
||||
*/
|
||||
public Optional<Conversation> findByIdAndParticipant(UUID conversationId, UUID membreId) {
|
||||
String query = """
|
||||
SELECT DISTINCT c FROM Conversation c
|
||||
JOIN c.participants p
|
||||
WHERE c.id = :conversationId
|
||||
AND p.id = :membreId
|
||||
AND (c.actif IS NULL OR c.actif = true)
|
||||
""";
|
||||
|
||||
return getEntityManager()
|
||||
.createQuery(query, Conversation.class)
|
||||
.setParameter("conversationId", conversationId)
|
||||
.setParameter("membreId", membreId)
|
||||
.getResultStream()
|
||||
.findFirst();
|
||||
public List<Conversation> findByMembreId(UUID membreId) {
|
||||
return find(
|
||||
"SELECT DISTINCT c FROM Conversation c " +
|
||||
"JOIN c.participants p " +
|
||||
"WHERE p.membre.id = ?1 AND p.actif = true " +
|
||||
"ORDER BY c.dernierMessageAt DESC NULLS LAST",
|
||||
membreId
|
||||
).list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve les conversations d'une organisation
|
||||
* Trouve une conversation directe existante entre deux membres dans une organisation.
|
||||
*/
|
||||
public List<Conversation> findByOrganisation(UUID organisationId) {
|
||||
return find("organisation.id = ?1 AND (actif IS NULL OR actif = true) ORDER BY updatedAt DESC NULLS LAST", organisationId)
|
||||
.list();
|
||||
public Optional<Conversation> findConversationDirecte(UUID membreAId, UUID membreBId, UUID organisationId) {
|
||||
return find(
|
||||
"SELECT DISTINCT c FROM Conversation c " +
|
||||
"JOIN c.participants p1 " +
|
||||
"JOIN c.participants p2 " +
|
||||
"WHERE c.typeConversation = 'DIRECTE' " +
|
||||
"AND c.organisation.id = ?3 " +
|
||||
"AND p1.membre.id = ?1 AND p1.actif = true " +
|
||||
"AND p2.membre.id = ?2 AND p2.actif = true",
|
||||
membreAId, membreBId, organisationId
|
||||
).firstResultOptional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le canal d'un rôle dans une organisation.
|
||||
*/
|
||||
public Optional<Conversation> findCanalRole(UUID organisationId, String roleCible) {
|
||||
return find(
|
||||
"organisation.id = ?1 AND roleCible = ?2 AND typeConversation = 'ROLE_CANAL' AND actif = true",
|
||||
organisationId, roleCible
|
||||
).firstResultOptional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les conversations actives d'un membre.
|
||||
*/
|
||||
public List<Conversation> findActivesByMembre(UUID membreId) {
|
||||
return find(
|
||||
"SELECT DISTINCT c FROM Conversation c " +
|
||||
"JOIN c.participants p " +
|
||||
"WHERE p.membre.id = ?1 AND p.actif = true AND c.statut = ?2 " +
|
||||
"ORDER BY c.dernierMessageAt DESC NULLS LAST",
|
||||
membreId, StatutConversation.ACTIVE
|
||||
).list();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package dev.lions.unionflow.server.repository;
|
||||
|
||||
import dev.lions.unionflow.server.entity.MemberBlock;
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository pour les blocages entre membres.
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 4.0
|
||||
* @since 2026-04-13
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class MemberBlockRepository implements PanacheRepositoryBase<MemberBlock, UUID> {
|
||||
|
||||
/**
|
||||
* Vérifie si un membre en a bloqué un autre dans une organisation.
|
||||
*/
|
||||
public boolean estBloque(UUID bloqueurId, UUID bloqueId, UUID organisationId) {
|
||||
return count("bloqueur.id = ?1 AND bloque.id = ?2 AND organisation.id = ?3 AND actif = true",
|
||||
bloqueurId, bloqueId, organisationId) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le blocage entre deux membres dans une organisation.
|
||||
*/
|
||||
public Optional<MemberBlock> findBlocage(UUID bloqueurId, UUID bloqueId, UUID organisationId) {
|
||||
return find("bloqueur.id = ?1 AND bloque.id = ?2 AND organisation.id = ?3 AND actif = true",
|
||||
bloqueurId, bloqueId, organisationId
|
||||
).firstResultOptional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les membres bloqués par un membre dans toutes ses organisations.
|
||||
*/
|
||||
public List<MemberBlock> findByBloqueur(UUID bloqueurId) {
|
||||
return find("bloqueur.id = ?1 AND actif = true", bloqueurId).list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les blocages actifs d'un membre dans une organisation spécifique.
|
||||
*/
|
||||
public List<MemberBlock> findByBloqueurEtOrganisation(UUID bloqueurId, UUID organisationId) {
|
||||
return find("bloqueur.id = ?1 AND organisation.id = ?2 AND actif = true",
|
||||
bloqueurId, organisationId).list();
|
||||
}
|
||||
}
|
||||
@@ -2,65 +2,77 @@ package dev.lions.unionflow.server.repository;
|
||||
|
||||
import dev.lions.unionflow.server.entity.Message;
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||
import io.quarkus.panache.common.Page;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository pour Message
|
||||
* Repository pour les messages de la messagerie.
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 1.0
|
||||
* @since 2026-03-16
|
||||
* @version 4.0
|
||||
* @since 2026-04-13
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class MessageRepository implements PanacheRepositoryBase<Message, UUID> {
|
||||
|
||||
/**
|
||||
* Trouve tous les messages d'une conversation (non supprimés)
|
||||
* Trouve un message par son ID avec Optional.
|
||||
*/
|
||||
public List<Message> findByConversation(UUID conversationId, int limit) {
|
||||
return find(
|
||||
"conversation.id = ?1 AND isDeleted = false AND (actif IS NULL OR actif = true) ORDER BY dateCreation DESC",
|
||||
conversationId
|
||||
)
|
||||
.page(0, limit)
|
||||
.list();
|
||||
public Optional<Message> findMessageById(UUID id) {
|
||||
return find("id", id).firstResultOptional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte les messages non lus d'une conversation pour un membre
|
||||
* Récupère les messages d'une conversation, paginés, du plus récent au plus ancien.
|
||||
*
|
||||
* @param conversationId ID de la conversation
|
||||
* @param page numéro de page (0-based)
|
||||
* @param size nombre de messages par page
|
||||
*/
|
||||
public long countUnreadByConversationAndMember(UUID conversationId, UUID membreId) {
|
||||
// Pour simplifier, on compte les messages SENT/DELIVERED (pas READ)
|
||||
// et dont le sender n'est PAS le membre en question
|
||||
public List<Message> findByConversationPagine(UUID conversationId, int page, int size) {
|
||||
return find(
|
||||
"conversation.id = ?1 AND actif = true ORDER BY dateCreation DESC",
|
||||
conversationId
|
||||
).page(Page.of(page, size)).list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte les messages non lus dans une conversation pour un membre donné.
|
||||
* Un message est non lu si sa dateCreation est postérieure au luJusqua du participant.
|
||||
*/
|
||||
public long countNonLus(UUID conversationId, UUID membreId) {
|
||||
return count(
|
||||
"conversation.id = ?1 AND sender.id != ?2 AND status IN ('SENT', 'DELIVERED') AND isDeleted = false",
|
||||
conversationId,
|
||||
membreId
|
||||
"SELECT COUNT(m) FROM Message m, ConversationParticipant p " +
|
||||
"WHERE m.conversation.id = ?1 " +
|
||||
"AND p.conversation.id = ?1 " +
|
||||
"AND p.membre.id = ?2 " +
|
||||
"AND m.actif = true " +
|
||||
"AND (p.luJusqua IS NULL OR m.dateCreation > p.luJusqua) " +
|
||||
"AND m.expediteur.id <> ?2",
|
||||
conversationId, membreId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque tous les messages d'une conversation comme lus pour un membre
|
||||
* Récupère les messages non supprimés d'une conversation (pour les tests).
|
||||
*/
|
||||
public int markAllAsReadByConversationAndMember(UUID conversationId, UUID membreId) {
|
||||
return update(
|
||||
"status = 'READ', readAt = CURRENT_TIMESTAMP WHERE conversation.id = ?1 AND sender.id != ?2 AND status != 'READ' AND isDeleted = false",
|
||||
conversationId,
|
||||
membreId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le dernier message d'une conversation
|
||||
*/
|
||||
public Message findLastByConversation(UUID conversationId) {
|
||||
public List<Message> findActifsByConversation(UUID conversationId) {
|
||||
return find(
|
||||
"conversation.id = ?1 AND isDeleted = false ORDER BY dateCreation DESC",
|
||||
"conversation.id = ?1 AND actif = true ORDER BY dateCreation ASC",
|
||||
conversationId
|
||||
)
|
||||
.firstResult();
|
||||
).list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le dernier message actif d'une conversation.
|
||||
*/
|
||||
public Optional<Message> findDernierMessage(UUID conversationId) {
|
||||
return find(
|
||||
"conversation.id = ?1 AND actif = true ORDER BY dateCreation DESC",
|
||||
conversationId
|
||||
).firstResultOptional();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.communication.request.CreateConversationRequest;
|
||||
import dev.lions.unionflow.server.api.dto.communication.response.ConversationResponse;
|
||||
import dev.lions.unionflow.server.service.ConversationService;
|
||||
import dev.lions.unionflow.server.service.support.SecuriteHelper;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
||||
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Resource REST pour la gestion des conversations
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 1.0
|
||||
* @since 2026-03-16
|
||||
*/
|
||||
@Path("/api/conversations")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "Communication", description = "Gestion des conversations et messages")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "MEMBRE", "ADMIN_ORGANISATION"})
|
||||
public class ConversationResource {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(ConversationResource.class);
|
||||
|
||||
@Inject
|
||||
ConversationService conversationService;
|
||||
|
||||
@Inject
|
||||
SecuriteHelper securiteHelper;
|
||||
|
||||
/**
|
||||
* Liste les conversations de l'utilisateur connecté
|
||||
*/
|
||||
@GET
|
||||
@Operation(summary = "Lister mes conversations")
|
||||
@APIResponse(responseCode = "200", description = "Liste des conversations")
|
||||
public Response getConversations(
|
||||
@Parameter(description = "Inclure conversations archivées")
|
||||
@QueryParam("includeArchived") @DefaultValue("false") boolean includeArchived,
|
||||
@Parameter(description = "Filtrer par organisation")
|
||||
@QueryParam("organisationId") String organisationId
|
||||
) {
|
||||
UUID membreId = securiteHelper.resolveMembreId();
|
||||
UUID orgId = organisationId != null ? UUID.fromString(organisationId) : null;
|
||||
|
||||
List<ConversationResponse> conversations = conversationService.getConversations(membreId, orgId, includeArchived);
|
||||
return Response.ok(conversations).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une conversation par ID
|
||||
*/
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@Operation(summary = "Récupérer une conversation")
|
||||
@APIResponse(responseCode = "200", description = "Conversation trouvée")
|
||||
@APIResponse(responseCode = "404", description = "Conversation non trouvée")
|
||||
public Response getConversationById(@PathParam("id") UUID conversationId) {
|
||||
UUID membreId = securiteHelper.resolveMembreId();
|
||||
ConversationResponse conversation = conversationService.getConversationById(conversationId, membreId);
|
||||
return Response.ok(conversation).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une nouvelle conversation
|
||||
*/
|
||||
@POST
|
||||
@Operation(summary = "Créer une conversation")
|
||||
@APIResponse(responseCode = "201", description = "Conversation créée")
|
||||
@APIResponse(responseCode = "400", description = "Données invalides")
|
||||
public Response createConversation(@Valid CreateConversationRequest request) {
|
||||
UUID creatorId = securiteHelper.resolveMembreId();
|
||||
ConversationResponse conversation = conversationService.createConversation(request, creatorId);
|
||||
return Response.status(Response.Status.CREATED).entity(conversation).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive une conversation
|
||||
*/
|
||||
@PUT
|
||||
@Path("/{id}/archive")
|
||||
@Operation(summary = "Archiver/désarchiver une conversation")
|
||||
@APIResponse(responseCode = "204", description = "Conversation archivée")
|
||||
public Response archiveConversation(
|
||||
@PathParam("id") UUID conversationId,
|
||||
@QueryParam("archive") @DefaultValue("true") boolean archive
|
||||
) {
|
||||
UUID membreId = securiteHelper.resolveMembreId();
|
||||
conversationService.archiveConversation(conversationId, membreId, archive);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque une conversation comme lue
|
||||
*/
|
||||
@PUT
|
||||
@Path("/{id}/mark-read")
|
||||
@Operation(summary = "Marquer conversation comme lue")
|
||||
@APIResponse(responseCode = "204", description = "Marquée comme lue")
|
||||
public Response markAsRead(@PathParam("id") UUID conversationId) {
|
||||
UUID membreId = securiteHelper.resolveMembreId();
|
||||
conversationService.markAsRead(conversationId, membreId);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mute conversation
|
||||
*/
|
||||
@PUT
|
||||
@Path("/{id}/toggle-mute")
|
||||
@Operation(summary = "Activer/désactiver le son")
|
||||
@APIResponse(responseCode = "204", description = "Paramètre modifié")
|
||||
public Response toggleMute(@PathParam("id") UUID conversationId) {
|
||||
UUID membreId = securiteHelper.resolveMembreId();
|
||||
conversationService.toggleMute(conversationId, membreId);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pin conversation
|
||||
*/
|
||||
@PUT
|
||||
@Path("/{id}/toggle-pin")
|
||||
@Operation(summary = "Épingler/désépingler")
|
||||
@APIResponse(responseCode = "204", description = "Paramètre modifié")
|
||||
public Response togglePin(@PathParam("id") UUID conversationId) {
|
||||
UUID membreId = securiteHelper.resolveMembreId();
|
||||
conversationService.togglePin(conversationId, membreId);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.communication.request.SendMessageRequest;
|
||||
import dev.lions.unionflow.server.api.dto.communication.response.MessageResponse;
|
||||
import dev.lions.unionflow.server.service.MessageService;
|
||||
import dev.lions.unionflow.server.service.support.SecuriteHelper;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
||||
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Resource REST pour la gestion des messages
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 1.0
|
||||
* @since 2026-03-16
|
||||
*/
|
||||
@Path("/api/messages")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "Communication", description = "Gestion des conversations et messages")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "MEMBRE", "ADMIN_ORGANISATION"})
|
||||
public class MessageResource {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(MessageResource.class);
|
||||
|
||||
@Inject
|
||||
MessageService messageService;
|
||||
|
||||
@Inject
|
||||
SecuriteHelper securiteHelper;
|
||||
|
||||
/**
|
||||
* Récupère les messages d'une conversation
|
||||
*/
|
||||
@GET
|
||||
@Operation(summary = "Lister les messages d'une conversation")
|
||||
@APIResponse(responseCode = "200", description = "Liste des messages")
|
||||
@APIResponse(responseCode = "404", description = "Conversation non trouvée")
|
||||
public Response getMessages(
|
||||
@Parameter(description = "ID de la conversation", required = true)
|
||||
@QueryParam("conversationId") UUID conversationId,
|
||||
@Parameter(description = "Nombre maximum de messages")
|
||||
@QueryParam("limit") @DefaultValue("50") int limit
|
||||
) {
|
||||
if (conversationId == null) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", "conversationId requis"))
|
||||
.build();
|
||||
}
|
||||
|
||||
UUID membreId = securiteHelper.resolveMembreId();
|
||||
List<MessageResponse> messages = messageService.getMessages(conversationId, membreId, limit);
|
||||
return Response.ok(messages).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un message
|
||||
*/
|
||||
@POST
|
||||
@Operation(summary = "Envoyer un message")
|
||||
@APIResponse(responseCode = "201", description = "Message envoyé")
|
||||
@APIResponse(responseCode = "400", description = "Données invalides")
|
||||
@APIResponse(responseCode = "404", description = "Conversation non trouvée")
|
||||
public Response sendMessage(@Valid SendMessageRequest request) {
|
||||
UUID senderId = securiteHelper.resolveMembreId();
|
||||
MessageResponse message = messageService.sendMessage(request, senderId);
|
||||
return Response.status(Response.Status.CREATED).entity(message).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Édite un message
|
||||
*/
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
@Operation(summary = "Éditer un message")
|
||||
@APIResponse(responseCode = "200", description = "Message édité")
|
||||
@APIResponse(responseCode = "404", description = "Message non trouvé")
|
||||
public Response editMessage(
|
||||
@PathParam("id") UUID messageId,
|
||||
Map<String, String> body
|
||||
) {
|
||||
String newContent = body.get("content");
|
||||
if (newContent == null || newContent.isEmpty()) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", "content requis"))
|
||||
.build();
|
||||
}
|
||||
|
||||
UUID senderId = securiteHelper.resolveMembreId();
|
||||
MessageResponse message = messageService.editMessage(messageId, senderId, newContent);
|
||||
return Response.ok(message).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un message
|
||||
*/
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
@Operation(summary = "Supprimer un message")
|
||||
@APIResponse(responseCode = "204", description = "Message supprimé")
|
||||
@APIResponse(responseCode = "404", description = "Message non trouvé")
|
||||
public Response deleteMessage(@PathParam("id") UUID messageId) {
|
||||
UUID senderId = securiteHelper.resolveMembreId();
|
||||
messageService.deleteMessage(messageId, senderId);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.request.BloquerMembreRequest;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.request.DemarrerConversationDirecteRequest;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.request.DemarrerConversationRoleRequest;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.request.EnvoyerMessageRequest;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.request.MettreAJourPolitiqueRequest;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.response.ContactPolicyResponse;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationResponse;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationSummaryResponse;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.response.MessageResponse;
|
||||
import dev.lions.unionflow.server.service.MessagingService;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Resource REST pour la messagerie instantanée.
|
||||
*
|
||||
* <p>Endpoints :
|
||||
* <ul>
|
||||
* <li>POST /api/messagerie/conversations/directe — démarrer conversation 1-1</li>
|
||||
* <li>POST /api/messagerie/conversations/role — contacter un rôle officiel</li>
|
||||
* <li>GET /api/messagerie/conversations — mes conversations</li>
|
||||
* <li>GET /api/messagerie/conversations/{id} — détail + messages</li>
|
||||
* <li>DELETE /api/messagerie/conversations/{id} — archiver</li>
|
||||
* <li>POST /api/messagerie/conversations/{id}/messages — envoyer un message</li>
|
||||
* <li>GET /api/messagerie/conversations/{id}/messages — historique</li>
|
||||
* <li>PUT /api/messagerie/conversations/{id}/lire — marquer comme lu</li>
|
||||
* <li>DELETE /api/messagerie/conversations/{cId}/messages/{mId} — supprimer message</li>
|
||||
* <li>POST /api/messagerie/blocages — bloquer un membre</li>
|
||||
* <li>DELETE /api/messagerie/blocages/{membreId} — débloquer</li>
|
||||
* <li>GET /api/messagerie/blocages — mes blocages</li>
|
||||
* <li>GET /api/messagerie/politique/{orgId} — politique de communication</li>
|
||||
* <li>PUT /api/messagerie/politique/{orgId} — mettre à jour (ADMIN)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 4.0
|
||||
* @since 2026-04-13
|
||||
*/
|
||||
@Path("/api/messagerie")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"})
|
||||
@Tag(name = "Messagerie", description = "Messagerie instantanée — conversations, messages, notes vocales")
|
||||
public class MessagingResource {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(MessagingResource.class);
|
||||
|
||||
@Inject
|
||||
MessagingService messagingService;
|
||||
|
||||
// ── Conversations ─────────────────────────────────────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("/conversations/directe")
|
||||
public Response demarrerConversationDirecte(@Valid DemarrerConversationDirecteRequest request) {
|
||||
LOG.infof("POST /api/messagerie/conversations/directe → destinataire: %s", request.destinataireId());
|
||||
ConversationResponse result = messagingService.demarrerConversationDirecte(request);
|
||||
return Response.status(Response.Status.CREATED).entity(result).build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/conversations/role")
|
||||
public Response demarrerConversationRole(@Valid DemarrerConversationRoleRequest request) {
|
||||
LOG.infof("POST /api/messagerie/conversations/role → rôle: %s, org: %s",
|
||||
request.roleCible(), request.organisationId());
|
||||
ConversationResponse result = messagingService.demarrerConversationRole(request);
|
||||
return Response.status(Response.Status.CREATED).entity(result).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/conversations")
|
||||
public Response getMesConversations() {
|
||||
LOG.debug("GET /api/messagerie/conversations");
|
||||
List<ConversationSummaryResponse> result = messagingService.getMesConversations();
|
||||
return Response.ok(result).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/conversations/{id}")
|
||||
public Response getConversation(@PathParam("id") UUID id) {
|
||||
LOG.infof("GET /api/messagerie/conversations/%s", id);
|
||||
ConversationResponse result = messagingService.getConversation(id);
|
||||
return Response.ok(result).build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/conversations/{id}")
|
||||
public Response archiverConversation(@PathParam("id") UUID id) {
|
||||
LOG.infof("DELETE /api/messagerie/conversations/%s", id);
|
||||
ConversationResponse result = messagingService.archiverConversation(id);
|
||||
return Response.ok(result).build();
|
||||
}
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("/conversations/{id}/messages")
|
||||
public Response envoyerMessage(
|
||||
@PathParam("id") UUID conversationId,
|
||||
@Valid EnvoyerMessageRequest request) {
|
||||
LOG.infof("POST /api/messagerie/conversations/%s/messages", conversationId);
|
||||
MessageResponse result = messagingService.envoyerMessage(conversationId, request);
|
||||
return Response.status(Response.Status.CREATED).entity(result).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/conversations/{id}/messages")
|
||||
public Response getMessages(
|
||||
@PathParam("id") UUID conversationId,
|
||||
@QueryParam("page") @DefaultValue("0") int page) {
|
||||
LOG.infof("GET /api/messagerie/conversations/%s/messages?page=%d", conversationId, page);
|
||||
List<MessageResponse> result = messagingService.getMessages(conversationId, page);
|
||||
return Response.ok(result).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/conversations/{id}/lire")
|
||||
public Response marquerLu(@PathParam("id") UUID conversationId) {
|
||||
LOG.infof("PUT /api/messagerie/conversations/%s/lire", conversationId);
|
||||
messagingService.marquerConversationLue(conversationId);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/conversations/{conversationId}/messages/{messageId}")
|
||||
public Response supprimerMessage(
|
||||
@PathParam("conversationId") UUID conversationId,
|
||||
@PathParam("messageId") UUID messageId) {
|
||||
LOG.infof("DELETE /api/messagerie/conversations/%s/messages/%s", conversationId, messageId);
|
||||
messagingService.supprimerMessage(conversationId, messageId);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
// ── Blocages ──────────────────────────────────────────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("/blocages")
|
||||
public Response bloquerMembre(@Valid BloquerMembreRequest request) {
|
||||
LOG.infof("POST /api/messagerie/blocages → bloquer: %s", request.membreABloquerId());
|
||||
messagingService.bloquerMembre(request);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/blocages/{membreId}")
|
||||
public Response debloquerMembre(
|
||||
@PathParam("membreId") UUID membreId,
|
||||
@QueryParam("organisationId") UUID organisationId) {
|
||||
LOG.infof("DELETE /api/messagerie/blocages/%s", membreId);
|
||||
messagingService.debloquerMembre(membreId, organisationId);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/blocages")
|
||||
public Response getMesBlocages() {
|
||||
LOG.debug("GET /api/messagerie/blocages");
|
||||
return Response.ok(messagingService.getMesBlocages()).build();
|
||||
}
|
||||
|
||||
// ── Politique de communication ────────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("/politique/{organisationId}")
|
||||
public Response getPolitique(@PathParam("organisationId") UUID organisationId) {
|
||||
LOG.infof("GET /api/messagerie/politique/%s", organisationId);
|
||||
ContactPolicyResponse result = messagingService.getPolitique(organisationId);
|
||||
return Response.ok(result).build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/politique/{organisationId}")
|
||||
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
|
||||
public Response mettreAJourPolitique(
|
||||
@PathParam("organisationId") UUID organisationId,
|
||||
@Valid MettreAJourPolitiqueRequest request) {
|
||||
LOG.infof("PUT /api/messagerie/politique/%s", organisationId);
|
||||
ContactPolicyResponse result = messagingService.mettreAJourPolitique(organisationId, request);
|
||||
return Response.ok(result).build();
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.communication.request.CreateConversationRequest;
|
||||
import dev.lions.unionflow.server.api.dto.communication.response.ConversationResponse;
|
||||
import dev.lions.unionflow.server.api.dto.communication.response.MessageResponse;
|
||||
import dev.lions.unionflow.server.entity.Conversation;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.Message;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.repository.ConversationRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.unionflow.server.repository.MessageRepository;
|
||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service de gestion des conversations
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 1.0
|
||||
* @since 2026-03-16
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class ConversationService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(ConversationService.class);
|
||||
|
||||
@Inject
|
||||
ConversationRepository conversationRepository;
|
||||
|
||||
@Inject
|
||||
MessageRepository messageRepository;
|
||||
|
||||
@Inject
|
||||
MembreRepository membreRepository;
|
||||
|
||||
@Inject
|
||||
OrganisationRepository organisationRepository;
|
||||
|
||||
/**
|
||||
* Liste les conversations d'un membre
|
||||
*/
|
||||
public List<ConversationResponse> getConversations(UUID membreId, UUID organisationId, boolean includeArchived) {
|
||||
LOG.infof("Récupération conversations pour membre %s", membreId);
|
||||
|
||||
List<Conversation> conversations;
|
||||
if (organisationId != null) {
|
||||
conversations = conversationRepository.findByOrganisation(organisationId);
|
||||
} else {
|
||||
conversations = conversationRepository.findByParticipant(membreId, includeArchived);
|
||||
}
|
||||
|
||||
return conversations.stream()
|
||||
.map(c -> convertToResponse(c, membreId))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une conversation par ID
|
||||
*/
|
||||
public ConversationResponse getConversationById(UUID conversationId, UUID membreId) {
|
||||
Conversation conversation = conversationRepository.findByIdAndParticipant(conversationId, membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Conversation non trouvée ou accès refusé"));
|
||||
|
||||
return convertToResponse(conversation, membreId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une nouvelle conversation
|
||||
*/
|
||||
@Transactional
|
||||
public ConversationResponse createConversation(CreateConversationRequest request, UUID creatorId) {
|
||||
LOG.infof("Création conversation: %s (type: %s)", request.name(), request.type());
|
||||
|
||||
Conversation conversation = new Conversation();
|
||||
conversation.setName(request.name());
|
||||
conversation.setDescription(request.description());
|
||||
conversation.setType(request.type());
|
||||
|
||||
// Ajouter les participants
|
||||
List<Membre> participants = request.participantIds().stream()
|
||||
.map(id -> membreRepository.findById(id))
|
||||
.filter(membre -> membre != null)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Ajouter le créateur s'il n'est pas dans la liste
|
||||
Membre creator = membreRepository.findById(creatorId);
|
||||
if (creator != null && !participants.contains(creator)) {
|
||||
participants.add(creator);
|
||||
}
|
||||
|
||||
conversation.setParticipants(participants);
|
||||
|
||||
// Organisation
|
||||
if (request.organisationId() != null) {
|
||||
Organisation org = organisationRepository.findById(request.organisationId());
|
||||
conversation.setOrganisation(org);
|
||||
}
|
||||
|
||||
conversation.setUpdatedAt(LocalDateTime.now());
|
||||
conversationRepository.persist(conversation);
|
||||
|
||||
return convertToResponse(conversation, creatorId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive/désarchive une conversation
|
||||
*/
|
||||
@Transactional
|
||||
public void archiveConversation(UUID conversationId, UUID membreId, boolean archive) {
|
||||
Conversation conversation = conversationRepository.findByIdAndParticipant(conversationId, membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Conversation non trouvée"));
|
||||
|
||||
conversation.setIsArchived(archive);
|
||||
conversation.setUpdatedAt(LocalDateTime.now());
|
||||
conversationRepository.persist(conversation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque une conversation comme lue
|
||||
*/
|
||||
@Transactional
|
||||
public void markAsRead(UUID conversationId, UUID membreId) {
|
||||
// Vérifier accès
|
||||
conversationRepository.findByIdAndParticipant(conversationId, membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Conversation non trouvée"));
|
||||
|
||||
messageRepository.markAllAsReadByConversationAndMember(conversationId, membreId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mute
|
||||
*/
|
||||
@Transactional
|
||||
public void toggleMute(UUID conversationId, UUID membreId) {
|
||||
Conversation conversation = conversationRepository.findByIdAndParticipant(conversationId, membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Conversation non trouvée"));
|
||||
|
||||
conversation.setIsMuted(!conversation.getIsMuted());
|
||||
conversation.setUpdatedAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pin
|
||||
*/
|
||||
@Transactional
|
||||
public void togglePin(UUID conversationId, UUID membreId) {
|
||||
Conversation conversation = conversationRepository.findByIdAndParticipant(conversationId, membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Conversation non trouvée"));
|
||||
|
||||
conversation.setIsPinned(!conversation.getIsPinned());
|
||||
conversation.setUpdatedAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit Conversation en DTO
|
||||
*/
|
||||
private ConversationResponse convertToResponse(Conversation c, UUID currentUserId) {
|
||||
Message lastMsg = messageRepository.findLastByConversation(c.getId());
|
||||
long unreadCount = messageRepository.countUnreadByConversationAndMember(c.getId(), currentUserId);
|
||||
|
||||
return ConversationResponse.builder()
|
||||
.id(c.getId())
|
||||
.name(c.getName())
|
||||
.description(c.getDescription())
|
||||
.type(c.getType())
|
||||
.participantIds(c.getParticipants().stream().map(Membre::getId).collect(Collectors.toList()))
|
||||
.organisationId(c.getOrganisation() != null ? c.getOrganisation().getId() : null)
|
||||
.lastMessage(lastMsg != null ? convertMessageToResponse(lastMsg) : null)
|
||||
.unreadCount((int) unreadCount)
|
||||
.muted(Boolean.TRUE.equals(c.getIsMuted()))
|
||||
.pinned(Boolean.TRUE.equals(c.getIsPinned()))
|
||||
.archived(Boolean.TRUE.equals(c.getIsArchived()))
|
||||
.createdAt(c.getDateCreation())
|
||||
.updatedAt(c.getUpdatedAt())
|
||||
.avatarUrl(c.getAvatarUrl())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit Message en DTO simple
|
||||
*/
|
||||
private MessageResponse convertMessageToResponse(Message m) {
|
||||
return MessageResponse.builder()
|
||||
.id(m.getId())
|
||||
.conversationId(m.getConversation().getId())
|
||||
.senderId(m.getSender().getId())
|
||||
.senderName(m.getSenderName())
|
||||
.senderAvatar(m.getSenderAvatar())
|
||||
.content(m.getContent())
|
||||
.type(m.getType())
|
||||
.status(m.getStatus())
|
||||
.priority(m.getPriority())
|
||||
.createdAt(m.getDateCreation())
|
||||
.edited(Boolean.TRUE.equals(m.getIsEdited()))
|
||||
.deleted(Boolean.TRUE.equals(m.getIsDeleted()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.communication.request.SendMessageRequest;
|
||||
import dev.lions.unionflow.server.api.dto.communication.response.MessageResponse;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessagePriority;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessageType;
|
||||
import dev.lions.unionflow.server.entity.Conversation;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.Message;
|
||||
import dev.lions.unionflow.server.repository.ConversationRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.unionflow.server.repository.MessageRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service de gestion des messages
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 1.0
|
||||
* @since 2026-03-16
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class MessageService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(MessageService.class);
|
||||
|
||||
@Inject
|
||||
MessageRepository messageRepository;
|
||||
|
||||
@Inject
|
||||
ConversationRepository conversationRepository;
|
||||
|
||||
@Inject
|
||||
MembreRepository membreRepository;
|
||||
|
||||
/**
|
||||
* Récupère les messages d'une conversation
|
||||
*/
|
||||
public List<MessageResponse> getMessages(UUID conversationId, UUID membreId, int limit) {
|
||||
LOG.infof("Récupération messages pour conversation %s (limit: %d)", conversationId, limit);
|
||||
|
||||
// Vérifier accès
|
||||
conversationRepository.findByIdAndParticipant(conversationId, membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Conversation non trouvée ou accès refusé"));
|
||||
|
||||
List<Message> messages = messageRepository.findByConversation(conversationId, limit);
|
||||
|
||||
return messages.stream()
|
||||
.map(this::convertToResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un message
|
||||
*/
|
||||
@Transactional
|
||||
public MessageResponse sendMessage(SendMessageRequest request, UUID senderId) {
|
||||
LOG.infof("Envoi message dans conversation %s", request.conversationId());
|
||||
|
||||
// Vérifier accès conversation
|
||||
Conversation conversation = conversationRepository.findByIdAndParticipant(request.conversationId(), senderId)
|
||||
.orElseThrow(() -> new NotFoundException("Conversation non trouvée ou accès refusé"));
|
||||
|
||||
Membre sender = membreRepository.findById(senderId);
|
||||
if (sender == null) {
|
||||
throw new NotFoundException("Expéditeur non trouvé");
|
||||
}
|
||||
|
||||
Message message = new Message();
|
||||
message.setConversation(conversation);
|
||||
message.setSender(sender);
|
||||
message.setSenderName(sender.getPrenom() + " " + sender.getNom());
|
||||
message.setSenderAvatar(sender.getPhotoUrl());
|
||||
message.setContent(request.content());
|
||||
message.setType(request.type() != null ? request.type() : MessageType.INDIVIDUAL);
|
||||
message.setStatus(MessageStatus.SENT);
|
||||
message.setPriority(request.priority() != null ? request.priority() : MessagePriority.NORMAL);
|
||||
|
||||
// Destinataires (pour targeted messages)
|
||||
if (request.recipientIds() != null && !request.recipientIds().isEmpty()) {
|
||||
message.setRecipientIds(request.recipientIds().stream()
|
||||
.map(UUID::toString)
|
||||
.collect(Collectors.joining(",")));
|
||||
}
|
||||
|
||||
// Rôles destinataires
|
||||
if (request.recipientRoles() != null && !request.recipientRoles().isEmpty()) {
|
||||
message.setRecipientRoles(String.join(",", request.recipientRoles()));
|
||||
}
|
||||
|
||||
// Pièces jointes
|
||||
if (request.attachments() != null && !request.attachments().isEmpty()) {
|
||||
message.setAttachments(String.join(",", request.attachments()));
|
||||
}
|
||||
|
||||
message.setOrganisation(conversation.getOrganisation());
|
||||
|
||||
messageRepository.persist(message);
|
||||
|
||||
// Mettre à jour la conversation
|
||||
conversation.setUpdatedAt(LocalDateTime.now());
|
||||
conversationRepository.persist(conversation);
|
||||
|
||||
LOG.infof("Message %s créé avec succès", message.getId());
|
||||
return convertToResponse(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Édite un message
|
||||
*/
|
||||
@Transactional
|
||||
public MessageResponse editMessage(UUID messageId, UUID senderId, String newContent) {
|
||||
Message message = messageRepository.findById(messageId);
|
||||
if (message == null) {
|
||||
throw new NotFoundException("Message non trouvé");
|
||||
}
|
||||
|
||||
if (!message.getSender().getId().equals(senderId)) {
|
||||
throw new IllegalStateException("Vous ne pouvez éditer que vos propres messages");
|
||||
}
|
||||
|
||||
message.setContent(newContent);
|
||||
message.markAsEdited();
|
||||
messageRepository.persist(message);
|
||||
|
||||
return convertToResponse(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un message (soft delete)
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteMessage(UUID messageId, UUID senderId) {
|
||||
Message message = messageRepository.findById(messageId);
|
||||
if (message == null) {
|
||||
throw new NotFoundException("Message non trouvé");
|
||||
}
|
||||
|
||||
if (!message.getSender().getId().equals(senderId)) {
|
||||
throw new IllegalStateException("Vous ne pouvez supprimer que vos propres messages");
|
||||
}
|
||||
|
||||
message.setIsDeleted(true);
|
||||
message.setContent("[Message supprimé]");
|
||||
messageRepository.persist(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit Message en DTO
|
||||
*/
|
||||
private MessageResponse convertToResponse(Message m) {
|
||||
// Parser recipient IDs
|
||||
List<UUID> recipientIds = null;
|
||||
if (m.getRecipientIds() != null && !m.getRecipientIds().isEmpty()) {
|
||||
recipientIds = List.of(m.getRecipientIds().split(",")).stream()
|
||||
.map(UUID::fromString)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// Parser roles
|
||||
List<String> roles = null;
|
||||
if (m.getRecipientRoles() != null && !m.getRecipientRoles().isEmpty()) {
|
||||
roles = List.of(m.getRecipientRoles().split(","));
|
||||
}
|
||||
|
||||
// Parser attachments
|
||||
List<String> attachments = null;
|
||||
if (m.getAttachments() != null && !m.getAttachments().isEmpty()) {
|
||||
attachments = List.of(m.getAttachments().split(","));
|
||||
}
|
||||
|
||||
return MessageResponse.builder()
|
||||
.id(m.getId())
|
||||
.conversationId(m.getConversation().getId())
|
||||
.senderId(m.getSender().getId())
|
||||
.senderName(m.getSenderName())
|
||||
.senderAvatar(m.getSenderAvatar())
|
||||
.content(m.getContent())
|
||||
.type(m.getType())
|
||||
.status(m.getStatus())
|
||||
.priority(m.getPriority())
|
||||
.recipientIds(recipientIds)
|
||||
.recipientRoles(roles)
|
||||
.organisationId(m.getOrganisation() != null ? m.getOrganisation().getId() : null)
|
||||
.createdAt(m.getDateCreation())
|
||||
.readAt(m.getReadAt())
|
||||
.attachments(attachments)
|
||||
.edited(Boolean.TRUE.equals(m.getIsEdited()))
|
||||
.editedAt(m.getEditedAt())
|
||||
.deleted(Boolean.TRUE.equals(m.getIsDeleted()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.request.BloquerMembreRequest;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.request.DemarrerConversationDirecteRequest;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.request.DemarrerConversationRoleRequest;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.request.EnvoyerMessageRequest;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.request.MettreAJourPolitiqueRequest;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.response.ContactPolicyResponse;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationResponse;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.response.ConversationSummaryResponse;
|
||||
import dev.lions.unionflow.server.api.dto.messagerie.response.MessageResponse;
|
||||
import dev.lions.unionflow.server.api.enums.messagerie.StatutConversation;
|
||||
import dev.lions.unionflow.server.api.enums.messagerie.TypeContenu;
|
||||
import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation;
|
||||
import dev.lions.unionflow.server.api.enums.messagerie.TypePolitiqueCommunication;
|
||||
import dev.lions.unionflow.server.entity.ContactPolicy;
|
||||
import dev.lions.unionflow.server.entity.Conversation;
|
||||
import dev.lions.unionflow.server.entity.ConversationParticipant;
|
||||
import dev.lions.unionflow.server.entity.MemberBlock;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||
import dev.lions.unionflow.server.entity.Message;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.repository.ContactPolicyRepository;
|
||||
import dev.lions.unionflow.server.repository.ConversationParticipantRepository;
|
||||
import dev.lions.unionflow.server.repository.ConversationRepository;
|
||||
import dev.lions.unionflow.server.repository.MemberBlockRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.unionflow.server.repository.MessageRepository;
|
||||
import dev.lions.unionflow.server.messaging.KafkaEventProducer;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.ForbiddenException;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Service métier pour la messagerie instantanée.
|
||||
*
|
||||
* <p>Gère les conversations (directes et canaux-rôle), les messages (texte,
|
||||
* vocal, image), les blocages et les politiques de communication.
|
||||
*
|
||||
* <p>Politique par appartenance : deux membres de la même organisation
|
||||
* peuvent se contacter sans demande d'amitié préalable.
|
||||
* L'adhésion est la relation de confiance.
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 4.0
|
||||
* @since 2026-04-13
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class MessagingService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(MessagingService.class);
|
||||
private static final int PAGE_SIZE_DEFAULT = 30;
|
||||
|
||||
@Inject ConversationRepository conversationRepository;
|
||||
@Inject ConversationParticipantRepository participantRepository;
|
||||
@Inject MessageRepository messageRepository;
|
||||
@Inject ContactPolicyRepository contactPolicyRepository;
|
||||
@Inject MemberBlockRepository memberBlockRepository;
|
||||
@Inject MembreRepository membreRepository;
|
||||
@Inject MembreOrganisationRepository membreOrganisationRepository;
|
||||
@Inject KafkaEventProducer kafkaEventProducer;
|
||||
@Inject io.quarkus.security.identity.SecurityIdentity securityIdentity;
|
||||
|
||||
// ── Conversations ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Démarre ou récupère une conversation directe 1-1.
|
||||
* Idempotent : si la conversation existe déjà, elle est retournée.
|
||||
*/
|
||||
@Transactional
|
||||
public ConversationResponse demarrerConversationDirecte(DemarrerConversationDirecteRequest request) {
|
||||
Membre moi = getMembreConnecte();
|
||||
Membre destinataire = membreRepository.findById(request.destinataireId());
|
||||
if (destinataire == null) {
|
||||
throw new NotFoundException("Membre destinataire non trouvé : " + request.destinataireId());
|
||||
}
|
||||
|
||||
UUID orgId = request.organisationId();
|
||||
verifierAppartenance(moi.getId(), orgId);
|
||||
verifierAppartenance(request.destinataireId(), orgId);
|
||||
verifierPolitique(moi.getId(), request.destinataireId(), orgId, false);
|
||||
|
||||
// Idempotence : chercher une conversation directe existante
|
||||
return conversationRepository
|
||||
.findConversationDirecte(moi.getId(), request.destinataireId(), orgId)
|
||||
.map(c -> {
|
||||
// Envoyer le message initial si fourni
|
||||
if (request.contenuInitial() != null && !request.contenuInitial().isBlank()) {
|
||||
envoyerMessageDansConversation(c, moi, request.contenuInitial(), TypeContenu.TEXTE, null, null, null);
|
||||
}
|
||||
return toConversationResponse(c, moi.getId());
|
||||
})
|
||||
.orElseGet(() -> {
|
||||
Organisation org = getOrganisation(orgId);
|
||||
Conversation conv = Conversation.builder()
|
||||
.organisation(org)
|
||||
.typeConversation(TypeConversation.DIRECTE)
|
||||
.statut(StatutConversation.ACTIVE)
|
||||
.build();
|
||||
conversationRepository.persist(conv);
|
||||
|
||||
ajouterParticipant(conv, moi, "INITIATEUR");
|
||||
ajouterParticipant(conv, destinataire, "PARTICIPANT");
|
||||
|
||||
if (request.contenuInitial() != null && !request.contenuInitial().isBlank()) {
|
||||
envoyerMessageDansConversation(conv, moi, request.contenuInitial(), TypeContenu.TEXTE, null, null, null);
|
||||
}
|
||||
|
||||
LOG.infof("Conversation directe créée: %s ↔ %s dans org %s",
|
||||
moi.getEmail(), destinataire.getEmail(), orgId);
|
||||
return toConversationResponse(conv, moi.getId());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre ou récupère un canal de rôle.
|
||||
* Le canal est partagé : tous les membres qui contactent "Le Trésorier"
|
||||
* aboutissent dans le même canal.
|
||||
*/
|
||||
@Transactional
|
||||
public ConversationResponse demarrerConversationRole(DemarrerConversationRoleRequest request) {
|
||||
Membre moi = getMembreConnecte();
|
||||
UUID orgId = request.organisationId();
|
||||
verifierAppartenance(moi.getId(), orgId);
|
||||
verifierPolitique(moi.getId(), null, orgId, true);
|
||||
|
||||
String roleCible = request.roleCible();
|
||||
List<Membre> porteurs = trouverPorteursDuRole(orgId, roleCible);
|
||||
if (porteurs.isEmpty()) {
|
||||
throw new NotFoundException("Aucun membre avec le rôle " + roleCible + " dans cette organisation");
|
||||
}
|
||||
|
||||
Organisation org = getOrganisation(orgId);
|
||||
String titreCanal = libelleDuRole(roleCible);
|
||||
|
||||
return conversationRepository.findCanalRole(orgId, roleCible)
|
||||
.map(c -> {
|
||||
// Ajouter l'initiateur s'il n'est pas encore participant
|
||||
if (!participantRepository.estParticipant(c.getId(), moi.getId())) {
|
||||
ajouterParticipant(c, moi, "PARTICIPANT");
|
||||
}
|
||||
envoyerMessageDansConversation(c, moi, request.contenuInitial(), TypeContenu.TEXTE, null, null, null);
|
||||
return toConversationResponse(c, moi.getId());
|
||||
})
|
||||
.orElseGet(() -> {
|
||||
Conversation conv = Conversation.builder()
|
||||
.organisation(org)
|
||||
.typeConversation(TypeConversation.ROLE_CANAL)
|
||||
.roleCible(roleCible)
|
||||
.titre(titreCanal)
|
||||
.statut(StatutConversation.ACTIVE)
|
||||
.build();
|
||||
conversationRepository.persist(conv);
|
||||
|
||||
ajouterParticipant(conv, moi, "INITIATEUR");
|
||||
porteurs.forEach(p -> ajouterParticipant(conv, p, "MODERATEUR"));
|
||||
|
||||
envoyerMessageDansConversation(conv, moi, request.contenuInitial(), TypeContenu.TEXTE, null, null, null);
|
||||
|
||||
LOG.infof("Canal rôle créé: %s dans org %s", roleCible, orgId);
|
||||
return toConversationResponse(conv, moi.getId());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la liste des conversations du membre connecté.
|
||||
*/
|
||||
public List<ConversationSummaryResponse> getMesConversations() {
|
||||
Membre moi = getMembreConnecte();
|
||||
return conversationRepository.findByMembreId(moi.getId()).stream()
|
||||
.map(c -> toConversationSummary(c, moi.getId()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le détail d'une conversation (avec les derniers messages).
|
||||
*/
|
||||
public ConversationResponse getConversation(UUID conversationId) {
|
||||
Membre moi = getMembreConnecte();
|
||||
Conversation conv = conversationRepository.findConversationById(conversationId)
|
||||
.orElseThrow(() -> new NotFoundException("Conversation non trouvée : " + conversationId));
|
||||
verifierParticipant(conv, moi.getId());
|
||||
return toConversationResponse(conv, moi.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive une conversation.
|
||||
*/
|
||||
@Transactional
|
||||
public ConversationResponse archiverConversation(UUID conversationId) {
|
||||
Membre moi = getMembreConnecte();
|
||||
Conversation conv = conversationRepository.findConversationById(conversationId)
|
||||
.orElseThrow(() -> new NotFoundException("Conversation non trouvée : " + conversationId));
|
||||
verifierParticipant(conv, moi.getId());
|
||||
conv.archiver();
|
||||
return toConversationResponse(conv, moi.getId());
|
||||
}
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Envoie un message dans une conversation existante.
|
||||
*/
|
||||
@Transactional
|
||||
public MessageResponse envoyerMessage(UUID conversationId, EnvoyerMessageRequest request) {
|
||||
Membre moi = getMembreConnecte();
|
||||
Conversation conv = conversationRepository.findConversationById(conversationId)
|
||||
.orElseThrow(() -> new NotFoundException("Conversation non trouvée : " + conversationId));
|
||||
|
||||
verifierParticipant(conv, moi.getId());
|
||||
if (!conv.estActive()) {
|
||||
throw new BadRequestException("Cette conversation est archivée");
|
||||
}
|
||||
|
||||
TypeContenu type = parseTypeContenu(request.typeMessage());
|
||||
validerContenuMessage(type, request);
|
||||
|
||||
Message message = envoyerMessageDansConversation(
|
||||
conv, moi,
|
||||
request.contenu(), type,
|
||||
request.urlFichier(), request.dureeAudio(),
|
||||
request.messageParentId()
|
||||
);
|
||||
return toMessageResponse(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les messages d'une conversation (paginés).
|
||||
*/
|
||||
public List<MessageResponse> getMessages(UUID conversationId, int page) {
|
||||
Membre moi = getMembreConnecte();
|
||||
Conversation conv = conversationRepository.findConversationById(conversationId)
|
||||
.orElseThrow(() -> new NotFoundException("Conversation non trouvée : " + conversationId));
|
||||
verifierParticipant(conv, moi.getId());
|
||||
|
||||
return messageRepository.findByConversationPagine(conversationId, page, PAGE_SIZE_DEFAULT)
|
||||
.stream()
|
||||
.map(this::toMessageResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque tous les messages d'une conversation comme lus.
|
||||
*/
|
||||
@Transactional
|
||||
public void marquerConversationLue(UUID conversationId) {
|
||||
Membre moi = getMembreConnecte();
|
||||
participantRepository.findParticipant(conversationId, moi.getId())
|
||||
.ifPresent(p -> {
|
||||
p.marquerLu();
|
||||
LOG.debugf("Conversation %s marquée lue par %s", conversationId, moi.getEmail());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un message (soft delete — contenu remplacé par "[Message supprimé]").
|
||||
*/
|
||||
@Transactional
|
||||
public void supprimerMessage(UUID conversationId, UUID messageId) {
|
||||
Membre moi = getMembreConnecte();
|
||||
Message message = messageRepository.findMessageById(messageId)
|
||||
.orElseThrow(() -> new NotFoundException("Message non trouvé : " + messageId));
|
||||
|
||||
if (!message.getConversation().getId().equals(conversationId)) {
|
||||
throw new NotFoundException("Message non trouvé dans cette conversation");
|
||||
}
|
||||
if (!message.getExpediteur().getId().equals(moi.getId())) {
|
||||
throw new ForbiddenException("Vous ne pouvez supprimer que vos propres messages");
|
||||
}
|
||||
message.supprimer();
|
||||
}
|
||||
|
||||
// ── Blocages ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Bloque un membre dans une organisation.
|
||||
*/
|
||||
@Transactional
|
||||
public void bloquerMembre(BloquerMembreRequest request) {
|
||||
Membre moi = getMembreConnecte();
|
||||
UUID membreABloquerId = request.membreABloquerId();
|
||||
UUID orgId = request.organisationId();
|
||||
|
||||
if (moi.getId().equals(membreABloquerId)) {
|
||||
throw new BadRequestException("Vous ne pouvez pas vous bloquer vous-même");
|
||||
}
|
||||
|
||||
Membre aBloquer = membreRepository.findById(membreABloquerId);
|
||||
if (aBloquer == null) {
|
||||
throw new NotFoundException("Membre non trouvé : " + membreABloquerId);
|
||||
}
|
||||
|
||||
if (memberBlockRepository.estBloque(moi.getId(), membreABloquerId, orgId)) {
|
||||
throw new BadRequestException("Ce membre est déjà bloqué");
|
||||
}
|
||||
|
||||
Organisation org = getOrganisation(orgId);
|
||||
MemberBlock block = MemberBlock.builder()
|
||||
.bloqueur(moi)
|
||||
.bloque(aBloquer)
|
||||
.organisation(org)
|
||||
.build();
|
||||
memberBlockRepository.persist(block);
|
||||
LOG.infof("%s a bloqué %s dans org %s", moi.getEmail(), aBloquer.getEmail(), orgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Débloque un membre dans une organisation.
|
||||
*/
|
||||
@Transactional
|
||||
public void debloquerMembre(UUID membreId, UUID organisationId) {
|
||||
Membre moi = getMembreConnecte();
|
||||
MemberBlock block = memberBlockRepository.findBlocage(moi.getId(), membreId, organisationId)
|
||||
.orElseThrow(() -> new NotFoundException("Aucun blocage trouvé pour ce membre"));
|
||||
block.setActif(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la liste des membres bloqués par le membre connecté.
|
||||
*/
|
||||
public List<MemberBlock> getMesBlocages() {
|
||||
Membre moi = getMembreConnecte();
|
||||
return memberBlockRepository.findByBloqueur(moi.getId());
|
||||
}
|
||||
|
||||
// ── Politique de communication ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retourne la politique de communication d'une organisation.
|
||||
* Crée une politique par défaut si elle n'existe pas encore.
|
||||
*/
|
||||
@Transactional
|
||||
public ContactPolicyResponse getPolitique(UUID organisationId) {
|
||||
ContactPolicy policy = contactPolicyRepository.findByOrganisationId(organisationId)
|
||||
.orElseGet(() -> creerPolitiqueParDefaut(organisationId));
|
||||
return toContactPolicyResponse(policy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la politique de communication.
|
||||
* Réservé aux ADMIN et ADMIN_ORGANISATION.
|
||||
*/
|
||||
@Transactional
|
||||
public ContactPolicyResponse mettreAJourPolitique(UUID organisationId, MettreAJourPolitiqueRequest request) {
|
||||
ContactPolicy policy = contactPolicyRepository.findByOrganisationId(organisationId)
|
||||
.orElseGet(() -> creerPolitiqueParDefaut(organisationId));
|
||||
|
||||
if (request.typePolitique() != null) {
|
||||
policy.setTypePolitique(TypePolitiqueCommunication.valueOf(request.typePolitique()));
|
||||
}
|
||||
if (request.autoriserMembreVersMembre() != null) {
|
||||
policy.setAutoriserMembreVersMembre(request.autoriserMembreVersMembre());
|
||||
}
|
||||
if (request.autoriserMembreVersRole() != null) {
|
||||
policy.setAutoriserMembreVersRole(request.autoriserMembreVersRole());
|
||||
}
|
||||
if (request.autoriserNotesVocales() != null) {
|
||||
policy.setAutoriserNotesVocales(request.autoriserNotesVocales());
|
||||
}
|
||||
return toContactPolicyResponse(policy);
|
||||
}
|
||||
|
||||
// ── Méthodes privées ──────────────────────────────────────────────────────
|
||||
|
||||
private Message envoyerMessageDansConversation(
|
||||
Conversation conv, Membre expediteur,
|
||||
String contenu, TypeContenu type,
|
||||
String urlFichier, Integer dureeAudio,
|
||||
UUID messageParentId) {
|
||||
|
||||
Message.MessageBuilder builder = Message.builder()
|
||||
.conversation(conv)
|
||||
.expediteur(expediteur)
|
||||
.typeMessage(type)
|
||||
.contenu(contenu)
|
||||
.urlFichier(urlFichier)
|
||||
.dureeAudio(dureeAudio);
|
||||
|
||||
if (messageParentId != null) {
|
||||
messageRepository.findMessageById(messageParentId)
|
||||
.ifPresent(builder::messageParent);
|
||||
}
|
||||
|
||||
Message message = builder.build();
|
||||
messageRepository.persist(message);
|
||||
conv.enregistrerNouveauMessage();
|
||||
|
||||
// Notifier via Kafka → WebSocket
|
||||
try {
|
||||
java.util.Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("conversationId", conv.getId().toString());
|
||||
data.put("messageId", message.getId() != null ? message.getId().toString() : "");
|
||||
data.put("expediteurId", expediteur.getId().toString());
|
||||
data.put("typeMessage", type.name());
|
||||
kafkaEventProducer.publishNouveauMessage(conv.getId(), conv.getOrganisation().getId().toString(), data);
|
||||
} catch (Exception e) {
|
||||
LOG.warnf("Impossible de publier l'event Kafka pour le message: %s", e.getMessage());
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private void ajouterParticipant(Conversation conv, Membre membre, String role) {
|
||||
if (!participantRepository.estParticipant(conv.getId(), membre.getId())) {
|
||||
ConversationParticipant participant = ConversationParticipant.builder()
|
||||
.conversation(conv)
|
||||
.membre(membre)
|
||||
.roleDansConversation(role)
|
||||
.notifier(true)
|
||||
.build();
|
||||
participantRepository.persist(participant);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifierAppartenance(UUID membreId, UUID organisationId) {
|
||||
boolean appartient = membreOrganisationRepository
|
||||
.count("membre.id = ?1 AND organisation.id = ?2 AND actif = true", membreId, organisationId) > 0;
|
||||
if (!appartient) {
|
||||
throw new ForbiddenException("Le membre n'appartient pas à cette organisation");
|
||||
}
|
||||
}
|
||||
|
||||
private void verifierPolitique(UUID expediteurId, UUID destinataireId, UUID orgId, boolean versRole) {
|
||||
contactPolicyRepository.findByOrganisationId(orgId).ifPresent(policy -> {
|
||||
if (versRole && !policy.getAutoriserMembreVersRole()) {
|
||||
throw new ForbiddenException("La politique de cette organisation n'autorise pas les contacts vers les rôles");
|
||||
}
|
||||
if (!versRole && !policy.getAutoriserMembreVersMembre()) {
|
||||
throw new ForbiddenException("La politique de cette organisation n'autorise pas les contacts entre membres");
|
||||
}
|
||||
});
|
||||
|
||||
// Vérifier le blocage
|
||||
if (destinataireId != null && memberBlockRepository.estBloque(destinataireId, expediteurId, orgId)) {
|
||||
throw new ForbiddenException("Vous ne pouvez pas contacter ce membre");
|
||||
}
|
||||
}
|
||||
|
||||
private void verifierParticipant(Conversation conv, UUID membreId) {
|
||||
if (!participantRepository.estParticipant(conv.getId(), membreId)) {
|
||||
throw new ForbiddenException("Vous n'êtes pas participant à cette conversation");
|
||||
}
|
||||
}
|
||||
|
||||
private List<Membre> trouverPorteursDuRole(UUID orgId, String role) {
|
||||
List<MembreOrganisation> membresOrg = membreOrganisationRepository
|
||||
.find("organisation.id = ?1 AND roleOrg = ?2 AND actif = true", orgId, role)
|
||||
.list();
|
||||
return membresOrg.stream()
|
||||
.map(MembreOrganisation::getMembre)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private ContactPolicy creerPolitiqueParDefaut(UUID organisationId) {
|
||||
Organisation org = getOrganisation(organisationId);
|
||||
ContactPolicy policy = ContactPolicy.builder()
|
||||
.organisation(org)
|
||||
.typePolitique(TypePolitiqueCommunication.OUVERT)
|
||||
.autoriserMembreVersMembre(true)
|
||||
.autoriserMembreVersRole(true)
|
||||
.autoriserNotesVocales(true)
|
||||
.build();
|
||||
contactPolicyRepository.persist(policy);
|
||||
return policy;
|
||||
}
|
||||
|
||||
private Membre getMembreConnecte() {
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
return membreRepository.find("email", email).firstResult();
|
||||
}
|
||||
|
||||
private Organisation getOrganisation(UUID orgId) {
|
||||
return (Organisation) dev.lions.unionflow.server.entity.Organisation.findById(orgId);
|
||||
}
|
||||
|
||||
private TypeContenu parseTypeContenu(String type) {
|
||||
if (type == null || type.isBlank()) return TypeContenu.TEXTE;
|
||||
try {
|
||||
return TypeContenu.valueOf(type.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return TypeContenu.TEXTE;
|
||||
}
|
||||
}
|
||||
|
||||
private void validerContenuMessage(TypeContenu type, EnvoyerMessageRequest req) {
|
||||
switch (type) {
|
||||
case TEXTE:
|
||||
if (req.contenu() == null || req.contenu().isBlank()) {
|
||||
throw new BadRequestException("Le contenu est obligatoire pour un message texte");
|
||||
}
|
||||
break;
|
||||
case VOCAL:
|
||||
if (req.urlFichier() == null || req.urlFichier().isBlank()) {
|
||||
throw new BadRequestException("L'URL du fichier audio est obligatoire pour une note vocale");
|
||||
}
|
||||
if (req.dureeAudio() == null) {
|
||||
throw new BadRequestException("La durée audio est obligatoire pour une note vocale");
|
||||
}
|
||||
break;
|
||||
case IMAGE:
|
||||
if (req.urlFichier() == null || req.urlFichier().isBlank()) {
|
||||
throw new BadRequestException("L'URL de l'image est obligatoire");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private String libelleDuRole(String role) {
|
||||
return switch (role) {
|
||||
case "PRESIDENT" -> "Président";
|
||||
case "TRESORIER" -> "Trésorier";
|
||||
case "SECRETAIRE" -> "Secrétaire";
|
||||
case "VICE_PRESIDENT" -> "Vice-Président";
|
||||
case "ADMIN" -> "Administrateur";
|
||||
case "ADMIN_ORGANISATION" -> "Administrateur";
|
||||
default -> role;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Conversions DTO ───────────────────────────────────────────────────────
|
||||
|
||||
private ConversationResponse toConversationResponse(Conversation conv, UUID membreConnecteId) {
|
||||
List<MessageResponse> msgs = messageRepository
|
||||
.findByConversationPagine(conv.getId(), 0, PAGE_SIZE_DEFAULT)
|
||||
.stream().map(this::toMessageResponse).collect(Collectors.toList());
|
||||
|
||||
List<ConversationResponse.ParticipantResponse> parts =
|
||||
participantRepository.findByConversation(conv.getId()).stream()
|
||||
.map(p -> ConversationResponse.ParticipantResponse.builder()
|
||||
.membreId(p.getMembre().getId())
|
||||
.prenom(p.getMembre().getPrenom())
|
||||
.nom(p.getMembre().getNom())
|
||||
.roleDansConversation(p.getRoleDansConversation())
|
||||
.luJusqua(p.getLuJusqua())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long nonLus = messageRepository.countNonLus(conv.getId(), membreConnecteId);
|
||||
|
||||
return ConversationResponse.builder()
|
||||
.id(conv.getId())
|
||||
.typeConversation(conv.getTypeConversation().name())
|
||||
.titre(resolverTitre(conv, membreConnecteId))
|
||||
.statut(conv.getStatut().name())
|
||||
.roleCible(conv.getRoleCible())
|
||||
.organisationId(conv.getOrganisation().getId())
|
||||
.organisationNom(conv.getOrganisation().getNom())
|
||||
.dateCreation(conv.getDateCreation())
|
||||
.dernierMessageAt(conv.getDernierMessageAt())
|
||||
.nombreMessages(conv.getNombreMessages())
|
||||
.participants(parts)
|
||||
.messages(msgs)
|
||||
.nonLus(nonLus)
|
||||
.build();
|
||||
}
|
||||
|
||||
private ConversationSummaryResponse toConversationSummary(Conversation conv, UUID membreConnecteId) {
|
||||
String apercu = messageRepository.findDernierMessage(conv.getId())
|
||||
.map(m -> {
|
||||
if (TypeContenu.VOCAL.equals(m.getTypeMessage())) return "🎤 Note vocale";
|
||||
if (TypeContenu.IMAGE.equals(m.getTypeMessage())) return "📷 Image";
|
||||
String c = m.getContenu();
|
||||
return c != null && c.length() > 100 ? c.substring(0, 97) + "..." : c;
|
||||
})
|
||||
.orElse(null);
|
||||
|
||||
String dernierType = messageRepository.findDernierMessage(conv.getId())
|
||||
.map(m -> m.getTypeMessage().name()).orElse(null);
|
||||
|
||||
long nonLus = messageRepository.countNonLus(conv.getId(), membreConnecteId);
|
||||
|
||||
return ConversationSummaryResponse.builder()
|
||||
.id(conv.getId())
|
||||
.typeConversation(conv.getTypeConversation().name())
|
||||
.titre(resolverTitre(conv, membreConnecteId))
|
||||
.statut(conv.getStatut().name())
|
||||
.dernierMessageApercu(apercu)
|
||||
.dernierMessageType(dernierType)
|
||||
.dernierMessageAt(conv.getDernierMessageAt())
|
||||
.nonLus(nonLus)
|
||||
.organisationId(conv.getOrganisation().getId())
|
||||
.build();
|
||||
}
|
||||
|
||||
private MessageResponse toMessageResponse(Message message) {
|
||||
String contenuAffiche = message.estSupprime()
|
||||
? "[Message supprimé]"
|
||||
: message.getContenu();
|
||||
|
||||
String parentApercu = null;
|
||||
UUID parentId = null;
|
||||
if (message.getMessageParent() != null) {
|
||||
parentId = message.getMessageParent().getId();
|
||||
String pc = message.getMessageParent().getContenu();
|
||||
parentApercu = pc != null && pc.length() > 100 ? pc.substring(0, 97) + "..." : pc;
|
||||
}
|
||||
|
||||
return MessageResponse.builder()
|
||||
.id(message.getId())
|
||||
.typeMessage(message.getTypeMessage().name())
|
||||
.contenu(contenuAffiche)
|
||||
.urlFichier(message.estSupprime() ? null : message.getUrlFichier())
|
||||
.dureeAudio(message.getDureeAudio())
|
||||
.supprime(message.estSupprime())
|
||||
.expediteurId(message.getExpediteur().getId())
|
||||
.expediteurNom(message.getExpediteur().getNom())
|
||||
.expediteurPrenom(message.getExpediteur().getPrenom())
|
||||
.messageParentId(parentId)
|
||||
.messageParentApercu(parentApercu)
|
||||
.dateEnvoi(message.getDateCreation())
|
||||
.build();
|
||||
}
|
||||
|
||||
private ContactPolicyResponse toContactPolicyResponse(ContactPolicy policy) {
|
||||
return ContactPolicyResponse.builder()
|
||||
.id(policy.getId())
|
||||
.organisationId(policy.getOrganisation().getId())
|
||||
.typePolitique(policy.getTypePolitique().name())
|
||||
.autoriserMembreVersMembre(Boolean.TRUE.equals(policy.getAutoriserMembreVersMembre()))
|
||||
.autoriserMembreVersRole(Boolean.TRUE.equals(policy.getAutoriserMembreVersRole()))
|
||||
.autoriserNotesVocales(Boolean.TRUE.equals(policy.getAutoriserNotesVocales()))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout le titre affiché pour une conversation.
|
||||
* Pour DIRECTE : "Prénom Nom" de l'autre participant.
|
||||
* Pour ROLE_CANAL : le titre du canal.
|
||||
*/
|
||||
private String resolverTitre(Conversation conv, UUID membreConnecteId) {
|
||||
if (conv.getTitre() != null) return conv.getTitre();
|
||||
if (TypeConversation.DIRECTE.equals(conv.getTypeConversation())) {
|
||||
return participantRepository.findByConversation(conv.getId()).stream()
|
||||
.filter(p -> !p.getMembre().getId().equals(membreConnecteId))
|
||||
.findFirst()
|
||||
.map(p -> p.getMembre().getPrenom() + " " + p.getMembre().getNom())
|
||||
.orElse("Conversation");
|
||||
}
|
||||
return conv.getRoleCible();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,149 +1,85 @@
|
||||
package dev.lions.unionflow.server.repository;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.communication.ConversationType;
|
||||
import dev.lions.unionflow.server.api.enums.messagerie.TypeConversation;
|
||||
import dev.lions.unionflow.server.entity.Conversation;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import io.quarkus.test.TestTransaction;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import jakarta.inject.Inject;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests d'intégration pour {@link ConversationRepository}.
|
||||
* Couvre les 3 méthodes : findByParticipant, findByIdAndParticipant, findByOrganisation.
|
||||
*/
|
||||
@QuarkusTest
|
||||
@DisplayName("ConversationRepository")
|
||||
class ConversationRepositoryTest {
|
||||
|
||||
@Inject
|
||||
ConversationRepository conversationRepository;
|
||||
|
||||
@Inject
|
||||
OrganisationRepository organisationRepository;
|
||||
|
||||
private Organisation createOrganisation() {
|
||||
Organisation o = new Organisation();
|
||||
o.setNom("Org Conversation");
|
||||
o.setTypeOrganisation("ASSOCIATION");
|
||||
o.setStatut("ACTIVE");
|
||||
o.setEmail("conv-" + UUID.randomUUID() + "@test.com");
|
||||
o.setActif(true);
|
||||
o.setDateCreation(LocalDateTime.now());
|
||||
organisationRepository.persist(o);
|
||||
return o;
|
||||
}
|
||||
|
||||
private Conversation createConversation(String name, Organisation org) {
|
||||
Conversation c = new Conversation();
|
||||
c.setName(name);
|
||||
c.setType(ConversationType.GROUP);
|
||||
c.setOrganisation(org);
|
||||
c.setActif(true);
|
||||
c.setDateCreation(LocalDateTime.now());
|
||||
conversationRepository.persist(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// findByParticipant — branches avec includeArchived=true et false
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findByParticipant avec includeArchived=true retourne liste vide si aucune conversation")
|
||||
void findByParticipant_noConversations_returnsEmptyList() {
|
||||
UUID randomMembre = UUID.randomUUID();
|
||||
|
||||
List<Conversation> result = conversationRepository.findByParticipant(randomMembre, true);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result).isEmpty();
|
||||
@DisplayName("findById retourne null pour UUID inexistant")
|
||||
void findById_inexistant_returnsNull() {
|
||||
assertThat(conversationRepository.findById(UUID.randomUUID())).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findByParticipant avec includeArchived=false retourne liste vide si aucune conversation")
|
||||
void findByParticipant_excludeArchived_returnsEmptyList() {
|
||||
UUID randomMembre = UUID.randomUUID();
|
||||
|
||||
List<Conversation> result = conversationRepository.findByParticipant(randomMembre, false);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// findByIdAndParticipant
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findByIdAndParticipant retourne empty pour ID et membreId inconnus")
|
||||
void findByIdAndParticipant_unknownIds_returnsEmpty() {
|
||||
Optional<Conversation> result = conversationRepository.findByIdAndParticipant(
|
||||
UUID.randomUUID(), UUID.randomUUID());
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
@DisplayName("findConversationById retourne empty pour UUID inexistant")
|
||||
void findConversationById_inexistant_returnsEmpty() {
|
||||
Optional<Conversation> opt = conversationRepository.findConversationById(UUID.randomUUID());
|
||||
assertThat(opt).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findByIdAndParticipant retourne empty pour conversationId inexistant")
|
||||
void findByIdAndParticipant_nonExistentConversation_returnsEmpty() {
|
||||
UUID nonExistentId = UUID.randomUUID();
|
||||
UUID membreId = UUID.randomUUID();
|
||||
|
||||
Optional<Conversation> result = conversationRepository.findByIdAndParticipant(
|
||||
nonExistentId, membreId);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// findByOrganisation
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findByOrganisation retourne liste vide si organisation sans conversation")
|
||||
void findByOrganisation_noConversations_returnsEmptyList() {
|
||||
Organisation org = createOrganisation();
|
||||
|
||||
List<Conversation> result = conversationRepository.findByOrganisation(org.getId());
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result).isEmpty();
|
||||
@DisplayName("findByMembreId retourne liste non nulle pour membre inexistant")
|
||||
void findByMembreId_inconnu_returnsEmpty() {
|
||||
List<Conversation> list = conversationRepository.findByMembreId(UUID.randomUUID());
|
||||
assertThat(list).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findByOrganisation retourne les conversations de l'organisation persistées")
|
||||
void findByOrganisation_withConversations_returnsList() {
|
||||
Organisation org = createOrganisation();
|
||||
createConversation("Conv1-" + UUID.randomUUID(), org);
|
||||
createConversation("Conv2-" + UUID.randomUUID(), org);
|
||||
|
||||
List<Conversation> result = conversationRepository.findByOrganisation(org.getId());
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result).hasSize(2);
|
||||
@DisplayName("findConversationDirecte retourne empty si aucune conversation")
|
||||
void findConversationDirecte_inconnu_returnsEmpty() {
|
||||
Optional<Conversation> opt = conversationRepository.findConversationDirecte(
|
||||
UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID());
|
||||
assertThat(opt).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findByOrganisation retourne liste vide pour organisationId inexistant")
|
||||
void findByOrganisation_unknownOrganisationId_returnsEmptyList() {
|
||||
List<Conversation> result = conversationRepository.findByOrganisation(UUID.randomUUID());
|
||||
@DisplayName("findCanalRole retourne empty si aucun canal")
|
||||
void findCanalRole_inconnu_returnsEmpty() {
|
||||
Optional<Conversation> opt = conversationRepository.findCanalRole(UUID.randomUUID(), "TRESORIER");
|
||||
assertThat(opt).isEmpty();
|
||||
}
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result).isEmpty();
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findActivesByMembre retourne liste non nulle")
|
||||
void findActivesByMembre_returnsNonNull() {
|
||||
List<Conversation> list = conversationRepository.findActivesByMembre(UUID.randomUUID());
|
||||
assertThat(list).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("count retourne un nombre >= 0")
|
||||
void count_returnsNonNegative() {
|
||||
assertThat(conversationRepository.count()).isGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("listAll retourne une liste non nulle")
|
||||
void listAll_returnsNonNull() {
|
||||
assertThat(conversationRepository.listAll()).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,256 +1,69 @@
|
||||
package dev.lions.unionflow.server.repository;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.communication.ConversationType;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessagePriority;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessageType;
|
||||
import dev.lions.unionflow.server.entity.Conversation;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.Message;
|
||||
import io.quarkus.test.TestTransaction;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@QuarkusTest
|
||||
@DisplayName("MessageRepository — tests des méthodes de requête")
|
||||
@DisplayName("MessageRepository")
|
||||
class MessageRepositoryTest {
|
||||
|
||||
@Inject
|
||||
MessageRepository messageRepository;
|
||||
|
||||
@Inject
|
||||
EntityManager em;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Membre persistMembre() {
|
||||
Membre m = new Membre();
|
||||
m.setNumeroMembre("MSG-MEM-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase());
|
||||
m.setPrenom("Jean");
|
||||
m.setNom("Messagerie");
|
||||
m.setEmail("msg." + UUID.randomUUID() + "@test.com");
|
||||
m.setDateNaissance(LocalDate.of(1990, 1, 1));
|
||||
m.setActif(true);
|
||||
em.persist(m);
|
||||
em.flush();
|
||||
return m;
|
||||
}
|
||||
|
||||
private Conversation persistConversation() {
|
||||
Conversation conv = new Conversation();
|
||||
conv.setName("Conv Test " + UUID.randomUUID().toString().substring(0, 8));
|
||||
conv.setType(ConversationType.GROUP);
|
||||
conv.setActif(true);
|
||||
em.persist(conv);
|
||||
em.flush();
|
||||
return conv;
|
||||
}
|
||||
|
||||
private Message persistMessage(Conversation conv, Membre sender, MessageStatus status) {
|
||||
Message msg = new Message();
|
||||
msg.setConversation(conv);
|
||||
msg.setSender(sender);
|
||||
msg.setSenderName(sender.getPrenom() + " " + sender.getNom());
|
||||
msg.setContent("Contenu test " + UUID.randomUUID());
|
||||
msg.setType(MessageType.INDIVIDUAL);
|
||||
msg.setStatus(status);
|
||||
msg.setPriority(MessagePriority.NORMAL);
|
||||
msg.setIsEdited(false);
|
||||
msg.setIsDeleted(false);
|
||||
msg.setActif(true);
|
||||
em.persist(msg);
|
||||
em.flush();
|
||||
return msg;
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findByConversation retourne les messages actifs non supprimés")
|
||||
void findByConversation_retourneMessagesActifs() {
|
||||
Membre sender = persistMembre();
|
||||
Conversation conv = persistConversation();
|
||||
persistMessage(conv, sender, MessageStatus.SENT);
|
||||
persistMessage(conv, sender, MessageStatus.READ);
|
||||
|
||||
List<Message> messages = messageRepository.findByConversation(conv.getId(), 10);
|
||||
|
||||
assertThat(messages).isNotNull();
|
||||
assertThat(messages).hasSizeGreaterThanOrEqualTo(2);
|
||||
messages.forEach(m -> assertThat(m.getIsDeleted()).isFalse());
|
||||
@DisplayName("findMessageById retourne empty pour UUID inexistant")
|
||||
void findMessageById_inexistant_returnsEmpty() {
|
||||
Optional<Message> opt = messageRepository.findMessageById(UUID.randomUUID());
|
||||
assertThat(opt).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findByConversation respecte la limite de pagination")
|
||||
void findByConversation_respecteLimite() {
|
||||
Membre sender = persistMembre();
|
||||
Conversation conv = persistConversation();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
persistMessage(conv, sender, MessageStatus.SENT);
|
||||
}
|
||||
|
||||
List<Message> messages = messageRepository.findByConversation(conv.getId(), 3);
|
||||
|
||||
assertThat(messages).hasSizeLessThanOrEqualTo(3);
|
||||
@DisplayName("findByConversationPagine retourne liste vide pour conversation inexistante")
|
||||
void findByConversationPagine_returnsEmpty() {
|
||||
List<Message> list = messageRepository.findByConversationPagine(UUID.randomUUID(), 0, 20);
|
||||
assertThat(list).isNotNull().isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findByConversation retourne liste vide pour conversation sans messages")
|
||||
void findByConversation_conversationVide_retourneListe() {
|
||||
Conversation conv = persistConversation();
|
||||
|
||||
List<Message> messages = messageRepository.findByConversation(conv.getId(), 10);
|
||||
|
||||
assertThat(messages).isNotNull();
|
||||
assertThat(messages).isEmpty();
|
||||
@DisplayName("countNonLus retourne >= 0 pour conversation inexistante")
|
||||
void countNonLus_returnsNonNegative() {
|
||||
long count = messageRepository.countNonLus(UUID.randomUUID(), UUID.randomUUID());
|
||||
assertThat(count).isGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findByConversation exclut les messages supprimés")
|
||||
void findByConversation_exclutMessagesSupprimés() {
|
||||
Membre sender = persistMembre();
|
||||
Conversation conv = persistConversation();
|
||||
// Message supprimé
|
||||
Message msgSupprime = persistMessage(conv, sender, MessageStatus.SENT);
|
||||
msgSupprime.setIsDeleted(true);
|
||||
em.flush();
|
||||
|
||||
List<Message> messages = messageRepository.findByConversation(conv.getId(), 10);
|
||||
|
||||
assertThat(messages).noneMatch(m -> m.getId().equals(msgSupprime.getId()));
|
||||
@DisplayName("findActifsByConversation retourne liste vide pour conversation inexistante")
|
||||
void findActifsByConversation_returnsEmpty() {
|
||||
List<Message> list = messageRepository.findActifsByConversation(UUID.randomUUID());
|
||||
assertThat(list).isNotNull().isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("countUnreadByConversationAndMember compte les messages SENT et DELIVERED d'autres membres")
|
||||
void countUnreadByConversationAndMember_compteMsgNonLusAutresMembres() {
|
||||
Membre sender = persistMembre();
|
||||
Membre reader = persistMembre();
|
||||
Conversation conv = persistConversation();
|
||||
persistMessage(conv, sender, MessageStatus.SENT);
|
||||
persistMessage(conv, sender, MessageStatus.DELIVERED);
|
||||
persistMessage(conv, sender, MessageStatus.READ); // déjà lu → exclu
|
||||
|
||||
long count = messageRepository.countUnreadByConversationAndMember(conv.getId(), reader.getId());
|
||||
|
||||
assertThat(count).isGreaterThanOrEqualTo(2);
|
||||
@DisplayName("findDernierMessage retourne empty pour conversation inexistante")
|
||||
void findDernierMessage_returnsEmpty() {
|
||||
Optional<Message> opt = messageRepository.findDernierMessage(UUID.randomUUID());
|
||||
assertThat(opt).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("countUnreadByConversationAndMember exclut les messages du membre lui-même")
|
||||
void countUnreadByConversationAndMember_excluMessagesPropresMembre() {
|
||||
Membre sender = persistMembre();
|
||||
Conversation conv = persistConversation();
|
||||
persistMessage(conv, sender, MessageStatus.SENT);
|
||||
|
||||
// Le sender lui-même : ses propres messages ne sont pas comptés comme non lus
|
||||
long count = messageRepository.countUnreadByConversationAndMember(conv.getId(), sender.getId());
|
||||
|
||||
assertThat(count).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("countUnreadByConversationAndMember retourne 0 pour conversation vide")
|
||||
void countUnreadByConversationAndMember_conversationVide_retourneZero() {
|
||||
Conversation conv = persistConversation();
|
||||
UUID membreId = UUID.randomUUID();
|
||||
|
||||
long count = messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId);
|
||||
|
||||
assertThat(count).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("markAllAsReadByConversationAndMember marque les messages SENT et DELIVERED en READ")
|
||||
void markAllAsReadByConversationAndMember_marqueLesMessagesEnRead() {
|
||||
Membre sender = persistMembre();
|
||||
Membre reader = persistMembre();
|
||||
Conversation conv = persistConversation();
|
||||
persistMessage(conv, sender, MessageStatus.SENT);
|
||||
persistMessage(conv, sender, MessageStatus.DELIVERED);
|
||||
|
||||
int updated = messageRepository.markAllAsReadByConversationAndMember(conv.getId(), reader.getId());
|
||||
|
||||
assertThat(updated).isGreaterThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("markAllAsReadByConversationAndMember ne touche pas les messages déjà READ")
|
||||
void markAllAsReadByConversationAndMember_neTouchePasMsgDejaRead() {
|
||||
Membre sender = persistMembre();
|
||||
Membre reader = persistMembre();
|
||||
Conversation conv = persistConversation();
|
||||
persistMessage(conv, sender, MessageStatus.READ); // déjà lu
|
||||
|
||||
int updated = messageRepository.markAllAsReadByConversationAndMember(conv.getId(), reader.getId());
|
||||
|
||||
assertThat(updated).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findLastByConversation retourne le dernier message")
|
||||
void findLastByConversation_retourneDernierMessage() {
|
||||
Membre sender = persistMembre();
|
||||
Conversation conv = persistConversation();
|
||||
// Set explicit dateCreation to guarantee ordering (PrePersist skips if non-null)
|
||||
Message premierMsg = new Message();
|
||||
premierMsg.setConversation(conv);
|
||||
premierMsg.setSender(sender);
|
||||
premierMsg.setSenderName(sender.getPrenom() + " " + sender.getNom());
|
||||
premierMsg.setContent("Premier message");
|
||||
premierMsg.setType(dev.lions.unionflow.server.api.enums.communication.MessageType.INDIVIDUAL);
|
||||
premierMsg.setStatus(MessageStatus.SENT);
|
||||
premierMsg.setPriority(dev.lions.unionflow.server.api.enums.communication.MessagePriority.NORMAL);
|
||||
premierMsg.setIsEdited(false);
|
||||
premierMsg.setIsDeleted(false);
|
||||
premierMsg.setActif(true);
|
||||
premierMsg.setDateCreation(java.time.LocalDateTime.now().minusSeconds(10));
|
||||
em.persist(premierMsg);
|
||||
em.flush();
|
||||
|
||||
Message dernierMsg = persistMessage(conv, sender, MessageStatus.DELIVERED);
|
||||
|
||||
Message last = messageRepository.findLastByConversation(conv.getId());
|
||||
|
||||
assertThat(last).isNotNull();
|
||||
assertThat(last.getId()).isEqualTo(dernierMsg.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findLastByConversation retourne null pour conversation vide")
|
||||
void findLastByConversation_conversationVide_retourneNull() {
|
||||
Conversation conv = persistConversation();
|
||||
|
||||
Message last = messageRepository.findLastByConversation(conv.getId());
|
||||
|
||||
assertThat(last).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestTransaction
|
||||
@DisplayName("findLastByConversation retourne null pour conversation inexistante")
|
||||
void findLastByConversation_conversationInexistante_retourneNull() {
|
||||
Message last = messageRepository.findLastByConversation(UUID.randomUUID());
|
||||
|
||||
assertThat(last).isNull();
|
||||
@DisplayName("count retourne un nombre >= 0")
|
||||
void count_returnsNonNegative() {
|
||||
assertThat(messageRepository.count()).isGreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,562 +0,0 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.communication.request.CreateConversationRequest;
|
||||
import dev.lions.unionflow.server.api.dto.communication.response.ConversationResponse;
|
||||
import dev.lions.unionflow.server.api.enums.communication.ConversationType;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessageType;
|
||||
import dev.lions.unionflow.server.entity.Conversation;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.Message;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.repository.ConversationRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.unionflow.server.repository.MessageRepository;
|
||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||
import io.quarkus.test.InjectMock;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.quarkus.test.junit.mockito.InjectSpy;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour ConversationService
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 1.0
|
||||
* @since 2026-03-20
|
||||
*/
|
||||
@QuarkusTest
|
||||
@TestMethodOrder(MethodOrderer.DisplayName.class)
|
||||
class ConversationServiceTest {
|
||||
|
||||
@Inject
|
||||
ConversationService conversationService;
|
||||
|
||||
@InjectMock
|
||||
ConversationRepository conversationRepository;
|
||||
|
||||
@InjectMock
|
||||
MessageRepository messageRepository;
|
||||
|
||||
@InjectSpy
|
||||
MembreRepository membreRepository;
|
||||
|
||||
@InjectSpy
|
||||
OrganisationRepository organisationRepository;
|
||||
|
||||
@InjectMock
|
||||
EntityManager entityManager;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private Conversation mockConversation() {
|
||||
Conversation c = new Conversation();
|
||||
c.setId(UUID.randomUUID());
|
||||
c.setName("Test Conv");
|
||||
c.setIsMuted(false);
|
||||
c.setIsPinned(false);
|
||||
c.setIsArchived(false);
|
||||
c.setParticipants(new ArrayList<>());
|
||||
return c;
|
||||
}
|
||||
|
||||
private Message mockMessage(Conversation conv) {
|
||||
Message msg = new Message();
|
||||
msg.setId(UUID.randomUUID());
|
||||
msg.setConversation(conv);
|
||||
Membre sender = new Membre();
|
||||
sender.setId(UUID.randomUUID());
|
||||
msg.setSender(sender);
|
||||
msg.setSenderName("Test Sender");
|
||||
msg.setContent("Hello");
|
||||
msg.setType(MessageType.INDIVIDUAL);
|
||||
msg.setStatus(MessageStatus.SENT);
|
||||
msg.setIsEdited(false);
|
||||
msg.setIsDeleted(false);
|
||||
return msg;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getConversations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("getConversations_withOrgId_callsByOrganisation")
|
||||
void getConversations_withOrgId_callsByOrganisation() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
UUID orgId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
|
||||
when(conversationRepository.findByOrganisation(orgId)).thenReturn(List.of(conv));
|
||||
when(messageRepository.findLastByConversation(conv.getId())).thenReturn(null);
|
||||
when(messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId)).thenReturn(0L);
|
||||
|
||||
List<ConversationResponse> result = conversationService.getConversations(membreId, orgId, false);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
verify(conversationRepository).findByOrganisation(orgId);
|
||||
verify(conversationRepository, never()).findByParticipant(any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getConversations_withoutOrgId_callsByParticipant")
|
||||
void getConversations_withoutOrgId_callsByParticipant() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
|
||||
when(conversationRepository.findByParticipant(membreId, false)).thenReturn(List.of(conv));
|
||||
when(messageRepository.findLastByConversation(conv.getId())).thenReturn(null);
|
||||
when(messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId)).thenReturn(0L);
|
||||
|
||||
List<ConversationResponse> result = conversationService.getConversations(membreId, null, false);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
verify(conversationRepository).findByParticipant(membreId, false);
|
||||
verify(conversationRepository, never()).findByOrganisation(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getConversations_includesLastMessageAndUnread")
|
||||
void getConversations_includesLastMessageAndUnread() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
Message lastMsg = mockMessage(conv);
|
||||
|
||||
when(conversationRepository.findByParticipant(membreId, true)).thenReturn(List.of(conv));
|
||||
when(messageRepository.findLastByConversation(conv.getId())).thenReturn(lastMsg);
|
||||
when(messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId)).thenReturn(3L);
|
||||
|
||||
List<ConversationResponse> result = conversationService.getConversations(membreId, null, true);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
ConversationResponse response = result.get(0);
|
||||
assertThat(response.getLastMessage()).isNotNull();
|
||||
assertThat(response.getLastMessage().getContent()).isEqualTo("Hello");
|
||||
assertThat(response.getUnreadCount()).isEqualTo(3);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getConversationById
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("getConversationById_notFound_throwsNotFound")
|
||||
void getConversationById_notFound_throwsNotFound() {
|
||||
UUID convId = UUID.randomUUID();
|
||||
UUID membreId = UUID.randomUUID();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(convId, membreId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> conversationService.getConversationById(convId, membreId))
|
||||
.isInstanceOf(NotFoundException.class)
|
||||
.hasMessageContaining("Conversation non trouvée");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getConversationById_found_returnsResponse")
|
||||
void getConversationById_found_returnsResponse() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
when(messageRepository.findLastByConversation(conv.getId())).thenReturn(null);
|
||||
when(messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId)).thenReturn(0L);
|
||||
|
||||
ConversationResponse response = conversationService.getConversationById(conv.getId(), membreId);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getId()).isEqualTo(conv.getId());
|
||||
assertThat(response.getName()).isEqualTo("Test Conv");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// createConversation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("createConversation_withoutOrg_success")
|
||||
void createConversation_withoutOrg_success() {
|
||||
UUID creatorId = UUID.randomUUID();
|
||||
Membre creator = new Membre();
|
||||
creator.setId(creatorId);
|
||||
|
||||
CreateConversationRequest request = CreateConversationRequest.builder()
|
||||
.name("New Conv")
|
||||
.description("desc")
|
||||
.type(ConversationType.GROUP)
|
||||
.participantIds(new ArrayList<>())
|
||||
.organisationId(null)
|
||||
.build();
|
||||
|
||||
when(entityManager.find(Membre.class, creatorId)).thenReturn(creator);
|
||||
when(messageRepository.findLastByConversation(any())).thenReturn(null);
|
||||
when(messageRepository.countUnreadByConversationAndMember(any(), eq(creatorId))).thenReturn(0L);
|
||||
|
||||
ConversationResponse response = conversationService.createConversation(request, creatorId);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getName()).isEqualTo("New Conv");
|
||||
verify(conversationRepository).persist(any(Conversation.class));
|
||||
verify(entityManager, never()).find(eq(Organisation.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createConversation_withOrg_setsOrganisation")
|
||||
void createConversation_withOrg_setsOrganisation() {
|
||||
UUID creatorId = UUID.randomUUID();
|
||||
UUID orgId = UUID.randomUUID();
|
||||
Membre creator = new Membre();
|
||||
creator.setId(creatorId);
|
||||
Organisation org = new Organisation();
|
||||
org.setId(orgId);
|
||||
|
||||
CreateConversationRequest request = CreateConversationRequest.builder()
|
||||
.name("Org Conv")
|
||||
.description(null)
|
||||
.type(ConversationType.BROADCAST)
|
||||
.participantIds(new ArrayList<>())
|
||||
.organisationId(orgId)
|
||||
.build();
|
||||
|
||||
when(entityManager.find(Membre.class, creatorId)).thenReturn(creator);
|
||||
when(entityManager.find(Organisation.class, orgId)).thenReturn(org);
|
||||
when(messageRepository.findLastByConversation(any())).thenReturn(null);
|
||||
when(messageRepository.countUnreadByConversationAndMember(any(), eq(creatorId))).thenReturn(0L);
|
||||
|
||||
ConversationResponse response = conversationService.createConversation(request, creatorId);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getOrganisationId()).isEqualTo(orgId);
|
||||
verify(entityManager).find(Organisation.class, orgId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createConversation_creatorNotInList_addsCreator")
|
||||
void createConversation_creatorNotInList_addsCreator() {
|
||||
UUID creatorId = UUID.randomUUID();
|
||||
UUID participant1Id = UUID.randomUUID();
|
||||
Membre creator = new Membre();
|
||||
creator.setId(creatorId);
|
||||
Membre participant1 = new Membre();
|
||||
participant1.setId(participant1Id);
|
||||
|
||||
CreateConversationRequest request = CreateConversationRequest.builder()
|
||||
.name("Conv")
|
||||
.description(null)
|
||||
.type(ConversationType.INDIVIDUAL)
|
||||
.participantIds(new ArrayList<>(List.of(participant1Id)))
|
||||
.organisationId(null)
|
||||
.build();
|
||||
|
||||
when(entityManager.find(Membre.class, participant1Id)).thenReturn(participant1);
|
||||
when(entityManager.find(Membre.class, creatorId)).thenReturn(creator);
|
||||
when(messageRepository.findLastByConversation(any())).thenReturn(null);
|
||||
when(messageRepository.countUnreadByConversationAndMember(any(), eq(creatorId))).thenReturn(0L);
|
||||
|
||||
ConversationResponse response = conversationService.createConversation(request, creatorId);
|
||||
|
||||
// Creator + participant1 = 2 participants
|
||||
assertThat(response.getParticipantIds()).hasSize(2);
|
||||
assertThat(response.getParticipantIds()).contains(creatorId, participant1Id);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createConversation_creatorAlreadyInList_doesNotDuplicate")
|
||||
void createConversation_creatorAlreadyInList_doesNotDuplicate() {
|
||||
UUID creatorId = UUID.randomUUID();
|
||||
Membre creator = new Membre();
|
||||
creator.setId(creatorId);
|
||||
|
||||
CreateConversationRequest request = CreateConversationRequest.builder()
|
||||
.name("Conv")
|
||||
.description(null)
|
||||
.type(ConversationType.INDIVIDUAL)
|
||||
.participantIds(new ArrayList<>(List.of(creatorId)))
|
||||
.organisationId(null)
|
||||
.build();
|
||||
|
||||
// findById(creatorId) appelé 2 fois: une pour le participant, une pour le créateur
|
||||
when(entityManager.find(Membre.class, creatorId)).thenReturn(creator);
|
||||
when(messageRepository.findLastByConversation(any())).thenReturn(null);
|
||||
when(messageRepository.countUnreadByConversationAndMember(any(), eq(creatorId))).thenReturn(0L);
|
||||
|
||||
ConversationResponse response = conversationService.createConversation(request, creatorId);
|
||||
|
||||
// Le créateur ne doit pas être dupliqué
|
||||
assertThat(response.getParticipantIds()).hasSize(1);
|
||||
assertThat(response.getParticipantIds()).containsExactly(creatorId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createConversation_participantNotFound_filtersNull")
|
||||
void createConversation_participantNotFound_filtersNull() {
|
||||
UUID creatorId = UUID.randomUUID();
|
||||
UUID unknownId = UUID.randomUUID();
|
||||
Membre creator = new Membre();
|
||||
creator.setId(creatorId);
|
||||
|
||||
CreateConversationRequest request = CreateConversationRequest.builder()
|
||||
.name("Conv")
|
||||
.description(null)
|
||||
.type(ConversationType.GROUP)
|
||||
.participantIds(new ArrayList<>(List.of(unknownId)))
|
||||
.organisationId(null)
|
||||
.build();
|
||||
|
||||
when(entityManager.find(Membre.class, creatorId)).thenReturn(creator);
|
||||
when(messageRepository.findLastByConversation(any())).thenReturn(null);
|
||||
when(messageRepository.countUnreadByConversationAndMember(any(), eq(creatorId))).thenReturn(0L);
|
||||
|
||||
ConversationResponse response = conversationService.createConversation(request, creatorId);
|
||||
|
||||
// unknownId est filtré, seul le créateur reste
|
||||
assertThat(response.getParticipantIds()).hasSize(1);
|
||||
assertThat(response.getParticipantIds()).containsExactly(creatorId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createConversation_creatorNotFound_doesNotAddCreator (creator == null → L97 false)")
|
||||
void createConversation_creatorNull_doesNotAddCreator() {
|
||||
UUID creatorId = UUID.randomUUID();
|
||||
|
||||
CreateConversationRequest request = CreateConversationRequest.builder()
|
||||
.name("No Creator Conv")
|
||||
.description(null)
|
||||
.type(ConversationType.GROUP)
|
||||
.participantIds(new ArrayList<>())
|
||||
.organisationId(null)
|
||||
.build();
|
||||
|
||||
// creator == null → condition L97: creator != null = false → pas d'ajout du créateur
|
||||
when(entityManager.find(Membre.class, creatorId)).thenReturn(null);
|
||||
when(messageRepository.findLastByConversation(any())).thenReturn(null);
|
||||
when(messageRepository.countUnreadByConversationAndMember(any(), eq(creatorId))).thenReturn(0L);
|
||||
|
||||
ConversationResponse response = conversationService.createConversation(request, creatorId);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
// Aucun participant car creator introuvable et liste vide
|
||||
assertThat(response.getParticipantIds()).isEmpty();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// archiveConversation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("archiveConversation_notFound_throwsNotFound")
|
||||
void archiveConversation_notFound_throwsNotFound() {
|
||||
UUID convId = UUID.randomUUID();
|
||||
UUID membreId = UUID.randomUUID();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(convId, membreId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> conversationService.archiveConversation(convId, membreId, true))
|
||||
.isInstanceOf(NotFoundException.class)
|
||||
.hasMessageContaining("Conversation non trouvée");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("archiveConversation_archive_setsTrue")
|
||||
void archiveConversation_archive_setsTrue() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
conv.setIsArchived(false);
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
|
||||
conversationService.archiveConversation(conv.getId(), membreId, true);
|
||||
|
||||
assertThat(conv.getIsArchived()).isTrue();
|
||||
verify(conversationRepository).persist(conv);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("archiveConversation_unarchive_setsFalse")
|
||||
void archiveConversation_unarchive_setsFalse() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
conv.setIsArchived(true);
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
|
||||
conversationService.archiveConversation(conv.getId(), membreId, false);
|
||||
|
||||
assertThat(conv.getIsArchived()).isFalse();
|
||||
verify(conversationRepository).persist(conv);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// markAsRead
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("markAsRead_notFound_throwsNotFound")
|
||||
void markAsRead_notFound_throwsNotFound() {
|
||||
UUID convId = UUID.randomUUID();
|
||||
UUID membreId = UUID.randomUUID();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(convId, membreId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> conversationService.markAsRead(convId, membreId))
|
||||
.isInstanceOf(NotFoundException.class)
|
||||
.hasMessageContaining("Conversation non trouvée");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("markAsRead_success_callsRepo")
|
||||
void markAsRead_success_callsRepo() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
when(messageRepository.markAllAsReadByConversationAndMember(conv.getId(), membreId)).thenReturn(5);
|
||||
|
||||
conversationService.markAsRead(conv.getId(), membreId);
|
||||
|
||||
verify(messageRepository).markAllAsReadByConversationAndMember(conv.getId(), membreId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// toggleMute
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("toggleMute_notFound_throwsNotFound")
|
||||
void toggleMute_notFound_throwsNotFound() {
|
||||
UUID convId = UUID.randomUUID();
|
||||
UUID membreId = UUID.randomUUID();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(convId, membreId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> conversationService.toggleMute(convId, membreId))
|
||||
.isInstanceOf(NotFoundException.class)
|
||||
.hasMessageContaining("Conversation non trouvée");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("toggleMute_false_setsTrue")
|
||||
void toggleMute_false_setsTrue() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
conv.setIsMuted(false);
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
|
||||
conversationService.toggleMute(conv.getId(), membreId);
|
||||
|
||||
assertThat(conv.getIsMuted()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("toggleMute_true_setsFalse")
|
||||
void toggleMute_true_setsFalse() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
conv.setIsMuted(true);
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
|
||||
conversationService.toggleMute(conv.getId(), membreId);
|
||||
|
||||
assertThat(conv.getIsMuted()).isFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// togglePin
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("togglePin_notFound_throwsNotFound")
|
||||
void togglePin_notFound_throwsNotFound() {
|
||||
UUID convId = UUID.randomUUID();
|
||||
UUID membreId = UUID.randomUUID();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(convId, membreId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> conversationService.togglePin(convId, membreId))
|
||||
.isInstanceOf(NotFoundException.class)
|
||||
.hasMessageContaining("Conversation non trouvée");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("togglePin_false_setsTrue")
|
||||
void togglePin_false_setsTrue() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
conv.setIsPinned(false);
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
|
||||
conversationService.togglePin(conv.getId(), membreId);
|
||||
|
||||
assertThat(conv.getIsPinned()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("togglePin_true_setsFalse")
|
||||
void togglePin_true_setsFalse() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
conv.setIsPinned(true);
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
|
||||
conversationService.togglePin(conv.getId(), membreId);
|
||||
|
||||
assertThat(conv.getIsPinned()).isFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// convertToResponse (via getConversationById)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToResponse_withLastMessage_includesMessage")
|
||||
void convertToResponse_withLastMessage_includesMessage() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
Message lastMsg = mockMessage(conv);
|
||||
lastMsg.setContent("Dernier message");
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
when(messageRepository.findLastByConversation(conv.getId())).thenReturn(lastMsg);
|
||||
when(messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId)).thenReturn(0L);
|
||||
|
||||
ConversationResponse response = conversationService.getConversationById(conv.getId(), membreId);
|
||||
|
||||
assertThat(response.getLastMessage()).isNotNull();
|
||||
assertThat(response.getLastMessage().getContent()).isEqualTo("Dernier message");
|
||||
assertThat(response.getLastMessage().getId()).isEqualTo(lastMsg.getId());
|
||||
assertThat(response.getLastMessage().getSenderId()).isEqualTo(lastMsg.getSender().getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToResponse_noLastMessage_nullLastMessage")
|
||||
void convertToResponse_noLastMessage_nullLastMessage() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
when(messageRepository.findLastByConversation(conv.getId())).thenReturn(null);
|
||||
when(messageRepository.countUnreadByConversationAndMember(conv.getId(), membreId)).thenReturn(0L);
|
||||
|
||||
ConversationResponse response = conversationService.getConversationById(conv.getId(), membreId);
|
||||
|
||||
assertThat(response.getLastMessage()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -1,621 +0,0 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.communication.request.SendMessageRequest;
|
||||
import dev.lions.unionflow.server.api.dto.communication.response.MessageResponse;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessagePriority;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessageStatus;
|
||||
import dev.lions.unionflow.server.api.enums.communication.MessageType;
|
||||
import dev.lions.unionflow.server.entity.Conversation;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.Message;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.repository.ConversationRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.unionflow.server.repository.MessageRepository;
|
||||
import io.quarkus.test.InjectMock;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.quarkus.test.junit.mockito.InjectSpy;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Tests unitaires pour MessageService
|
||||
*
|
||||
* @author UnionFlow Team
|
||||
* @version 1.0
|
||||
* @since 2026-03-20
|
||||
*/
|
||||
@QuarkusTest
|
||||
@TestMethodOrder(MethodOrderer.DisplayName.class)
|
||||
class MessageServiceTest {
|
||||
|
||||
@Inject
|
||||
MessageService messageService;
|
||||
|
||||
@InjectSpy
|
||||
MessageRepository messageRepository;
|
||||
|
||||
@InjectMock
|
||||
ConversationRepository conversationRepository;
|
||||
|
||||
@InjectSpy
|
||||
MembreRepository membreRepository;
|
||||
|
||||
@InjectMock
|
||||
EntityManager entityManager;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private Conversation mockConversation() {
|
||||
Conversation c = new Conversation();
|
||||
c.setId(UUID.randomUUID());
|
||||
c.setParticipants(new ArrayList<>());
|
||||
return c;
|
||||
}
|
||||
|
||||
private Membre mockMembre(UUID id) {
|
||||
Membre m = new Membre();
|
||||
m.setId(id);
|
||||
m.setPrenom("Jean");
|
||||
m.setNom("Dupont");
|
||||
return m;
|
||||
}
|
||||
|
||||
private Message mockMessage(UUID senderId) {
|
||||
Message msg = new Message();
|
||||
msg.setId(UUID.randomUUID());
|
||||
Conversation conv = mockConversation();
|
||||
msg.setConversation(conv);
|
||||
Membre sender = mockMembre(senderId);
|
||||
msg.setSender(sender);
|
||||
msg.setSenderName("Jean Dupont");
|
||||
msg.setContent("Hello");
|
||||
msg.setType(MessageType.INDIVIDUAL);
|
||||
msg.setStatus(MessageStatus.SENT);
|
||||
msg.setPriority(MessagePriority.NORMAL);
|
||||
msg.setIsEdited(false);
|
||||
msg.setIsDeleted(false);
|
||||
return msg;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getMessages
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("getMessages_conversationNotFound_throwsNotFound")
|
||||
void getMessages_conversationNotFound_throwsNotFound() {
|
||||
UUID convId = UUID.randomUUID();
|
||||
UUID membreId = UUID.randomUUID();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(convId, membreId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> messageService.getMessages(convId, membreId, 20))
|
||||
.isInstanceOf(NotFoundException.class)
|
||||
.hasMessageContaining("Conversation non trouvée");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getMessages_found_returnsList")
|
||||
void getMessages_found_returnsList() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
Message msg = mockMessage(UUID.randomUUID());
|
||||
msg.setConversation(conv);
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
|
||||
|
||||
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getContent()).isEqualTo("Hello");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// sendMessage
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("sendMessage_conversationNotFound_throwsNotFound")
|
||||
void sendMessage_conversationNotFound_throwsNotFound() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
UUID convId = UUID.randomUUID();
|
||||
|
||||
SendMessageRequest request = SendMessageRequest.builder()
|
||||
.conversationId(convId)
|
||||
.content("test")
|
||||
.build();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(convId, senderId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> messageService.sendMessage(request, senderId))
|
||||
.isInstanceOf(NotFoundException.class)
|
||||
.hasMessageContaining("Conversation non trouvée");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sendMessage_senderNotFound_throwsNotFound")
|
||||
void sendMessage_senderNotFound_throwsNotFound() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
|
||||
SendMessageRequest request = SendMessageRequest.builder()
|
||||
.conversationId(conv.getId())
|
||||
.content("test")
|
||||
.build();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
|
||||
// entityManager.find returns null by default → findById(senderId) returns null → service throws
|
||||
|
||||
assertThatThrownBy(() -> messageService.sendMessage(request, senderId))
|
||||
.isInstanceOf(NotFoundException.class)
|
||||
.hasMessageContaining("Expéditeur non trouvé");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sendMessage_withDefaultTypeAndPriority_success")
|
||||
void sendMessage_withDefaultTypeAndPriority_success() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
Membre sender = mockMembre(senderId);
|
||||
|
||||
SendMessageRequest request = SendMessageRequest.builder()
|
||||
.conversationId(conv.getId())
|
||||
.content("Bonjour")
|
||||
.type(null)
|
||||
.priority(null)
|
||||
.recipientIds(null)
|
||||
.recipientRoles(null)
|
||||
.attachments(null)
|
||||
.build();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
|
||||
when(entityManager.find(Membre.class, senderId)).thenReturn(sender);
|
||||
|
||||
MessageResponse response = messageService.sendMessage(request, senderId);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.getContent()).isEqualTo("Bonjour");
|
||||
assertThat(response.getType()).isEqualTo(MessageType.INDIVIDUAL);
|
||||
assertThat(response.getPriority()).isEqualTo(MessagePriority.NORMAL);
|
||||
assertThat(response.getSenderName()).isEqualTo("Jean Dupont");
|
||||
verify(messageRepository).persist(any(Message.class));
|
||||
verify(conversationRepository).persist(conv);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sendMessage_withExplicitType_usesType")
|
||||
void sendMessage_withExplicitType_usesType() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
Membre sender = mockMembre(senderId);
|
||||
|
||||
SendMessageRequest request = SendMessageRequest.builder()
|
||||
.conversationId(conv.getId())
|
||||
.content("Broadcast!")
|
||||
.type(MessageType.BROADCAST)
|
||||
.priority(MessagePriority.HIGH)
|
||||
.recipientIds(null)
|
||||
.recipientRoles(null)
|
||||
.attachments(null)
|
||||
.build();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
|
||||
when(entityManager.find(Membre.class, senderId)).thenReturn(sender);
|
||||
|
||||
MessageResponse response = messageService.sendMessage(request, senderId);
|
||||
|
||||
assertThat(response.getType()).isEqualTo(MessageType.BROADCAST);
|
||||
assertThat(response.getPriority()).isEqualTo(MessagePriority.HIGH);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sendMessage_withRecipientIds_setsCSV")
|
||||
void sendMessage_withRecipientIds_setsCSV() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
UUID recipient1 = UUID.randomUUID();
|
||||
UUID recipient2 = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
Membre sender = mockMembre(senderId);
|
||||
|
||||
SendMessageRequest request = SendMessageRequest.builder()
|
||||
.conversationId(conv.getId())
|
||||
.content("Targeted")
|
||||
.type(MessageType.TARGETED)
|
||||
.priority(null)
|
||||
.recipientIds(List.of(recipient1, recipient2))
|
||||
.recipientRoles(null)
|
||||
.attachments(null)
|
||||
.build();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
|
||||
when(entityManager.find(Membre.class, senderId)).thenReturn(sender);
|
||||
|
||||
MessageResponse response = messageService.sendMessage(request, senderId);
|
||||
|
||||
assertThat(response.getRecipientIds()).isNotNull();
|
||||
assertThat(response.getRecipientIds()).containsExactlyInAnyOrder(recipient1, recipient2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sendMessage_withRecipientRoles_setsCSV")
|
||||
void sendMessage_withRecipientRoles_setsCSV() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
Membre sender = mockMembre(senderId);
|
||||
|
||||
SendMessageRequest request = SendMessageRequest.builder()
|
||||
.conversationId(conv.getId())
|
||||
.content("Role msg")
|
||||
.type(null)
|
||||
.priority(null)
|
||||
.recipientIds(null)
|
||||
.recipientRoles(List.of("ADMIN", "TRESORIER"))
|
||||
.attachments(null)
|
||||
.build();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
|
||||
when(entityManager.find(Membre.class, senderId)).thenReturn(sender);
|
||||
|
||||
MessageResponse response = messageService.sendMessage(request, senderId);
|
||||
|
||||
assertThat(response.getRecipientRoles()).isNotNull();
|
||||
assertThat(response.getRecipientRoles()).containsExactly("ADMIN", "TRESORIER");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sendMessage_withAttachments_setsCSV")
|
||||
void sendMessage_withAttachments_setsCSV() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
Membre sender = mockMembre(senderId);
|
||||
|
||||
SendMessageRequest request = SendMessageRequest.builder()
|
||||
.conversationId(conv.getId())
|
||||
.content("Msg avec PJ")
|
||||
.type(null)
|
||||
.priority(null)
|
||||
.recipientIds(null)
|
||||
.recipientRoles(null)
|
||||
.attachments(List.of("https://cdn.example.com/doc1.pdf", "https://cdn.example.com/img1.png"))
|
||||
.build();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
|
||||
when(entityManager.find(Membre.class, senderId)).thenReturn(sender);
|
||||
|
||||
MessageResponse response = messageService.sendMessage(request, senderId);
|
||||
|
||||
assertThat(response.getAttachments()).isNotNull();
|
||||
assertThat(response.getAttachments()).containsExactly(
|
||||
"https://cdn.example.com/doc1.pdf",
|
||||
"https://cdn.example.com/img1.png"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sendMessage_noRecipientsNoRolesNoAttachments_noCSV")
|
||||
void sendMessage_noRecipientsNoRolesNoAttachments_noCSV() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
Membre sender = mockMembre(senderId);
|
||||
|
||||
SendMessageRequest request = SendMessageRequest.builder()
|
||||
.conversationId(conv.getId())
|
||||
.content("Simple")
|
||||
.type(null)
|
||||
.priority(null)
|
||||
.recipientIds(new ArrayList<>())
|
||||
.recipientRoles(new ArrayList<>())
|
||||
.attachments(new ArrayList<>())
|
||||
.build();
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), senderId)).thenReturn(Optional.of(conv));
|
||||
when(entityManager.find(Membre.class, senderId)).thenReturn(sender);
|
||||
|
||||
MessageResponse response = messageService.sendMessage(request, senderId);
|
||||
|
||||
assertThat(response.getRecipientIds()).isNull();
|
||||
assertThat(response.getRecipientRoles()).isNull();
|
||||
assertThat(response.getAttachments()).isNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// editMessage
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("editMessage_notFound_throwsNotFound")
|
||||
void editMessage_notFound_throwsNotFound() {
|
||||
UUID messageId = UUID.randomUUID();
|
||||
UUID senderId = UUID.randomUUID();
|
||||
|
||||
// entityManager.find returns null by default → findById(messageId) returns null → service throws
|
||||
|
||||
assertThatThrownBy(() -> messageService.editMessage(messageId, senderId, "nouveau contenu"))
|
||||
.isInstanceOf(NotFoundException.class)
|
||||
.hasMessageContaining("Message non trouvé");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("editMessage_wrongSender_throwsIllegalState")
|
||||
void editMessage_wrongSender_throwsIllegalState() {
|
||||
UUID realSenderId = UUID.randomUUID();
|
||||
UUID wrongSenderId = UUID.randomUUID();
|
||||
Message msg = mockMessage(realSenderId);
|
||||
|
||||
when(entityManager.find(Message.class, msg.getId())).thenReturn(msg);
|
||||
|
||||
assertThatThrownBy(() -> messageService.editMessage(msg.getId(), wrongSenderId, "contenu modifié"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("propres messages");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("editMessage_success_updatesContent")
|
||||
void editMessage_success_updatesContent() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Message msg = mockMessage(senderId);
|
||||
msg.setIsEdited(false);
|
||||
|
||||
when(entityManager.find(Message.class, msg.getId())).thenReturn(msg);
|
||||
|
||||
MessageResponse response = messageService.editMessage(msg.getId(), senderId, "Contenu édité");
|
||||
|
||||
assertThat(msg.getContent()).isEqualTo("Contenu édité");
|
||||
assertThat(msg.getIsEdited()).isTrue();
|
||||
assertThat(msg.getEditedAt()).isNotNull();
|
||||
verify(messageRepository).persist(msg);
|
||||
assertThat(response.getContent()).isEqualTo("Contenu édité");
|
||||
assertThat(response.isEdited()).isTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// deleteMessage
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteMessage_notFound_throwsNotFound")
|
||||
void deleteMessage_notFound_throwsNotFound() {
|
||||
UUID messageId = UUID.randomUUID();
|
||||
UUID senderId = UUID.randomUUID();
|
||||
|
||||
// entityManager.find returns null by default → findById(messageId) returns null → service throws
|
||||
|
||||
assertThatThrownBy(() -> messageService.deleteMessage(messageId, senderId))
|
||||
.isInstanceOf(NotFoundException.class)
|
||||
.hasMessageContaining("Message non trouvé");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteMessage_wrongSender_throwsIllegalState")
|
||||
void deleteMessage_wrongSender_throwsIllegalState() {
|
||||
UUID realSenderId = UUID.randomUUID();
|
||||
UUID wrongSenderId = UUID.randomUUID();
|
||||
Message msg = mockMessage(realSenderId);
|
||||
|
||||
when(entityManager.find(Message.class, msg.getId())).thenReturn(msg);
|
||||
|
||||
assertThatThrownBy(() -> messageService.deleteMessage(msg.getId(), wrongSenderId))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("propres messages");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteMessage_success_softDeletes")
|
||||
void deleteMessage_success_softDeletes() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Message msg = mockMessage(senderId);
|
||||
msg.setIsDeleted(false);
|
||||
msg.setContent("Contenu original");
|
||||
|
||||
when(entityManager.find(Message.class, msg.getId())).thenReturn(msg);
|
||||
|
||||
messageService.deleteMessage(msg.getId(), senderId);
|
||||
|
||||
assertThat(msg.getIsDeleted()).isTrue();
|
||||
assertThat(msg.getContent()).isEqualTo("[Message supprimé]");
|
||||
verify(messageRepository).persist(msg);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// convertToResponse (via getMessages)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToResponse_withRecipientIds_parsesCsv")
|
||||
void convertToResponse_withRecipientIds_parsesCsv() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
UUID r1 = UUID.randomUUID();
|
||||
UUID r2 = UUID.randomUUID();
|
||||
|
||||
Message msg = mockMessage(UUID.randomUUID());
|
||||
msg.setConversation(conv);
|
||||
msg.setRecipientIds(r1 + "," + r2);
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
|
||||
|
||||
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getRecipientIds()).containsExactlyInAnyOrder(r1, r2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToResponse_withRecipientRoles_parsesCsv")
|
||||
void convertToResponse_withRecipientRoles_parsesCsv() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
|
||||
Message msg = mockMessage(UUID.randomUUID());
|
||||
msg.setConversation(conv);
|
||||
msg.setRecipientRoles("ADMIN,SECRETAIRE");
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
|
||||
|
||||
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
|
||||
|
||||
assertThat(result.get(0).getRecipientRoles()).containsExactly("ADMIN", "SECRETAIRE");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToResponse_withAttachments_parsesCsv")
|
||||
void convertToResponse_withAttachments_parsesCsv() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
|
||||
Message msg = mockMessage(UUID.randomUUID());
|
||||
msg.setConversation(conv);
|
||||
msg.setAttachments("https://cdn.example.com/a.pdf,https://cdn.example.com/b.png");
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
|
||||
|
||||
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
|
||||
|
||||
assertThat(result.get(0).getAttachments()).containsExactly(
|
||||
"https://cdn.example.com/a.pdf",
|
||||
"https://cdn.example.com/b.png"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToResponse_noRecipients_nullFields")
|
||||
void convertToResponse_noRecipients_nullFields() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
|
||||
Message msg = mockMessage(UUID.randomUUID());
|
||||
msg.setConversation(conv);
|
||||
// recipientIds, recipientRoles et attachments sont null par défaut
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
|
||||
|
||||
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
|
||||
|
||||
MessageResponse response = result.get(0);
|
||||
assertThat(response.getRecipientIds()).isNull();
|
||||
assertThat(response.getRecipientRoles()).isNull();
|
||||
assertThat(response.getAttachments()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToResponse_withOrganisation_setsOrgId")
|
||||
void convertToResponse_withOrganisation_setsOrgId() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
UUID orgId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
Organisation org = new Organisation();
|
||||
org.setId(orgId);
|
||||
conv.setOrganisation(org);
|
||||
|
||||
Message msg = mockMessage(UUID.randomUUID());
|
||||
msg.setConversation(conv);
|
||||
msg.setOrganisation(org);
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
|
||||
|
||||
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
|
||||
|
||||
assertThat(result.get(0).getOrganisationId()).isEqualTo(orgId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToResponse_noOrganisation_nullOrgId")
|
||||
void convertToResponse_noOrganisation_nullOrgId() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
// organisation est null
|
||||
|
||||
Message msg = mockMessage(UUID.randomUUID());
|
||||
msg.setConversation(conv);
|
||||
msg.setOrganisation(null);
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
|
||||
|
||||
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
|
||||
|
||||
assertThat(result.get(0).getOrganisationId()).isNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// convertToResponse — branches isEmpty() (non-null mais vide)
|
||||
// L163: recipientIds != null && !isEmpty() → false (empty string → isEmpty = true)
|
||||
// L172: recipientRoles != null && !isEmpty() → false
|
||||
// L178: attachments != null && !isEmpty() → false
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToResponse_recipientIdsEmptyString_returnsNullRecipientIds (L163 false)")
|
||||
void convertToResponse_recipientIdsEmptyString_returnsNullRecipientIds() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
|
||||
Message msg = mockMessage(UUID.randomUUID());
|
||||
msg.setConversation(conv);
|
||||
// non-null mais vide → L163: isEmpty() = true → condition false → recipientIds = null
|
||||
msg.setRecipientIds("");
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
|
||||
|
||||
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
|
||||
|
||||
assertThat(result.get(0).getRecipientIds()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToResponse_recipientRolesEmptyString_returnsNullRoles (L172 false)")
|
||||
void convertToResponse_recipientRolesEmptyString_returnsNullRoles() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
|
||||
Message msg = mockMessage(UUID.randomUUID());
|
||||
msg.setConversation(conv);
|
||||
msg.setRecipientRoles(""); // non-null mais vide → L172 false → roles = null
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
|
||||
|
||||
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
|
||||
|
||||
assertThat(result.get(0).getRecipientRoles()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("convertToResponse_attachmentsEmptyString_returnsNullAttachments (L178 false)")
|
||||
void convertToResponse_attachmentsEmptyString_returnsNullAttachments() {
|
||||
UUID membreId = UUID.randomUUID();
|
||||
Conversation conv = mockConversation();
|
||||
|
||||
Message msg = mockMessage(UUID.randomUUID());
|
||||
msg.setConversation(conv);
|
||||
msg.setAttachments(""); // non-null mais vide → L178 false → attachments = null
|
||||
|
||||
when(conversationRepository.findByIdAndParticipant(conv.getId(), membreId)).thenReturn(Optional.of(conv));
|
||||
doReturn(List.of(msg)).when(messageRepository).findByConversation(conv.getId(), 20);
|
||||
|
||||
List<MessageResponse> result = messageService.getMessages(conv.getId(), membreId, 20);
|
||||
|
||||
assertThat(result.get(0).getAttachments()).isNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user