From 184d82f17f3c29b072ac56ccd272749126e6c2da Mon Sep 17 00:00:00 2001 From: hodoon Date: Wed, 1 Jul 2026 20:46:10 +0900 Subject: [PATCH 01/35] =?UTF-8?q?chore:=20gitignore=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6afa374f..c4896bba 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ CLAUDE.md .codex .agents/* AGENTS.md + +# graphify 지식그래프 산출물 +graphify-out/ From 92830e655e0526307725d96713480cc00d244c03 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 09:51:45 +0900 Subject: [PATCH 02/35] =?UTF-8?q?feat:=20ChatRoomType/ChatMessageType=20en?= =?UTF-8?q?um=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dreamteam/alter/domain/chat/type/ChatMessageType.java | 6 ++++++ .../com/dreamteam/alter/domain/chat/type/ChatRoomType.java | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/domain/chat/type/ChatMessageType.java create mode 100644 src/main/java/com/dreamteam/alter/domain/chat/type/ChatRoomType.java diff --git a/src/main/java/com/dreamteam/alter/domain/chat/type/ChatMessageType.java b/src/main/java/com/dreamteam/alter/domain/chat/type/ChatMessageType.java new file mode 100644 index 00000000..de5251fd --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/chat/type/ChatMessageType.java @@ -0,0 +1,6 @@ +package com.dreamteam.alter.domain.chat.type; + +public enum ChatMessageType { + NORMAL, + NOTICE +} diff --git a/src/main/java/com/dreamteam/alter/domain/chat/type/ChatRoomType.java b/src/main/java/com/dreamteam/alter/domain/chat/type/ChatRoomType.java new file mode 100644 index 00000000..64be98e3 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/chat/type/ChatRoomType.java @@ -0,0 +1,6 @@ +package com.dreamteam.alter.domain.chat.type; + +public enum ChatRoomType { + DIRECT, + GROUP +} From a0a9947a7ebfce5dad9309894a0a9865b25d06f1 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 09:54:21 +0900 Subject: [PATCH 03/35] =?UTF-8?q?feat:=20ChatRoom/ChatMessage=EC=97=90=20t?= =?UTF-8?q?ype=C2=B7workspaceId=20=EC=B6=94=EA=B0=80,=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=EB=B0=A9=20=ED=8C=A9=ED=86=A0=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AbstractSendChatMessageUseCase.java | 2 ++ .../alter/domain/chat/entity/ChatMessage.java | 7 ++++++ .../alter/domain/chat/entity/ChatRoom.java | 25 ++++++++++++++++--- .../domain/chat/entity/ChatRoomTest.java | 17 +++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTest.java diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java index 21ccb529..7451ec6a 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java @@ -8,6 +8,7 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.entity.ChatMessage; import com.dreamteam.alter.domain.chat.entity.ChatRoom; +import com.dreamteam.alter.domain.chat.type.ChatMessageType; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; @@ -50,6 +51,7 @@ public final void execute(U user, SendChatMessageRequestDto request, Long chatRo chatRoom.getId(), senderId, senderScope, + ChatMessageType.NORMAL, request.getContent() ); ChatMessage savedMessage = chatMessageRepository.save(chatMessage); diff --git a/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatMessage.java b/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatMessage.java index 58a8069f..f8de2d14 100644 --- a/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatMessage.java +++ b/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatMessage.java @@ -1,6 +1,7 @@ package com.dreamteam.alter.domain.chat.entity; import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.type.ChatMessageType; import jakarta.persistence.*; import lombok.*; import org.springframework.data.annotation.CreatedDate; @@ -35,6 +36,10 @@ public class ChatMessage { @Column(name = "content", length = 1000, nullable = false) private String content; + @Enumerated(EnumType.STRING) + @Column(name = "type", length = 20, nullable = false) + private ChatMessageType type; + @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -43,12 +48,14 @@ public static ChatMessage create( Long chatRoomId, Long senderId, TokenScope senderScope, + ChatMessageType type, String content ) { return ChatMessage.builder() .chatRoomId(chatRoomId) .senderId(senderId) .senderScope(senderScope) + .type(type) .content(content) .build(); } diff --git a/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatRoom.java b/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatRoom.java index d0d2b020..d5764486 100644 --- a/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatRoom.java +++ b/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatRoom.java @@ -1,6 +1,7 @@ package com.dreamteam.alter.domain.chat.entity; import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.type.ChatRoomType; import jakarta.persistence.*; import lombok.*; import org.springframework.data.annotation.CreatedDate; @@ -23,20 +24,27 @@ public class ChatRoom { @Column(name = "id", nullable = false) private Long id; - @Column(name = "participant1_id", nullable = false) + @Column(name = "participant1_id") private Long participant1Id; @Enumerated(EnumType.STRING) - @Column(name = "participant1_scope", length = 20, nullable = false) + @Column(name = "participant1_scope", length = 20) private TokenScope participant1Scope; - @Column(name = "participant2_id", nullable = false) + @Column(name = "participant2_id") private Long participant2Id; @Enumerated(EnumType.STRING) - @Column(name = "participant2_scope", length = 20, nullable = false) + @Column(name = "participant2_scope", length = 20) private TokenScope participant2Scope; + @Enumerated(EnumType.STRING) + @Column(name = "type", length = 20, nullable = false) + private ChatRoomType type; + + @Column(name = "workspace_id") + private Long workspaceId; + @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -56,6 +64,15 @@ public static ChatRoom create( .participant1Scope(participant1Scope) .participant2Id(participant2Id) .participant2Scope(participant2Scope) + .type(ChatRoomType.DIRECT) + .build(); + } + + public static ChatRoom createGroup(Long workspaceId) { + return ChatRoom.builder() + .type(ChatRoomType.GROUP) + .workspaceId(workspaceId) + // participant 컬럼은 DIRECT 전용: 그룹은 멤버 테이블로 관리하므로 사용 안 함 .build(); } diff --git a/src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTest.java b/src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTest.java new file mode 100644 index 00000000..f632c25d --- /dev/null +++ b/src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTest.java @@ -0,0 +1,17 @@ +package com.dreamteam.alter.domain.chat.entity; + +import com.dreamteam.alter.domain.chat.type.ChatRoomType; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChatRoomTest { + + @Test + void createGroup_업장_그룹방_생성() { + ChatRoom room = ChatRoom.createGroup(100L); + + assertThat(room.getType()).isEqualTo(ChatRoomType.GROUP); + assertThat(room.getWorkspaceId()).isEqualTo(100L); + } +} From f39e63ca28dcc2862fd0ba7a5a336c01149691fa Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 10:04:07 +0900 Subject: [PATCH 04/35] =?UTF-8?q?feat:=20ChatRoomMember=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=C2=B7=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChatRoomMemberJpaRepository.java | 7 ++ .../ChatRoomMemberQueryRepositoryImpl.java | 47 +++++++++++ .../ChatRoomMemberRepositoryImpl.java | 25 ++++++ .../domain/chat/entity/ChatRoomMember.java | 78 +++++++++++++++++++ .../ChatRoomMemberQueryRepository.java | 12 +++ .../outbound/ChatRoomMemberRepository.java | 9 +++ .../ChatRoomMemberRepositoryImplTest.java | 64 +++++++++++++++ 7 files changed, 242 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberJpaRepository.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberQueryRepositoryImpl.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImpl.java create mode 100644 src/main/java/com/dreamteam/alter/domain/chat/entity/ChatRoomMember.java create mode 100644 src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatRoomMemberQueryRepository.java create mode 100644 src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatRoomMemberRepository.java create mode 100644 src/test/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImplTest.java diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberJpaRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberJpaRepository.java new file mode 100644 index 00000000..fe9fd84d --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberJpaRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.adapter.outbound.chat.persistence; + +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatRoomMemberJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberQueryRepositoryImpl.java new file mode 100644 index 00000000..e63985b7 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberQueryRepositoryImpl.java @@ -0,0 +1,47 @@ +package com.dreamteam.alter.adapter.outbound.chat.persistence; + +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; +import com.dreamteam.alter.domain.chat.entity.QChatRoomMember; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ChatRoomMemberQueryRepositoryImpl implements ChatRoomMemberQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findActiveByRoom(Long chatRoomId) { + QChatRoomMember m = QChatRoomMember.chatRoomMember; + return queryFactory.selectFrom(m) + .where(m.chatRoomId.eq(chatRoomId), m.leftAt.isNull()) + .fetch(); + } + + @Override + public Optional findByRoomAndMember(Long chatRoomId, Long memberId, TokenScope memberScope) { + QChatRoomMember m = QChatRoomMember.chatRoomMember; + return Optional.ofNullable( + queryFactory.selectFrom(m) + .where(m.chatRoomId.eq(chatRoomId), m.memberId.eq(memberId), m.memberScope.eq(memberScope)) + .fetchOne() + ); + } + + @Override + public boolean existsActive(Long chatRoomId, Long memberId, TokenScope memberScope) { + QChatRoomMember m = QChatRoomMember.chatRoomMember; + Integer hit = queryFactory.selectOne().from(m) + .where(m.chatRoomId.eq(chatRoomId), m.memberId.eq(memberId), + m.memberScope.eq(memberScope), m.leftAt.isNull()) + .fetchFirst(); + return hit != null; + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImpl.java new file mode 100644 index 00000000..c50b3afa --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.dreamteam.alter.adapter.outbound.chat.persistence; + +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ChatRoomMemberRepositoryImpl implements ChatRoomMemberRepository { + + private final ChatRoomMemberJpaRepository jpaRepository; + + @Override + public ChatRoomMember save(ChatRoomMember member) { + return jpaRepository.save(member); + } + + @Override + public List saveAll(List members) { + return jpaRepository.saveAll(members); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatRoomMember.java b/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatRoomMember.java new file mode 100644 index 00000000..bba868ea --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatRoomMember.java @@ -0,0 +1,78 @@ +package com.dreamteam.alter.domain.chat.entity; + +import com.dreamteam.alter.domain.auth.type.TokenScope; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "chat_room_members") +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@EntityListeners(AuditingEntityListener.class) +public class ChatRoomMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "chat_room_id", nullable = false) + private Long chatRoomId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "member_scope", length = 20, nullable = false) + private TokenScope memberScope; + + @Column(name = "last_read_message_id") + private Long lastReadMessageId; + + @Column(name = "joined_at", nullable = false) + private LocalDateTime joinedAt; + + @Column(name = "left_at") + private LocalDateTime leftAt; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public static ChatRoomMember create(Long chatRoomId, Long memberId, TokenScope memberScope) { + return ChatRoomMember.builder() + .chatRoomId(chatRoomId) + .memberId(memberId) + .memberScope(memberScope) + .joinedAt(LocalDateTime.now()) + .build(); + } + + public void updateLastRead(Long messageId) { + if (this.lastReadMessageId == null || messageId > this.lastReadMessageId) { + this.lastReadMessageId = messageId; + } + } + + public void leave() { + this.leftAt = LocalDateTime.now(); + } + + public void rejoin() { + this.leftAt = null; + } + + public boolean isActive() { + return this.leftAt == null; + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatRoomMemberQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatRoomMemberQueryRepository.java new file mode 100644 index 00000000..47774639 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatRoomMemberQueryRepository.java @@ -0,0 +1,12 @@ +package com.dreamteam.alter.domain.chat.port.outbound; + +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; +import java.util.List; +import java.util.Optional; + +public interface ChatRoomMemberQueryRepository { + List findActiveByRoom(Long chatRoomId); + Optional findByRoomAndMember(Long chatRoomId, Long memberId, TokenScope memberScope); + boolean existsActive(Long chatRoomId, Long memberId, TokenScope memberScope); +} diff --git a/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatRoomMemberRepository.java b/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatRoomMemberRepository.java new file mode 100644 index 00000000..d27cb120 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatRoomMemberRepository.java @@ -0,0 +1,9 @@ +package com.dreamteam.alter.domain.chat.port.outbound; + +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; +import java.util.List; + +public interface ChatRoomMemberRepository { + ChatRoomMember save(ChatRoomMember member); + List saveAll(List members); +} diff --git a/src/test/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImplTest.java b/src/test/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImplTest.java new file mode 100644 index 00000000..b323fd1d --- /dev/null +++ b/src/test/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImplTest.java @@ -0,0 +1,64 @@ +package com.dreamteam.alter.adapter.outbound.chat.persistence; + +import com.dreamteam.alter.common.config.QueryDslConfig; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import({ + QueryDslConfig.class, + ChatRoomMemberRepositoryImpl.class, + ChatRoomMemberQueryRepositoryImpl.class +}) +class ChatRoomMemberRepositoryImplTest { + + @Autowired + private ChatRoomMemberRepositoryImpl repository; + + @Autowired + private ChatRoomMemberQueryRepositoryImpl queryRepository; + + @Test + void findActiveByRoom_활성_멤버만_반환() { + repository.save(ChatRoomMember.create(1L, 10L, TokenScope.APP)); + ChatRoomMember b = repository.save(ChatRoomMember.create(1L, 20L, TokenScope.MANAGER)); + b.leave(); + repository.save(b); + + List active = queryRepository.findActiveByRoom(1L); + + assertThat(active).hasSize(1); + assertThat(active.get(0).getMemberId()).isEqualTo(10L); + } + + @Test + void findByRoomAndMember_존재하면_조회됨() { + repository.save(ChatRoomMember.create(2L, 30L, TokenScope.APP)); + + Optional found = queryRepository.findByRoomAndMember(2L, 30L, TokenScope.APP); + + assertThat(found).isPresent(); + assertThat(found.get().getChatRoomId()).isEqualTo(2L); + } + + @Test + void existsActive_leave하면_false() { + ChatRoomMember member = repository.save(ChatRoomMember.create(3L, 40L, TokenScope.APP)); + + assertThat(queryRepository.existsActive(3L, 40L, TokenScope.APP)).isTrue(); + + member.leave(); + repository.save(member); + + assertThat(queryRepository.existsActive(3L, 40L, TokenScope.APP)).isFalse(); + } +} From 18c8e603e9cdbd0dcd9c40dd4aef3210312899c0 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 10:12:19 +0900 Subject: [PATCH 05/35] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98(V4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2A/2B에서 변경된 ChatRoom/ChatMessage/ChatRoomMember 엔티티에 맞춰 실 Postgres 스키마를 정합화한다. - chat_rooms: type, workspace_id 컬럼 추가 - chat_messages: type 컬럼 추가 - chat_rooms: participant1/2_id, participant1/2_scope NOT NULL 해제 (그룹 채팅은 멤버 테이블로 관리하므로 참가자 컬럼을 쓰지 않음) - chat_room_members 테이블 신설 및 기존 1:1 방 참가자 백필 --- .../db/migration/V4__unify_chat_schema.sql | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/main/resources/db/migration/V4__unify_chat_schema.sql diff --git a/src/main/resources/db/migration/V4__unify_chat_schema.sql b/src/main/resources/db/migration/V4__unify_chat_schema.sql new file mode 100644 index 00000000..8a4b3252 --- /dev/null +++ b/src/main/resources/db/migration/V4__unify_chat_schema.sql @@ -0,0 +1,37 @@ +-- 1:1/그룹 채팅 통합: chat_rooms에 type/workspace_id, chat_messages에 type 추가, +-- 그룹 채팅용 chat_room_members 신설. participant1/2 컬럼은 그룹 방에서 사용하지 않으므로 +-- NOT NULL 제약을 해제한다(Task 2A 엔티티 변경 반영). +-- 기존 participant1/2 컬럼 자체는 코드 컷오버 후 별도 마이그레이션(V?)에서 제거한다. + +ALTER TABLE chat_rooms ADD COLUMN type varchar(20) NOT NULL DEFAULT 'DIRECT'; +ALTER TABLE chat_rooms ADD COLUMN workspace_id bigint NULL; + +ALTER TABLE chat_messages ADD COLUMN type varchar(20) NOT NULL DEFAULT 'NORMAL'; + +ALTER TABLE chat_rooms ALTER COLUMN participant1_id DROP NOT NULL; +ALTER TABLE chat_rooms ALTER COLUMN participant1_scope DROP NOT NULL; +ALTER TABLE chat_rooms ALTER COLUMN participant2_id DROP NOT NULL; +ALTER TABLE chat_rooms ALTER COLUMN participant2_scope DROP NOT NULL; + +CREATE TABLE chat_room_members ( + id bigserial PRIMARY KEY, + chat_room_id bigint NOT NULL REFERENCES chat_rooms (id), + member_id bigint NOT NULL, + member_scope varchar(20) NOT NULL, + last_read_message_id bigint NULL, + joined_at timestamp NOT NULL DEFAULT now(), + left_at timestamp NULL, + created_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT uq_chat_room_member UNIQUE (chat_room_id, member_id, member_scope) +); + +CREATE INDEX idx_chat_room_members_room ON chat_room_members (chat_room_id); +CREATE INDEX idx_chat_room_members_member ON chat_room_members (member_id, member_scope); + +-- 기존 1:1 방을 멤버 2행으로 백필 +INSERT INTO chat_room_members (chat_room_id, member_id, member_scope, joined_at, created_at, updated_at) +SELECT id, participant1_id, participant1_scope, created_at, now(), now() FROM chat_rooms; + +INSERT INTO chat_room_members (chat_room_id, member_id, member_scope, joined_at, created_at, updated_at) +SELECT id, participant2_id, participant2_scope, created_at, now(), now() FROM chat_rooms; From 64dec7b6ef44d38f4db72b2cc5a4d72c35d120a7 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 14:07:43 +0900 Subject: [PATCH 06/35] =?UTF-8?q?feat:=20=EC=97=85=EC=9E=A5=20=EB=8B=A8?= =?UTF-8?q?=ED=86=A1=20=EB=A9=A4=EB=B2=84=EC=8B=AD=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20UseCase(join/leave/createGroupRoom)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomQueryRepository에 findGroupRoomByWorkspaceId 추가 (QueryDSL) - SyncWorkspaceChatMembershipUseCase 인바운드 포트 및 구현 추가 - join 멱등성(활성 멤버 중복 생성 방지, 퇴장 멤버 rejoin) 단위 테스트 작성 --- .../ChatRoomQueryRepositoryImpl.java | 16 ++++ .../usecase/SyncWorkspaceChatMembership.java | 64 +++++++++++++ .../SyncWorkspaceChatMembershipUseCase.java | 9 ++ .../outbound/ChatRoomQueryRepository.java | 2 + .../SyncWorkspaceChatMembershipTest.java | 92 +++++++++++++++++++ 5 files changed, 183 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembership.java create mode 100644 src/main/java/com/dreamteam/alter/domain/chat/port/inbound/SyncWorkspaceChatMembershipUseCase.java create mode 100644 src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTest.java diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomQueryRepositoryImpl.java index 3c6d5717..3d6e4ea2 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomQueryRepositoryImpl.java @@ -7,6 +7,7 @@ import com.dreamteam.alter.domain.chat.entity.ChatRoom; import com.dreamteam.alter.domain.chat.entity.QChatRoom; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; +import com.dreamteam.alter.domain.chat.type.ChatRoomType; import com.dreamteam.alter.domain.user.entity.QUser; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; @@ -153,6 +154,21 @@ public Optional findByIdAndParticipant( return Optional.ofNullable(result); } + @Override + public Optional findGroupRoomByWorkspaceId(Long workspaceId) { + QChatRoom qChatRoom = QChatRoom.chatRoom; + + ChatRoom result = queryFactory + .selectFrom(qChatRoom) + .where( + qChatRoom.type.eq(ChatRoomType.GROUP), + qChatRoom.workspaceId.eq(workspaceId) + ) + .fetchOne(); + + return Optional.ofNullable(result); + } + private BooleanExpression buildChatRoomCondition( QChatRoom qChatRoom, Long participant1Id, diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembership.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembership.java new file mode 100644 index 00000000..be8715bf --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembership.java @@ -0,0 +1,64 @@ +package com.dreamteam.alter.application.chat.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.entity.ChatRoom; +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; +import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; +import com.dreamteam.alter.domain.chat.port.outbound.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service("syncWorkspaceChatMembership") +@RequiredArgsConstructor +@Transactional +public class SyncWorkspaceChatMembership implements SyncWorkspaceChatMembershipUseCase { + + private final ChatRoomRepository chatRoomRepository; + private final ChatRoomQueryRepository chatRoomQueryRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + + @Override + public Long createGroupRoom(Long workspaceId) { + return chatRoomQueryRepository.findGroupRoomByWorkspaceId(workspaceId) + .map(ChatRoom::getId) + .orElseGet(() -> chatRoomRepository.save(ChatRoom.createGroup(workspaceId)).getId()); + } + + @Override + public void join(Long workspaceId, Long memberId, TokenScope scope) { + ChatRoom room = resolveGroupRoom(workspaceId); + Optional existing = + chatRoomMemberQueryRepository.findByRoomAndMember(room.getId(), memberId, scope); + if (existing.isPresent()) { + ChatRoomMember member = existing.get(); + if (!member.isActive()) { + member.rejoin(); + chatRoomMemberRepository.save(member); + } + return; + } + chatRoomMemberRepository.save(ChatRoomMember.create(room.getId(), memberId, scope)); + } + + @Override + public void leave(Long workspaceId, Long memberId, TokenScope scope) { + ChatRoom room = resolveGroupRoom(workspaceId); + chatRoomMemberQueryRepository.findByRoomAndMember(room.getId(), memberId, scope) + .filter(ChatRoomMember::isActive) + .ifPresent(member -> { + member.leave(); + chatRoomMemberRepository.save(member); + }); + } + + private ChatRoom resolveGroupRoom(Long workspaceId) { + return chatRoomQueryRepository.findGroupRoomByWorkspaceId(workspaceId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "업장 단톡방을 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/chat/port/inbound/SyncWorkspaceChatMembershipUseCase.java b/src/main/java/com/dreamteam/alter/domain/chat/port/inbound/SyncWorkspaceChatMembershipUseCase.java new file mode 100644 index 00000000..dd6cead5 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/chat/port/inbound/SyncWorkspaceChatMembershipUseCase.java @@ -0,0 +1,9 @@ +package com.dreamteam.alter.domain.chat.port.inbound; + +import com.dreamteam.alter.domain.auth.type.TokenScope; + +public interface SyncWorkspaceChatMembershipUseCase { + Long createGroupRoom(Long workspaceId); + void join(Long workspaceId, Long memberId, TokenScope scope); + void leave(Long workspaceId, Long memberId, TokenScope scope); +} diff --git a/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatRoomQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatRoomQueryRepository.java index e9a113dc..3321ca13 100644 --- a/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatRoomQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatRoomQueryRepository.java @@ -30,4 +30,6 @@ Optional findByIdAndParticipant( Long userId, TokenScope userScope ); + + Optional findGroupRoomByWorkspaceId(Long workspaceId); } diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTest.java new file mode 100644 index 00000000..91a1f3e6 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTest.java @@ -0,0 +1,92 @@ +package com.dreamteam.alter.application.chat.usecase; + +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.entity.ChatRoom; +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SyncWorkspaceChatMembership 테스트") +class SyncWorkspaceChatMembershipTest { + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatRoomQueryRepository chatRoomQueryRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @Mock + private ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + + @InjectMocks + private SyncWorkspaceChatMembership sut; + + @Nested + @DisplayName("join") + class JoinTests { + + @Test + @DisplayName("이미 활성 멤버면 중복 생성 안함") + void join_이미_활성_멤버면_중복_생성_안함() { + // given + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getId()).willReturn(5L); + given(chatRoomQueryRepository.findGroupRoomByWorkspaceId(1L)) + .willReturn(Optional.of(groupRoom)); + + ChatRoomMember activeMember = ChatRoomMember.create(5L, 10L, TokenScope.APP); + given(chatRoomMemberQueryRepository.findByRoomAndMember(5L, 10L, TokenScope.APP)) + .willReturn(Optional.of(activeMember)); + + // when + sut.join(1L, 10L, TokenScope.APP); + + // then + then(chatRoomMemberRepository).should(never()).save(any()); + } + + @Test + @DisplayName("퇴장했던 멤버면 rejoin") + void join_퇴장했던_멤버면_rejoin() { + // given + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getId()).willReturn(5L); + given(chatRoomQueryRepository.findGroupRoomByWorkspaceId(1L)) + .willReturn(Optional.of(groupRoom)); + + ChatRoomMember left = ChatRoomMember.create(5L, 10L, TokenScope.APP); + left.leave(); + given(chatRoomMemberQueryRepository.findByRoomAndMember(5L, 10L, TokenScope.APP)) + .willReturn(Optional.of(left)); + + // when + sut.join(1L, 10L, TokenScope.APP); + + // then + assertThat(left.isActive()).isTrue(); + then(chatRoomMemberRepository).should().save(left); + } + } +} From 15c7fb2b7a446aa430412bef40dcdb1de9040174 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 14:21:44 +0900 Subject: [PATCH 07/35] =?UTF-8?q?feat:=20=EC=97=85=EC=9E=A5=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EA=B7=B8=EB=A3=B9=20=EB=8B=A8=ED=86=A1?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A7=A4=EB=8B=88=EC=A0=80=20=EC=9E=85=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 업장 등록 신청이 승인되어 Workspace가 생성되는 시점에 SyncWorkspaceChatMembershipUseCase를 호출해 그룹 채팅방을 생성하고 업장 매니저를 해당 방에 입장시킨다. reject 경로에는 영향 없음. --- .../usecase/UpdateWorkspaceRequestStatus.java | 6 +++ .../UpdateWorkspaceRequestStatusTests.java | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java index e93f173c..4afd5b44 100644 --- a/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java @@ -13,6 +13,8 @@ import com.dreamteam.alter.application.file.FileDeleteService; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; import com.dreamteam.alter.domain.file.entity.File; import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; import com.dreamteam.alter.domain.file.type.FileTargetType; @@ -49,6 +51,7 @@ public class UpdateWorkspaceRequestStatus implements UpdateWorkspaceRequestStatu private final WorkspaceRequestImageQueryRepository workspaceRequestImageQueryRepository; private final FileQueryRepository fileQueryRepository; private final FileDeleteService fileDeleteService; + private final SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; @Override public void execute(Long workspaceRequestId, WorkspaceRequestStatus status) { @@ -90,6 +93,9 @@ private void approve(WorkspaceRequest workspaceRequest, Long workspaceRequestId) workspaceRepository.save(workspace); + syncWorkspaceChatMembership.createGroupRoom(workspace.getId()); + syncWorkspaceChatMembership.join(workspace.getId(), managerUser.getId(), TokenScope.MANAGER); + attachRepresentativeImages(workspaceRequestId, workspace); Optional file = fileQueryRepository.findByTargetTypeAndTargetId( diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java index 0f3b294a..5d941cab 100644 --- a/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java @@ -3,6 +3,8 @@ import com.dreamteam.alter.application.file.FileDeleteService; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; import com.dreamteam.alter.domain.file.entity.File; import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; import com.dreamteam.alter.domain.file.type.FileTargetType; @@ -25,6 +27,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -36,8 +39,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -69,6 +74,9 @@ class UpdateWorkspaceRequestStatusTests { @Mock private FileDeleteService fileDeleteService; + @Mock + private SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; + @InjectMocks private UpdateWorkspaceRequestStatus updateWorkspaceRequestStatus; @@ -143,10 +151,12 @@ class ExecuteTests { // given User user = mock(User.class); WorkspaceRequest request = mock(WorkspaceRequest.class); + ManagerUser newManagerUser = mock(ManagerUser.class); given(request.getUser()).willReturn(user); given(user.getId()).willReturn(1L); given(workspaceRequestQueryRepository.findByIdWithUser(1L)).willReturn(Optional.of(request)); given(managerUserQueryRepository.findByUserId(1L)).willReturn(Optional.empty()); + given(managerUserRepository.save(any(ManagerUser.class))).willReturn(newManagerUser); given(workspaceRequestImageQueryRepository.findAllByWorkspaceRequestId(1L)).willReturn(List.of()); given(fileQueryRepository.findByTargetTypeAndTargetId(FileTargetType.WORKSPACE_OWN_IDENTITY, "1")) .willReturn(Optional.empty()); @@ -161,6 +171,32 @@ class ExecuteTests { then(fileDeleteService).should(never()).delete(any()); } + @Test + @DisplayName("ACTIVATED 면 업장 저장 후 그룹 채팅방을 생성하고 매니저를 그룹방에 입장시킨다") + void execute_ACTIVATED_그룹채팅방생성및매니저입장() { + // given + User user = mock(User.class); + WorkspaceRequest request = mock(WorkspaceRequest.class); + ManagerUser managerUser = mock(ManagerUser.class); + given(request.getUser()).willReturn(user); + given(user.getId()).willReturn(1L); + given(managerUser.getId()).willReturn(99L); + given(workspaceRequestQueryRepository.findByIdWithUser(1L)).willReturn(Optional.of(request)); + given(managerUserQueryRepository.findByUserId(1L)).willReturn(Optional.of(managerUser)); + given(workspaceRequestImageQueryRepository.findAllByWorkspaceRequestId(1L)).willReturn(List.of()); + given(fileQueryRepository.findByTargetTypeAndTargetId(FileTargetType.WORKSPACE_OWN_IDENTITY, "1")) + .willReturn(Optional.empty()); + + // when + updateWorkspaceRequestStatus.execute(1L, WorkspaceRequestStatus.ACTIVATED); + + // then + InOrder inOrder = inOrder(workspaceRepository, syncWorkspaceChatMembership); + inOrder.verify(workspaceRepository).save(any(Workspace.class)); + inOrder.verify(syncWorkspaceChatMembership).createGroupRoom(any()); + inOrder.verify(syncWorkspaceChatMembership).join(any(), eq(99L), eq(TokenScope.MANAGER)); + } + @Test @DisplayName("REVOKED 면 reject 만 처리하고 업장/매니저는 생성하지 않는다") void execute_REVOKED_반려() { @@ -176,6 +212,8 @@ class ExecuteTests { then(workspaceRepository).should(never()).save(any()); then(managerUserRepository).should(never()).save(any()); then(fileDeleteService).should(never()).delete(any()); + then(syncWorkspaceChatMembership).should(never()).createGroupRoom(any()); + then(syncWorkspaceChatMembership).should(never()).join(any(), any(), any()); } @Test From ab5660b64d797a221335419840ebccaa4b11ff44 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 14:31:12 +0900 Subject: [PATCH 08/35] =?UTF-8?q?feat:=20=EC=9B=8C=EC=BB=A4=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85/=ED=87=B4=EC=82=AC=20=EC=8B=9C=20=EC=97=85=EC=9E=A5?= =?UTF-8?q?=20=EB=8B=A8=ED=86=A1=20=EB=A9=A4=EB=B2=84=EC=8B=AD=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AddWorkerToWorkspace: 워커 추가 저장 직후 syncWorkspaceChatMembership.join 호출 (APP scope) - WorkerResignationServiceImpl: 퇴사 처리(worker.resign()) 직후 syncWorkspaceChatMembership.leave 호출 (APP scope) - ManagerResignWorkspaceWorker, UserResignWorkspaceWorker 모두 이 서비스에 위임하므로 한 곳의 훅으로 양쪽 퇴사 플로우 커버 --- .../usecase/AddWorkerToWorkspace.java | 5 ++ .../usecase/WorkerResignationServiceImpl.java | 5 ++ .../usecase/AddWorkerToWorkspaceTest.java | 79 +++++++++++++++++++ .../WorkerResignationServiceImplTest.java | 21 +++++ 4 files changed, 110 insertions(+) create mode 100644 src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTest.java diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspace.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspace.java index e3c23f6e..a1ad5971 100644 --- a/src/main/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspace.java +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspace.java @@ -2,6 +2,8 @@ import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.workspace.entity.Workspace; import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; @@ -19,6 +21,7 @@ public class AddWorkerToWorkspace implements CreateWorkspaceWorkerUseCase { private final WorkspaceWorkerRepository workspaceWorkerRepository; private final WorkspaceWorkerQueryRepository workspaceWorkerQueryRepository; + private final SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; @Override public void execute(Workspace workspace, User user) { @@ -28,6 +31,8 @@ public void execute(Workspace workspace, User user) { } workspaceWorkerRepository.save(WorkspaceWorker.create(workspace, user)); + + syncWorkspaceChatMembership.join(workspace.getId(), user.getId(), TokenScope.APP); } } diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImpl.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImpl.java index e85b038d..55b66898 100644 --- a/src/main/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImpl.java +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImpl.java @@ -1,5 +1,7 @@ package com.dreamteam.alter.application.workspace.usecase; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; import com.dreamteam.alter.domain.workspace.entity.SubstituteRequest; import com.dreamteam.alter.domain.workspace.entity.WorkspaceShift; import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; @@ -23,6 +25,7 @@ public class WorkerResignationServiceImpl implements WorkerResignationService { private final WorkspaceShiftQueryRepository workspaceShiftQueryRepository; private final WorkspaceWorkerScheduleQueryRepository workspaceWorkerScheduleQueryRepository; private final SubstituteRequestQueryRepository substituteRequestQueryRepository; + private final SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; @Override public void resign(WorkspaceWorker worker) { @@ -45,5 +48,7 @@ public void resign(WorkspaceWorker worker) { ); worker.resign(); + + syncWorkspaceChatMembership.leave(worker.getWorkspace().getId(), worker.getUser().getId(), TokenScope.APP); } } diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTest.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTest.java new file mode 100644 index 00000000..41918650 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTest.java @@ -0,0 +1,79 @@ +package com.dreamteam.alter.application.workspace.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceWorkerQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceWorkerRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AddWorkerToWorkspace 테스트") +class AddWorkerToWorkspaceTest { + + @Mock + private WorkspaceWorkerRepository workspaceWorkerRepository; + + @Mock + private WorkspaceWorkerQueryRepository workspaceWorkerQueryRepository; + + @Mock + private SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; + + @InjectMocks + private AddWorkerToWorkspace addWorkerToWorkspace; + + @Test + @DisplayName("신규 근무자 추가 시 업장 단톡에 join 한다") + void execute_신규추가_단톡join호출() { + // given + Workspace workspace = mock(Workspace.class); + when(workspace.getId()).thenReturn(1L); + User user = mock(User.class); + when(user.getId()).thenReturn(2L); + + when(workspaceWorkerQueryRepository.findActiveWorkerByWorkspaceAndUser(workspace, user)) + .thenReturn(Optional.empty()); + + // when + addWorkerToWorkspace.execute(workspace, user); + + // then + then(syncWorkspaceChatMembership).should().join(1L, 2L, TokenScope.APP); + } + + @Test + @DisplayName("이미 근무중인 근무자면 예외를 던지고 단톡 join을 호출하지 않는다") + void execute_이미근무중_단톡join미호출() { + // given + Workspace workspace = mock(Workspace.class); + User user = mock(User.class); + WorkspaceWorker existingWorker = mock(WorkspaceWorker.class); + + when(workspaceWorkerQueryRepository.findActiveWorkerByWorkspaceAndUser(workspace, user)) + .thenReturn(Optional.of(existingWorker)); + + // when & then + assertThatThrownBy(() -> addWorkerToWorkspace.execute(workspace, user)) + .isInstanceOf(CustomException.class); + + then(syncWorkspaceChatMembership).should(never()).join(any(), any(), any()); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTest.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTest.java index 83f82224..c0f9604b 100644 --- a/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTest.java +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTest.java @@ -1,6 +1,10 @@ package com.dreamteam.alter.application.workspace.usecase; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; +import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.workspace.entity.SubstituteRequest; +import com.dreamteam.alter.domain.workspace.entity.Workspace; import com.dreamteam.alter.domain.workspace.entity.WorkspaceShift; import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorkerSchedule; @@ -36,6 +40,9 @@ class WorkerResignationServiceImplTest { @Mock private SubstituteRequestQueryRepository substituteRequestQueryRepository; + @Mock + private SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; + @InjectMocks private WorkerResignationServiceImpl workerResignationService; @@ -45,6 +52,12 @@ class WorkerResignationServiceImplTest { // given WorkspaceWorker worker = mock(WorkspaceWorker.class); when(worker.getId()).thenReturn(10L); + Workspace workspace = mock(Workspace.class); + when(workspace.getId()).thenReturn(100L); + User user = mock(User.class); + when(user.getId()).thenReturn(200L); + when(worker.getWorkspace()).thenReturn(workspace); + when(worker.getUser()).thenReturn(user); WorkspaceShift futureShift = mock(WorkspaceShift.class); when(workspaceShiftQueryRepository.findFutureShiftsByAssignedWorker(any(), any(LocalDateTime.class))) @@ -71,6 +84,7 @@ class WorkerResignationServiceImplTest { verify(requesterRequest, times(1)).cancel(); verify(targetRequest, times(1)).cancelPendingTargetAndCancelIfNoPendingTargets(10L); verify(worker, times(1)).resign(); + verify(syncWorkspaceChatMembership, times(1)).leave(100L, 200L, TokenScope.APP); } @Test @@ -79,6 +93,12 @@ class WorkerResignationServiceImplTest { // given WorkspaceWorker worker = mock(WorkspaceWorker.class); when(worker.getId()).thenReturn(20L); + Workspace workspace = mock(Workspace.class); + when(workspace.getId()).thenReturn(300L); + User user = mock(User.class); + when(user.getId()).thenReturn(400L); + when(worker.getWorkspace()).thenReturn(workspace); + when(worker.getUser()).thenReturn(user); when(workspaceShiftQueryRepository.findFutureShiftsByAssignedWorker(any(), any(LocalDateTime.class))) .thenReturn(List.of()); when(workspaceWorkerScheduleQueryRepository.getByWorkspaceWorker(worker)).thenReturn(List.of()); @@ -90,5 +110,6 @@ class WorkerResignationServiceImplTest { // then verify(worker, times(1)).resign(); + verify(syncWorkspaceChatMembership, times(1)).leave(300L, 400L, TokenScope.APP); } } From e717f929f44bdfc6b110227a3313226996907e21 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 15:00:50 +0900 Subject: [PATCH 09/35] =?UTF-8?q?feat:=20=ED=86=B5=ED=95=A9=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20UseCase=20-=20NOTICE=20=EA=B6=8C=ED=95=9C=C2=B7?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EB=A9=A4=EB=B2=84=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=EC=9E=90=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AbstractSendChatMessageUseCase에 ChatRoomMemberQueryRepository 주입, findById로 방을 먼저 조회한 뒤 DIRECT는 기존 findByIdAndParticipant, GROUP은 existsActive로 참여자 검증하는 이중 경로 구조로 리팩터링 - NOTICE 타입 메시지는 MANAGER만 전송 가능하도록 검증 (CHAT_NOTICE_FORBIDDEN) - SendChatMessageRequestDto에 type 필드 추가 (null이면 NORMAL로 취급) - FCM 알림은 DIRECT 방에서만 발송 (그룹 팬아웃은 Phase 5로 이연) - SendChatMessage/ManagerSendChatMessage 생성자에 새 의존성 주입 배선 --- .../chat/dto/SendChatMessageRequestDto.java | 4 + .../AbstractSendChatMessageUseCase.java | 45 +++-- .../chat/usecase/ManagerSendChatMessage.java | 6 +- .../chat/usecase/SendChatMessage.java | 6 +- .../alter/common/exception/ErrorCode.java | 1 + .../usecase/ManagerSendChatMessageTest.java | 123 +++++++++++ .../chat/usecase/SendChatMessageTest.java | 191 ++++++++++++++++++ 7 files changed, 360 insertions(+), 16 deletions(-) create mode 100644 src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java create mode 100644 src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/SendChatMessageRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/SendChatMessageRequestDto.java index f24f02cd..4c3cf60a 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/SendChatMessageRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/SendChatMessageRequestDto.java @@ -1,5 +1,6 @@ package com.dreamteam.alter.adapter.inbound.general.chat.dto; +import com.dreamteam.alter.domain.chat.type.ChatMessageType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.*; @@ -12,4 +13,7 @@ public class SendChatMessageRequestDto { @NotBlank @Schema(description = "메시지 내용") private String content; + + @Schema(description = "메시지 타입(NORMAL/NOTICE), 기본 NORMAL)") + private ChatMessageType type; } diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java index 7451ec6a..0250a817 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java @@ -9,7 +9,9 @@ import com.dreamteam.alter.domain.chat.entity.ChatMessage; import com.dreamteam.alter.domain.chat.entity.ChatRoom; import com.dreamteam.alter.domain.chat.type.ChatMessageType; +import com.dreamteam.alter.domain.chat.type.ChatRoomType; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; import com.dreamteam.alter.application.notification.NotificationService; @@ -33,33 +35,50 @@ public abstract class AbstractSendChatMessageUseCase extends AbstractChatUseC protected final UserQueryRepository userQueryRepository; protected final NotificationService notificationService; protected final SimpMessagingTemplate messagingTemplate; + protected final ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; public final void execute(U user, SendChatMessageRequestDto request, Long chatRoomId) { TokenScope senderScope = getParticipantScope(user); Long senderId = getParticipantId(user); - // 1. 채팅방 존재 확인 및 참여자 검증 - ChatRoom chatRoom = chatRoomQueryRepository.findByIdAndParticipant( - chatRoomId, - senderId, - senderScope - ) + // 1. 채팅방 존재 확인 + ChatRoom chatRoom = chatRoomQueryRepository.findById(chatRoomId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "채팅방을 찾을 수 없습니다.")); - // 2. 메시지 저장 + // 2. 참여자 검증 (DIRECT: participant 컬럼 기반, GROUP: 멤버 테이블 기반) + if (chatRoom.getType() == ChatRoomType.GROUP) { + if (!chatRoomMemberQueryRepository.existsActive(chatRoomId, senderId, senderScope)) { + throw new CustomException(ErrorCode.NOT_FOUND, "채팅방을 찾을 수 없습니다."); + } + } else { + chatRoom = chatRoomQueryRepository.findByIdAndParticipant( + chatRoomId, + senderId, + senderScope + ) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "채팅방을 찾을 수 없습니다.")); + } + + // 3. NOTICE 권한 검증 (매니저만 공지 작성 가능) + ChatMessageType type = request.getType() == null ? ChatMessageType.NORMAL : request.getType(); + if (type == ChatMessageType.NOTICE && senderScope != TokenScope.MANAGER) { + throw new CustomException(ErrorCode.CHAT_NOTICE_FORBIDDEN); + } + + // 4. 메시지 저장 ChatMessage chatMessage = ChatMessage.create( chatRoom.getId(), senderId, senderScope, - ChatMessageType.NORMAL, + type, request.getContent() ); ChatMessage savedMessage = chatMessageRepository.save(chatMessage); - // 3. ChatRoom의 updatedAt 갱신 + // 5. ChatRoom의 updatedAt 갱신 chatRoom.updateUpdatedAt(); - // 4. WebSocket으로 실시간 전송 + // 6. WebSocket으로 실시간 전송 try { ChatMessageResponse messageResponse = new ChatMessageResponse( savedMessage.getId(), @@ -74,8 +93,10 @@ public final void execute(U user, SendChatMessageRequestDto request, Long chatRo log.error("WebSocket 메시지 전송 실패. ChatRoomId: {}, Error: {}", chatRoom.getId(), e.getMessage(), e); } - // 5. 상대방에게 FCM 알림 전송 - sendFcmNotification(chatRoom, senderId, senderScope, request.getContent()); + // 7. DIRECT 방일 때만 상대방에게 FCM 알림 전송 (그룹 팬아웃은 Phase 5) + if (chatRoom.getType() == ChatRoomType.DIRECT) { + sendFcmNotification(chatRoom, senderId, senderScope, request.getContent()); + } } private void sendFcmNotification( diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessage.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessage.java index 99048099..c8ba7230 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessage.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessage.java @@ -3,6 +3,7 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.port.inbound.ManagerSendChatMessageUseCase; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; import com.dreamteam.alter.application.notification.NotificationService; @@ -21,11 +22,12 @@ public ManagerSendChatMessage( ChatMessageRepository chatMessageRepository, UserQueryRepository userQueryRepository, NotificationService notificationService, - SimpMessagingTemplate messagingTemplate + SimpMessagingTemplate messagingTemplate, + ChatRoomMemberQueryRepository chatRoomMemberQueryRepository ) { super( chatRoomQueryRepository, chatRoomRepository, chatMessageRepository, userQueryRepository, - notificationService, messagingTemplate + notificationService, messagingTemplate, chatRoomMemberQueryRepository ); } diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/SendChatMessage.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/SendChatMessage.java index 4eb28034..88ec229c 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/SendChatMessage.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/SendChatMessage.java @@ -3,6 +3,7 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.port.inbound.SendChatMessageUseCase; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; import com.dreamteam.alter.application.notification.NotificationService; @@ -20,11 +21,12 @@ public SendChatMessage( ChatMessageRepository chatMessageRepository, UserQueryRepository userQueryRepository, NotificationService notificationService, - SimpMessagingTemplate messagingTemplate + SimpMessagingTemplate messagingTemplate, + ChatRoomMemberQueryRepository chatRoomMemberQueryRepository ) { super( chatRoomQueryRepository, chatRoomRepository, chatMessageRepository, userQueryRepository, - notificationService, messagingTemplate + notificationService, messagingTemplate, chatRoomMemberQueryRepository ); } diff --git a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java index 442b9c8d..a413e006 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java +++ b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java @@ -51,6 +51,7 @@ public enum ErrorCode { INVALID_FILE_TYPE(400, "B023", "허용되지 않는 파일 형식입니다."), FILE_SIZE_EXCEEDED(400, "B024", "파일 크기가 제한을 초과합니다."), FILE_ALREADY_ATTACHED(409, "B025", "이미 연결된 파일입니다."), + CHAT_NOTICE_FORBIDDEN(400, "B026", "공지는 매니저만 작성할 수 있습니다."), TOO_MANY_REQUESTS(429, "E001", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java new file mode 100644 index 00000000..24b85464 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java @@ -0,0 +1,123 @@ +package com.dreamteam.alter.application.chat.usecase; + +import com.dreamteam.alter.adapter.inbound.general.chat.dto.SendChatMessageRequestDto; +import com.dreamteam.alter.application.notification.NotificationService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.entity.ChatMessage; +import com.dreamteam.alter.domain.chat.entity.ChatRoom; +import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; +import com.dreamteam.alter.domain.chat.type.ChatMessageType; +import com.dreamteam.alter.domain.chat.type.ChatRoomType; +import com.dreamteam.alter.domain.user.entity.ManagerUser; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ManagerSendChatMessage 테스트") +class ManagerSendChatMessageTest { + + @Mock + private ChatRoomQueryRepository chatRoomQueryRepository; + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatMessageRepository chatMessageRepository; + + @Mock + private UserQueryRepository userQueryRepository; + + @Mock + private NotificationService notificationService; + + @Mock + private org.springframework.messaging.simp.SimpMessagingTemplate messagingTemplate; + + @Mock + private ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + + @InjectMocks + private ManagerSendChatMessage sut; + + @Test + @DisplayName("매니저가 GROUP 방 멤버로서 NOTICE 전송시 type=NOTICE로 저장되고 브로드캐스트됨") + void execute_매니저가_GROUP_방에서_NOTICE_전송시_저장됨() { + // given + User innerUser = mock(User.class); + given(innerUser.getId()).willReturn(2L); + ManagerUser managerUser = mock(ManagerUser.class); + given(managerUser.getUser()).willReturn(innerUser); + + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getId()).willReturn(5L); + given(groupRoom.getType()).willReturn(ChatRoomType.GROUP); + + given(chatRoomQueryRepository.findById(5L)).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberQueryRepository.existsActive(5L, 2L, TokenScope.MANAGER)).willReturn(true); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getType()).willReturn(ChatMessageType.NOTICE); + given(request.getContent()).willReturn("공지 내용"); + + ChatMessage savedMessage = ChatMessage.create(5L, 2L, TokenScope.MANAGER, ChatMessageType.NOTICE, "공지 내용"); + ArgumentCaptor captor = ArgumentCaptor.forClass(ChatMessage.class); + given(chatMessageRepository.save(captor.capture())).willReturn(savedMessage); + + // when + sut.execute(managerUser, request, 5L); + + // then + assertThat(captor.getValue().getType()).isEqualTo(ChatMessageType.NOTICE); + then(messagingTemplate).should().convertAndSend(eq("/sub/chat.5"), any(Object.class)); + then(notificationService).should(never()).sendNotificationOnly(any(), any(), any(), any()); + } + + @Test + @DisplayName("GROUP 방 비멤버 매니저가 전송하면 NOT_FOUND") + void execute_GROUP_비멤버_매니저면_NOT_FOUND() { + // given + User innerUser = mock(User.class); + given(innerUser.getId()).willReturn(2L); + ManagerUser managerUser = mock(ManagerUser.class); + given(managerUser.getUser()).willReturn(innerUser); + + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getType()).willReturn(ChatRoomType.GROUP); + + given(chatRoomQueryRepository.findById(5L)).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberQueryRepository.existsActive(5L, 2L, TokenScope.MANAGER)).willReturn(false); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + + // when & then + assertThatThrownBy(() -> sut.execute(managerUser, request, 5L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND); + + then(chatMessageRepository).should(never()).save(any()); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java new file mode 100644 index 00000000..5afaee18 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java @@ -0,0 +1,191 @@ +package com.dreamteam.alter.application.chat.usecase; + +import com.dreamteam.alter.adapter.inbound.general.chat.dto.SendChatMessageRequestDto; +import com.dreamteam.alter.application.notification.NotificationService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.entity.ChatMessage; +import com.dreamteam.alter.domain.chat.entity.ChatRoom; +import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; +import com.dreamteam.alter.domain.chat.type.ChatMessageType; +import com.dreamteam.alter.domain.chat.type.ChatRoomType; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SendChatMessage 테스트") +class SendChatMessageTest { + + @Mock + private ChatRoomQueryRepository chatRoomQueryRepository; + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatMessageRepository chatMessageRepository; + + @Mock + private UserQueryRepository userQueryRepository; + + @Mock + private NotificationService notificationService; + + @Mock + private org.springframework.messaging.simp.SimpMessagingTemplate messagingTemplate; + + @Mock + private ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + + @InjectMocks + private SendChatMessage sut; + + @Test + @DisplayName("존재하지 않는 채팅방이면 NOT_FOUND") + void execute_존재하지_않는_채팅방이면_NOT_FOUND() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + given(chatRoomQueryRepository.findById(100L)).willReturn(Optional.empty()); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + + // when & then + assertThatThrownBy(() -> sut.execute(user, request, 100L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND); + } + + @Test + @DisplayName("유저(APP)가 NOTICE 전송시 CHAT_NOTICE_FORBIDDEN 예외") + void execute_유저가_NOTICE_전송시_예외() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + + ChatRoom directRoom = mock(ChatRoom.class); + given(directRoom.getType()).willReturn(ChatRoomType.DIRECT); + + given(chatRoomQueryRepository.findById(100L)).willReturn(Optional.of(directRoom)); + given(chatRoomQueryRepository.findByIdAndParticipant(100L, 1L, TokenScope.APP)) + .willReturn(Optional.of(directRoom)); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getType()).willReturn(ChatMessageType.NOTICE); + + // when & then + assertThatThrownBy(() -> sut.execute(user, request, 100L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.CHAT_NOTICE_FORBIDDEN); + + then(chatMessageRepository).should(never()).save(any()); + } + + @Test + @DisplayName("DIRECT 방에서 정상 전송시 저장·브로드캐스트·FCM 발송") + void execute_DIRECT_정상_전송() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + + ChatRoom directRoom = mock(ChatRoom.class); + given(directRoom.getId()).willReturn(100L); + given(directRoom.getType()).willReturn(ChatRoomType.DIRECT); + given(directRoom.getParticipant1Id()).willReturn(1L); + given(directRoom.getParticipant1Scope()).willReturn(TokenScope.APP); + given(directRoom.getParticipant2Id()).willReturn(2L); + + given(chatRoomQueryRepository.findById(100L)).willReturn(Optional.of(directRoom)); + given(chatRoomQueryRepository.findByIdAndParticipant(100L, 1L, TokenScope.APP)) + .willReturn(Optional.of(directRoom)); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getContent()).willReturn("안녕하세요"); + + ChatMessage savedMessage = ChatMessage.create(100L, 1L, TokenScope.APP, ChatMessageType.NORMAL, "안녕하세요"); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(userQueryRepository.findById(1L)).willReturn(Optional.empty()); + + // when + sut.execute(user, request, 100L); + + // then + then(chatMessageRepository).should().save(any(ChatMessage.class)); + then(messagingTemplate).should().convertAndSend(eq("/sub/chat.100"), any(Object.class)); + then(notificationService).should().sendNotificationOnly(eq(2L), any(), anyString(), anyString()); + } + + @Test + @DisplayName("GROUP 방에서 비멤버가 전송하면 NOT_FOUND") + void execute_GROUP_비멤버면_NOT_FOUND() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getType()).willReturn(ChatRoomType.GROUP); + + given(chatRoomQueryRepository.findById(200L)).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberQueryRepository.existsActive(200L, 1L, TokenScope.APP)).willReturn(false); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + + // when & then + assertThatThrownBy(() -> sut.execute(user, request, 200L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND); + + then(chatMessageRepository).should(never()).save(any()); + } + + @Test + @DisplayName("GROUP 방 멤버가 NORMAL 전송시 FCM은 스킵되고 브로드캐스트만 발생") + void execute_GROUP_멤버가_NORMAL_전송시_FCM_스킵() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getId()).willReturn(200L); + given(groupRoom.getType()).willReturn(ChatRoomType.GROUP); + + given(chatRoomQueryRepository.findById(200L)).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberQueryRepository.existsActive(200L, 1L, TokenScope.APP)).willReturn(true); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getContent()).willReturn("그룹 메시지"); + + ChatMessage savedMessage = ChatMessage.create(200L, 1L, TokenScope.APP, ChatMessageType.NORMAL, "그룹 메시지"); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + + // when + sut.execute(user, request, 200L); + + // then + then(messagingTemplate).should().convertAndSend(eq("/sub/chat.200"), any(Object.class)); + then(notificationService).should(never()).sendNotificationOnly(any(), any(), anyString(), anyString()); + assertThat(savedMessage.getType()).isEqualTo(ChatMessageType.NORMAL); + } +} From 2d5486dc8c2014d3a07e64f4691f770b8329f710 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 15:19:04 +0900 Subject: [PATCH 10/35] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=9D=BD=EC=9D=8C=20=EC=B2=98=EB=A6=AC=20UseCase=20=EB=B0=8F?= =?UTF-8?q?=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 멤버가 채팅방을 특정 메시지까지 읽음 처리할 수 있도록 MarkChatRoomReadUseCase를 추가한다. lastReadMessageId는 과거 값으로 되돌아가지 않도록 멱등하게 갱신되며, 이후 Task 4.2에서 이 값을 기준으로 메시지별 안읽음 수를 계산한다. - domain/chat/port/inbound/MarkChatRoomReadUseCase 추가 - application/chat/usecase/MarkChatRoomRead 구현 (memberId+scope 기반, 단일 빈으로 앱/매니저 공용) - POST /app/chat/rooms/{chatRoomId}/read, POST /manager/chat/rooms/{chatRoomId}/read 엔드포인트 추가 --- .../chat/controller/UserChatController.java | 17 ++++ .../controller/UserChatControllerSpec.java | 10 ++ .../chat/dto/MarkChatRoomReadRequestDto.java | 19 ++++ .../controller/ManagerChatController.java | 17 ++++ .../controller/ManagerChatControllerSpec.java | 10 ++ .../chat/usecase/MarkChatRoomRead.java | 30 ++++++ .../port/inbound/MarkChatRoomReadUseCase.java | 7 ++ .../chat/usecase/MarkChatRoomReadTest.java | 95 +++++++++++++++++++ 8 files changed, 205 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/MarkChatRoomReadRequestDto.java create mode 100644 src/main/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomRead.java create mode 100644 src/main/java/com/dreamteam/alter/domain/chat/port/inbound/MarkChatRoomReadUseCase.java create mode 100644 src/test/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomReadTest.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatController.java index b0b220ee..dbaa6350 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatController.java @@ -8,11 +8,14 @@ import com.dreamteam.alter.adapter.inbound.general.chat.dto.ChatRoomResponseDto; import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomRequestDto; import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomResponseDto; +import com.dreamteam.alter.adapter.inbound.general.chat.dto.MarkChatRoomReadRequestDto; import com.dreamteam.alter.application.aop.AppActionContext; +import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.port.inbound.CreateOrGetChatRoomUseCase; import com.dreamteam.alter.domain.chat.port.inbound.GetChatMessagesUseCase; import com.dreamteam.alter.domain.chat.port.inbound.GetChatRoomInfoUseCase; import com.dreamteam.alter.domain.chat.port.inbound.GetMyChatRoomListUseCase; +import com.dreamteam.alter.domain.chat.port.inbound.MarkChatRoomReadUseCase; import com.dreamteam.alter.domain.user.context.AppActor; import jakarta.annotation.Resource; import lombok.RequiredArgsConstructor; @@ -40,6 +43,9 @@ public class UserChatController implements UserChatControllerSpec { @Resource(name = "getChatRoomInfo") private final GetChatRoomInfoUseCase getChatRoomInfo; + @Resource(name = "markChatRoomRead") + private final MarkChatRoomReadUseCase markChatRoomRead; + @Override @PostMapping("/rooms") public ResponseEntity> createOrGetChatRoom( @@ -78,4 +84,15 @@ public ResponseEntity> getCha AppActor actor = AppActionContext.getInstance().getActor(); return ResponseEntity.ok(getChatMessages.execute(actor, chatRoomId, pageRequest)); } + + @Override + @PostMapping("/rooms/{chatRoomId}/read") + public ResponseEntity> markChatRoomRead( + @PathVariable Long chatRoomId, + @RequestBody MarkChatRoomReadRequestDto request + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + markChatRoomRead.execute(actor.getUserId(), TokenScope.APP, chatRoomId, request.getLastReadMessageId()); + return ResponseEntity.ok(CommonApiResponse.empty()); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatControllerSpec.java index 977635b4..4865c76c 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatControllerSpec.java @@ -8,6 +8,7 @@ import com.dreamteam.alter.adapter.inbound.general.chat.dto.ChatRoomResponseDto; import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomRequestDto; import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomResponseDto; +import com.dreamteam.alter.adapter.inbound.general.chat.dto.MarkChatRoomReadRequestDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -52,4 +53,13 @@ ResponseEntity> getChatMessag @PathVariable Long chatRoomId, CursorPageRequestDto pageRequest ); + + @Operation(summary = "채팅방 읽음 처리", description = "특정 채팅방을 마지막으로 읽은 메시지까지 읽음 처리합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "읽음 처리 성공") + }) + ResponseEntity> markChatRoomRead( + @PathVariable Long chatRoomId, + @Valid @RequestBody MarkChatRoomReadRequestDto request + ); } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/MarkChatRoomReadRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/MarkChatRoomReadRequestDto.java new file mode 100644 index 00000000..3bf25c47 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/MarkChatRoomReadRequestDto.java @@ -0,0 +1,19 @@ +package com.dreamteam.alter.adapter.inbound.general.chat.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "채팅방 읽음 처리 요청 DTO") +public class MarkChatRoomReadRequestDto { + + @NotNull + @Schema(description = "마지막으로 읽은 메시지 ID") + private Long lastReadMessageId; + +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java index ccb4eac0..0e580522 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java @@ -8,11 +8,14 @@ import com.dreamteam.alter.adapter.inbound.general.chat.dto.ChatRoomResponseDto; import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomRequestDto; import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomResponseDto; +import com.dreamteam.alter.adapter.inbound.general.chat.dto.MarkChatRoomReadRequestDto; import com.dreamteam.alter.application.aop.ManagerActionContext; +import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.port.inbound.ManagerCreateOrGetChatRoomUseCase; import com.dreamteam.alter.domain.chat.port.inbound.ManagerGetChatMessagesUseCase; import com.dreamteam.alter.domain.chat.port.inbound.ManagerGetChatRoomUseCase; import com.dreamteam.alter.domain.chat.port.inbound.ManagerGetMyChatRoomListUseCase; +import com.dreamteam.alter.domain.chat.port.inbound.MarkChatRoomReadUseCase; import com.dreamteam.alter.domain.user.context.ManagerActor; import jakarta.annotation.Resource; import lombok.RequiredArgsConstructor; @@ -40,6 +43,9 @@ public class ManagerChatController implements ManagerChatControllerSpec { @Resource(name = "managerGetChatRoom") private final ManagerGetChatRoomUseCase managerGetChatRoomUseCase; + @Resource(name = "markChatRoomRead") + private final MarkChatRoomReadUseCase markChatRoomRead; + @Override @PostMapping("/rooms") public ResponseEntity> createOrGetChatRoom( @@ -77,4 +83,15 @@ public ResponseEntity> getCha ManagerActor actor = ManagerActionContext.getInstance().getActor(); return ResponseEntity.ok(managerGetChatMessagesUseCase.execute(actor, chatRoomId, pageRequest)); } + + @Override + @PostMapping("/rooms/{chatRoomId}/read") + public ResponseEntity> markChatRoomRead( + @PathVariable Long chatRoomId, + @RequestBody MarkChatRoomReadRequestDto request + ) { + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + markChatRoomRead.execute(actor.getUserId(), TokenScope.MANAGER, chatRoomId, request.getLastReadMessageId()); + return ResponseEntity.ok(CommonApiResponse.empty()); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatControllerSpec.java index e7121f94..d5abe9fc 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatControllerSpec.java @@ -8,6 +8,7 @@ import com.dreamteam.alter.adapter.inbound.general.chat.dto.ChatRoomResponseDto; import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomRequestDto; import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomResponseDto; +import com.dreamteam.alter.adapter.inbound.general.chat.dto.MarkChatRoomReadRequestDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -52,5 +53,14 @@ ResponseEntity> getChatMessag @PathVariable Long chatRoomId, CursorPageRequestDto pageRequest ); + + @Operation(summary = "채팅방 읽음 처리", description = "특정 채팅방을 마지막으로 읽은 메시지까지 읽음 처리합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "읽음 처리 성공") + }) + ResponseEntity> markChatRoomRead( + @PathVariable Long chatRoomId, + @Valid @RequestBody MarkChatRoomReadRequestDto request + ); } diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomRead.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomRead.java new file mode 100644 index 00000000..a7ce5419 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomRead.java @@ -0,0 +1,30 @@ +package com.dreamteam.alter.application.chat.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; +import com.dreamteam.alter.domain.chat.port.inbound.MarkChatRoomReadUseCase; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("markChatRoomRead") +@RequiredArgsConstructor +@Transactional +public class MarkChatRoomRead implements MarkChatRoomReadUseCase { + + private final ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; + + @Override + public void execute(Long memberId, TokenScope scope, Long roomId, Long lastReadMessageId) { + ChatRoomMember member = chatRoomMemberQueryRepository.findByRoomAndMember(roomId, memberId, scope) + .filter(ChatRoomMember::isActive) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "채팅방 멤버가 아닙니다.")); + member.updateLastRead(lastReadMessageId); + chatRoomMemberRepository.save(member); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/chat/port/inbound/MarkChatRoomReadUseCase.java b/src/main/java/com/dreamteam/alter/domain/chat/port/inbound/MarkChatRoomReadUseCase.java new file mode 100644 index 00000000..911e7eaf --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/chat/port/inbound/MarkChatRoomReadUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.chat.port.inbound; + +import com.dreamteam.alter.domain.auth.type.TokenScope; + +public interface MarkChatRoomReadUseCase { + void execute(Long memberId, TokenScope scope, Long roomId, Long lastReadMessageId); +} diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomReadTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomReadTest.java new file mode 100644 index 00000000..a36c803f --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomReadTest.java @@ -0,0 +1,95 @@ +package com.dreamteam.alter.application.chat.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MarkChatRoomRead 테스트") +class MarkChatRoomReadTest { + + @Mock + private ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @InjectMocks + private MarkChatRoomRead sut; + + @Test + @DisplayName("lastReadMessageId를 갱신한다") + void execute_lastReadMessageId_갱신() { + // given + ChatRoomMember member = ChatRoomMember.create(1L, 10L, TokenScope.APP); + given(chatRoomMemberQueryRepository.findByRoomAndMember(1L, 10L, TokenScope.APP)) + .willReturn(Optional.of(member)); + + // when + sut.execute(10L, TokenScope.APP, 1L, 100L); + + // then + assertThat(member.getLastReadMessageId()).isEqualTo(100L); + then(chatRoomMemberRepository).should().save(member); + } + + @Test + @DisplayName("과거 id로는 되돌아가지 않는다 (멱등)") + void execute_과거_id로는_되돌아가지_않음() { + // given + ChatRoomMember member = ChatRoomMember.create(1L, 10L, TokenScope.APP); + member.updateLastRead(100L); + given(chatRoomMemberQueryRepository.findByRoomAndMember(1L, 10L, TokenScope.APP)) + .willReturn(Optional.of(member)); + + // when + sut.execute(10L, TokenScope.APP, 1L, 50L); + + // then + assertThat(member.getLastReadMessageId()).isEqualTo(100L); + } + + @Test + @DisplayName("채팅방 멤버가 아니면 NOT_FOUND 예외") + void execute_멤버_아니면_NOT_FOUND() { + // given + given(chatRoomMemberQueryRepository.findByRoomAndMember(1L, 10L, TokenScope.APP)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> sut.execute(10L, TokenScope.APP, 1L, 100L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND); + } + + @Test + @DisplayName("비활성(퇴장) 멤버면 NOT_FOUND 예외") + void execute_비활성_멤버면_NOT_FOUND() { + // given + ChatRoomMember left = ChatRoomMember.create(1L, 10L, TokenScope.APP); + left.leave(); + given(chatRoomMemberQueryRepository.findByRoomAndMember(1L, 10L, TokenScope.APP)) + .willReturn(Optional.of(left)); + + // when & then + assertThatThrownBy(() -> sut.execute(10L, TokenScope.APP, 1L, 100L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND); + } +} From 93a122dc1f333504ec7f0651641db4d2b111476f Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 15:28:40 +0900 Subject: [PATCH 11/35] =?UTF-8?q?fix:=20=EC=97=85=EC=9E=A5=20=EB=8B=A8?= =?UTF-8?q?=ED=86=A1=20=EB=A7=A4=EB=8B=88=EC=A0=80=20=EC=9E=85=EC=9E=A5=20?= =?UTF-8?q?=EC=8B=9C=20=EC=B0=B8=EC=97=AC=EC=9E=90=20id=EB=A5=BC=20User=20?= =?UTF-8?q?id=EB=A1=9C=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SyncWorkspaceChatMembership.join 호출에 ManagerUser 엔티티 id를 넘기고 있어, 채팅 도메인이 참여자 식별에 사용하는 User(계정) id와 불일치했다. 이로 인해 매니저가 자신의 업장 단톡방에 공지를 보내거나(existsActive 조회 실패) 읽음 처리를 해도(MarkChatRoomRead) 멤버십 행을 찾지 못하는 문제가 있었다. AbstractChatUseCase.getParticipantId(ManagerUser) 컨벤션에 맞춰 managerUser.getUser().getId()를 사용하도록 수정. --- .../usecase/UpdateWorkspaceRequestStatus.java | 2 +- .../UpdateWorkspaceRequestStatusTests.java | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java index 4afd5b44..7000abfc 100644 --- a/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java @@ -94,7 +94,7 @@ private void approve(WorkspaceRequest workspaceRequest, Long workspaceRequestId) workspaceRepository.save(workspace); syncWorkspaceChatMembership.createGroupRoom(workspace.getId()); - syncWorkspaceChatMembership.join(workspace.getId(), managerUser.getId(), TokenScope.MANAGER); + syncWorkspaceChatMembership.join(workspace.getId(), managerUser.getUser().getId(), TokenScope.MANAGER); attachRepresentativeImages(workspaceRequestId, workspace); diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java index 5d941cab..a63c6e74 100644 --- a/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java @@ -126,9 +126,12 @@ class ExecuteTests { User user = mock(User.class); WorkspaceRequest request = mock(WorkspaceRequest.class); ManagerUser managerUser = mock(ManagerUser.class); + User managerUnderlyingUser = mock(User.class); File identityFile = mock(File.class); given(request.getUser()).willReturn(user); given(user.getId()).willReturn(1L); + given(managerUser.getUser()).willReturn(managerUnderlyingUser); + given(managerUnderlyingUser.getId()).willReturn(2L); given(workspaceRequestQueryRepository.findByIdWithUser(1L)).willReturn(Optional.of(request)); given(managerUserQueryRepository.findByUserId(1L)).willReturn(Optional.of(managerUser)); given(workspaceRequestImageQueryRepository.findAllByWorkspaceRequestId(1L)).willReturn(List.of()); @@ -154,6 +157,7 @@ class ExecuteTests { ManagerUser newManagerUser = mock(ManagerUser.class); given(request.getUser()).willReturn(user); given(user.getId()).willReturn(1L); + given(newManagerUser.getUser()).willReturn(user); given(workspaceRequestQueryRepository.findByIdWithUser(1L)).willReturn(Optional.of(request)); given(managerUserQueryRepository.findByUserId(1L)).willReturn(Optional.empty()); given(managerUserRepository.save(any(ManagerUser.class))).willReturn(newManagerUser); @@ -178,9 +182,11 @@ class ExecuteTests { User user = mock(User.class); WorkspaceRequest request = mock(WorkspaceRequest.class); ManagerUser managerUser = mock(ManagerUser.class); + User managerUnderlyingUser = mock(User.class); given(request.getUser()).willReturn(user); given(user.getId()).willReturn(1L); - given(managerUser.getId()).willReturn(99L); + given(managerUser.getUser()).willReturn(managerUnderlyingUser); + given(managerUnderlyingUser.getId()).willReturn(42L); given(workspaceRequestQueryRepository.findByIdWithUser(1L)).willReturn(Optional.of(request)); given(managerUserQueryRepository.findByUserId(1L)).willReturn(Optional.of(managerUser)); given(workspaceRequestImageQueryRepository.findAllByWorkspaceRequestId(1L)).willReturn(List.of()); @@ -194,7 +200,8 @@ class ExecuteTests { InOrder inOrder = inOrder(workspaceRepository, syncWorkspaceChatMembership); inOrder.verify(workspaceRepository).save(any(Workspace.class)); inOrder.verify(syncWorkspaceChatMembership).createGroupRoom(any()); - inOrder.verify(syncWorkspaceChatMembership).join(any(), eq(99L), eq(TokenScope.MANAGER)); + // 참여자 id는 ManagerUser 엔티티 id가 아닌, 채팅 도메인 기준 식별자인 User(계정) id 여야 한다 + inOrder.verify(syncWorkspaceChatMembership).join(any(), eq(42L), eq(TokenScope.MANAGER)); } @Test @@ -290,7 +297,11 @@ private WorkspaceRequest mockApprovedRequest() { given(user.getId()).willReturn(1L); given(request.getUser()).willReturn(user); given(workspaceRequestQueryRepository.findByIdWithUser(10L)).willReturn(Optional.of(request)); - given(managerUserQueryRepository.findByUserId(1L)).willReturn(Optional.of(mock(ManagerUser.class))); + ManagerUser managerUser = mock(ManagerUser.class); + User managerUnderlyingUser = mock(User.class); + given(managerUser.getUser()).willReturn(managerUnderlyingUser); + given(managerUnderlyingUser.getId()).willReturn(2L); + given(managerUserQueryRepository.findByUserId(1L)).willReturn(Optional.of(managerUser)); return request; } From cfca2cc2ef734390b047eff5ed9563ed3a61cb2d Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 15:49:35 +0900 Subject: [PATCH 12/35] =?UTF-8?q?feat:=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=EB=B3=84=20=EC=95=88=20=EC=9D=BD=EC=9D=80=20=EC=82=AC=EB=9E=8C?= =?UTF-8?q?=20=EC=88=98=20=EC=A7=91=EA=B3=84=20=EB=B0=8F=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메시지 목록 조회 시 각 메시지의 unreadCount(활성 멤버 중 아직 읽지 않고 발신자 본인이 아닌 인원 수)를 계산해 응답에 포함한다. 방 활성 멤버 목록을 쿼리 1회로 로드해 메모리에서 메시지별로 계산하여 N+1을 방지한다. 실시간 브로드캐스트 페이로드는 범위 밖. --- .../chat/dto/ChatMessageResponseDto.java | 23 +++++ .../AbstractGetChatMessagesUseCase.java | 34 ++++++- .../chat/usecase/GetChatMessages.java | 6 +- .../chat/usecase/ManagerGetChatMessages.java | 6 +- .../chat/usecase/GetChatMessagesTest.java | 92 +++++++++++++++++++ 5 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTest.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/ChatMessageResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/ChatMessageResponseDto.java index 5c4ba97d..50dfb7f5 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/ChatMessageResponseDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/ChatMessageResponseDto.java @@ -33,6 +33,9 @@ public class ChatMessageResponseDto { @Schema(description = "본인이 보낸 메시지 여부") private Boolean isMine; + @Schema(description = "안 읽은 사람 수") + private Integer unreadCount; + public static ChatMessageResponseDto from(ChatMessageResponse response) { return ChatMessageResponseDto.builder() .id(response.getId()) @@ -58,4 +61,24 @@ public static ChatMessageResponseDto from(ChatMessageResponse response, Long cur .build(); } + public static ChatMessageResponseDto from( + ChatMessageResponse response, + Long currentUserId, + TokenScope currentUserScope, + Integer unreadCount + ) { + boolean isMine = response.getSenderId().equals(currentUserId) + && response.getSenderScope().equals(currentUserScope); + + return ChatMessageResponseDto.builder() + .id(response.getId()) + .senderId(response.getSenderId()) + .senderScope(DescribedEnumDto.of(response.getSenderScope(), TokenScope.describe())) + .content(response.getContent()) + .createdAt(response.getCreatedAt()) + .isMine(isMine) + .unreadCount(unreadCount) + .build(); + } + } diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java index c82d228b..9cc7175f 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java @@ -11,7 +11,9 @@ import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.common.util.CursorUtil; import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -27,6 +29,7 @@ public abstract class AbstractGetChatMessagesUseCase extends AbstractChatUseC protected final ChatRoomQueryRepository chatRoomQueryRepository; protected final ChatMessageQueryRepository chatMessageQueryRepository; protected final ObjectMapper objectMapper; + protected final ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; protected CursorPaginatedApiResponse execute( A actor, @@ -60,12 +63,20 @@ protected CursorPaginatedApiResponse execute( // 4. 클라이언트 표시 순서에 맞게 오름차순으로 변환 (가장 오래된 메시지가 첫 번째) List sortedMessages = messages.reversed(); - // 5. DTO 변환 (본인 메시지 여부 포함) + // 5. 방의 활성 멤버 목록을 한 번만 로드 (메시지별 안 읽은 사람 수 계산용, N+1 방지) + List activeMembers = chatRoomMemberQueryRepository.findActiveByRoom(chatRoomId); + + // 6. DTO 변환 (본인 메시지 여부 + 안 읽은 사람 수 포함) List messageList = sortedMessages.stream() - .map(message -> ChatMessageResponseDto.from(message, participantId, participantScope)) + .map(message -> ChatMessageResponseDto.from( + message, + participantId, + participantScope, + calculateUnreadCount(message, activeMembers) + )) .toList(); - // 6. 커서 생성 (가장 오래된 메시지 기준으로 설정하여 다음 페이지 조회 시 이전 메시지 조회) + // 7. 커서 생성 (가장 오래된 메시지 기준으로 설정하여 다음 페이지 조회 시 이전 메시지 조회) ChatMessageResponse oldestMessage = sortedMessages.getFirst(); CursorPageResponseDto pageResponseDto = CursorPageResponseDto.of( CursorUtil.encodeCursor(new CursorDto(oldestMessage.getId(), oldestMessage.getCreatedAt()), objectMapper), @@ -79,4 +90,21 @@ protected CursorPaginatedApiResponse execute( protected abstract TokenScope getParticipantScope(A actor); protected abstract Long getParticipantId(A actor); + + // 메시지별 안 읽은 사람 수 = 활성 멤버 중 (아직 읽지 않았고) AND (발신자 본인이 아닌) 인원 수 + private int calculateUnreadCount(ChatMessageResponse message, List activeMembers) { + return (int) activeMembers.stream() + .filter(member -> hasNotRead(member, message)) + .filter(member -> !isSender(member, message)) + .count(); + } + + private boolean hasNotRead(ChatRoomMember member, ChatMessageResponse message) { + return member.getLastReadMessageId() == null || member.getLastReadMessageId() < message.getId(); + } + + private boolean isSender(ChatRoomMember member, ChatMessageResponse message) { + return member.getMemberId().equals(message.getSenderId()) + && member.getMemberScope().equals(message.getSenderScope()); + } } diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/GetChatMessages.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/GetChatMessages.java index d45fc447..e9abb364 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/GetChatMessages.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/GetChatMessages.java @@ -6,6 +6,7 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.port.inbound.GetChatMessagesUseCase; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.user.context.AppActor; import com.fasterxml.jackson.databind.ObjectMapper; @@ -17,9 +18,10 @@ public class GetChatMessages extends AbstractGetChatMessagesUseCase im public GetChatMessages( ChatRoomQueryRepository chatRoomQueryRepository, ChatMessageQueryRepository chatMessageQueryRepository, - ObjectMapper objectMapper + ObjectMapper objectMapper, + ChatRoomMemberQueryRepository chatRoomMemberQueryRepository ) { - super(chatRoomQueryRepository, chatMessageQueryRepository, objectMapper); + super(chatRoomQueryRepository, chatMessageQueryRepository, objectMapper, chatRoomMemberQueryRepository); } @Override diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerGetChatMessages.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerGetChatMessages.java index 14c4e795..073d2ab2 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerGetChatMessages.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerGetChatMessages.java @@ -6,6 +6,7 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.port.inbound.ManagerGetChatMessagesUseCase; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.user.context.ManagerActor; import com.fasterxml.jackson.databind.ObjectMapper; @@ -18,9 +19,10 @@ public class ManagerGetChatMessages extends AbstractGetChatMessagesUseCase= 8) + ChatRoomMember memberB = ChatRoomMember.create(chatRoomId, 300L, TokenScope.APP); + memberB.updateLastRead(5L); // 안 읽음 (5 < 8) + ChatRoomMember memberC = ChatRoomMember.create(chatRoomId, 400L, TokenScope.APP); + // memberC.lastReadMessageId == null → 안 읽음 + ChatRoomMember sender = ChatRoomMember.create(chatRoomId, senderId, TokenScope.APP); // 발신자 본인 → 제외 + + given(chatRoomMemberQueryRepository.findActiveByRoom(chatRoomId)) + .willReturn(List.of(memberA, memberB, memberC, sender)); + + // when + CursorPaginatedApiResponse response = + sut.execute(actor, chatRoomId, CursorPageRequestDto.of(null, 10)); + + // then + assertThat(response.data()).hasSize(1); + assertThat(response.data().getFirst().getUnreadCount()).isEqualTo(2); + } +} From 3e3994447eff3074e38e3651397420c300d9c944 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 15:58:22 +0900 Subject: [PATCH 13/35] =?UTF-8?q?feat:=20Redis=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20presence=20=EC=A0=80=EC=9E=A5=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STOMP connect/disconnect 리스너(5.2)와 push fallback(5.3)에서 쓸 온라인 상태 포트/어댑터 추가. EmailVerificationSessionStoreRepositoryImpl 컨벤션을 따라 domain/chat/port/outbound에 포트, adapter/outbound/chat/redis에 StringRedisTemplate 기반 구현체를 둔다. TTL 60초, 키는 chat:presence:{SCOPE}:{memberId}. --- .../ChatPresenceStoreRepositoryImpl.java | 43 +++++++++ .../chat/port/outbound/ChatPresenceStore.java | 14 +++ .../ChatPresenceStoreRepositoryImplTest.java | 92 +++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImpl.java create mode 100644 src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatPresenceStore.java create mode 100644 src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTest.java diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImpl.java new file mode 100644 index 00000000..c514bdf8 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.dreamteam.alter.adapter.outbound.chat.redis; + +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.port.outbound.ChatPresenceStore; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class ChatPresenceStoreRepositoryImpl implements ChatPresenceStore { + + private final StringRedisTemplate redisTemplate; + + private static final String KEY_PREFIX = "chat:presence:"; + private static final Duration TTL = Duration.ofSeconds(60); + + private String key(TokenScope scope, Long memberId) { + return KEY_PREFIX + scope.name() + ":" + memberId; + } + + @Override + public void markOnline(TokenScope scope, Long memberId) { + redisTemplate.opsForValue().set(key(scope, memberId), "1", TTL); + } + + @Override + public void refresh(TokenScope scope, Long memberId) { + redisTemplate.expire(key(scope, memberId), TTL); + } + + @Override + public void markOffline(TokenScope scope, Long memberId) { + redisTemplate.delete(key(scope, memberId)); + } + + @Override + public boolean isOnline(TokenScope scope, Long memberId) { + return Boolean.TRUE.equals(redisTemplate.hasKey(key(scope, memberId))); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatPresenceStore.java b/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatPresenceStore.java new file mode 100644 index 00000000..f62600b0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatPresenceStore.java @@ -0,0 +1,14 @@ +package com.dreamteam.alter.domain.chat.port.outbound; + +import com.dreamteam.alter.domain.auth.type.TokenScope; + +public interface ChatPresenceStore { + + void markOnline(TokenScope scope, Long memberId); + + void refresh(TokenScope scope, Long memberId); + + void markOffline(TokenScope scope, Long memberId); + + boolean isOnline(TokenScope scope, Long memberId); +} diff --git a/src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTest.java b/src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTest.java new file mode 100644 index 00000000..04f94318 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTest.java @@ -0,0 +1,92 @@ +package com.dreamteam.alter.adapter.outbound.chat.redis; + +import com.dreamteam.alter.domain.auth.type.TokenScope; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatPresenceStoreRepositoryImpl 테스트") +class ChatPresenceStoreRepositoryImplTest { + + @Mock + private StringRedisTemplate redisTemplate; + + @InjectMocks + private ChatPresenceStoreRepositoryImpl chatPresenceStoreRepository; + + private static final String KEY = "chat:presence:APP:1"; + + @Test + @DisplayName("markOnline 시 TTL 60초로 키를 저장한다") + void markOnline_키저장() { + // given + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + + // when + chatPresenceStoreRepository.markOnline(TokenScope.APP, 1L); + + // then + then(valueOperations).should().set(KEY, "1", Duration.ofSeconds(60)); + } + + @Test + @DisplayName("refresh 시 TTL을 60초로 갱신한다") + void refresh_TTL갱신() { + // when + chatPresenceStoreRepository.refresh(TokenScope.APP, 1L); + + // then + then(redisTemplate).should().expire(KEY, Duration.ofSeconds(60)); + } + + @Test + @DisplayName("markOffline 시 키를 삭제한다") + void markOffline_키삭제() { + // when + chatPresenceStoreRepository.markOffline(TokenScope.APP, 1L); + + // then + then(redisTemplate).should().delete(KEY); + } + + @Test + @DisplayName("isOnline은 hasKey 결과를 그대로 반환한다 - true") + void isOnline_true() { + // given + given(redisTemplate.hasKey(KEY)).willReturn(true); + + // when + boolean result = chatPresenceStoreRepository.isOnline(TokenScope.APP, 1L); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("isOnline은 hasKey 결과를 그대로 반환한다 - false") + void isOnline_false() { + // given + given(redisTemplate.hasKey(KEY)).willReturn(false); + + // when + boolean result = chatPresenceStoreRepository.isOnline(TokenScope.APP, 1L); + + // then + assertThat(result).isFalse(); + } +} From c9d48cbffb282da839bff29eb441dada081f9458 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 20:04:46 +0900 Subject: [PATCH 14/35] =?UTF-8?q?feat:=20STOMP=20=EC=97=B0=EA=B2=B0/?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A1=9C=20pre?= =?UTF-8?q?sence=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONNECT 시 Principal에서 (scope, memberId)를 추출해 ChatPresenceStore.markOnline, DISCONNECT 시 markOffline을 호출하는 WebSocketEventListener 추가. Principal → LoginUserDto 추출은 UserChatWebSocketController 패턴을 재사용. --- .../common/config/WebSocketEventListener.java | 44 +++++++ .../config/WebSocketEventListenerTest.java | 116 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/common/config/WebSocketEventListener.java create mode 100644 src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTest.java diff --git a/src/main/java/com/dreamteam/alter/common/config/WebSocketEventListener.java b/src/main/java/com/dreamteam/alter/common/config/WebSocketEventListener.java new file mode 100644 index 00000000..0c9c1c1d --- /dev/null +++ b/src/main/java/com/dreamteam/alter/common/config/WebSocketEventListener.java @@ -0,0 +1,44 @@ +package com.dreamteam.alter.common.config; + +import com.dreamteam.alter.adapter.inbound.general.auth.dto.LoginUserDto; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.port.outbound.ChatPresenceStore; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionConnectedEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +import java.security.Principal; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class WebSocketEventListener { + + private final ChatPresenceStore chatPresenceStore; + + @EventListener + public void onConnected(SessionConnectedEvent event) { + resolve(event.getUser()) + .ifPresent(principal -> chatPresenceStore.markOnline(principal.scope(), principal.memberId())); + } + + @EventListener + public void onDisconnect(SessionDisconnectEvent event) { + resolve(event.getUser()) + .ifPresent(principal -> chatPresenceStore.markOffline(principal.scope(), principal.memberId())); + } + + private Optional resolve(Principal principal) { + if (principal instanceof Authentication authentication + && authentication.getDetails() instanceof LoginUserDto loginUser) { + return Optional.of(new PresencePrincipal(loginUser.getScope(), loginUser.getId())); + } + return Optional.empty(); + } + + private record PresencePrincipal(TokenScope scope, Long memberId) { + } +} diff --git a/src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTest.java b/src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTest.java new file mode 100644 index 00000000..6c7a7e99 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTest.java @@ -0,0 +1,116 @@ +package com.dreamteam.alter.common.config; + +import com.dreamteam.alter.adapter.inbound.general.auth.dto.LoginUserDto; +import com.dreamteam.alter.application.auth.token.AccessTokenAuthentication; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.port.outbound.ChatPresenceStore; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.Message; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.messaging.SessionConnectedEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +import java.security.Principal; +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WebSocketEventListener 테스트") +class WebSocketEventListenerTest { + + @Mock + private ChatPresenceStore chatPresenceStore; + + @InjectMocks + private WebSocketEventListener webSocketEventListener; + + @SuppressWarnings("unchecked") + private final Message message = mock(Message.class); + + private Principal accessTokenPrincipal(TokenScope scope, Long memberId) { + LoginUserDto dto = mock(LoginUserDto.class); + lenient().when(dto.getScope()).thenReturn(scope); + lenient().when(dto.getId()).thenReturn(memberId); + return new AccessTokenAuthentication("token", dto, Collections.emptyList()); + } + + @Test + @DisplayName("연결 시 인증된 사용자면 markOnline 호출") + void onConnected_인증된사용자_markOnline() { + // given + Principal principal = accessTokenPrincipal(TokenScope.APP, 1L); + SessionConnectedEvent event = new SessionConnectedEvent(this, message, principal); + + // when + webSocketEventListener.onConnected(event); + + // then + then(chatPresenceStore).should().markOnline(TokenScope.APP, 1L); + } + + @Test + @DisplayName("연결 시 principal이 없으면 markOnline 호출 안함") + void onConnected_principal없음_markOnline호출안함() { + // given + SessionConnectedEvent event = new SessionConnectedEvent(this, message, null); + + // when + webSocketEventListener.onConnected(event); + + // then + then(chatPresenceStore).should(never()).markOnline(any(), any()); + } + + @Test + @DisplayName("연결 시 LoginUserDto가 아닌 principal이면 markOnline 호출 안함") + void onConnected_알수없는principal_markOnline호출안함() { + // given + Principal principal = new TestingAuthenticationToken("foo", "bar"); + SessionConnectedEvent event = new SessionConnectedEvent(this, message, principal); + + // when + webSocketEventListener.onConnected(event); + + // then + then(chatPresenceStore).should(never()).markOnline(any(), any()); + } + + @Test + @DisplayName("연결 해제 시 인증된 사용자면 markOffline 호출") + void onDisconnect_인증된사용자_markOffline() { + // given + Principal principal = accessTokenPrincipal(TokenScope.MANAGER, 2L); + SessionDisconnectEvent event = new SessionDisconnectEvent(this, message, "session-1", CloseStatus.NORMAL, principal); + + // when + webSocketEventListener.onDisconnect(event); + + // then + then(chatPresenceStore).should().markOffline(TokenScope.MANAGER, 2L); + } + + @Test + @DisplayName("연결 해제 시 principal이 없으면 markOffline 호출 안함") + void onDisconnect_principal없음_markOffline호출안함() { + // given + SessionDisconnectEvent event = new SessionDisconnectEvent(this, message, "session-1", CloseStatus.NORMAL, null); + + // when + webSocketEventListener.onDisconnect(event); + + // then + then(chatPresenceStore).should(never()).markOffline(any(), any()); + } +} From 42cb0d09a6dfc9ffe1e324654726dbb8370caa82 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 20:14:43 +0900 Subject: [PATCH 15/35] =?UTF-8?q?feat:=20presence=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=20=ED=8F=B4=EB=B0=B1=20-=20=EC=98=A4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=EB=A9=A4=EB=B2=84=EC=97=90?= =?UTF-8?q?=EA=B2=8C=EB=A7=8C=20FCM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DIRECT: 상대방이 온라인이면 FCM 알림 생략 (WebSocket으로 이미 수신) - GROUP: 발신자를 제외한 활성 멤버 중 오프라인 멤버에게만 FCM 팬아웃 발송 (기존엔 GROUP 전체 스킵) - ChatPresenceStore 포트를 AbstractSendChatMessageUseCase에 주입, SendChatMessage/ManagerSendChatMessage 생성자에 배선 --- .../AbstractSendChatMessageUseCase.java | 57 +++++++++++- .../chat/usecase/ManagerSendChatMessage.java | 6 +- .../chat/usecase/SendChatMessage.java | 6 +- .../usecase/ManagerSendChatMessageTest.java | 51 ++++++++++ .../chat/usecase/SendChatMessageTest.java | 93 ++++++++++++++++++- 5 files changed, 205 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java index 0250a817..feea203c 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java @@ -8,9 +8,11 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.entity.ChatMessage; import com.dreamteam.alter.domain.chat.entity.ChatRoom; +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; import com.dreamteam.alter.domain.chat.type.ChatMessageType; import com.dreamteam.alter.domain.chat.type.ChatRoomType; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatPresenceStore; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; @@ -24,6 +26,8 @@ import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @RequiredArgsConstructor @Transactional @@ -36,6 +40,7 @@ public abstract class AbstractSendChatMessageUseCase extends AbstractChatUseC protected final NotificationService notificationService; protected final SimpMessagingTemplate messagingTemplate; protected final ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + protected final ChatPresenceStore chatPresenceStore; public final void execute(U user, SendChatMessageRequestDto request, Long chatRoomId) { TokenScope senderScope = getParticipantScope(user); @@ -93,13 +98,15 @@ public final void execute(U user, SendChatMessageRequestDto request, Long chatRo log.error("WebSocket 메시지 전송 실패. ChatRoomId: {}, Error: {}", chatRoom.getId(), e.getMessage(), e); } - // 7. DIRECT 방일 때만 상대방에게 FCM 알림 전송 (그룹 팬아웃은 Phase 5) + // 7. FCM 알림 전송 (온라인 멤버는 제외한 presence 기반 폴백) if (chatRoom.getType() == ChatRoomType.DIRECT) { - sendFcmNotification(chatRoom, senderId, senderScope, request.getContent()); + sendDirectFcmNotification(chatRoom, senderId, senderScope, request.getContent()); + } else { + sendGroupFcmNotification(chatRoom, senderId, senderScope, request.getContent()); } } - private void sendFcmNotification( + private void sendDirectFcmNotification( ChatRoom chatRoom, Long senderId, TokenScope senderScope, @@ -108,12 +115,20 @@ private void sendFcmNotification( try { // 상대방 정보 확인 Long opponentId; + TokenScope opponentScope; if (chatRoom.getParticipant1Id() .equals(senderId) && chatRoom.getParticipant1Scope() .equals(senderScope)) { opponentId = chatRoom.getParticipant2Id(); + opponentScope = chatRoom.getParticipant2Scope(); } else { opponentId = chatRoom.getParticipant1Id(); + opponentScope = chatRoom.getParticipant1Scope(); + } + + // 상대방이 온라인이면 FCM 생략 (WebSocket으로 이미 수신) + if (chatPresenceStore.isOnline(opponentScope, opponentId)) { + return; } // 발신자 이름 조회 @@ -136,6 +151,42 @@ private void sendFcmNotification( } } + private void sendGroupFcmNotification( + ChatRoom chatRoom, + Long senderId, + TokenScope senderScope, + String content + ) { + try { + // 발신자 이름 조회 + String senderName = getSenderName(senderId); + + // 알림 메시지 생성 + String title = NotificationMessageConstants.Chat.NEW_MESSAGE_TITLE; + String body = String.format( + NotificationMessageConstants.Chat.NEW_MESSAGE_BODY, + senderName, + truncateContent(content) + ); + + // 활성 멤버 중 발신자를 제외한 오프라인 멤버에게만 FCM 발송 + List members = chatRoomMemberQueryRepository.findActiveByRoom(chatRoom.getId()); + for (ChatRoomMember member : members) { + if (member.getMemberId().equals(senderId) && member.getMemberScope() == senderScope) { + continue; + } + if (chatPresenceStore.isOnline(member.getMemberScope(), member.getMemberId())) { + continue; + } + notificationService.sendNotificationOnly(member.getMemberId(), NotificationType.CHAT, title, body); + } + + } catch (Exception e) { + // 알림 실패는 로그만 남기고 메시지 전송은 성공 처리 + log.error("채팅 메시지 FCM 알림 발송 실패. ChatRoomId: {}, Error: {}", chatRoom.getId(), e.getMessage(), e); + } + } + private String getSenderName(Long senderId) { return userQueryRepository.findById(senderId) .map(User::getName) diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessage.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessage.java index c8ba7230..fb970afd 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessage.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessage.java @@ -3,6 +3,7 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.port.inbound.ManagerSendChatMessageUseCase; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatPresenceStore; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; @@ -23,11 +24,12 @@ public ManagerSendChatMessage( UserQueryRepository userQueryRepository, NotificationService notificationService, SimpMessagingTemplate messagingTemplate, - ChatRoomMemberQueryRepository chatRoomMemberQueryRepository + ChatRoomMemberQueryRepository chatRoomMemberQueryRepository, + ChatPresenceStore chatPresenceStore ) { super( chatRoomQueryRepository, chatRoomRepository, chatMessageRepository, userQueryRepository, - notificationService, messagingTemplate, chatRoomMemberQueryRepository + notificationService, messagingTemplate, chatRoomMemberQueryRepository, chatPresenceStore ); } diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/SendChatMessage.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/SendChatMessage.java index 88ec229c..b4422c3b 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/SendChatMessage.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/SendChatMessage.java @@ -3,6 +3,7 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.port.inbound.SendChatMessageUseCase; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatPresenceStore; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; @@ -22,11 +23,12 @@ public SendChatMessage( UserQueryRepository userQueryRepository, NotificationService notificationService, SimpMessagingTemplate messagingTemplate, - ChatRoomMemberQueryRepository chatRoomMemberQueryRepository + ChatRoomMemberQueryRepository chatRoomMemberQueryRepository, + ChatPresenceStore chatPresenceStore ) { super( chatRoomQueryRepository, chatRoomRepository, chatMessageRepository, userQueryRepository, - notificationService, messagingTemplate, chatRoomMemberQueryRepository + notificationService, messagingTemplate, chatRoomMemberQueryRepository, chatPresenceStore ); } diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java index 24b85464..56b32b2e 100644 --- a/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java @@ -7,7 +7,9 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.entity.ChatMessage; import com.dreamteam.alter.domain.chat.entity.ChatRoom; +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatPresenceStore; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; @@ -24,6 +26,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -60,6 +63,9 @@ class ManagerSendChatMessageTest { @Mock private ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + @Mock + private ChatPresenceStore chatPresenceStore; + @InjectMocks private ManagerSendChatMessage sut; @@ -78,6 +84,7 @@ class ManagerSendChatMessageTest { given(chatRoomQueryRepository.findById(5L)).willReturn(Optional.of(groupRoom)); given(chatRoomMemberQueryRepository.existsActive(5L, 2L, TokenScope.MANAGER)).willReturn(true); + given(chatRoomMemberQueryRepository.findActiveByRoom(5L)).willReturn(List.of()); SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); given(request.getType()).willReturn(ChatMessageType.NOTICE); @@ -96,6 +103,50 @@ class ManagerSendChatMessageTest { then(notificationService).should(never()).sendNotificationOnly(any(), any(), any(), any()); } + @Test + @DisplayName("매니저가 GROUP 방에서 NORMAL 전송시 오프라인 멤버에게만 FCM 발송") + void execute_매니저가_GROUP_방에서_NORMAL_전송시_오프라인_멤버에게_FCM_발송() { + // given + User innerUser = mock(User.class); + given(innerUser.getId()).willReturn(2L); + ManagerUser managerUser = mock(ManagerUser.class); + given(managerUser.getUser()).willReturn(innerUser); + + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getId()).willReturn(5L); + given(groupRoom.getType()).willReturn(ChatRoomType.GROUP); + + given(chatRoomQueryRepository.findById(5L)).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberQueryRepository.existsActive(5L, 2L, TokenScope.MANAGER)).willReturn(true); + + ChatRoomMember senderMember = mock(ChatRoomMember.class); + given(senderMember.getMemberId()).willReturn(2L); + given(senderMember.getMemberScope()).willReturn(TokenScope.MANAGER); + + ChatRoomMember offlineMember = mock(ChatRoomMember.class); + given(offlineMember.getMemberId()).willReturn(10L); + given(offlineMember.getMemberScope()).willReturn(TokenScope.APP); + + given(chatRoomMemberQueryRepository.findActiveByRoom(5L)) + .willReturn(List.of(senderMember, offlineMember)); + given(chatPresenceStore.isOnline(TokenScope.APP, 10L)).willReturn(false); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getType()).willReturn(ChatMessageType.NORMAL); + given(request.getContent()).willReturn("일반 메시지"); + + ChatMessage savedMessage = ChatMessage.create(5L, 2L, TokenScope.MANAGER, ChatMessageType.NORMAL, "일반 메시지"); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(userQueryRepository.findById(2L)).willReturn(Optional.empty()); + + // when + sut.execute(managerUser, request, 5L); + + // then + then(notificationService).should().sendNotificationOnly(eq(10L), any(), any(), any()); + then(notificationService).should(never()).sendNotificationOnly(eq(2L), any(), any(), any()); + } + @Test @DisplayName("GROUP 방 비멤버 매니저가 전송하면 NOT_FOUND") void execute_GROUP_비멤버_매니저면_NOT_FOUND() { diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java index 5afaee18..0f9b8bbf 100644 --- a/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java @@ -7,7 +7,9 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.entity.ChatMessage; import com.dreamteam.alter.domain.chat.entity.ChatRoom; +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatPresenceStore; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; @@ -22,6 +24,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -59,6 +62,9 @@ class SendChatMessageTest { @Mock private ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + @Mock + private ChatPresenceStore chatPresenceStore; + @InjectMocks private SendChatMessage sut; @@ -116,6 +122,7 @@ class SendChatMessageTest { given(directRoom.getParticipant1Id()).willReturn(1L); given(directRoom.getParticipant1Scope()).willReturn(TokenScope.APP); given(directRoom.getParticipant2Id()).willReturn(2L); + given(directRoom.getParticipant2Scope()).willReturn(TokenScope.APP); given(chatRoomQueryRepository.findById(100L)).willReturn(Optional.of(directRoom)); given(chatRoomQueryRepository.findByIdAndParticipant(100L, 1L, TokenScope.APP)) @@ -127,6 +134,7 @@ class SendChatMessageTest { ChatMessage savedMessage = ChatMessage.create(100L, 1L, TokenScope.APP, ChatMessageType.NORMAL, "안녕하세요"); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); given(userQueryRepository.findById(1L)).willReturn(Optional.empty()); + given(chatPresenceStore.isOnline(TokenScope.APP, 2L)).willReturn(false); // when sut.execute(user, request, 100L); @@ -137,6 +145,40 @@ class SendChatMessageTest { then(notificationService).should().sendNotificationOnly(eq(2L), any(), anyString(), anyString()); } + @Test + @DisplayName("DIRECT 방에서 상대가 온라인이면 FCM 미발송") + void execute_DIRECT_상대_온라인이면_FCM_미발송() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + + ChatRoom directRoom = mock(ChatRoom.class); + given(directRoom.getId()).willReturn(100L); + given(directRoom.getType()).willReturn(ChatRoomType.DIRECT); + given(directRoom.getParticipant1Id()).willReturn(1L); + given(directRoom.getParticipant1Scope()).willReturn(TokenScope.APP); + given(directRoom.getParticipant2Id()).willReturn(2L); + given(directRoom.getParticipant2Scope()).willReturn(TokenScope.APP); + + given(chatRoomQueryRepository.findById(100L)).willReturn(Optional.of(directRoom)); + given(chatRoomQueryRepository.findByIdAndParticipant(100L, 1L, TokenScope.APP)) + .willReturn(Optional.of(directRoom)); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getContent()).willReturn("안녕하세요"); + + ChatMessage savedMessage = ChatMessage.create(100L, 1L, TokenScope.APP, ChatMessageType.NORMAL, "안녕하세요"); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(chatPresenceStore.isOnline(TokenScope.APP, 2L)).willReturn(true); + + // when + sut.execute(user, request, 100L); + + // then + then(messagingTemplate).should().convertAndSend(eq("/sub/chat.100"), any(Object.class)); + then(notificationService).should(never()).sendNotificationOnly(any(), any(), anyString(), anyString()); + } + @Test @DisplayName("GROUP 방에서 비멤버가 전송하면 NOT_FOUND") void execute_GROUP_비멤버면_NOT_FOUND() { @@ -161,7 +203,7 @@ class SendChatMessageTest { } @Test - @DisplayName("GROUP 방 멤버가 NORMAL 전송시 FCM은 스킵되고 브로드캐스트만 발생") + @DisplayName("GROUP 방에 발신자 외 멤버가 없으면 FCM은 미발송, 브로드캐스트만 발생") void execute_GROUP_멤버가_NORMAL_전송시_FCM_스킵() { // given User user = mock(User.class); @@ -173,12 +215,14 @@ class SendChatMessageTest { given(chatRoomQueryRepository.findById(200L)).willReturn(Optional.of(groupRoom)); given(chatRoomMemberQueryRepository.existsActive(200L, 1L, TokenScope.APP)).willReturn(true); + given(chatRoomMemberQueryRepository.findActiveByRoom(200L)).willReturn(List.of()); SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); given(request.getContent()).willReturn("그룹 메시지"); ChatMessage savedMessage = ChatMessage.create(200L, 1L, TokenScope.APP, ChatMessageType.NORMAL, "그룹 메시지"); given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(userQueryRepository.findById(1L)).willReturn(Optional.empty()); // when sut.execute(user, request, 200L); @@ -188,4 +232,51 @@ class SendChatMessageTest { then(notificationService).should(never()).sendNotificationOnly(any(), any(), anyString(), anyString()); assertThat(savedMessage.getType()).isEqualTo(ChatMessageType.NORMAL); } + + @Test + @DisplayName("GROUP 방에서 발신자·온라인 멤버는 제외하고 오프라인 멤버에게만 FCM 발송") + void execute_GROUP_오프라인_멤버에게만_FCM_발송() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getId()).willReturn(200L); + given(groupRoom.getType()).willReturn(ChatRoomType.GROUP); + + given(chatRoomQueryRepository.findById(200L)).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberQueryRepository.existsActive(200L, 1L, TokenScope.APP)).willReturn(true); + + ChatRoomMember senderMember = mock(ChatRoomMember.class); + given(senderMember.getMemberId()).willReturn(1L); + given(senderMember.getMemberScope()).willReturn(TokenScope.APP); + + ChatRoomMember onlineMember = mock(ChatRoomMember.class); + given(onlineMember.getMemberId()).willReturn(2L); + given(onlineMember.getMemberScope()).willReturn(TokenScope.APP); + + ChatRoomMember offlineMember = mock(ChatRoomMember.class); + given(offlineMember.getMemberId()).willReturn(3L); + given(offlineMember.getMemberScope()).willReturn(TokenScope.APP); + + given(chatRoomMemberQueryRepository.findActiveByRoom(200L)) + .willReturn(List.of(senderMember, onlineMember, offlineMember)); + given(chatPresenceStore.isOnline(TokenScope.APP, 2L)).willReturn(true); + given(chatPresenceStore.isOnline(TokenScope.APP, 3L)).willReturn(false); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getContent()).willReturn("그룹 메시지"); + + ChatMessage savedMessage = ChatMessage.create(200L, 1L, TokenScope.APP, ChatMessageType.NORMAL, "그룹 메시지"); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(userQueryRepository.findById(1L)).willReturn(Optional.empty()); + + // when + sut.execute(user, request, 200L); + + // then + then(notificationService).should().sendNotificationOnly(eq(3L), any(), anyString(), anyString()); + then(notificationService).should(never()).sendNotificationOnly(eq(1L), any(), anyString(), anyString()); + then(notificationService).should(never()).sendNotificationOnly(eq(2L), any(), anyString(), anyString()); + } } From 01dd9f9ad61a4d0902a88ff2baaa54cc7ac33c27 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 20:23:24 +0900 Subject: [PATCH 16/35] =?UTF-8?q?feat:=20STOMP=20=EB=B8=8C=EB=A1=9C?= =?UTF-8?q?=EC=BB=A4=20RabbitMQ=20relay=20=ED=86=A0=EA=B8=80=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90(=EA=B8=B0=EB=B3=B8=20SimpleBroker=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat.broker.relay.enabled 프로퍼티로 SimpleBroker/RabbitMQ relay를 전환한다. 기본값은 false로 두어 기존 테스트/배포는 SimpleBroker 경로를 그대로 사용하고, RabbitMQ(STOMP 플러그인) 배포 후 CHAT_BROKER_RELAY_ENABLED=true로 전환 가능하도록 한다. 오프라인 gradle 캐시 제약으로 relay 런타임 연결에 필요한 reactor-netty-core만 추가한다 (netty-all은 캐시에 없어 오프라인 빌드가 깨지므로 제외). --- build.gradle | 3 +++ local-infra/docker-compose.yml | 13 ++++++++++ local-infra/local.env | 5 ++++ .../alter/common/config/WebSocketConfig.java | 24 ++++++++++++++++++- src/main/resources/application.yml | 8 +++++++ src/test/resources/application.yml | 4 ++++ 6 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 local-infra/docker-compose.yml create mode 100644 local-infra/local.env diff --git a/build.gradle b/build.gradle index 8cc20b60..9d8a4319 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,9 @@ dependencies { implementation 'software.amazon.awssdk:s3:2.34.0' implementation 'com.fasterxml.uuid:java-uuid-generator:5.1.0' + // STOMP broker relay (RabbitMQ) — 런타임 relay 연결용 + implementation 'io.projectreactor.netty:reactor-netty-core' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' diff --git a/local-infra/docker-compose.yml b/local-infra/docker-compose.yml new file mode 100644 index 00000000..0a38a751 --- /dev/null +++ b/local-infra/docker-compose.yml @@ -0,0 +1,13 @@ +services: + alter-rabbitmq: + container_name: alter-rabbitmq + image: rabbitmq:4.0-management + ports: + - "5672:5672" + - "15672:15672" + - "61613:61613" + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + command: > + bash -c "rabbitmq-plugins enable --offline rabbitmq_stomp && rabbitmq-server" diff --git a/local-infra/local.env b/local-infra/local.env new file mode 100644 index 00000000..dba28b42 --- /dev/null +++ b/local-infra/local.env @@ -0,0 +1,5 @@ +CHAT_BROKER_RELAY_ENABLED=false +RABBITMQ_RELAY_HOST=localhost +RABBITMQ_RELAY_PORT=61613 +RABBITMQ_RELAY_USER=guest +RABBITMQ_RELAY_PASS=guest diff --git a/src/main/java/com/dreamteam/alter/common/config/WebSocketConfig.java b/src/main/java/com/dreamteam/alter/common/config/WebSocketConfig.java index 6eb6c033..c3d3fcc8 100644 --- a/src/main/java/com/dreamteam/alter/common/config/WebSocketConfig.java +++ b/src/main/java/com/dreamteam/alter/common/config/WebSocketConfig.java @@ -1,6 +1,7 @@ package com.dreamteam.alter.common.config; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; @@ -15,9 +16,30 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final JwtChannelInterceptor jwtChannelInterceptor; + @Value("${chat.broker.relay.enabled:false}") + private boolean relayEnabled; + @Value("${chat.broker.relay.host:localhost}") + private String relayHost; + @Value("${chat.broker.relay.port:61613}") + private int relayPort; + @Value("${chat.broker.relay.client-login:guest}") + private String relayLogin; + @Value("${chat.broker.relay.client-passcode:guest}") + private String relayPasscode; + @Override public void configureMessageBroker(MessageBrokerRegistry config) { - config.enableSimpleBroker("/sub"); + if (relayEnabled) { + config.enableStompBrokerRelay("/sub") + .setRelayHost(relayHost) + .setRelayPort(relayPort) + .setClientLogin(relayLogin) + .setClientPasscode(relayPasscode) + .setSystemLogin(relayLogin) + .setSystemPasscode(relayPasscode); + } else { + config.enableSimpleBroker("/sub"); + } config.setApplicationDestinationPrefixes("/pub"); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 29f38c27..640921a8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -94,3 +94,11 @@ aws: public-bucket: ${AWS_S3_PUBLIC_BUCKET} private-bucket: ${AWS_S3_PRIVATE_BUCKET} presigned-url-expiration-minutes: ${AWS_S3_PRESIGNED_URL_EXPIRATION_MINUTES} +chat: + broker: + relay: + enabled: ${CHAT_BROKER_RELAY_ENABLED:false} + host: ${RABBITMQ_RELAY_HOST:localhost} + port: ${RABBITMQ_RELAY_PORT:61613} + client-login: ${RABBITMQ_RELAY_USER:guest} + client-passcode: ${RABBITMQ_RELAY_PASS:guest} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index a4723dd7..eb10a385 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -79,3 +79,7 @@ aws: public-bucket: mock-public-bucket private-bucket: mock-private-bucket presigned-url-expiration-minutes: 30 +chat: + broker: + relay: + enabled: false From bb614937fc7d79374a65b5d9bb0699ef1783953c Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 20:25:52 +0900 Subject: [PATCH 17/35] =?UTF-8?q?chore:=20=EC=98=A4=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=EB=90=9C=20local-infra=20=EC=A0=9C=EA=B1=B0=20(=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20local-infra=EB=8A=94=20=EC=9B=8C=ED=81=AC=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=A3=A8=ED=8A=B8=EC=97=90=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RabbitMQ(STOMP) 서비스는 워크스페이스 루트 local-infra/docker-compose.yml에 추가함. relay 코드(build.gradle, WebSocketConfig, application.yml)는 유지. --- local-infra/docker-compose.yml | 13 ------------- local-infra/local.env | 5 ----- 2 files changed, 18 deletions(-) delete mode 100644 local-infra/docker-compose.yml delete mode 100644 local-infra/local.env diff --git a/local-infra/docker-compose.yml b/local-infra/docker-compose.yml deleted file mode 100644 index 0a38a751..00000000 --- a/local-infra/docker-compose.yml +++ /dev/null @@ -1,13 +0,0 @@ -services: - alter-rabbitmq: - container_name: alter-rabbitmq - image: rabbitmq:4.0-management - ports: - - "5672:5672" - - "15672:15672" - - "61613:61613" - environment: - RABBITMQ_DEFAULT_USER: guest - RABBITMQ_DEFAULT_PASS: guest - command: > - bash -c "rabbitmq-plugins enable --offline rabbitmq_stomp && rabbitmq-server" diff --git a/local-infra/local.env b/local-infra/local.env deleted file mode 100644 index dba28b42..00000000 --- a/local-infra/local.env +++ /dev/null @@ -1,5 +0,0 @@ -CHAT_BROKER_RELAY_ENABLED=false -RABBITMQ_RELAY_HOST=localhost -RABBITMQ_RELAY_PORT=61613 -RABBITMQ_RELAY_USER=guest -RABBITMQ_RELAY_PASS=guest From 9379f85bcae5406240c7c90f48b3d5536cfebfff Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 20:30:02 +0900 Subject: [PATCH 18/35] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80-only?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=9C=84=ED=95=B4=20chat=5Fme?= =?UTF-8?q?ssages.content=20nullable=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이미지만 있는 메시지를 허용하기 위해 content NOT NULL 제약을 완화. DTO/검증/첨부 연동은 후속 태스크(6.2/6.3)에서 처리. --- .../com/dreamteam/alter/domain/chat/entity/ChatMessage.java | 2 +- .../db/migration/V5__chat_message_content_nullable.sql | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V5__chat_message_content_nullable.sql diff --git a/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatMessage.java b/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatMessage.java index f8de2d14..803339bc 100644 --- a/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatMessage.java +++ b/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatMessage.java @@ -33,7 +33,7 @@ public class ChatMessage { @Column(name = "sender_scope", length = 20, nullable = false) private TokenScope senderScope; - @Column(name = "content", length = 1000, nullable = false) + @Column(name = "content", length = 1000) private String content; @Enumerated(EnumType.STRING) diff --git a/src/main/resources/db/migration/V5__chat_message_content_nullable.sql b/src/main/resources/db/migration/V5__chat_message_content_nullable.sql new file mode 100644 index 00000000..89c30e1a --- /dev/null +++ b/src/main/resources/db/migration/V5__chat_message_content_nullable.sql @@ -0,0 +1,2 @@ +-- 이미지-only 메시지 허용을 위해 content NOT NULL 제약 완화. +ALTER TABLE chat_messages ALTER COLUMN content DROP NOT NULL; From d00f9b4303e46734d25fc05a4b786db8117757e2 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 20:32:48 +0900 Subject: [PATCH 19/35] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EC=9A=94=EC=B2=AD=EC=97=90=20fileIds=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=B2=A8=EB=B6=80=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20ErrorCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이미지 전용 메시지를 지원하기 위해 content의 @NotBlank 제약을 제거하고 fileIds 필드를 추가한다. content/fileIds 중 하나는 필수라는 검증은 usecase 단에서 처리한다(Task 6.3). 첨부 관련 ErrorCode(B027, B028) 추가. --- .../general/chat/dto/SendChatMessageRequestDto.java | 8 ++++++-- .../com/dreamteam/alter/common/exception/ErrorCode.java | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/SendChatMessageRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/SendChatMessageRequestDto.java index 4c3cf60a..0b9f16f0 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/SendChatMessageRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/SendChatMessageRequestDto.java @@ -2,7 +2,7 @@ import com.dreamteam.alter.domain.chat.type.ChatMessageType; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; +import java.util.List; import lombok.*; @Getter @@ -10,10 +10,14 @@ @AllArgsConstructor @Schema(description = "채팅 메시지 전송 요청 DTO") public class SendChatMessageRequestDto { - @NotBlank + public static final int MAX_ATTACHMENTS = 10; + @Schema(description = "메시지 내용") private String content; @Schema(description = "메시지 타입(NORMAL/NOTICE), 기본 NORMAL)") private ChatMessageType type; + + @Schema(description = "첨부 이미지 fileId 목록", example = "[\"...\"]") + private List fileIds; } diff --git a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java index a413e006..30fcd2cf 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java +++ b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java @@ -52,6 +52,8 @@ public enum ErrorCode { FILE_SIZE_EXCEEDED(400, "B024", "파일 크기가 제한을 초과합니다."), FILE_ALREADY_ATTACHED(409, "B025", "이미 연결된 파일입니다."), CHAT_NOTICE_FORBIDDEN(400, "B026", "공지는 매니저만 작성할 수 있습니다."), + CHAT_EMPTY_MESSAGE(400, "B027", "내용 또는 이미지를 첨부해야 합니다."), + CHAT_TOO_MANY_ATTACHMENTS(400, "B028", "이미지는 최대 10개까지 첨부할 수 있습니다."), TOO_MANY_REQUESTS(429, "E001", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), From 581d7adf594e88c407bc24c9b5defa1e7dbeab67 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 20:41:27 +0900 Subject: [PATCH 20/35] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B2=A8?= =?UTF-8?q?=EB=B6=80=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20payload=EC=97=90=20URL=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AbstractSendChatMessageUseCase에 AttachFilesUseCase/FileQueryRepository/FileUrlService 주입 - 내용·이미지 모두 없으면 CHAT_EMPTY_MESSAGE, 첨부 10개 초과시 CHAT_TOO_MANY_ATTACHMENTS 검증 추가 - 메시지 저장 후 fileIds를 CHAT_MESSAGE 타겟으로 연결하고, 브로드캐스트 payload(ChatMessageResponse)에 FileResponseDto 목록(attachments)을 포함해 실시간으로 첨부 URL 전달 - ChatMessageResponse는 QueryDSL Projections.constructor 호환을 위해 명시적 6-arg 생성자를 유지하고 attachments 필드는 @Setter로 추가 (프로젝션 안전) - SendChatMessage/ManagerSendChatMessage 생성자에 3개 의존성 추가 및 관련 테스트 보강 --- .../readonly/ChatMessageResponse.java | 23 +++- .../AbstractSendChatMessageUseCase.java | 35 +++++ .../chat/usecase/ManagerSendChatMessage.java | 11 +- .../chat/usecase/SendChatMessage.java | 11 +- .../usecase/ManagerSendChatMessageTest.java | 124 +++++++++++++++++ .../chat/usecase/SendChatMessageTest.java | 126 ++++++++++++++++++ 6 files changed, 324 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/readonly/ChatMessageResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/readonly/ChatMessageResponse.java index ddb02649..cfabebd8 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/readonly/ChatMessageResponse.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/readonly/ChatMessageResponse.java @@ -1,15 +1,17 @@ package com.dreamteam.alter.adapter.outbound.chat.persistence.readonly; +import com.dreamteam.alter.adapter.inbound.common.dto.FileResponseDto; import com.dreamteam.alter.domain.auth.type.TokenScope; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import java.time.LocalDateTime; +import java.util.List; @Getter +@Setter @NoArgsConstructor -@AllArgsConstructor public class ChatMessageResponse { private Long id; private Long chatRoomId; @@ -17,4 +19,21 @@ public class ChatMessageResponse { private TokenScope senderScope; private String content; private LocalDateTime createdAt; + private List attachments; + + public ChatMessageResponse( + Long id, + Long chatRoomId, + Long senderId, + TokenScope senderScope, + String content, + LocalDateTime createdAt + ) { + this.id = id; + this.chatRoomId = chatRoomId; + this.senderId = senderId; + this.senderScope = senderScope; + this.content = content; + this.createdAt = createdAt; + } } diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java index feea203c..c12600b3 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java @@ -17,6 +17,11 @@ import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; import com.dreamteam.alter.application.notification.NotificationService; +import com.dreamteam.alter.domain.file.port.inbound.AttachFilesUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.dreamteam.alter.application.file.FileUrlService; +import com.dreamteam.alter.adapter.inbound.common.dto.FileResponseDto; import com.dreamteam.alter.domain.notification.type.NotificationType; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; @@ -41,6 +46,9 @@ public abstract class AbstractSendChatMessageUseCase extends AbstractChatUseC protected final SimpMessagingTemplate messagingTemplate; protected final ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; protected final ChatPresenceStore chatPresenceStore; + protected final AttachFilesUseCase attachFilesUseCase; + protected final FileQueryRepository fileQueryRepository; + protected final FileUrlService fileUrlService; public final void execute(U user, SendChatMessageRequestDto request, Long chatRoomId) { TokenScope senderScope = getParticipantScope(user); @@ -70,6 +78,16 @@ public final void execute(U user, SendChatMessageRequestDto request, Long chatRo throw new CustomException(ErrorCode.CHAT_NOTICE_FORBIDDEN); } + // 3-1. 내용/첨부 검증 + List fileIds = request.getFileIds() == null ? List.of() : request.getFileIds(); + boolean hasText = !ObjectUtils.isEmpty(request.getContent()); + if (!hasText && fileIds.isEmpty()) { + throw new CustomException(ErrorCode.CHAT_EMPTY_MESSAGE); + } + if (fileIds.size() > SendChatMessageRequestDto.MAX_ATTACHMENTS) { + throw new CustomException(ErrorCode.CHAT_TOO_MANY_ATTACHMENTS); + } + // 4. 메시지 저장 ChatMessage chatMessage = ChatMessage.create( chatRoom.getId(), @@ -80,6 +98,22 @@ public final void execute(U user, SendChatMessageRequestDto request, Long chatRo ); ChatMessage savedMessage = chatMessageRepository.save(chatMessage); + // 4-1. 첨부 파일 연결 + List attachments = List.of(); + if (!fileIds.isEmpty()) { + attachFilesUseCase.execute( + fileIds, + FileTargetType.CHAT_MESSAGE, + String.valueOf(savedMessage.getId()), + senderId + ); + attachments = fileQueryRepository + .findAllByTargetTypeAndTargetIdIn(FileTargetType.CHAT_MESSAGE, List.of(String.valueOf(savedMessage.getId()))) + .stream() + .map(fileUrlService::resolve) + .toList(); + } + // 5. ChatRoom의 updatedAt 갱신 chatRoom.updateUpdatedAt(); @@ -93,6 +127,7 @@ public final void execute(U user, SendChatMessageRequestDto request, Long chatRo savedMessage.getContent(), savedMessage.getCreatedAt() ); + messageResponse.setAttachments(attachments); messagingTemplate.convertAndSend("/sub/chat." + chatRoom.getId(), messageResponse); } catch (Exception e) { log.error("WebSocket 메시지 전송 실패. ChatRoomId: {}, Error: {}", chatRoom.getId(), e.getMessage(), e); diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessage.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessage.java index fb970afd..66c15d24 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessage.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessage.java @@ -8,6 +8,9 @@ import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; import com.dreamteam.alter.application.notification.NotificationService; +import com.dreamteam.alter.application.file.FileUrlService; +import com.dreamteam.alter.domain.file.port.inbound.AttachFilesUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; import com.dreamteam.alter.domain.user.entity.ManagerUser; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -25,11 +28,15 @@ public ManagerSendChatMessage( NotificationService notificationService, SimpMessagingTemplate messagingTemplate, ChatRoomMemberQueryRepository chatRoomMemberQueryRepository, - ChatPresenceStore chatPresenceStore + ChatPresenceStore chatPresenceStore, + AttachFilesUseCase attachFilesUseCase, + FileQueryRepository fileQueryRepository, + FileUrlService fileUrlService ) { super( chatRoomQueryRepository, chatRoomRepository, chatMessageRepository, userQueryRepository, - notificationService, messagingTemplate, chatRoomMemberQueryRepository, chatPresenceStore + notificationService, messagingTemplate, chatRoomMemberQueryRepository, chatPresenceStore, + attachFilesUseCase, fileQueryRepository, fileUrlService ); } diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/SendChatMessage.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/SendChatMessage.java index b4422c3b..83c2e0dd 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/SendChatMessage.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/SendChatMessage.java @@ -8,6 +8,9 @@ import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; import com.dreamteam.alter.application.notification.NotificationService; +import com.dreamteam.alter.application.file.FileUrlService; +import com.dreamteam.alter.domain.file.port.inbound.AttachFilesUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -24,11 +27,15 @@ public SendChatMessage( NotificationService notificationService, SimpMessagingTemplate messagingTemplate, ChatRoomMemberQueryRepository chatRoomMemberQueryRepository, - ChatPresenceStore chatPresenceStore + ChatPresenceStore chatPresenceStore, + AttachFilesUseCase attachFilesUseCase, + FileQueryRepository fileQueryRepository, + FileUrlService fileUrlService ) { super( chatRoomQueryRepository, chatRoomRepository, chatMessageRepository, userQueryRepository, - notificationService, messagingTemplate, chatRoomMemberQueryRepository, chatPresenceStore + notificationService, messagingTemplate, chatRoomMemberQueryRepository, chatPresenceStore, + attachFilesUseCase, fileQueryRepository, fileUrlService ); } diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java index 56b32b2e..04b67e1e 100644 --- a/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java @@ -1,6 +1,7 @@ package com.dreamteam.alter.application.chat.usecase; import com.dreamteam.alter.adapter.inbound.general.chat.dto.SendChatMessageRequestDto; +import com.dreamteam.alter.adapter.outbound.chat.persistence.readonly.ChatMessageResponse; import com.dreamteam.alter.application.notification.NotificationService; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; @@ -15,6 +16,12 @@ import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; import com.dreamteam.alter.domain.chat.type.ChatMessageType; import com.dreamteam.alter.domain.chat.type.ChatRoomType; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.inbound.AttachFilesUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.dreamteam.alter.application.file.FileUrlService; +import com.dreamteam.alter.adapter.inbound.common.dto.FileResponseDto; import com.dreamteam.alter.domain.user.entity.ManagerUser; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; @@ -32,6 +39,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -66,6 +74,15 @@ class ManagerSendChatMessageTest { @Mock private ChatPresenceStore chatPresenceStore; + @Mock + private AttachFilesUseCase attachFilesUseCase; + + @Mock + private FileQueryRepository fileQueryRepository; + + @Mock + private FileUrlService fileUrlService; + @InjectMocks private ManagerSendChatMessage sut; @@ -171,4 +188,111 @@ class ManagerSendChatMessageTest { then(chatMessageRepository).should(never()).save(any()); } + + @Test + @DisplayName("내용도 이미지도 없으면 CHAT_EMPTY_MESSAGE 예외, 저장 안 됨") + void execute_내용도_이미지도_없으면_예외() { + // given + User innerUser = mock(User.class); + given(innerUser.getId()).willReturn(2L); + ManagerUser managerUser = mock(ManagerUser.class); + given(managerUser.getUser()).willReturn(innerUser); + + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getType()).willReturn(ChatRoomType.GROUP); + + given(chatRoomQueryRepository.findById(5L)).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberQueryRepository.existsActive(5L, 2L, TokenScope.MANAGER)).willReturn(true); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getContent()).willReturn(null); + given(request.getFileIds()).willReturn(List.of()); + + // when & then + assertThatThrownBy(() -> sut.execute(managerUser, request, 5L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.CHAT_EMPTY_MESSAGE); + + then(chatMessageRepository).should(never()).save(any()); + } + + @Test + @DisplayName("첨부 이미지가 10개 초과이면 CHAT_TOO_MANY_ATTACHMENTS 예외, 저장 안 됨") + void execute_첨부가_10개_초과이면_예외() { + // given + User innerUser = mock(User.class); + given(innerUser.getId()).willReturn(2L); + ManagerUser managerUser = mock(ManagerUser.class); + given(managerUser.getUser()).willReturn(innerUser); + + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getType()).willReturn(ChatRoomType.GROUP); + + given(chatRoomQueryRepository.findById(5L)).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberQueryRepository.existsActive(5L, 2L, TokenScope.MANAGER)).willReturn(true); + + List fileIds = java.util.stream.IntStream.range(0, 11) + .mapToObj(i -> "f" + i) + .toList(); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getContent()).willReturn(null); + given(request.getFileIds()).willReturn(fileIds); + + // when & then + assertThatThrownBy(() -> sut.execute(managerUser, request, 5L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.CHAT_TOO_MANY_ATTACHMENTS); + + then(chatMessageRepository).should(never()).save(any()); + } + + @Test + @DisplayName("이미지 첨부시 attachFilesUseCase 호출 및 브로드캐스트 payload에 attachments 포함") + void execute_이미지_첨부시_attach_호출_및_payload_포함() { + // given + User innerUser = mock(User.class); + given(innerUser.getId()).willReturn(2L); + ManagerUser managerUser = mock(ManagerUser.class); + given(managerUser.getUser()).willReturn(innerUser); + + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getId()).willReturn(5L); + given(groupRoom.getType()).willReturn(ChatRoomType.GROUP); + + given(chatRoomQueryRepository.findById(5L)).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberQueryRepository.existsActive(5L, 2L, TokenScope.MANAGER)).willReturn(true); + given(chatRoomMemberQueryRepository.findActiveByRoom(5L)).willReturn(List.of()); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getContent()).willReturn(null); + given(request.getFileIds()).willReturn(List.of("f1", "f2")); + + ChatMessage savedMessage = ChatMessage.create(5L, 2L, TokenScope.MANAGER, ChatMessageType.NORMAL, null); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + + File file1 = mock(File.class); + File file2 = mock(File.class); + given(fileQueryRepository.findAllByTargetTypeAndTargetIdIn( + eq(FileTargetType.CHAT_MESSAGE), eq(List.of(String.valueOf(savedMessage.getId()))) + )).willReturn(List.of(file1, file2)); + + FileResponseDto dto1 = FileResponseDto.of(file1, "https://cdn.example.com/f1"); + FileResponseDto dto2 = FileResponseDto.of(file2, "https://cdn.example.com/f2"); + given(fileUrlService.resolve(file1)).willReturn(dto1); + given(fileUrlService.resolve(file2)).willReturn(dto2); + + // when + sut.execute(managerUser, request, 5L); + + // then + then(attachFilesUseCase).should().execute( + eq(List.of("f1", "f2")), eq(FileTargetType.CHAT_MESSAGE), anyString(), eq(2L) + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); + then(messagingTemplate).should().convertAndSend(eq("/sub/chat.5"), captor.capture()); + ChatMessageResponse payload = (ChatMessageResponse) captor.getValue(); + assertThat(payload.getAttachments()).containsExactly(dto1, dto2); + } } diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java index 0f9b8bbf..7067d225 100644 --- a/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java @@ -1,6 +1,7 @@ package com.dreamteam.alter.application.chat.usecase; import com.dreamteam.alter.adapter.inbound.general.chat.dto.SendChatMessageRequestDto; +import com.dreamteam.alter.adapter.outbound.chat.persistence.readonly.ChatMessageResponse; import com.dreamteam.alter.application.notification.NotificationService; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; @@ -15,11 +16,18 @@ import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; import com.dreamteam.alter.domain.chat.type.ChatMessageType; import com.dreamteam.alter.domain.chat.type.ChatRoomType; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.inbound.AttachFilesUseCase; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.file.type.FileTargetType; +import com.dreamteam.alter.application.file.FileUrlService; +import com.dreamteam.alter.adapter.inbound.common.dto.FileResponseDto; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -65,6 +73,15 @@ class SendChatMessageTest { @Mock private ChatPresenceStore chatPresenceStore; + @Mock + private AttachFilesUseCase attachFilesUseCase; + + @Mock + private FileQueryRepository fileQueryRepository; + + @Mock + private FileUrlService fileUrlService; + @InjectMocks private SendChatMessage sut; @@ -279,4 +296,113 @@ class SendChatMessageTest { then(notificationService).should(never()).sendNotificationOnly(eq(1L), any(), anyString(), anyString()); then(notificationService).should(never()).sendNotificationOnly(eq(2L), any(), anyString(), anyString()); } + + @Test + @DisplayName("내용도 이미지도 없으면 CHAT_EMPTY_MESSAGE 예외, 저장 안 됨") + void execute_내용도_이미지도_없으면_예외() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + + ChatRoom directRoom = mock(ChatRoom.class); + given(directRoom.getType()).willReturn(ChatRoomType.DIRECT); + + given(chatRoomQueryRepository.findById(100L)).willReturn(Optional.of(directRoom)); + given(chatRoomQueryRepository.findByIdAndParticipant(100L, 1L, TokenScope.APP)) + .willReturn(Optional.of(directRoom)); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getContent()).willReturn(null); + given(request.getFileIds()).willReturn(List.of()); + + // when & then + assertThatThrownBy(() -> sut.execute(user, request, 100L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.CHAT_EMPTY_MESSAGE); + + then(chatMessageRepository).should(never()).save(any()); + } + + @Test + @DisplayName("첨부 이미지가 10개 초과이면 CHAT_TOO_MANY_ATTACHMENTS 예외, 저장 안 됨") + void execute_첨부가_10개_초과이면_예외() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + + ChatRoom directRoom = mock(ChatRoom.class); + given(directRoom.getType()).willReturn(ChatRoomType.DIRECT); + + given(chatRoomQueryRepository.findById(100L)).willReturn(Optional.of(directRoom)); + given(chatRoomQueryRepository.findByIdAndParticipant(100L, 1L, TokenScope.APP)) + .willReturn(Optional.of(directRoom)); + + List fileIds = java.util.stream.IntStream.range(0, 11) + .mapToObj(i -> "f" + i) + .toList(); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getContent()).willReturn(null); + given(request.getFileIds()).willReturn(fileIds); + + // when & then + assertThatThrownBy(() -> sut.execute(user, request, 100L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.CHAT_TOO_MANY_ATTACHMENTS); + + then(chatMessageRepository).should(never()).save(any()); + } + + @Test + @DisplayName("이미지 첨부시 attachFilesUseCase 호출 및 브로드캐스트 payload에 attachments 포함") + void execute_이미지_첨부시_attach_호출_및_payload_포함() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + + ChatRoom directRoom = mock(ChatRoom.class); + given(directRoom.getId()).willReturn(100L); + given(directRoom.getType()).willReturn(ChatRoomType.DIRECT); + given(directRoom.getParticipant1Id()).willReturn(1L); + given(directRoom.getParticipant1Scope()).willReturn(TokenScope.APP); + given(directRoom.getParticipant2Id()).willReturn(2L); + given(directRoom.getParticipant2Scope()).willReturn(TokenScope.APP); + + given(chatRoomQueryRepository.findById(100L)).willReturn(Optional.of(directRoom)); + given(chatRoomQueryRepository.findByIdAndParticipant(100L, 1L, TokenScope.APP)) + .willReturn(Optional.of(directRoom)); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getContent()).willReturn(null); + given(request.getFileIds()).willReturn(List.of("f1", "f2")); + + ChatMessage savedMessage = ChatMessage.create(100L, 1L, TokenScope.APP, ChatMessageType.NORMAL, null); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + given(userQueryRepository.findById(1L)).willReturn(Optional.empty()); + given(chatPresenceStore.isOnline(TokenScope.APP, 2L)).willReturn(false); + + File file1 = mock(File.class); + File file2 = mock(File.class); + given(fileQueryRepository.findAllByTargetTypeAndTargetIdIn( + eq(FileTargetType.CHAT_MESSAGE), eq(List.of(String.valueOf(savedMessage.getId()))) + )).willReturn(List.of(file1, file2)); + + FileResponseDto dto1 = FileResponseDto.of(file1, "https://cdn.example.com/f1"); + FileResponseDto dto2 = FileResponseDto.of(file2, "https://cdn.example.com/f2"); + given(fileUrlService.resolve(file1)).willReturn(dto1); + given(fileUrlService.resolve(file2)).willReturn(dto2); + + // when + sut.execute(user, request, 100L); + + // then + then(attachFilesUseCase).should().execute( + eq(List.of("f1", "f2")), eq(FileTargetType.CHAT_MESSAGE), anyString(), eq(1L) + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); + then(messagingTemplate).should().convertAndSend(eq("/sub/chat.100"), captor.capture()); + ChatMessageResponse payload = (ChatMessageResponse) captor.getValue(); + assertThat(payload.getAttachments()).containsExactly(dto1, dto2); + } } From 4ccbee5e78dad5023336c6fa2fa4b08af1e4f559 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 20:51:55 +0900 Subject: [PATCH 21/35] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B2=A8=EB=B6=80=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메시지 목록 조회 시 페이지 내 메시지 id를 모아 첨부파일을 findAllByTargetTypeAndTargetIdIn으로 한 번에 조회하고 targetId 기준으로 그룹핑하여 각 메시지에 매핑한다 (N+1 방지). --- .../chat/dto/ChatMessageResponseDto.java | 6 ++ .../AbstractGetChatMessagesUseCase.java | 34 ++++++++ .../chat/usecase/GetChatMessages.java | 15 +++- .../chat/usecase/ManagerGetChatMessages.java | 15 +++- .../chat/usecase/GetChatMessagesTest.java | 86 ++++++++++++++++++- 5 files changed, 151 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/ChatMessageResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/ChatMessageResponseDto.java index 50dfb7f5..f8569328 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/ChatMessageResponseDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/ChatMessageResponseDto.java @@ -1,12 +1,14 @@ package com.dreamteam.alter.adapter.inbound.general.chat.dto; import com.dreamteam.alter.adapter.inbound.common.dto.DescribedEnumDto; +import com.dreamteam.alter.adapter.inbound.common.dto.FileResponseDto; import com.dreamteam.alter.adapter.outbound.chat.persistence.readonly.ChatMessageResponse; import com.dreamteam.alter.domain.auth.type.TokenScope; import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; import java.time.LocalDateTime; +import java.util.List; @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -36,6 +38,9 @@ public class ChatMessageResponseDto { @Schema(description = "안 읽은 사람 수") private Integer unreadCount; + @Schema(description = "첨부 파일 목록") + private List attachments; + public static ChatMessageResponseDto from(ChatMessageResponse response) { return ChatMessageResponseDto.builder() .id(response.getId()) @@ -78,6 +83,7 @@ public static ChatMessageResponseDto from( .createdAt(response.getCreatedAt()) .isMine(isMine) .unreadCount(unreadCount) + .attachments(response.getAttachments()) .build(); } diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java index 9cc7175f..fa71cc71 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java @@ -11,16 +11,24 @@ import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.common.util.CursorUtil; import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.adapter.inbound.common.dto.FileResponseDto; +import com.dreamteam.alter.application.file.FileUrlService; import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.file.type.FileTargetType; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.ObjectUtils; import org.springframework.transaction.annotation.Transactional; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @RequiredArgsConstructor @Transactional(readOnly = true) @@ -30,6 +38,8 @@ public abstract class AbstractGetChatMessagesUseCase extends AbstractChatUseC protected final ChatMessageQueryRepository chatMessageQueryRepository; protected final ObjectMapper objectMapper; protected final ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + protected final FileQueryRepository fileQueryRepository; + protected final FileUrlService fileUrlService; protected CursorPaginatedApiResponse execute( A actor, @@ -66,6 +76,9 @@ protected CursorPaginatedApiResponse execute( // 5. 방의 활성 멤버 목록을 한 번만 로드 (메시지별 안 읽은 사람 수 계산용, N+1 방지) List activeMembers = chatRoomMemberQueryRepository.findActiveByRoom(chatRoomId); + // 5-1. 페이지 내 메시지들의 첨부 파일을 한 번에 조회하여 매핑 (N+1 방지) + attachAttachments(sortedMessages); + // 6. DTO 변환 (본인 메시지 여부 + 안 읽은 사람 수 포함) List messageList = sortedMessages.stream() .map(message -> ChatMessageResponseDto.from( @@ -91,6 +104,27 @@ protected CursorPaginatedApiResponse execute( protected abstract Long getParticipantId(A actor); + // 메시지 목록의 첨부 파일을 한 번에 조회하여 targetId(=메시지 id) 기준으로 그룹핑 후 매핑 (N+1 방지) + private void attachAttachments(List messages) { + List messageIds = messages.stream() + .map(message -> String.valueOf(message.getId())) + .toList(); + + List files = fileQueryRepository.findAllByTargetTypeAndTargetIdIn(FileTargetType.CHAT_MESSAGE, messageIds); + + Map> attachmentsByMessageId = files.stream() + .collect(Collectors.groupingBy( + File::getTargetId, + Collectors.mapping(fileUrlService::resolve, Collectors.toList()) + )); + + for (ChatMessageResponse message : messages) { + message.setAttachments( + attachmentsByMessageId.getOrDefault(String.valueOf(message.getId()), Collections.emptyList()) + ); + } + } + // 메시지별 안 읽은 사람 수 = 활성 멤버 중 (아직 읽지 않았고) AND (발신자 본인이 아닌) 인원 수 private int calculateUnreadCount(ChatMessageResponse message, List activeMembers) { return (int) activeMembers.stream() diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/GetChatMessages.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/GetChatMessages.java index e9abb364..52a3dde0 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/GetChatMessages.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/GetChatMessages.java @@ -3,11 +3,13 @@ import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequestDto; import com.dreamteam.alter.adapter.inbound.common.dto.CursorPaginatedApiResponse; import com.dreamteam.alter.adapter.inbound.general.chat.dto.ChatMessageResponseDto; +import com.dreamteam.alter.application.file.FileUrlService; import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.port.inbound.GetChatMessagesUseCase; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; import com.dreamteam.alter.domain.user.context.AppActor; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Service; @@ -19,9 +21,18 @@ public GetChatMessages( ChatRoomQueryRepository chatRoomQueryRepository, ChatMessageQueryRepository chatMessageQueryRepository, ObjectMapper objectMapper, - ChatRoomMemberQueryRepository chatRoomMemberQueryRepository + ChatRoomMemberQueryRepository chatRoomMemberQueryRepository, + FileQueryRepository fileQueryRepository, + FileUrlService fileUrlService ) { - super(chatRoomQueryRepository, chatMessageQueryRepository, objectMapper, chatRoomMemberQueryRepository); + super( + chatRoomQueryRepository, + chatMessageQueryRepository, + objectMapper, + chatRoomMemberQueryRepository, + fileQueryRepository, + fileUrlService + ); } @Override diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerGetChatMessages.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerGetChatMessages.java index 073d2ab2..961a1701 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerGetChatMessages.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerGetChatMessages.java @@ -3,11 +3,13 @@ import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequestDto; import com.dreamteam.alter.adapter.inbound.common.dto.CursorPaginatedApiResponse; import com.dreamteam.alter.adapter.inbound.general.chat.dto.ChatMessageResponseDto; +import com.dreamteam.alter.application.file.FileUrlService; import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.port.inbound.ManagerGetChatMessagesUseCase; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; import com.dreamteam.alter.domain.user.context.ManagerActor; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Service; @@ -20,9 +22,18 @@ public ManagerGetChatMessages( ChatRoomQueryRepository chatRoomQueryRepository, ChatMessageQueryRepository chatMessageQueryRepository, ObjectMapper objectMapper, - ChatRoomMemberQueryRepository chatRoomMemberQueryRepository + ChatRoomMemberQueryRepository chatRoomMemberQueryRepository, + FileQueryRepository fileQueryRepository, + FileUrlService fileUrlService ) { - super(chatRoomQueryRepository, chatMessageQueryRepository, objectMapper, chatRoomMemberQueryRepository); + super( + chatRoomQueryRepository, + chatMessageQueryRepository, + objectMapper, + chatRoomMemberQueryRepository, + fileQueryRepository, + fileUrlService + ); } @Override diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTest.java index 3fdd71cd..328487b4 100644 --- a/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTest.java +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTest.java @@ -2,14 +2,19 @@ import com.dreamteam.alter.adapter.inbound.common.dto.CursorPageRequestDto; import com.dreamteam.alter.adapter.inbound.common.dto.CursorPaginatedApiResponse; +import com.dreamteam.alter.adapter.inbound.common.dto.FileResponseDto; import com.dreamteam.alter.adapter.inbound.general.chat.dto.ChatMessageResponseDto; import com.dreamteam.alter.adapter.outbound.chat.persistence.readonly.ChatMessageResponse; +import com.dreamteam.alter.application.file.FileUrlService; import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.entity.ChatRoom; import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; +import com.dreamteam.alter.domain.file.entity.File; +import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; +import com.dreamteam.alter.domain.file.type.FileTargetType; import com.dreamteam.alter.domain.user.context.AppActor; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; @@ -21,12 +26,16 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @DisplayName("GetChatMessages 테스트") @@ -41,6 +50,12 @@ class GetChatMessagesTest { @Mock private ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + @Mock + private FileQueryRepository fileQueryRepository; + + @Mock + private FileUrlService fileUrlService; + private GetChatMessages sut; @BeforeEach @@ -49,7 +64,9 @@ void setUp() { chatRoomQueryRepository, chatMessageQueryRepository, new ObjectMapper().registerModule(new JavaTimeModule()), - chatRoomMemberQueryRepository + chatRoomMemberQueryRepository, + fileQueryRepository, + fileUrlService ); } @@ -89,4 +106,71 @@ void setUp() { assertThat(response.data()).hasSize(1); assertThat(response.data().getFirst().getUnreadCount()).isEqualTo(2); } + + @Test + @DisplayName("메시지 목록 조회 시 첨부 파일을 일괄 조회하여 각 메시지에 매핑한다 (N+1 방지)") + void execute_첨부파일_일괄조회_매핑() { + // given + Long chatRoomId = 1L; + Long senderId = 100L; + AppActor actor = new AppActor(senderId, null, null); + + given(chatRoomQueryRepository.findByIdAndParticipant(chatRoomId, senderId, TokenScope.APP)) + .willReturn(Optional.of(ChatRoom.createGroup(1L))); + + ChatMessageResponse messageWithFile = new ChatMessageResponse( + 1L, chatRoomId, senderId, TokenScope.APP, "첨부 있음", LocalDateTime.now() + ); + ChatMessageResponse messageWithoutFile = new ChatMessageResponse( + 2L, chatRoomId, senderId, TokenScope.APP, "첨부 없음", LocalDateTime.now() + ); + given(chatMessageQueryRepository.getChatMessagesWithCursor(any(), any())) + .willReturn(List.of(messageWithoutFile, messageWithFile)); + + given(chatRoomMemberQueryRepository.findActiveByRoom(chatRoomId)) + .willReturn(Collections.emptyList()); + + File file = File.create( + FileTargetType.CHAT_MESSAGE, + "photo.png", + "stored/key.png", + "https://cdn.example.com/photo.png", + "image/png", + 1024L, + com.dreamteam.alter.domain.file.type.BucketType.PUBLIC, + senderId + ); + file.attach(String.valueOf(messageWithFile.getId())); + + given(fileQueryRepository.findAllByTargetTypeAndTargetIdIn( + eq(FileTargetType.CHAT_MESSAGE), + any() + )).willReturn(List.of(file)); + + FileResponseDto fileResponseDto = FileResponseDto.of(file, "https://cdn.example.com/photo.png"); + given(fileUrlService.resolve(file)).willReturn(fileResponseDto); + + // when + CursorPaginatedApiResponse response = + sut.execute(actor, chatRoomId, CursorPageRequestDto.of(null, 10)); + + // then + assertThat(response.data()).hasSize(2); + + ChatMessageResponseDto dtoWithFile = response.data().stream() + .filter(dto -> dto.getId().equals(messageWithFile.getId())) + .findFirst() + .orElseThrow(); + ChatMessageResponseDto dtoWithoutFile = response.data().stream() + .filter(dto -> dto.getId().equals(messageWithoutFile.getId())) + .findFirst() + .orElseThrow(); + + assertThat(dtoWithFile.getAttachments()).hasSize(1); + assertThat(dtoWithFile.getAttachments().getFirst().getUrl()).isEqualTo("https://cdn.example.com/photo.png"); + assertThat(dtoWithoutFile.getAttachments()).isEmpty(); + + verify(fileQueryRepository, times(1)) + .findAllByTargetTypeAndTargetIdIn(eq(FileTargetType.CHAT_MESSAGE), any()); + } } From 96f86aa7cc050cc64a92fbf0006c506da706970e Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 20:58:32 +0900 Subject: [PATCH 22/35] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=91=B8=EC=8B=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B3=B8=EB=AC=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이미지-only 채팅 메시지의 FCM 푸시 body가 "senderName: "으로 어색하게 비어있던 문제 수정. content가 없으면 "senderName: 사진을 보냈습니다"로 표시하고, 텍스트 메시지는 기존 미리보기 body를 유지한다. DIRECT/GROUP FCM 발송 두 경로에서 중복되던 body 조합 로직을 buildNotificationBody() 헬퍼로 통합. --- .../AbstractSendChatMessageUseCase.java | 19 +++-- .../NotificationMessageConstants.java | 1 + .../chat/usecase/SendChatMessageTest.java | 81 +++++++++++++++++++ 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java index c12600b3..1c8f80b0 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java @@ -171,11 +171,7 @@ private void sendDirectFcmNotification( // 알림 메시지 생성 String title = NotificationMessageConstants.Chat.NEW_MESSAGE_TITLE; - String body = String.format( - NotificationMessageConstants.Chat.NEW_MESSAGE_BODY, - senderName, - truncateContent(content) - ); + String body = buildNotificationBody(senderName, content); // FCM 알림 전송 notificationService.sendNotificationOnly(opponentId, NotificationType.CHAT, title, body); @@ -198,11 +194,7 @@ private void sendGroupFcmNotification( // 알림 메시지 생성 String title = NotificationMessageConstants.Chat.NEW_MESSAGE_TITLE; - String body = String.format( - NotificationMessageConstants.Chat.NEW_MESSAGE_BODY, - senderName, - truncateContent(content) - ); + String body = buildNotificationBody(senderName, content); // 활성 멤버 중 발신자를 제외한 오프라인 멤버에게만 FCM 발송 List members = chatRoomMemberQueryRepository.findActiveByRoom(chatRoom.getId()); @@ -228,6 +220,13 @@ private String getSenderName(Long senderId) { .orElse("알 수 없음"); } + private String buildNotificationBody(String senderName, String content) { + if (ObjectUtils.isEmpty(content)) { + return String.format(NotificationMessageConstants.Chat.PHOTO_MESSAGE_BODY, senderName); + } + return String.format(NotificationMessageConstants.Chat.NEW_MESSAGE_BODY, senderName, truncateContent(content)); + } + private String truncateContent(String content) { if (ObjectUtils.isEmpty(content)) { return ""; diff --git a/src/main/java/com/dreamteam/alter/common/notification/NotificationMessageConstants.java b/src/main/java/com/dreamteam/alter/common/notification/NotificationMessageConstants.java index d4f88b3d..63042a1a 100644 --- a/src/main/java/com/dreamteam/alter/common/notification/NotificationMessageConstants.java +++ b/src/main/java/com/dreamteam/alter/common/notification/NotificationMessageConstants.java @@ -66,6 +66,7 @@ public static final class SubstituteRequest { public static final class Chat { public static final String NEW_MESSAGE_TITLE = "새로운 메시지"; public static final String NEW_MESSAGE_BODY = "%s: %s"; + public static final String PHOTO_MESSAGE_BODY = "%s: 사진을 보냈습니다"; } /** diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java index 7067d225..14a58ad1 100644 --- a/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java @@ -353,6 +353,87 @@ class SendChatMessageTest { then(chatMessageRepository).should(never()).save(any()); } + @Test + @DisplayName("이미지만 있는 메시지 전송시 FCM 알림 본문은 '이름: 사진을 보냈습니다'") + void execute_이미지만_있으면_FCM_본문_사진안내() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + + ChatRoom directRoom = mock(ChatRoom.class); + given(directRoom.getId()).willReturn(100L); + given(directRoom.getType()).willReturn(ChatRoomType.DIRECT); + given(directRoom.getParticipant1Id()).willReturn(1L); + given(directRoom.getParticipant1Scope()).willReturn(TokenScope.APP); + given(directRoom.getParticipant2Id()).willReturn(2L); + given(directRoom.getParticipant2Scope()).willReturn(TokenScope.APP); + + given(chatRoomQueryRepository.findById(100L)).willReturn(Optional.of(directRoom)); + given(chatRoomQueryRepository.findByIdAndParticipant(100L, 1L, TokenScope.APP)) + .willReturn(Optional.of(directRoom)); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getContent()).willReturn(null); + given(request.getFileIds()).willReturn(List.of("f1")); + + ChatMessage savedMessage = ChatMessage.create(100L, 1L, TokenScope.APP, ChatMessageType.NORMAL, null); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + + User sender = mock(User.class); + given(sender.getName()).willReturn("홍길동"); + given(userQueryRepository.findById(1L)).willReturn(Optional.of(sender)); + given(chatPresenceStore.isOnline(TokenScope.APP, 2L)).willReturn(false); + + given(fileQueryRepository.findAllByTargetTypeAndTargetIdIn(any(), any())).willReturn(List.of()); + + // when + sut.execute(user, request, 100L); + + // then + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + then(notificationService).should().sendNotificationOnly(eq(2L), any(), anyString(), bodyCaptor.capture()); + assertThat(bodyCaptor.getValue()).isEqualTo("홍길동: 사진을 보냈습니다"); + } + + @Test + @DisplayName("텍스트 메시지 전송시 FCM 알림 본문은 기존 텍스트 미리보기 유지") + void execute_텍스트_메시지면_FCM_본문_기존유지() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + + ChatRoom directRoom = mock(ChatRoom.class); + given(directRoom.getId()).willReturn(100L); + given(directRoom.getType()).willReturn(ChatRoomType.DIRECT); + given(directRoom.getParticipant1Id()).willReturn(1L); + given(directRoom.getParticipant1Scope()).willReturn(TokenScope.APP); + given(directRoom.getParticipant2Id()).willReturn(2L); + given(directRoom.getParticipant2Scope()).willReturn(TokenScope.APP); + + given(chatRoomQueryRepository.findById(100L)).willReturn(Optional.of(directRoom)); + given(chatRoomQueryRepository.findByIdAndParticipant(100L, 1L, TokenScope.APP)) + .willReturn(Optional.of(directRoom)); + + SendChatMessageRequestDto request = mock(SendChatMessageRequestDto.class); + given(request.getContent()).willReturn("안녕하세요"); + + ChatMessage savedMessage = ChatMessage.create(100L, 1L, TokenScope.APP, ChatMessageType.NORMAL, "안녕하세요"); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + + User sender = mock(User.class); + given(sender.getName()).willReturn("홍길동"); + given(userQueryRepository.findById(1L)).willReturn(Optional.of(sender)); + given(chatPresenceStore.isOnline(TokenScope.APP, 2L)).willReturn(false); + + // when + sut.execute(user, request, 100L); + + // then + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + then(notificationService).should().sendNotificationOnly(eq(2L), any(), anyString(), bodyCaptor.capture()); + assertThat(bodyCaptor.getValue()).isEqualTo("홍길동: 안녕하세요"); + } + @Test @DisplayName("이미지 첨부시 attachFilesUseCase 호출 및 브로드캐스트 payload에 attachments 포함") void execute_이미지_첨부시_attach_호출_및_payload_포함() { From 55e7d6a6a0c8bd17e14825925c1ef717c5b6f2bc Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 21:15:07 +0900 Subject: [PATCH 23/35] =?UTF-8?q?fix:=20=EC=83=88=201:1=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EB=B0=A9=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20chat=5Fro?= =?UTF-8?q?om=5Fmembers=20=ED=96=89=20=EC=83=9D=EC=84=B1=20(=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C/=EC=95=88=EC=9D=BD=EC=9D=80=EC=88=98=20=EC=A0=95?= =?UTF-8?q?=EC=83=81=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AbstractCreateOrGetChatRoomUseCase가 기존 방이 없어 새 DIRECT 방을 생성할 때 chat_room_members 행을 만들지 않아, MarkChatRoomRead가 404를 반환하고 unreadCount가 항상 0으로 계산되는 문제를 수정. - ChatRoomMemberRepository를 주입받아 새 방 저장 직후 참여자 2명의 ChatRoomMember를 saveAll로 생성 (기존 방 재사용 시에는 생성하지 않음, V4에서 이미 백필됨) - CreateOrGetChatRoom, ManagerCreateOrGetChatRoom 생성자에 의존성 추가 --- .../AbstractCreateOrGetChatRoomUseCase.java | 13 ++- .../chat/usecase/CreateOrGetChatRoom.java | 6 +- .../usecase/ManagerCreateOrGetChatRoom.java | 6 +- .../chat/usecase/CreateOrGetChatRoomTest.java | 101 ++++++++++++++++++ 4 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/dreamteam/alter/application/chat/usecase/CreateOrGetChatRoomTest.java diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractCreateOrGetChatRoomUseCase.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractCreateOrGetChatRoomUseCase.java index 1c6a0c3a..cfd5bdce 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractCreateOrGetChatRoomUseCase.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractCreateOrGetChatRoomUseCase.java @@ -5,17 +5,22 @@ import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.entity.ChatRoom; +import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @RequiredArgsConstructor @Transactional public abstract class AbstractCreateOrGetChatRoomUseCase extends AbstractChatUseCase { protected final ChatRoomQueryRepository chatRoomQueryRepository; protected final ChatRoomRepository chatRoomRepository; + protected final ChatRoomMemberRepository chatRoomMemberRepository; protected CreateChatRoomResponseDto execute(A actor, Long opponentUserId, TokenScope opponentScope) { Long currentUserId = getParticipantId(actor); @@ -42,8 +47,12 @@ protected CreateChatRoomResponseDto execute(A actor, Long opponentUserId, TokenS opponentUserId, opponentScope ); - return chatRoomRepository.save(newChatRoom) - .getId(); + ChatRoom saved = chatRoomRepository.save(newChatRoom); + chatRoomMemberRepository.saveAll(List.of( + ChatRoomMember.create(saved.getId(), currentUserId, currentScope), + ChatRoomMember.create(saved.getId(), opponentUserId, opponentScope) + )); + return saved.getId(); }); return CreateChatRoomResponseDto.of(chatRoomId); diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/CreateOrGetChatRoom.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/CreateOrGetChatRoom.java index 1ec25151..560e2439 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/CreateOrGetChatRoom.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/CreateOrGetChatRoom.java @@ -3,6 +3,7 @@ import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomResponseDto; import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.port.inbound.CreateOrGetChatRoomUseCase; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomRepository; import com.dreamteam.alter.domain.user.context.AppActor; @@ -14,9 +15,10 @@ public class CreateOrGetChatRoom extends AbstractCreateOrGetChatRoomUseCase> captor = ArgumentCaptor.forClass(List.class); + then(chatRoomMemberRepository).should().saveAll(captor.capture()); + List savedMembers = captor.getValue(); + + assertThat(savedMembers).hasSize(2); + assertThat(savedMembers) + .extracting(ChatRoomMember::getChatRoomId, ChatRoomMember::getMemberId, ChatRoomMember::getMemberScope) + .containsExactlyInAnyOrder( + org.assertj.core.api.Assertions.tuple(99L, 1L, TokenScope.APP), + org.assertj.core.api.Assertions.tuple(99L, 2L, TokenScope.APP) + ); + } + + @Test + @DisplayName("기존 채팅방이 있으면 재사용하고 chat_room_members 행을 생성하지 않는다") + void execute_기존방재사용_멤버행미생성() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + AppActor actor = AppActor.from(user, List.of()); + + ChatRoom existingRoom = mock(ChatRoom.class); + given(existingRoom.getId()).willReturn(42L); + given(chatRoomQueryRepository.findExistingChatRoom(1L, TokenScope.APP, 2L, TokenScope.APP)) + .willReturn(Optional.of(existingRoom)); + + // when + CreateChatRoomResponseDto response = sut.execute(actor, 2L, TokenScope.APP); + + // then + assertThat(response.getChatRoomId()).isEqualTo(42L); + then(chatRoomRepository).should(never()).save(any()); + then(chatRoomMemberRepository).should(never()).saveAll(any()); + } +} From 97956ee1ce7869382806d8167cb21f4590463da6 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 21:18:43 +0900 Subject: [PATCH 24/35] =?UTF-8?q?fix:=20=EC=97=85=EC=9E=A5=20=EB=8B=A8?= =?UTF-8?q?=ED=86=A1=20=EB=A9=A4=EB=B2=84=EC=8B=AD=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20self-healing=20(=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?=EC=97=85=EC=9E=A5=20=EC=B1=84=EC=9A=A9/=ED=87=B4=EC=82=AC=20?= =?UTF-8?q?=EB=A1=A4=EB=B0=B1=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SyncWorkspaceChatMembership의 join/leave가 단톡방이 없으면 NOT_FOUND 예외를 던져, 업장 @Transactional 안에서 호출되는 AddWorkerToWorkspace/WorkerResignationServiceImpl 트랜잭션이 롤백되는 문제를 수정. GROUP 방은 UpdateWorkspaceRequestStatus에서만 생성되므로, 그 전에 존재하던 레거시 업장은 방이 없었음. - join: resolveGroupRoom(예외 발생) 대신 createGroupRoom(find-or-create) 사용 — 레거시 업장 워커가 join 할 때 단톡방을 지연 생성 - leave: 단톡방이 없으면 정리할 멤버십도 없으므로 조용히 no-op - 더 이상 쓰이지 않는 resolveGroupRoom 제거 --- .../usecase/SyncWorkspaceChatMembership.java | 29 ++++++------- .../SyncWorkspaceChatMembershipTest.java | 41 +++++++++++++++++++ 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembership.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembership.java index be8715bf..bf144aba 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembership.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembership.java @@ -1,7 +1,5 @@ package com.dreamteam.alter.application.chat.usecase; -import com.dreamteam.alter.common.exception.CustomException; -import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.entity.ChatRoom; import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; @@ -32,9 +30,10 @@ public Long createGroupRoom(Long workspaceId) { @Override public void join(Long workspaceId, Long memberId, TokenScope scope) { - ChatRoom room = resolveGroupRoom(workspaceId); + // 레거시 업장 등 단톡방이 아직 없으면 여기서 생성 (self-healing, 절대 throw하지 않음) + Long roomId = createGroupRoom(workspaceId); Optional existing = - chatRoomMemberQueryRepository.findByRoomAndMember(room.getId(), memberId, scope); + chatRoomMemberQueryRepository.findByRoomAndMember(roomId, memberId, scope); if (existing.isPresent()) { ChatRoomMember member = existing.get(); if (!member.isActive()) { @@ -43,22 +42,18 @@ public void join(Long workspaceId, Long memberId, TokenScope scope) { } return; } - chatRoomMemberRepository.save(ChatRoomMember.create(room.getId(), memberId, scope)); + chatRoomMemberRepository.save(ChatRoomMember.create(roomId, memberId, scope)); } @Override public void leave(Long workspaceId, Long memberId, TokenScope scope) { - ChatRoom room = resolveGroupRoom(workspaceId); - chatRoomMemberQueryRepository.findByRoomAndMember(room.getId(), memberId, scope) - .filter(ChatRoomMember::isActive) - .ifPresent(member -> { - member.leave(); - chatRoomMemberRepository.save(member); - }); - } - - private ChatRoom resolveGroupRoom(Long workspaceId) { - return chatRoomQueryRepository.findGroupRoomByWorkspaceId(workspaceId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "업장 단톡방을 찾을 수 없습니다.")); + // 단톡방이 없으면(레거시 업장 등) 정리할 멤버십도 없으므로 조용히 무시 (throw하지 않음) + chatRoomQueryRepository.findGroupRoomByWorkspaceId(workspaceId) + .ifPresent(room -> chatRoomMemberQueryRepository.findByRoomAndMember(room.getId(), memberId, scope) + .filter(ChatRoomMember::isActive) + .ifPresent(member -> { + member.leave(); + chatRoomMemberRepository.save(member); + })); } } diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTest.java index 91a1f3e6..7b84f598 100644 --- a/src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTest.java +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTest.java @@ -18,6 +18,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.any; @@ -88,5 +89,45 @@ class JoinTests { assertThat(left.isActive()).isTrue(); then(chatRoomMemberRepository).should().save(left); } + + @Test + @DisplayName("업장 단톡방이 없으면(레거시 업장) 단톡방을 새로 생성하고 멤버로 추가하며 예외를 던지지 않는다") + void join_단톡방없으면_단톡방생성후_멤버추가_예외없음() { + // given + given(chatRoomQueryRepository.findGroupRoomByWorkspaceId(1L)) + .willReturn(Optional.empty()); + + ChatRoom createdRoom = mock(ChatRoom.class); + given(createdRoom.getId()).willReturn(7L); + given(chatRoomRepository.save(any(ChatRoom.class))).willReturn(createdRoom); + + given(chatRoomMemberQueryRepository.findByRoomAndMember(7L, 10L, TokenScope.APP)) + .willReturn(Optional.empty()); + + // when & then + assertThatCode(() -> sut.join(1L, 10L, TokenScope.APP)).doesNotThrowAnyException(); + + then(chatRoomRepository).should().save(any(ChatRoom.class)); + then(chatRoomMemberRepository).should().save(any(ChatRoomMember.class)); + } + } + + @Nested + @DisplayName("leave") + class LeaveTests { + + @Test + @DisplayName("업장 단톡방이 없으면 아무 작업도 하지 않고 예외를 던지지 않는다") + void leave_단톡방없으면_noop_예외없음() { + // given + given(chatRoomQueryRepository.findGroupRoomByWorkspaceId(1L)) + .willReturn(Optional.empty()); + + // when & then + assertThatCode(() -> sut.leave(1L, 10L, TokenScope.APP)).doesNotThrowAnyException(); + + then(chatRoomMemberQueryRepository).should(never()).findByRoomAndMember(any(), any(), any()); + then(chatRoomMemberRepository).should(never()).save(any()); + } } } From cd34c950487e43381992271c8a8dff99639f75ab Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 21:29:21 +0900 Subject: [PATCH 25/35] =?UTF-8?q?fix:=20=EA=B7=B8=EB=A3=B9=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20dual-path=20(=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B0=80=EB=8A=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GROUP 채팅방은 participant1/2 컬럼이 null이라 findByIdAndParticipant가 항상 실패해 정상 멤버도 NOT_FOUND를 받는 버그를 수정. AbstractSendChatMessageUseCase와 동일하게 findById로 방을 조회한 뒤 GROUP이면 chatRoomMemberQueryRepository.existsActive로, DIRECT면 기존 findByIdAndParticipant로 인증하도록 분기. --- .../AbstractGetChatMessagesUseCase.java | 15 +++- .../chat/usecase/GetChatMessagesTest.java | 68 ++++++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java index fa71cc71..e445d684 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java @@ -13,10 +13,12 @@ import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.adapter.inbound.common.dto.FileResponseDto; import com.dreamteam.alter.application.file.FileUrlService; +import com.dreamteam.alter.domain.chat.entity.ChatRoom; import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; +import com.dreamteam.alter.domain.chat.type.ChatRoomType; import com.dreamteam.alter.domain.file.entity.File; import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; import com.dreamteam.alter.domain.file.type.FileTargetType; @@ -49,10 +51,19 @@ protected CursorPaginatedApiResponse execute( TokenScope participantScope = getParticipantScope(actor); Long participantId = getParticipantId(actor); - // 1. 채팅방 존재 확인 및 참여자 검증 - chatRoomQueryRepository.findByIdAndParticipant(chatRoomId, participantId, participantScope) + // 1. 채팅방 존재 확인 및 참여자 검증 (DIRECT: participant 컬럼 기반, GROUP: 멤버 테이블 기반) + ChatRoom chatRoom = chatRoomQueryRepository.findById(chatRoomId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "채팅방을 찾을 수 없습니다.")); + if (chatRoom.getType() == ChatRoomType.GROUP) { + if (!chatRoomMemberQueryRepository.existsActive(chatRoomId, participantId, participantScope)) { + throw new CustomException(ErrorCode.NOT_FOUND, "채팅방을 찾을 수 없습니다."); + } + } else { + chatRoomQueryRepository.findByIdAndParticipant(chatRoomId, participantId, participantScope) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "채팅방을 찾을 수 없습니다.")); + } + // 2. 커서 디코딩 CursorDto cursorDto = null; if (ObjectUtils.isNotEmpty(pageRequest.cursor())) { diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTest.java index 328487b4..f140da57 100644 --- a/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTest.java +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTest.java @@ -6,12 +6,15 @@ import com.dreamteam.alter.adapter.inbound.general.chat.dto.ChatMessageResponseDto; import com.dreamteam.alter.adapter.outbound.chat.persistence.readonly.ChatMessageResponse; import com.dreamteam.alter.application.file.FileUrlService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.chat.entity.ChatRoom; import com.dreamteam.alter.domain.chat.entity.ChatRoomMember; import com.dreamteam.alter.domain.chat.port.outbound.ChatMessageQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; +import com.dreamteam.alter.domain.chat.type.ChatRoomType; import com.dreamteam.alter.domain.file.entity.File; import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; import com.dreamteam.alter.domain.file.type.FileTargetType; @@ -31,9 +34,12 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -78,8 +84,10 @@ void setUp() { Long senderId = 100L; AppActor actor = new AppActor(senderId, null, null); + ChatRoom directRoom = ChatRoom.create(senderId, TokenScope.APP, 999L, TokenScope.APP); + given(chatRoomQueryRepository.findById(chatRoomId)).willReturn(Optional.of(directRoom)); given(chatRoomQueryRepository.findByIdAndParticipant(chatRoomId, senderId, TokenScope.APP)) - .willReturn(Optional.of(ChatRoom.createGroup(1L))); + .willReturn(Optional.of(directRoom)); ChatMessageResponse message = new ChatMessageResponse( 8L, chatRoomId, senderId, TokenScope.APP, "hello", LocalDateTime.now() @@ -115,8 +123,10 @@ void setUp() { Long senderId = 100L; AppActor actor = new AppActor(senderId, null, null); + ChatRoom directRoom = ChatRoom.create(senderId, TokenScope.APP, 999L, TokenScope.APP); + given(chatRoomQueryRepository.findById(chatRoomId)).willReturn(Optional.of(directRoom)); given(chatRoomQueryRepository.findByIdAndParticipant(chatRoomId, senderId, TokenScope.APP)) - .willReturn(Optional.of(ChatRoom.createGroup(1L))); + .willReturn(Optional.of(directRoom)); ChatMessageResponse messageWithFile = new ChatMessageResponse( 1L, chatRoomId, senderId, TokenScope.APP, "첨부 있음", LocalDateTime.now() @@ -173,4 +183,58 @@ void setUp() { verify(fileQueryRepository, times(1)) .findAllByTargetTypeAndTargetIdIn(eq(FileTargetType.CHAT_MESSAGE), any()); } + + @Test + @DisplayName("GROUP 채팅방의 활성 멤버는 메시지 목록을 조회할 수 있다") + void execute_GROUP_활성멤버는_메시지목록_조회_성공() { + // given + Long chatRoomId = 2L; + Long participantId = 500L; + AppActor actor = new AppActor(participantId, null, null); + + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getType()).willReturn(ChatRoomType.GROUP); + given(chatRoomQueryRepository.findById(chatRoomId)).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberQueryRepository.existsActive(chatRoomId, participantId, TokenScope.APP)) + .willReturn(true); + + ChatMessageResponse message = new ChatMessageResponse( + 10L, chatRoomId, participantId, TokenScope.APP, "안녕하세요", LocalDateTime.now() + ); + given(chatMessageQueryRepository.getChatMessagesWithCursor(any(), any())) + .willReturn(List.of(message)); + given(chatRoomMemberQueryRepository.findActiveByRoom(chatRoomId)) + .willReturn(Collections.emptyList()); + + // when + CursorPaginatedApiResponse response = + sut.execute(actor, chatRoomId, CursorPageRequestDto.of(null, 10)); + + // then + assertThat(response.data()).hasSize(1); + verify(chatRoomMemberQueryRepository).existsActive(chatRoomId, participantId, TokenScope.APP); + verify(chatRoomQueryRepository, never()).findByIdAndParticipant(any(), any(), any()); + } + + @Test + @DisplayName("GROUP 채팅방의 비멤버는 메시지 목록을 조회할 수 없다 (NOT_FOUND)") + void execute_GROUP_비멤버는_NOT_FOUND() { + // given + Long chatRoomId = 3L; + Long participantId = 999L; + AppActor actor = new AppActor(participantId, null, null); + + ChatRoom groupRoom = mock(ChatRoom.class); + given(groupRoom.getType()).willReturn(ChatRoomType.GROUP); + given(chatRoomQueryRepository.findById(chatRoomId)).willReturn(Optional.of(groupRoom)); + given(chatRoomMemberQueryRepository.existsActive(chatRoomId, participantId, TokenScope.APP)) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> sut.execute(actor, chatRoomId, CursorPageRequestDto.of(null, 10))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND); + + verify(chatMessageQueryRepository, never()).getChatMessagesWithCursor(any(), any()); + } } From 7832c92d83211f6b7a3d28e78307d07478aedb3c Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 21:50:57 +0900 Subject: [PATCH 26/35] =?UTF-8?q?feat:=20=EC=97=85=EC=9E=A5=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=A1=B0=ED=9A=8C(di?= =?UTF-8?q?scovery)=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 클라이언트가 업장의 그룹 채팅방 id를 얻어 /sub/chat.{roomId} 구독 및 히스토리 조회를 시작할 수 있도록 GET /app/chat/workspace/{workspaceId}/room, GET /manager/chat/workspace/{workspaceId}/room 엔드포인트를 추가한다. 요청자가 해당 그룹 채팅방의 활성 멤버일 때만 room id를 반환하며, 아직 방이 생성되지 않은 레거시 업장은 NOT_FOUND로 응답한다(백필은 별도 작업). --- .../chat/controller/UserChatController.java | 14 ++++ .../controller/UserChatControllerSpec.java | 8 ++ .../controller/ManagerChatController.java | 14 ++++ .../controller/ManagerChatControllerSpec.java | 8 ++ .../usecase/GetWorkspaceGroupChatRoom.java | 33 ++++++++ .../GetWorkspaceGroupChatRoomUseCase.java | 7 ++ .../GetWorkspaceGroupChatRoomTest.java | 78 +++++++++++++++++++ 7 files changed, 162 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoom.java create mode 100644 src/main/java/com/dreamteam/alter/domain/chat/port/inbound/GetWorkspaceGroupChatRoomUseCase.java create mode 100644 src/test/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoomTest.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatController.java index dbaa6350..29d882ef 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatController.java @@ -15,6 +15,7 @@ import com.dreamteam.alter.domain.chat.port.inbound.GetChatMessagesUseCase; import com.dreamteam.alter.domain.chat.port.inbound.GetChatRoomInfoUseCase; import com.dreamteam.alter.domain.chat.port.inbound.GetMyChatRoomListUseCase; +import com.dreamteam.alter.domain.chat.port.inbound.GetWorkspaceGroupChatRoomUseCase; import com.dreamteam.alter.domain.chat.port.inbound.MarkChatRoomReadUseCase; import com.dreamteam.alter.domain.user.context.AppActor; import jakarta.annotation.Resource; @@ -46,6 +47,9 @@ public class UserChatController implements UserChatControllerSpec { @Resource(name = "markChatRoomRead") private final MarkChatRoomReadUseCase markChatRoomRead; + @Resource(name = "getWorkspaceGroupChatRoom") + private final GetWorkspaceGroupChatRoomUseCase getWorkspaceGroupChatRoom; + @Override @PostMapping("/rooms") public ResponseEntity> createOrGetChatRoom( @@ -95,4 +99,14 @@ public ResponseEntity> markChatRoomRead( markChatRoomRead.execute(actor.getUserId(), TokenScope.APP, chatRoomId, request.getLastReadMessageId()); return ResponseEntity.ok(CommonApiResponse.empty()); } + + @Override + @GetMapping("/workspace/{workspaceId}/room") + public ResponseEntity> getWorkspaceGroupChatRoom( + @PathVariable Long workspaceId + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + Long roomId = getWorkspaceGroupChatRoom.execute(actor.getUserId(), TokenScope.APP, workspaceId); + return ResponseEntity.ok(CommonApiResponse.of(CreateChatRoomResponseDto.of(roomId))); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatControllerSpec.java index 4865c76c..c394ff8d 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatControllerSpec.java @@ -62,4 +62,12 @@ ResponseEntity> markChatRoomRead( @PathVariable Long chatRoomId, @Valid @RequestBody MarkChatRoomReadRequestDto request ); + + @Operation(summary = "업장 그룹 채팅방 조회", description = "업장의 그룹 채팅방 ID를 조회합니다. 해당 채팅방의 활성 멤버인 경우에만 조회됩니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "업장 그룹 채팅방 조회 성공") + }) + ResponseEntity> getWorkspaceGroupChatRoom( + @PathVariable Long workspaceId + ); } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java index 0e580522..b73acfa8 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java @@ -11,6 +11,7 @@ import com.dreamteam.alter.adapter.inbound.general.chat.dto.MarkChatRoomReadRequestDto; import com.dreamteam.alter.application.aop.ManagerActionContext; import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.port.inbound.GetWorkspaceGroupChatRoomUseCase; import com.dreamteam.alter.domain.chat.port.inbound.ManagerCreateOrGetChatRoomUseCase; import com.dreamteam.alter.domain.chat.port.inbound.ManagerGetChatMessagesUseCase; import com.dreamteam.alter.domain.chat.port.inbound.ManagerGetChatRoomUseCase; @@ -46,6 +47,9 @@ public class ManagerChatController implements ManagerChatControllerSpec { @Resource(name = "markChatRoomRead") private final MarkChatRoomReadUseCase markChatRoomRead; + @Resource(name = "getWorkspaceGroupChatRoom") + private final GetWorkspaceGroupChatRoomUseCase getWorkspaceGroupChatRoom; + @Override @PostMapping("/rooms") public ResponseEntity> createOrGetChatRoom( @@ -94,4 +98,14 @@ public ResponseEntity> markChatRoomRead( markChatRoomRead.execute(actor.getUserId(), TokenScope.MANAGER, chatRoomId, request.getLastReadMessageId()); return ResponseEntity.ok(CommonApiResponse.empty()); } + + @Override + @GetMapping("/workspace/{workspaceId}/room") + public ResponseEntity> getWorkspaceGroupChatRoom( + @PathVariable Long workspaceId + ) { + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + Long roomId = getWorkspaceGroupChatRoom.execute(actor.getUserId(), TokenScope.MANAGER, workspaceId); + return ResponseEntity.ok(CommonApiResponse.of(CreateChatRoomResponseDto.of(roomId))); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatControllerSpec.java index d5abe9fc..733c0066 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatControllerSpec.java @@ -62,5 +62,13 @@ ResponseEntity> markChatRoomRead( @PathVariable Long chatRoomId, @Valid @RequestBody MarkChatRoomReadRequestDto request ); + + @Operation(summary = "업장 그룹 채팅방 조회", description = "업장의 그룹 채팅방 ID를 조회합니다. 해당 채팅방의 활성 멤버인 경우에만 조회됩니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "업장 그룹 채팅방 조회 성공") + }) + ResponseEntity> getWorkspaceGroupChatRoom( + @PathVariable Long workspaceId + ); } diff --git a/src/main/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoom.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoom.java new file mode 100644 index 00000000..1175d581 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoom.java @@ -0,0 +1,33 @@ +package com.dreamteam.alter.application.chat.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.entity.ChatRoom; +import com.dreamteam.alter.domain.chat.port.inbound.GetWorkspaceGroupChatRoomUseCase; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("getWorkspaceGroupChatRoom") +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GetWorkspaceGroupChatRoom implements GetWorkspaceGroupChatRoomUseCase { + + private final ChatRoomQueryRepository chatRoomQueryRepository; + private final ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + + @Override + public Long execute(Long memberId, TokenScope scope, Long workspaceId) { + ChatRoom room = chatRoomQueryRepository.findGroupRoomByWorkspaceId(workspaceId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND, "업장 단톡방을 찾을 수 없습니다.")); + + if (!chatRoomMemberQueryRepository.existsActive(room.getId(), memberId, scope)) { + throw new CustomException(ErrorCode.NOT_FOUND, "채팅방 멤버가 아닙니다."); + } + + return room.getId(); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/chat/port/inbound/GetWorkspaceGroupChatRoomUseCase.java b/src/main/java/com/dreamteam/alter/domain/chat/port/inbound/GetWorkspaceGroupChatRoomUseCase.java new file mode 100644 index 00000000..dc295c73 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/chat/port/inbound/GetWorkspaceGroupChatRoomUseCase.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.chat.port.inbound; + +import com.dreamteam.alter.domain.auth.type.TokenScope; + +public interface GetWorkspaceGroupChatRoomUseCase { + Long execute(Long memberId, TokenScope scope, Long workspaceId); +} diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoomTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoomTest.java new file mode 100644 index 00000000..9ae7a779 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoomTest.java @@ -0,0 +1,78 @@ +package com.dreamteam.alter.application.chat.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.entity.ChatRoom; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomMemberQueryRepository; +import com.dreamteam.alter.domain.chat.port.outbound.ChatRoomQueryRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GetWorkspaceGroupChatRoom 테스트") +class GetWorkspaceGroupChatRoomTest { + + @Mock + private ChatRoomQueryRepository chatRoomQueryRepository; + + @Mock + private ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + + @InjectMocks + private GetWorkspaceGroupChatRoom sut; + + @Test + @DisplayName("업장 단톡방 멤버면 채팅방 ID를 반환한다") + void execute_멤버면_roomId_반환() { + // given + ChatRoom room = mock(ChatRoom.class); + given(room.getId()).willReturn(1L); + given(chatRoomQueryRepository.findGroupRoomByWorkspaceId(100L)).willReturn(Optional.of(room)); + given(chatRoomMemberQueryRepository.existsActive(1L, 10L, TokenScope.APP)).willReturn(true); + + // when + Long roomId = sut.execute(10L, TokenScope.APP, 100L); + + // then + assertThat(roomId).isEqualTo(1L); + } + + @Test + @DisplayName("업장 단톡방 멤버가 아니면 NOT_FOUND 예외") + void execute_멤버_아니면_NOT_FOUND() { + // given + ChatRoom room = mock(ChatRoom.class); + given(room.getId()).willReturn(1L); + given(chatRoomQueryRepository.findGroupRoomByWorkspaceId(100L)).willReturn(Optional.of(room)); + given(chatRoomMemberQueryRepository.existsActive(1L, 10L, TokenScope.APP)).willReturn(false); + + // when & then + assertThatThrownBy(() -> sut.execute(10L, TokenScope.APP, 100L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND); + } + + @Test + @DisplayName("업장 단톡방이 없으면 NOT_FOUND 예외") + void execute_단톡방_없으면_NOT_FOUND() { + // given + given(chatRoomQueryRepository.findGroupRoomByWorkspaceId(100L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> sut.execute(10L, TokenScope.APP, 100L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_FOUND); + } +} From e06b4a5c31f96b43c78f5385208263a3586b5221 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 22:11:40 +0900 Subject: [PATCH 27/35] =?UTF-8?q?refactor:=20=EC=97=85=EC=9E=A5=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EB=A9=A4=EB=B2=84=EC=8B=AD=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=EB=A5=BC=20AFTER=5FCOMMIT=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC(=EC=BD=94?= =?UTF-8?q?=EC=96=B4=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=A1=A4?= =?UTF-8?q?=EB=B0=B1=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatMembershipJoinedEvent/LeftEvent + ChatMembershipSyncEventListener 추가 - UpdateWorkspaceRequestStatus, AddWorkerToWorkspace, WorkerResignationServiceImpl 에서 SyncWorkspaceChatMembershipUseCase 인라인 호출을 이벤트 발행으로 교체 - 리스너는 @TransactionalEventListener(AFTER_COMMIT) + try/catch 로 best-effort 동기화 수행 (채팅 동기화 실패가 업장 승인/입사/퇴사 같은 코어 트랜잭션을 롤백시키지 않도록 완전 분리) - join()이 createGroupRoom을 self-heal 하므로(C1) 별도 createGroupRoom 호출 제거 --- .../chat/event/ChatMembershipJoinedEvent.java | 13 ++++ .../chat/event/ChatMembershipLeftEvent.java | 13 ++++ .../ChatMembershipSyncEventListener.java | 36 +++++++++ .../usecase/AddWorkerToWorkspace.java | 7 +- .../usecase/UpdateWorkspaceRequestStatus.java | 9 ++- .../usecase/WorkerResignationServiceImpl.java | 8 +- .../ChatMembershipSyncEventListenerTest.java | 75 +++++++++++++++++++ .../usecase/AddWorkerToWorkspaceTest.java | 27 +++++-- .../UpdateWorkspaceRequestStatusTests.java | 27 +++---- .../WorkerResignationServiceImplTest.java | 23 +++++- 10 files changed, 203 insertions(+), 35 deletions(-) create mode 100644 src/main/java/com/dreamteam/alter/application/chat/event/ChatMembershipJoinedEvent.java create mode 100644 src/main/java/com/dreamteam/alter/application/chat/event/ChatMembershipLeftEvent.java create mode 100644 src/main/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListener.java create mode 100644 src/test/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListenerTest.java diff --git a/src/main/java/com/dreamteam/alter/application/chat/event/ChatMembershipJoinedEvent.java b/src/main/java/com/dreamteam/alter/application/chat/event/ChatMembershipJoinedEvent.java new file mode 100644 index 00000000..409d1045 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/chat/event/ChatMembershipJoinedEvent.java @@ -0,0 +1,13 @@ +package com.dreamteam.alter.application.chat.event; + +import com.dreamteam.alter.domain.auth.type.TokenScope; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ChatMembershipJoinedEvent { + private Long workspaceId; + private Long memberId; + private TokenScope scope; +} diff --git a/src/main/java/com/dreamteam/alter/application/chat/event/ChatMembershipLeftEvent.java b/src/main/java/com/dreamteam/alter/application/chat/event/ChatMembershipLeftEvent.java new file mode 100644 index 00000000..48d5e496 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/chat/event/ChatMembershipLeftEvent.java @@ -0,0 +1,13 @@ +package com.dreamteam.alter.application.chat.event; + +import com.dreamteam.alter.domain.auth.type.TokenScope; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ChatMembershipLeftEvent { + private Long workspaceId; + private Long memberId; + private TokenScope scope; +} diff --git a/src/main/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListener.java b/src/main/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListener.java new file mode 100644 index 00000000..39052d4f --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListener.java @@ -0,0 +1,36 @@ +package com.dreamteam.alter.application.chat.event; + +import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatMembershipSyncEventListener { + + private final SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onJoined(ChatMembershipJoinedEvent event) { + try { + syncWorkspaceChatMembership.join(event.getWorkspaceId(), event.getMemberId(), event.getScope()); + } catch (Exception e) { + log.error("업장 채팅 멤버십 join 동기화 실패. workspaceId={}, memberId={}, scope={}", + event.getWorkspaceId(), event.getMemberId(), event.getScope(), e); + } + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onLeft(ChatMembershipLeftEvent event) { + try { + syncWorkspaceChatMembership.leave(event.getWorkspaceId(), event.getMemberId(), event.getScope()); + } catch (Exception e) { + log.error("업장 채팅 멤버십 leave 동기화 실패. workspaceId={}, memberId={}, scope={}", + event.getWorkspaceId(), event.getMemberId(), event.getScope(), e); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspace.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspace.java index a1ad5971..fd1c26e6 100644 --- a/src/main/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspace.java +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspace.java @@ -1,9 +1,9 @@ package com.dreamteam.alter.application.workspace.usecase; +import com.dreamteam.alter.application.chat.event.ChatMembershipJoinedEvent; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.auth.type.TokenScope; -import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.workspace.entity.Workspace; import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; @@ -11,6 +11,7 @@ import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceWorkerQueryRepository; import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceWorkerRepository; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,7 +22,7 @@ public class AddWorkerToWorkspace implements CreateWorkspaceWorkerUseCase { private final WorkspaceWorkerRepository workspaceWorkerRepository; private final WorkspaceWorkerQueryRepository workspaceWorkerQueryRepository; - private final SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; + private final ApplicationEventPublisher eventPublisher; @Override public void execute(Workspace workspace, User user) { @@ -32,7 +33,7 @@ public void execute(Workspace workspace, User user) { workspaceWorkerRepository.save(WorkspaceWorker.create(workspace, user)); - syncWorkspaceChatMembership.join(workspace.getId(), user.getId(), TokenScope.APP); + eventPublisher.publishEvent(new ChatMembershipJoinedEvent(workspace.getId(), user.getId(), TokenScope.APP)); } } diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java index 7000abfc..883e7a6d 100644 --- a/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatus.java @@ -7,14 +7,15 @@ import java.util.function.Function; import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.dreamteam.alter.application.chat.event.ChatMembershipJoinedEvent; import com.dreamteam.alter.application.file.FileDeleteService; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.auth.type.TokenScope; -import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; import com.dreamteam.alter.domain.file.entity.File; import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; import com.dreamteam.alter.domain.file.type.FileTargetType; @@ -51,7 +52,7 @@ public class UpdateWorkspaceRequestStatus implements UpdateWorkspaceRequestStatu private final WorkspaceRequestImageQueryRepository workspaceRequestImageQueryRepository; private final FileQueryRepository fileQueryRepository; private final FileDeleteService fileDeleteService; - private final SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; + private final ApplicationEventPublisher eventPublisher; @Override public void execute(Long workspaceRequestId, WorkspaceRequestStatus status) { @@ -93,8 +94,8 @@ private void approve(WorkspaceRequest workspaceRequest, Long workspaceRequestId) workspaceRepository.save(workspace); - syncWorkspaceChatMembership.createGroupRoom(workspace.getId()); - syncWorkspaceChatMembership.join(workspace.getId(), managerUser.getUser().getId(), TokenScope.MANAGER); + eventPublisher.publishEvent( + new ChatMembershipJoinedEvent(workspace.getId(), managerUser.getUser().getId(), TokenScope.MANAGER)); attachRepresentativeImages(workspaceRequestId, workspace); diff --git a/src/main/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImpl.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImpl.java index 55b66898..510ae339 100644 --- a/src/main/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImpl.java +++ b/src/main/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImpl.java @@ -1,7 +1,7 @@ package com.dreamteam.alter.application.workspace.usecase; +import com.dreamteam.alter.application.chat.event.ChatMembershipLeftEvent; import com.dreamteam.alter.domain.auth.type.TokenScope; -import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; import com.dreamteam.alter.domain.workspace.entity.SubstituteRequest; import com.dreamteam.alter.domain.workspace.entity.WorkspaceShift; import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; @@ -11,6 +11,7 @@ import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceShiftQueryRepository; import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceWorkerScheduleQueryRepository; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,7 +26,7 @@ public class WorkerResignationServiceImpl implements WorkerResignationService { private final WorkspaceShiftQueryRepository workspaceShiftQueryRepository; private final WorkspaceWorkerScheduleQueryRepository workspaceWorkerScheduleQueryRepository; private final SubstituteRequestQueryRepository substituteRequestQueryRepository; - private final SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; + private final ApplicationEventPublisher eventPublisher; @Override public void resign(WorkspaceWorker worker) { @@ -49,6 +50,7 @@ public void resign(WorkspaceWorker worker) { worker.resign(); - syncWorkspaceChatMembership.leave(worker.getWorkspace().getId(), worker.getUser().getId(), TokenScope.APP); + eventPublisher.publishEvent( + new ChatMembershipLeftEvent(worker.getWorkspace().getId(), worker.getUser().getId(), TokenScope.APP)); } } diff --git a/src/test/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListenerTest.java b/src/test/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListenerTest.java new file mode 100644 index 00000000..d365bf76 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListenerTest.java @@ -0,0 +1,75 @@ +package com.dreamteam.alter.application.chat.event; + +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatMembershipSyncEventListener 테스트") +class ChatMembershipSyncEventListenerTest { + + @Mock + private SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; + + @InjectMocks + private ChatMembershipSyncEventListener listener; + + @Test + @DisplayName("onJoined 은 이벤트 필드로 sync.join 을 호출한다") + void onJoined_sync_join_호출() { + // given + ChatMembershipJoinedEvent event = new ChatMembershipJoinedEvent(1L, 2L, TokenScope.MANAGER); + + // when + listener.onJoined(event); + + // then + then(syncWorkspaceChatMembership).should().join(1L, 2L, TokenScope.MANAGER); + } + + @Test + @DisplayName("onJoined 에서 sync.join 이 예외를 던져도 삼키고 재전파하지 않는다") + void onJoined_예외삼킴() { + // given + ChatMembershipJoinedEvent event = new ChatMembershipJoinedEvent(1L, 2L, TokenScope.MANAGER); + willThrow(new RuntimeException("chat sync failed")) + .given(syncWorkspaceChatMembership).join(1L, 2L, TokenScope.MANAGER); + + // when & then + Assertions.assertThatCode(() -> listener.onJoined(event)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("onLeft 은 이벤트 필드로 sync.leave 를 호출한다") + void onLeft_sync_leave_호출() { + // given + ChatMembershipLeftEvent event = new ChatMembershipLeftEvent(3L, 4L, TokenScope.APP); + + // when + listener.onLeft(event); + + // then + then(syncWorkspaceChatMembership).should().leave(3L, 4L, TokenScope.APP); + } + + @Test + @DisplayName("onLeft 에서 sync.leave 가 예외를 던져도 삼키고 재전파하지 않는다") + void onLeft_예외삼킴() { + // given + ChatMembershipLeftEvent event = new ChatMembershipLeftEvent(3L, 4L, TokenScope.APP); + willThrow(new RuntimeException("chat sync failed")) + .given(syncWorkspaceChatMembership).leave(3L, 4L, TokenScope.APP); + + // when & then + Assertions.assertThatCode(() -> listener.onLeft(event)).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTest.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTest.java index 41918650..62ea71ff 100644 --- a/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTest.java +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTest.java @@ -1,8 +1,8 @@ package com.dreamteam.alter.application.workspace.usecase; +import com.dreamteam.alter.application.chat.event.ChatMembershipJoinedEvent; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.domain.auth.type.TokenScope; -import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.workspace.entity.Workspace; import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; @@ -11,12 +11,16 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.then; @@ -35,14 +39,17 @@ class AddWorkerToWorkspaceTest { private WorkspaceWorkerQueryRepository workspaceWorkerQueryRepository; @Mock - private SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; + private ApplicationEventPublisher eventPublisher; @InjectMocks private AddWorkerToWorkspace addWorkerToWorkspace; + @Captor + private ArgumentCaptor eventCaptor; + @Test - @DisplayName("신규 근무자 추가 시 업장 단톡에 join 한다") - void execute_신규추가_단톡join호출() { + @DisplayName("신규 근무자 추가 시 업장 단톡 join 이벤트를 발행한다") + void execute_신규추가_단톡join이벤트발행() { // given Workspace workspace = mock(Workspace.class); when(workspace.getId()).thenReturn(1L); @@ -56,12 +63,16 @@ class AddWorkerToWorkspaceTest { addWorkerToWorkspace.execute(workspace, user); // then - then(syncWorkspaceChatMembership).should().join(1L, 2L, TokenScope.APP); + then(eventPublisher).should().publishEvent(eventCaptor.capture()); + ChatMembershipJoinedEvent event = eventCaptor.getValue(); + assertThat(event.getWorkspaceId()).isEqualTo(1L); + assertThat(event.getMemberId()).isEqualTo(2L); + assertThat(event.getScope()).isEqualTo(TokenScope.APP); } @Test - @DisplayName("이미 근무중인 근무자면 예외를 던지고 단톡 join을 호출하지 않는다") - void execute_이미근무중_단톡join미호출() { + @DisplayName("이미 근무중인 근무자면 예외를 던지고 단톡 join 이벤트를 발행하지 않는다") + void execute_이미근무중_단톡join이벤트미발행() { // given Workspace workspace = mock(Workspace.class); User user = mock(User.class); @@ -74,6 +85,6 @@ class AddWorkerToWorkspaceTest { assertThatThrownBy(() -> addWorkerToWorkspace.execute(workspace, user)) .isInstanceOf(CustomException.class); - then(syncWorkspaceChatMembership).should(never()).join(any(), any(), any()); + then(eventPublisher).should(never()).publishEvent(any()); } } diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java index a63c6e74..10e06102 100644 --- a/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/UpdateWorkspaceRequestStatusTests.java @@ -1,10 +1,10 @@ package com.dreamteam.alter.application.workspace.usecase; +import com.dreamteam.alter.application.chat.event.ChatMembershipJoinedEvent; import com.dreamteam.alter.application.file.FileDeleteService; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.auth.type.TokenScope; -import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; import com.dreamteam.alter.domain.file.entity.File; import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; import com.dreamteam.alter.domain.file.type.FileTargetType; @@ -27,10 +27,10 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; -import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import java.util.List; import java.util.Optional; @@ -39,10 +39,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -75,7 +73,7 @@ class UpdateWorkspaceRequestStatusTests { private FileDeleteService fileDeleteService; @Mock - private SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; + private ApplicationEventPublisher eventPublisher; @InjectMocks private UpdateWorkspaceRequestStatus updateWorkspaceRequestStatus; @@ -83,6 +81,9 @@ class UpdateWorkspaceRequestStatusTests { @Captor private ArgumentCaptor> imagesCaptor; + @Captor + private ArgumentCaptor eventCaptor; + @Nested @DisplayName("execute") class ExecuteTests { @@ -176,8 +177,8 @@ class ExecuteTests { } @Test - @DisplayName("ACTIVATED 면 업장 저장 후 그룹 채팅방을 생성하고 매니저를 그룹방에 입장시킨다") - void execute_ACTIVATED_그룹채팅방생성및매니저입장() { + @DisplayName("ACTIVATED 면 업장 저장 후 채팅 멤버십 join 이벤트를 발행한다") + void execute_ACTIVATED_채팅멤버십join이벤트발행() { // given User user = mock(User.class); WorkspaceRequest request = mock(WorkspaceRequest.class); @@ -197,11 +198,12 @@ class ExecuteTests { updateWorkspaceRequestStatus.execute(1L, WorkspaceRequestStatus.ACTIVATED); // then - InOrder inOrder = inOrder(workspaceRepository, syncWorkspaceChatMembership); - inOrder.verify(workspaceRepository).save(any(Workspace.class)); - inOrder.verify(syncWorkspaceChatMembership).createGroupRoom(any()); + then(workspaceRepository).should().save(any(Workspace.class)); + then(eventPublisher).should().publishEvent(eventCaptor.capture()); + ChatMembershipJoinedEvent event = eventCaptor.getValue(); // 참여자 id는 ManagerUser 엔티티 id가 아닌, 채팅 도메인 기준 식별자인 User(계정) id 여야 한다 - inOrder.verify(syncWorkspaceChatMembership).join(any(), eq(42L), eq(TokenScope.MANAGER)); + assertThat(event.getMemberId()).isEqualTo(42L); + assertThat(event.getScope()).isEqualTo(TokenScope.MANAGER); } @Test @@ -219,8 +221,7 @@ class ExecuteTests { then(workspaceRepository).should(never()).save(any()); then(managerUserRepository).should(never()).save(any()); then(fileDeleteService).should(never()).delete(any()); - then(syncWorkspaceChatMembership).should(never()).createGroupRoom(any()); - then(syncWorkspaceChatMembership).should(never()).join(any(), any(), any()); + then(eventPublisher).should(never()).publishEvent(any()); } @Test diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTest.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTest.java index c0f9604b..fe2e0efc 100644 --- a/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTest.java +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTest.java @@ -1,7 +1,7 @@ package com.dreamteam.alter.application.workspace.usecase; +import com.dreamteam.alter.application.chat.event.ChatMembershipLeftEvent; import com.dreamteam.alter.domain.auth.type.TokenScope; -import com.dreamteam.alter.domain.chat.port.inbound.SyncWorkspaceChatMembershipUseCase; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.workspace.entity.SubstituteRequest; import com.dreamteam.alter.domain.workspace.entity.Workspace; @@ -14,13 +14,17 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import java.time.LocalDateTime; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -41,11 +45,14 @@ class WorkerResignationServiceImplTest { private SubstituteRequestQueryRepository substituteRequestQueryRepository; @Mock - private SyncWorkspaceChatMembershipUseCase syncWorkspaceChatMembership; + private ApplicationEventPublisher eventPublisher; @InjectMocks private WorkerResignationServiceImpl workerResignationService; + @Captor + private ArgumentCaptor eventCaptor; + @Test @DisplayName("퇴직 정리 대상을 일괄 정리하고 worker를 퇴직 처리한다") void resign_정리대상존재_일괄정리() { @@ -84,7 +91,11 @@ class WorkerResignationServiceImplTest { verify(requesterRequest, times(1)).cancel(); verify(targetRequest, times(1)).cancelPendingTargetAndCancelIfNoPendingTargets(10L); verify(worker, times(1)).resign(); - verify(syncWorkspaceChatMembership, times(1)).leave(100L, 200L, TokenScope.APP); + verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); + ChatMembershipLeftEvent event = eventCaptor.getValue(); + assertThat(event.getWorkspaceId()).isEqualTo(100L); + assertThat(event.getMemberId()).isEqualTo(200L); + assertThat(event.getScope()).isEqualTo(TokenScope.APP); } @Test @@ -110,6 +121,10 @@ class WorkerResignationServiceImplTest { // then verify(worker, times(1)).resign(); - verify(syncWorkspaceChatMembership, times(1)).leave(300L, 400L, TokenScope.APP); + verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); + ChatMembershipLeftEvent event = eventCaptor.getValue(); + assertThat(event.getWorkspaceId()).isEqualTo(300L); + assertThat(event.getMemberId()).isEqualTo(400L); + assertThat(event.getScope()).isEqualTo(TokenScope.APP); } } From b379d92117778671c078195f17bba8886150f63d Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 22:59:30 +0900 Subject: [PATCH 28/35] =?UTF-8?q?fix:=20STOMP=20relay=EC=9A=A9=20reactor-n?= =?UTF-8?q?etty=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=95=EC=A0=95=20(reac?= =?UTF-8?q?tor-netty-core=E2=86=92reactor-netty)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reactor-netty-core만으론 Spring StompBrokerRelay가 'No compatible version of Reactor Netty'로 기동 실패. reactor-netty 애그리게이트로 정정해 relay TCP 연결 정상화. Docker 실환경 검증(CHAT_BROKER_RELAY_ENABLED=true)으로 확인. --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9d8a4319..f6af5ba6 100644 --- a/build.gradle +++ b/build.gradle @@ -52,7 +52,7 @@ dependencies { implementation 'com.fasterxml.uuid:java-uuid-generator:5.1.0' // STOMP broker relay (RabbitMQ) — 런타임 relay 연결용 - implementation 'io.projectreactor.netty:reactor-netty-core' + implementation 'io.projectreactor.netty:reactor-netty' compileOnly 'org.projectlombok:lombok' From 682bf970db6f6568a441d60cebf3240fbb7919e3 Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 9 Jul 2026 23:04:29 +0900 Subject: [PATCH 29/35] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=20=EC=97=85?= =?UTF-8?q?=EC=9E=A5=20=EA=B7=B8=EB=A3=B9=20=EB=8B=A8=ED=86=A1=EB=B0=A9?= =?UTF-8?q?=C2=B7=EB=A9=A4=EB=B2=84=20=EB=B0=B1=ED=95=84=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98(V6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포 이전 존재하던 업장에 GROUP 방과 멤버(오너 매니저 + 활성 워커)를 생성. 신규 업장은 활성화 이벤트로 자동 생성되므로 GROUP 방이 없는 기존 업장만 대상. Docker 실환경(8업장/8워커/4매니저)에서 rollback 트랜잭션으로 검증: GROUP방 8, 매니저멤버 8, 활성워커멤버 6, 재실행 멱등(유니크 위반 없음). --- .../V6__backfill_workspace_group_chat.sql | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/main/resources/db/migration/V6__backfill_workspace_group_chat.sql diff --git a/src/main/resources/db/migration/V6__backfill_workspace_group_chat.sql b/src/main/resources/db/migration/V6__backfill_workspace_group_chat.sql new file mode 100644 index 00000000..618b3a27 --- /dev/null +++ b/src/main/resources/db/migration/V6__backfill_workspace_group_chat.sql @@ -0,0 +1,35 @@ +-- 배포 이전에 이미 존재하던 업장에 대해 GROUP 단톡방과 멤버를 백필한다. +-- 신규 업장은 활성화 시 AFTER_COMMIT 이벤트로 방/멤버가 자동 생성되므로, 여기서는 +-- GROUP 방이 아직 없는 기존 업장만 대상으로 한다. +-- 멤버 식별자 규약: 매니저는 manager_users.user_id(계정 User id)/MANAGER, 워커는 worker_id/APP. + +-- 1. GROUP 방이 없는 업장에 GROUP 방 생성 (participant 컬럼은 GROUP에서 미사용 → NULL) +INSERT INTO chat_rooms (type, workspace_id, created_at, updated_at) +SELECT 'GROUP', w.id, now(), now() +FROM workspaces w +WHERE NOT EXISTS ( + SELECT 1 FROM chat_rooms cr WHERE cr.type = 'GROUP' AND cr.workspace_id = w.id +); + +-- 2. 오너 매니저를 멤버로 추가 (중복 방지) +INSERT INTO chat_room_members (chat_room_id, member_id, member_scope, joined_at, created_at, updated_at) +SELECT cr.id, mu.user_id, 'MANAGER', now(), now(), now() +FROM chat_rooms cr +JOIN workspaces w ON w.id = cr.workspace_id +JOIN manager_users mu ON mu.id = w.manager_id +WHERE cr.type = 'GROUP' + AND NOT EXISTS ( + SELECT 1 FROM chat_room_members m + WHERE m.chat_room_id = cr.id AND m.member_id = mu.user_id AND m.member_scope = 'MANAGER' + ); + +-- 3. 활성(ACTIVATED) 워커를 멤버로 추가 (중복 방지) +INSERT INTO chat_room_members (chat_room_id, member_id, member_scope, joined_at, created_at, updated_at) +SELECT cr.id, ww.worker_id, 'APP', now(), now(), now() +FROM chat_rooms cr +JOIN workspace_workers ww ON ww.workspace_id = cr.workspace_id AND ww.status = 'ACTIVATED' +WHERE cr.type = 'GROUP' + AND NOT EXISTS ( + SELECT 1 FROM chat_room_members m + WHERE m.chat_room_id = cr.id AND m.member_id = ww.worker_id AND m.member_scope = 'APP' + ); From 3c3e2958ed3488078032def919ea69c738c0f1cc Mon Sep 17 00:00:00 2001 From: hodoon Date: Fri, 10 Jul 2026 11:51:57 +0900 Subject: [PATCH 30/35] =?UTF-8?q?fix:=20[ALT-270]=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=EC=9A=94=EC=B2=AD=20@Valid,=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=EB=B0=A9=20=EC=9C=A0=EB=8B=88=ED=81=AC=20=EC=A0=9C=EC=95=BD,?= =?UTF-8?q?=20=EC=B2=A8=EB=B6=80=20setter=20=EB=B2=94=EC=9C=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MarkChatRoomRead 요청 @RequestBody에 @Valid 추가(User/Manager 컨트롤러): Spec 인터페이스의 @Valid가 구현체로 병합 보장 안 돼 @NotNull(lastReadMessageId) 검증이 누락될 수 있던 문제 해소. - V7: chat_rooms(workspace_id) WHERE type='GROUP' 부분 유니크 인덱스 — 레거시 업장 동시 워커 가입 시 그룹방 중복 생성 경쟁을 DB에서 차단. - ChatMessageResponse: 클래스 @Setter → attachments 필드 한정(다른 필드는 생성자/프로젝션만). --- .../inbound/general/chat/controller/UserChatController.java | 3 ++- .../manager/chat/controller/ManagerChatController.java | 3 ++- .../chat/persistence/readonly/ChatMessageResponse.java | 4 +++- .../db/migration/V7__unique_workspace_group_chat_room.sql | 6 ++++++ 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/migration/V7__unique_workspace_group_chat_room.sql diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatController.java index 29d882ef..0c93ecc3 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/controller/UserChatController.java @@ -19,6 +19,7 @@ import com.dreamteam.alter.domain.chat.port.inbound.MarkChatRoomReadUseCase; import com.dreamteam.alter.domain.user.context.AppActor; import jakarta.annotation.Resource; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -93,7 +94,7 @@ public ResponseEntity> getCha @PostMapping("/rooms/{chatRoomId}/read") public ResponseEntity> markChatRoomRead( @PathVariable Long chatRoomId, - @RequestBody MarkChatRoomReadRequestDto request + @Valid @RequestBody MarkChatRoomReadRequestDto request ) { AppActor actor = AppActionContext.getInstance().getActor(); markChatRoomRead.execute(actor.getUserId(), TokenScope.APP, chatRoomId, request.getLastReadMessageId()); diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java index b73acfa8..514fbd3f 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java @@ -19,6 +19,7 @@ import com.dreamteam.alter.domain.chat.port.inbound.MarkChatRoomReadUseCase; import com.dreamteam.alter.domain.user.context.ManagerActor; import jakarta.annotation.Resource; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -92,7 +93,7 @@ public ResponseEntity> getCha @PostMapping("/rooms/{chatRoomId}/read") public ResponseEntity> markChatRoomRead( @PathVariable Long chatRoomId, - @RequestBody MarkChatRoomReadRequestDto request + @Valid @RequestBody MarkChatRoomReadRequestDto request ) { ManagerActor actor = ManagerActionContext.getInstance().getActor(); markChatRoomRead.execute(actor.getUserId(), TokenScope.MANAGER, chatRoomId, request.getLastReadMessageId()); diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/readonly/ChatMessageResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/readonly/ChatMessageResponse.java index cfabebd8..1bc1f43d 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/readonly/ChatMessageResponse.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/readonly/ChatMessageResponse.java @@ -10,7 +10,6 @@ import java.util.List; @Getter -@Setter @NoArgsConstructor public class ChatMessageResponse { private Long id; @@ -19,6 +18,9 @@ public class ChatMessageResponse { private TokenScope senderScope; private String content; private LocalDateTime createdAt; + + // 조회/전송 시 후처리로 세팅되는 첨부(다른 필드는 생성자/프로젝션으로만 설정) + @Setter private List attachments; public ChatMessageResponse( diff --git a/src/main/resources/db/migration/V7__unique_workspace_group_chat_room.sql b/src/main/resources/db/migration/V7__unique_workspace_group_chat_room.sql new file mode 100644 index 00000000..d454d0ee --- /dev/null +++ b/src/main/resources/db/migration/V7__unique_workspace_group_chat_room.sql @@ -0,0 +1,6 @@ +-- 업장당 GROUP 단톡방은 1개여야 한다. 레거시 업장에 워커가 동시에 가입해 자기치유 +-- 로직이 그룹방을 각각 생성하는 경쟁 상황을 DB 차원에서 차단(부분 유니크 인덱스). +-- findGroupRoomByWorkspaceId의 fetchOne()도 항상 최대 1건이 보장된다. +CREATE UNIQUE INDEX uq_chat_group_room_per_workspace + ON chat_rooms (workspace_id) + WHERE type = 'GROUP'; From 07f302add7da9d31a74a1842ffa3fce5d1f0b29b Mon Sep 17 00:00:00 2001 From: hodoon Date: Fri, 10 Jul 2026 12:51:13 +0900 Subject: [PATCH 31/35] =?UTF-8?q?fix:=20[ALT-270]=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=EB=B0=A9=20=EC=A1=B0=ED=9A=8C=20fetchOne=E2=86=92fetchFirst=20?= =?UTF-8?q?(=EC=A4=91=EB=B3=B5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=EC=98=88=EC=99=B8=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단일성은 V7 부분 유니크 인덱스가 보증하되, 만일의 중복 데이터에도 NonUniqueResultException을 던지지 않도록 방어. --- .../chat/persistence/ChatRoomQueryRepositoryImpl.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomQueryRepositoryImpl.java index 3d6e4ea2..db4ea29e 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomQueryRepositoryImpl.java @@ -164,7 +164,9 @@ public Optional findGroupRoomByWorkspaceId(Long workspaceId) { qChatRoom.type.eq(ChatRoomType.GROUP), qChatRoom.workspaceId.eq(workspaceId) ) - .fetchOne(); + // 유니크 인덱스(V7)로 단일성이 보장되지만, 혹시 모를 중복 데이터에도 + // NonUniqueResultException을 던지지 않도록 fetchFirst 사용 + .fetchFirst(); return Optional.ofNullable(result); } From d2ad9d8b9fd9073562fff0ac507065fdc3abf910 Mon Sep 17 00:00:00 2001 From: hodoon Date: Fri, 10 Jul 2026 13:06:55 +0900 Subject: [PATCH 32/35] =?UTF-8?q?fix:=20[ALT-270]=20ChatRoom.createGroup?= =?UTF-8?q?=20workspaceId=20null=20=EA=B2=80=EC=A6=9D=20(=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=B6=88=EB=B3=80=EC=8B=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dreamteam/alter/domain/chat/entity/ChatRoom.java | 4 ++++ .../dreamteam/alter/domain/chat/entity/ChatRoomTest.java | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatRoom.java b/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatRoom.java index d5764486..e43aa1f7 100644 --- a/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatRoom.java +++ b/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatRoom.java @@ -69,6 +69,10 @@ public static ChatRoom create( } public static ChatRoom createGroup(Long workspaceId) { + // GROUP 방은 반드시 업장과 연관되어야 한다 (도메인 불변식) + if (workspaceId == null) { + throw new IllegalArgumentException("GROUP 채팅방은 workspaceId가 필수입니다."); + } return ChatRoom.builder() .type(ChatRoomType.GROUP) .workspaceId(workspaceId) diff --git a/src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTest.java b/src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTest.java index f632c25d..ea7465d7 100644 --- a/src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTest.java +++ b/src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class ChatRoomTest { @@ -14,4 +15,10 @@ class ChatRoomTest { assertThat(room.getType()).isEqualTo(ChatRoomType.GROUP); assertThat(room.getWorkspaceId()).isEqualTo(100L); } + + @Test + void createGroup_workspaceId_null이면_예외() { + assertThatThrownBy(() -> ChatRoom.createGroup(null)) + .isInstanceOf(IllegalArgumentException.class); + } } From eee228e39a90239a76be836d993dc0243539511d Mon Sep 17 00:00:00 2001 From: hodoon Date: Fri, 10 Jul 2026 13:14:05 +0900 Subject: [PATCH 33/35] =?UTF-8?q?fix:=20[ALT-270]=20presence=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EC=84=B8=EC=85=98=20=EC=B0=B8=EC=A1=B0=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=20=E2=80=94=20=EB=A7=88=EC=A7=80=EB=A7=89=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=A2=85=EB=A3=8C=20=EC=8B=9C=EC=97=90?= =?UTF-8?q?=EB=A7=8C=20=EC=98=A4=ED=94=84=EB=9D=BC=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 멀티탭/멀티디바이스로 접속한 사용자가 세션 하나만 닫아도 presence가 즉시 오프라인으로 바뀌어 FCM 푸시가 잘못 발송되는 문제를 수정. per-user 단일 키 대신 Redis SET(chat:presence:sessions:{scope}:{memberId})에 세션 id를 저장해 참조 카운트를 세고, SET이 비어야(마지막 세션 종료) 오프라인이 되도록 변경. isOnline(TokenScope, Long) 시그니처는 유지해 AbstractSendChatMessageUseCase는 영향 없음. WebSocketEventListener는 StompHeaderAccessor로 세션 id를 추출해 markOnline/markOffline에 전달. refresh()는 호출부가 없어 제거. --- .../ChatPresenceStoreRepositoryImpl.java | 20 +++--- .../common/config/WebSocketEventListener.java | 13 +++- .../chat/port/outbound/ChatPresenceStore.java | 6 +- .../ChatPresenceStoreRepositoryImplTest.java | 64 +++++++++++------ .../config/WebSocketEventListenerTest.java | 71 ++++++++++++++++--- 5 files changed, 124 insertions(+), 50 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImpl.java index c514bdf8..d4051af4 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImpl.java @@ -14,7 +14,7 @@ public class ChatPresenceStoreRepositoryImpl implements ChatPresenceStore { private final StringRedisTemplate redisTemplate; - private static final String KEY_PREFIX = "chat:presence:"; + private static final String KEY_PREFIX = "chat:presence:sessions:"; private static final Duration TTL = Duration.ofSeconds(60); private String key(TokenScope scope, Long memberId) { @@ -22,22 +22,20 @@ private String key(TokenScope scope, Long memberId) { } @Override - public void markOnline(TokenScope scope, Long memberId) { - redisTemplate.opsForValue().set(key(scope, memberId), "1", TTL); + public void markOnline(TokenScope scope, Long memberId, String sessionId) { + String key = key(scope, memberId); + redisTemplate.opsForSet().add(key, sessionId); + redisTemplate.expire(key, TTL); } @Override - public void refresh(TokenScope scope, Long memberId) { - redisTemplate.expire(key(scope, memberId), TTL); - } - - @Override - public void markOffline(TokenScope scope, Long memberId) { - redisTemplate.delete(key(scope, memberId)); + public void markOffline(TokenScope scope, Long memberId, String sessionId) { + redisTemplate.opsForSet().remove(key(scope, memberId), sessionId); } @Override public boolean isOnline(TokenScope scope, Long memberId) { - return Boolean.TRUE.equals(redisTemplate.hasKey(key(scope, memberId))); + Long size = redisTemplate.opsForSet().size(key(scope, memberId)); + return size != null && size > 0; } } diff --git a/src/main/java/com/dreamteam/alter/common/config/WebSocketEventListener.java b/src/main/java/com/dreamteam/alter/common/config/WebSocketEventListener.java index 0c9c1c1d..ef6d71c2 100644 --- a/src/main/java/com/dreamteam/alter/common/config/WebSocketEventListener.java +++ b/src/main/java/com/dreamteam/alter/common/config/WebSocketEventListener.java @@ -5,6 +5,7 @@ import com.dreamteam.alter.domain.chat.port.outbound.ChatPresenceStore; import lombok.RequiredArgsConstructor; import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; import org.springframework.web.socket.messaging.SessionConnectedEvent; @@ -21,14 +22,22 @@ public class WebSocketEventListener { @EventListener public void onConnected(SessionConnectedEvent event) { + String sessionId = StompHeaderAccessor.wrap(event.getMessage()).getSessionId(); + if (sessionId == null) { + return; + } resolve(event.getUser()) - .ifPresent(principal -> chatPresenceStore.markOnline(principal.scope(), principal.memberId())); + .ifPresent(principal -> chatPresenceStore.markOnline(principal.scope(), principal.memberId(), sessionId)); } @EventListener public void onDisconnect(SessionDisconnectEvent event) { + String sessionId = StompHeaderAccessor.wrap(event.getMessage()).getSessionId(); + if (sessionId == null) { + return; + } resolve(event.getUser()) - .ifPresent(principal -> chatPresenceStore.markOffline(principal.scope(), principal.memberId())); + .ifPresent(principal -> chatPresenceStore.markOffline(principal.scope(), principal.memberId(), sessionId)); } private Optional resolve(Principal principal) { diff --git a/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatPresenceStore.java b/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatPresenceStore.java index f62600b0..357e0fe0 100644 --- a/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatPresenceStore.java +++ b/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatPresenceStore.java @@ -4,11 +4,9 @@ public interface ChatPresenceStore { - void markOnline(TokenScope scope, Long memberId); + void markOnline(TokenScope scope, Long memberId, String sessionId); - void refresh(TokenScope scope, Long memberId); - - void markOffline(TokenScope scope, Long memberId); + void markOffline(TokenScope scope, Long memberId, String sessionId); boolean isOnline(TokenScope scope, Long memberId); } diff --git a/src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTest.java b/src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTest.java index 04f94318..c5814fc5 100644 --- a/src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTest.java +++ b/src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTest.java @@ -7,8 +7,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.SetOperations; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.ValueOperations; import java.time.Duration; @@ -27,61 +27,79 @@ class ChatPresenceStoreRepositoryImplTest { @InjectMocks private ChatPresenceStoreRepositoryImpl chatPresenceStoreRepository; - private static final String KEY = "chat:presence:APP:1"; + private static final String KEY = "chat:presence:sessions:APP:1"; @Test - @DisplayName("markOnline 시 TTL 60초로 키를 저장한다") - void markOnline_키저장() { + @DisplayName("markOnline 시 세션 id를 SET에 추가하고 TTL 60초로 갱신한다") + void markOnline_세션추가() { // given @SuppressWarnings("unchecked") - ValueOperations valueOperations = mock(ValueOperations.class); - given(redisTemplate.opsForValue()).willReturn(valueOperations); + SetOperations setOperations = mock(SetOperations.class); + given(redisTemplate.opsForSet()).willReturn(setOperations); // when - chatPresenceStoreRepository.markOnline(TokenScope.APP, 1L); + chatPresenceStoreRepository.markOnline(TokenScope.APP, 1L, "sess1"); // then - then(valueOperations).should().set(KEY, "1", Duration.ofSeconds(60)); + then(setOperations).should().add(KEY, "sess1"); + then(redisTemplate).should().expire(KEY, Duration.ofSeconds(60)); } @Test - @DisplayName("refresh 시 TTL을 60초로 갱신한다") - void refresh_TTL갱신() { + @DisplayName("markOffline 시 세션 id만 SET에서 제거한다") + void markOffline_세션제거() { + // given + @SuppressWarnings("unchecked") + SetOperations setOperations = mock(SetOperations.class); + given(redisTemplate.opsForSet()).willReturn(setOperations); + // when - chatPresenceStoreRepository.refresh(TokenScope.APP, 1L); + chatPresenceStoreRepository.markOffline(TokenScope.APP, 1L, "sess1"); // then - then(redisTemplate).should().expire(KEY, Duration.ofSeconds(60)); + then(setOperations).should().remove(KEY, "sess1"); } @Test - @DisplayName("markOffline 시 키를 삭제한다") - void markOffline_키삭제() { + @DisplayName("isOnline은 SET 크기가 0보다 크면 true") + void isOnline_true() { + // given + @SuppressWarnings("unchecked") + SetOperations setOperations = mock(SetOperations.class); + given(redisTemplate.opsForSet()).willReturn(setOperations); + given(setOperations.size(KEY)).willReturn(2L); + // when - chatPresenceStoreRepository.markOffline(TokenScope.APP, 1L); + boolean result = chatPresenceStoreRepository.isOnline(TokenScope.APP, 1L); // then - then(redisTemplate).should().delete(KEY); + assertThat(result).isTrue(); } @Test - @DisplayName("isOnline은 hasKey 결과를 그대로 반환한다 - true") - void isOnline_true() { + @DisplayName("isOnline은 SET 크기가 0이면 false") + void isOnline_false_크기0() { // given - given(redisTemplate.hasKey(KEY)).willReturn(true); + @SuppressWarnings("unchecked") + SetOperations setOperations = mock(SetOperations.class); + given(redisTemplate.opsForSet()).willReturn(setOperations); + given(setOperations.size(KEY)).willReturn(0L); // when boolean result = chatPresenceStoreRepository.isOnline(TokenScope.APP, 1L); // then - assertThat(result).isTrue(); + assertThat(result).isFalse(); } @Test - @DisplayName("isOnline은 hasKey 결과를 그대로 반환한다 - false") - void isOnline_false() { + @DisplayName("isOnline은 SET이 없어(size=null) 오프라인이면 false") + void isOnline_false_null() { // given - given(redisTemplate.hasKey(KEY)).willReturn(false); + @SuppressWarnings("unchecked") + SetOperations setOperations = mock(SetOperations.class); + given(redisTemplate.opsForSet()).willReturn(setOperations); + given(setOperations.size(KEY)).willReturn(null); // when boolean result = chatPresenceStoreRepository.isOnline(TokenScope.APP, 1L); diff --git a/src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTest.java b/src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTest.java index 6c7a7e99..dbe7436c 100644 --- a/src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTest.java +++ b/src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTest.java @@ -11,6 +11,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.messaging.SessionConnectedEvent; @@ -20,7 +23,6 @@ import java.util.Collections; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; @@ -36,8 +38,11 @@ class WebSocketEventListenerTest { @InjectMocks private WebSocketEventListener webSocketEventListener; - @SuppressWarnings("unchecked") - private final Message message = mock(Message.class); + private Message messageWithSessionId(String sessionId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.CONNECTED); + accessor.setSessionId(sessionId); + return MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + } private Principal accessTokenPrincipal(TokenScope scope, Long memberId) { LoginUserDto dto = mock(LoginUserDto.class); @@ -47,30 +52,32 @@ private Principal accessTokenPrincipal(TokenScope scope, Long memberId) { } @Test - @DisplayName("연결 시 인증된 사용자면 markOnline 호출") + @DisplayName("연결 시 인증된 사용자면 세션 id와 함께 markOnline 호출") void onConnected_인증된사용자_markOnline() { // given Principal principal = accessTokenPrincipal(TokenScope.APP, 1L); + Message message = messageWithSessionId("sess1"); SessionConnectedEvent event = new SessionConnectedEvent(this, message, principal); // when webSocketEventListener.onConnected(event); // then - then(chatPresenceStore).should().markOnline(TokenScope.APP, 1L); + then(chatPresenceStore).should().markOnline(TokenScope.APP, 1L, "sess1"); } @Test @DisplayName("연결 시 principal이 없으면 markOnline 호출 안함") void onConnected_principal없음_markOnline호출안함() { // given + Message message = messageWithSessionId("sess1"); SessionConnectedEvent event = new SessionConnectedEvent(this, message, null); // when webSocketEventListener.onConnected(event); // then - then(chatPresenceStore).should(never()).markOnline(any(), any()); + then(chatPresenceStore).should(never()).markOnline(any(), any(), any()); } @Test @@ -78,39 +85,83 @@ private Principal accessTokenPrincipal(TokenScope scope, Long memberId) { void onConnected_알수없는principal_markOnline호출안함() { // given Principal principal = new TestingAuthenticationToken("foo", "bar"); + Message message = messageWithSessionId("sess1"); + SessionConnectedEvent event = new SessionConnectedEvent(this, message, principal); + + // when + webSocketEventListener.onConnected(event); + + // then + then(chatPresenceStore).should(never()).markOnline(any(), any(), any()); + } + + @Test + @DisplayName("연결 시 세션 id가 없으면 markOnline 호출 안함") + void onConnected_세션id없음_markOnline호출안함() { + // given + Principal principal = accessTokenPrincipal(TokenScope.APP, 1L); + Message message = MessageBuilder.withPayload(new byte[0]).build(); SessionConnectedEvent event = new SessionConnectedEvent(this, message, principal); // when webSocketEventListener.onConnected(event); // then - then(chatPresenceStore).should(never()).markOnline(any(), any()); + then(chatPresenceStore).should(never()).markOnline(any(), any(), any()); } @Test - @DisplayName("연결 해제 시 인증된 사용자면 markOffline 호출") + @DisplayName("연결 해제 시 인증된 사용자면 세션 id와 함께 markOffline 호출") void onDisconnect_인증된사용자_markOffline() { // given Principal principal = accessTokenPrincipal(TokenScope.MANAGER, 2L); + Message message = messageWithSessionId("session-1"); SessionDisconnectEvent event = new SessionDisconnectEvent(this, message, "session-1", CloseStatus.NORMAL, principal); // when webSocketEventListener.onDisconnect(event); // then - then(chatPresenceStore).should().markOffline(TokenScope.MANAGER, 2L); + then(chatPresenceStore).should().markOffline(TokenScope.MANAGER, 2L, "session-1"); } @Test @DisplayName("연결 해제 시 principal이 없으면 markOffline 호출 안함") void onDisconnect_principal없음_markOffline호출안함() { // given + Message message = messageWithSessionId("session-1"); SessionDisconnectEvent event = new SessionDisconnectEvent(this, message, "session-1", CloseStatus.NORMAL, null); // when webSocketEventListener.onDisconnect(event); // then - then(chatPresenceStore).should(never()).markOffline(any(), any()); + then(chatPresenceStore).should(never()).markOffline(any(), any(), any()); + } + + @Test + @DisplayName("다중 세션 - 하나의 세션만 종료해도 다른 세션은 여전히 markOnline 상태로 유지된다") + void 다중세션_한세션종료해도_다른세션온라인유지() { + // given: 같은 사용자가 두 개의 세션(sess1, sess2)으로 연결 + Principal principal = accessTokenPrincipal(TokenScope.APP, 1L); + SessionConnectedEvent connectedSess1 = new SessionConnectedEvent(this, messageWithSessionId("sess1"), principal); + SessionConnectedEvent connectedSess2 = new SessionConnectedEvent(this, messageWithSessionId("sess2"), principal); + + // when: 두 세션 모두 연결 + webSocketEventListener.onConnected(connectedSess1); + webSocketEventListener.onConnected(connectedSess2); + + // then: 각 세션 id로 markOnline이 호출된다 + then(chatPresenceStore).should().markOnline(TokenScope.APP, 1L, "sess1"); + then(chatPresenceStore).should().markOnline(TokenScope.APP, 1L, "sess2"); + + // when: sess1만 연결 해제 + SessionDisconnectEvent disconnectSess1 = + new SessionDisconnectEvent(this, messageWithSessionId("sess1"), "sess1", CloseStatus.NORMAL, principal); + webSocketEventListener.onDisconnect(disconnectSess1); + + // then: sess1만 markOffline이 호출되고, sess2는 여전히 온라인(참조 카운트가 남아 있음) + then(chatPresenceStore).should().markOffline(TokenScope.APP, 1L, "sess1"); + then(chatPresenceStore).should(never()).markOffline(TokenScope.APP, 1L, "sess2"); } } From c429f6fbd4438889f30175feb7405c7767d6e559 Mon Sep 17 00:00:00 2001 From: hodoon Date: Fri, 10 Jul 2026 13:40:12 +0900 Subject: [PATCH 34/35] =?UTF-8?q?test:=20[ALT-270]=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20*Test=20?= =?UTF-8?q?=E2=86=92=20*Tests=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 지적(경로 지침) 반영 — 이번 PR에서 추가된 14개 테스트 클래스명·파일명을 프로젝트 컨벤션(*Tests)에 맞춤. --- ...sitoryImplTest.java => ChatRoomMemberRepositoryImplTests.java} | 0 ...oryImplTest.java => ChatPresenceStoreRepositoryImplTests.java} | 0 ...istenerTest.java => ChatMembershipSyncEventListenerTests.java} | 0 ...CreateOrGetChatRoomTest.java => CreateOrGetChatRoomTests.java} | 0 .../{GetChatMessagesTest.java => GetChatMessagesTests.java} | 0 ...GroupChatRoomTest.java => GetWorkspaceGroupChatRoomTests.java} | 0 ...rSendChatMessageTest.java => ManagerSendChatMessageTests.java} | 0 .../{MarkChatRoomReadTest.java => MarkChatRoomReadTests.java} | 0 .../{SendChatMessageTest.java => SendChatMessageTests.java} | 0 ...tMembershipTest.java => SyncWorkspaceChatMembershipTests.java} | 0 ...dWorkerToWorkspaceTest.java => AddWorkerToWorkspaceTests.java} | 0 ...erviceImplTest.java => WorkerResignationServiceImplTests.java} | 0 ...ketEventListenerTest.java => WebSocketEventListenerTests.java} | 0 .../domain/chat/entity/{ChatRoomTest.java => ChatRoomTests.java} | 0 14 files changed, 0 insertions(+), 0 deletions(-) rename src/test/java/com/dreamteam/alter/adapter/outbound/chat/persistence/{ChatRoomMemberRepositoryImplTest.java => ChatRoomMemberRepositoryImplTests.java} (100%) rename src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/{ChatPresenceStoreRepositoryImplTest.java => ChatPresenceStoreRepositoryImplTests.java} (100%) rename src/test/java/com/dreamteam/alter/application/chat/event/{ChatMembershipSyncEventListenerTest.java => ChatMembershipSyncEventListenerTests.java} (100%) rename src/test/java/com/dreamteam/alter/application/chat/usecase/{CreateOrGetChatRoomTest.java => CreateOrGetChatRoomTests.java} (100%) rename src/test/java/com/dreamteam/alter/application/chat/usecase/{GetChatMessagesTest.java => GetChatMessagesTests.java} (100%) rename src/test/java/com/dreamteam/alter/application/chat/usecase/{GetWorkspaceGroupChatRoomTest.java => GetWorkspaceGroupChatRoomTests.java} (100%) rename src/test/java/com/dreamteam/alter/application/chat/usecase/{ManagerSendChatMessageTest.java => ManagerSendChatMessageTests.java} (100%) rename src/test/java/com/dreamteam/alter/application/chat/usecase/{MarkChatRoomReadTest.java => MarkChatRoomReadTests.java} (100%) rename src/test/java/com/dreamteam/alter/application/chat/usecase/{SendChatMessageTest.java => SendChatMessageTests.java} (100%) rename src/test/java/com/dreamteam/alter/application/chat/usecase/{SyncWorkspaceChatMembershipTest.java => SyncWorkspaceChatMembershipTests.java} (100%) rename src/test/java/com/dreamteam/alter/application/workspace/usecase/{AddWorkerToWorkspaceTest.java => AddWorkerToWorkspaceTests.java} (100%) rename src/test/java/com/dreamteam/alter/application/workspace/usecase/{WorkerResignationServiceImplTest.java => WorkerResignationServiceImplTests.java} (100%) rename src/test/java/com/dreamteam/alter/common/config/{WebSocketEventListenerTest.java => WebSocketEventListenerTests.java} (100%) rename src/test/java/com/dreamteam/alter/domain/chat/entity/{ChatRoomTest.java => ChatRoomTests.java} (100%) diff --git a/src/test/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImplTest.java b/src/test/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImplTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImplTest.java rename to src/test/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImplTests.java diff --git a/src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTest.java b/src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTest.java rename to src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTests.java diff --git a/src/test/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListenerTest.java b/src/test/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListenerTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListenerTest.java rename to src/test/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListenerTests.java diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/CreateOrGetChatRoomTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/CreateOrGetChatRoomTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/application/chat/usecase/CreateOrGetChatRoomTest.java rename to src/test/java/com/dreamteam/alter/application/chat/usecase/CreateOrGetChatRoomTests.java diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTest.java rename to src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTests.java diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoomTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoomTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoomTest.java rename to src/test/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoomTests.java diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTest.java rename to src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTests.java diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomReadTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomReadTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomReadTest.java rename to src/test/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomReadTests.java diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTest.java rename to src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTests.java diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTest.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTest.java rename to src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTests.java diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTest.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTest.java rename to src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTests.java diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTest.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTest.java rename to src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTests.java diff --git a/src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTest.java b/src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTest.java rename to src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTests.java diff --git a/src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTest.java b/src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTests.java similarity index 100% rename from src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTest.java rename to src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTests.java From d1aafb51ff2c6b775383e4e848f14d16ad61f580 Mon Sep 17 00:00:00 2001 From: hodoon Date: Fri, 10 Jul 2026 14:41:36 +0900 Subject: [PATCH 35/35] =?UTF-8?q?test:=20[ALT-270]=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20BDD=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC=20(when=E2=86=92given)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkerResignationServiceImplTests, AddWorkerToWorkspaceTests의 classic Mockito when/thenReturn 스타일을 BDD given/willReturn으로 변환하여 기존 BDD 스타일 테스트들과 통일 --- .../usecase/AddWorkerToWorkspaceTests.java | 14 +++--- .../WorkerResignationServiceImplTests.java | 48 +++++++++---------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTests.java index 62ea71ff..90366b1d 100644 --- a/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTests.java +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTests.java @@ -23,10 +23,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @DisplayName("AddWorkerToWorkspace 테스트") @@ -52,12 +52,12 @@ class AddWorkerToWorkspaceTest { void execute_신규추가_단톡join이벤트발행() { // given Workspace workspace = mock(Workspace.class); - when(workspace.getId()).thenReturn(1L); + given(workspace.getId()).willReturn(1L); User user = mock(User.class); - when(user.getId()).thenReturn(2L); + given(user.getId()).willReturn(2L); - when(workspaceWorkerQueryRepository.findActiveWorkerByWorkspaceAndUser(workspace, user)) - .thenReturn(Optional.empty()); + given(workspaceWorkerQueryRepository.findActiveWorkerByWorkspaceAndUser(workspace, user)) + .willReturn(Optional.empty()); // when addWorkerToWorkspace.execute(workspace, user); @@ -78,8 +78,8 @@ class AddWorkerToWorkspaceTest { User user = mock(User.class); WorkspaceWorker existingWorker = mock(WorkspaceWorker.class); - when(workspaceWorkerQueryRepository.findActiveWorkerByWorkspaceAndUser(workspace, user)) - .thenReturn(Optional.of(existingWorker)); + given(workspaceWorkerQueryRepository.findActiveWorkerByWorkspaceAndUser(workspace, user)) + .willReturn(Optional.of(existingWorker)); // when & then assertThatThrownBy(() -> addWorkerToWorkspace.execute(workspace, user)) diff --git a/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTests.java b/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTests.java index fe2e0efc..9304ffa1 100644 --- a/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTests.java +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTests.java @@ -26,10 +26,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @DisplayName("WorkerResignationServiceImpl 테스트") @@ -58,29 +58,29 @@ class WorkerResignationServiceImplTest { void resign_정리대상존재_일괄정리() { // given WorkspaceWorker worker = mock(WorkspaceWorker.class); - when(worker.getId()).thenReturn(10L); + given(worker.getId()).willReturn(10L); Workspace workspace = mock(Workspace.class); - when(workspace.getId()).thenReturn(100L); + given(workspace.getId()).willReturn(100L); User user = mock(User.class); - when(user.getId()).thenReturn(200L); - when(worker.getWorkspace()).thenReturn(workspace); - when(worker.getUser()).thenReturn(user); + given(user.getId()).willReturn(200L); + given(worker.getWorkspace()).willReturn(workspace); + given(worker.getUser()).willReturn(user); WorkspaceShift futureShift = mock(WorkspaceShift.class); - when(workspaceShiftQueryRepository.findFutureShiftsByAssignedWorker(any(), any(LocalDateTime.class))) - .thenReturn(List.of(futureShift)); + given(workspaceShiftQueryRepository.findFutureShiftsByAssignedWorker(any(), any(LocalDateTime.class))) + .willReturn(List.of(futureShift)); WorkspaceWorkerSchedule fixedSchedule = mock(WorkspaceWorkerSchedule.class); - when(workspaceWorkerScheduleQueryRepository.getByWorkspaceWorker(worker)) - .thenReturn(List.of(fixedSchedule)); + given(workspaceWorkerScheduleQueryRepository.getByWorkspaceWorker(worker)) + .willReturn(List.of(fixedSchedule)); SubstituteRequest requesterRequest = mock(SubstituteRequest.class); - when(substituteRequestQueryRepository.findAllActiveByRequesterWorkerId(10L)) - .thenReturn(List.of(requesterRequest)); + given(substituteRequestQueryRepository.findAllActiveByRequesterWorkerId(10L)) + .willReturn(List.of(requesterRequest)); SubstituteRequest targetRequest = mock(SubstituteRequest.class); - when(substituteRequestQueryRepository.findAllPendingTargetRequestsByTargetWorkerId(10L)) - .thenReturn(List.of(targetRequest)); + given(substituteRequestQueryRepository.findAllPendingTargetRequestsByTargetWorkerId(10L)) + .willReturn(List.of(targetRequest)); // when workerResignationService.resign(worker); @@ -103,18 +103,18 @@ class WorkerResignationServiceImplTest { void resign_정리대상없음_퇴직처리() { // given WorkspaceWorker worker = mock(WorkspaceWorker.class); - when(worker.getId()).thenReturn(20L); + given(worker.getId()).willReturn(20L); Workspace workspace = mock(Workspace.class); - when(workspace.getId()).thenReturn(300L); + given(workspace.getId()).willReturn(300L); User user = mock(User.class); - when(user.getId()).thenReturn(400L); - when(worker.getWorkspace()).thenReturn(workspace); - when(worker.getUser()).thenReturn(user); - when(workspaceShiftQueryRepository.findFutureShiftsByAssignedWorker(any(), any(LocalDateTime.class))) - .thenReturn(List.of()); - when(workspaceWorkerScheduleQueryRepository.getByWorkspaceWorker(worker)).thenReturn(List.of()); - when(substituteRequestQueryRepository.findAllActiveByRequesterWorkerId(20L)).thenReturn(List.of()); - when(substituteRequestQueryRepository.findAllPendingTargetRequestsByTargetWorkerId(20L)).thenReturn(List.of()); + given(user.getId()).willReturn(400L); + given(worker.getWorkspace()).willReturn(workspace); + given(worker.getUser()).willReturn(user); + given(workspaceShiftQueryRepository.findFutureShiftsByAssignedWorker(any(), any(LocalDateTime.class))) + .willReturn(List.of()); + given(workspaceWorkerScheduleQueryRepository.getByWorkspaceWorker(worker)).willReturn(List.of()); + given(substituteRequestQueryRepository.findAllActiveByRequesterWorkerId(20L)).willReturn(List.of()); + given(substituteRequestQueryRepository.findAllPendingTargetRequestsByTargetWorkerId(20L)).willReturn(List.of()); // when workerResignationService.resign(worker);