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/ diff --git a/build.gradle b/build.gradle index 8cc20b60..f6af5ba6 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' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' 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..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 @@ -8,13 +8,18 @@ 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.GetWorkspaceGroupChatRoomUseCase; +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; @@ -40,6 +45,12 @@ public class UserChatController implements UserChatControllerSpec { @Resource(name = "getChatRoomInfo") private final GetChatRoomInfoUseCase getChatRoomInfo; + @Resource(name = "markChatRoomRead") + private final MarkChatRoomReadUseCase markChatRoomRead; + + @Resource(name = "getWorkspaceGroupChatRoom") + private final GetWorkspaceGroupChatRoomUseCase getWorkspaceGroupChatRoom; + @Override @PostMapping("/rooms") public ResponseEntity> createOrGetChatRoom( @@ -78,4 +89,25 @@ 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, + @Valid @RequestBody MarkChatRoomReadRequestDto request + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + 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 977635b4..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 @@ -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,21 @@ 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 + ); + + @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/general/chat/dto/ChatMessageResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/ChatMessageResponseDto.java index 5c4ba97d..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) @@ -33,6 +35,12 @@ public class ChatMessageResponseDto { @Schema(description = "본인이 보낸 메시지 여부") private Boolean isMine; + @Schema(description = "안 읽은 사람 수") + private Integer unreadCount; + + @Schema(description = "첨부 파일 목록") + private List attachments; + public static ChatMessageResponseDto from(ChatMessageResponse response) { return ChatMessageResponseDto.builder() .id(response.getId()) @@ -58,4 +66,25 @@ 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) + .attachments(response.getAttachments()) + .build(); + } + } 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/general/chat/dto/SendChatMessageRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/chat/dto/SendChatMessageRequestDto.java index f24f02cd..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 @@ -1,7 +1,8 @@ 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 java.util.List; import lombok.*; @Getter @@ -9,7 +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/adapter/inbound/manager/chat/controller/ManagerChatController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/chat/controller/ManagerChatController.java index ccb4eac0..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 @@ -8,13 +8,18 @@ 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.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; 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 jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -40,6 +45,12 @@ public class ManagerChatController implements ManagerChatControllerSpec { @Resource(name = "managerGetChatRoom") private final ManagerGetChatRoomUseCase managerGetChatRoomUseCase; + @Resource(name = "markChatRoomRead") + private final MarkChatRoomReadUseCase markChatRoomRead; + + @Resource(name = "getWorkspaceGroupChatRoom") + private final GetWorkspaceGroupChatRoomUseCase getWorkspaceGroupChatRoom; + @Override @PostMapping("/rooms") public ResponseEntity> createOrGetChatRoom( @@ -77,4 +88,25 @@ 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, + @Valid @RequestBody MarkChatRoomReadRequestDto request + ) { + ManagerActor actor = ManagerActionContext.getInstance().getActor(); + 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 e7121f94..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 @@ -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,22 @@ 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 + ); + + @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/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/adapter/outbound/chat/persistence/ChatRoomQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomQueryRepositoryImpl.java index 3c6d5717..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 @@ -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,23 @@ 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) + ) + // 유니크 인덱스(V7)로 단일성이 보장되지만, 혹시 모를 중복 데이터에도 + // NonUniqueResultException을 던지지 않도록 fetchFirst 사용 + .fetchFirst(); + + return Optional.ofNullable(result); + } + private BooleanExpression buildChatRoomCondition( QChatRoom qChatRoom, Long participant1Id, 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..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 @@ -1,15 +1,16 @@ 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 @NoArgsConstructor -@AllArgsConstructor public class ChatMessageResponse { private Long id; private Long chatRoomId; @@ -17,4 +18,24 @@ public class ChatMessageResponse { private TokenScope senderScope; private String content; private LocalDateTime createdAt; + + // 조회/전송 시 후처리로 세팅되는 첨부(다른 필드는 생성자/프로젝션으로만 설정) + @Setter + 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/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..d4051af4 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImpl.java @@ -0,0 +1,41 @@ +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:sessions:"; + 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, String sessionId) { + String key = key(scope, memberId); + redisTemplate.opsForSet().add(key, sessionId); + redisTemplate.expire(key, TTL); + } + + @Override + public void markOffline(TokenScope scope, Long memberId, String sessionId) { + redisTemplate.opsForSet().remove(key(scope, memberId), sessionId); + } + + @Override + public boolean isOnline(TokenScope scope, Long memberId) { + Long size = redisTemplate.opsForSet().size(key(scope, memberId)); + return size != null && size > 0; + } +} 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/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/AbstractGetChatMessagesUseCase.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractGetChatMessagesUseCase.java index c82d228b..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 @@ -11,14 +11,26 @@ 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.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; 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) @@ -27,6 +39,9 @@ public abstract class AbstractGetChatMessagesUseCase extends AbstractChatUseC protected final ChatRoomQueryRepository chatRoomQueryRepository; 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, @@ -36,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())) { @@ -60,12 +84,23 @@ protected CursorPaginatedApiResponse execute( // 4. 클라이언트 표시 순서에 맞게 오름차순으로 변환 (가장 오래된 메시지가 첫 번째) List sortedMessages = messages.reversed(); - // 5. DTO 변환 (본인 메시지 여부 포함) + // 5. 방의 활성 멤버 목록을 한 번만 로드 (메시지별 안 읽은 사람 수 계산용, N+1 방지) + List activeMembers = chatRoomMemberQueryRepository.findActiveByRoom(chatRoomId); + + // 5-1. 페이지 내 메시지들의 첨부 파일을 한 번에 조회하여 매핑 (N+1 방지) + attachAttachments(sortedMessages); + + // 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 +114,42 @@ protected CursorPaginatedApiResponse execute( protected abstract TokenScope getParticipantScope(A actor); 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() + .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/AbstractSendChatMessageUseCase.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/AbstractSendChatMessageUseCase.java index 21ccb529..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 @@ -8,10 +8,20 @@ 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; 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; @@ -21,6 +31,8 @@ import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @RequiredArgsConstructor @Transactional @@ -32,32 +44,80 @@ public abstract class AbstractSendChatMessageUseCase extends AbstractChatUseC protected final UserQueryRepository userQueryRepository; protected final NotificationService notificationService; 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); 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); + } + + // 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(), senderId, senderScope, + type, request.getContent() ); ChatMessage savedMessage = chatMessageRepository.save(chatMessage); - // 3. ChatRoom의 updatedAt 갱신 + // 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(); - // 4. WebSocket으로 실시간 전송 + // 6. WebSocket으로 실시간 전송 try { ChatMessageResponse messageResponse = new ChatMessageResponse( savedMessage.getId(), @@ -67,16 +127,21 @@ 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); } - // 5. 상대방에게 FCM 알림 전송 - sendFcmNotification(chatRoom, senderId, senderScope, request.getContent()); + // 7. FCM 알림 전송 (온라인 멤버는 제외한 presence 기반 폴백) + if (chatRoom.getType() == ChatRoomType.DIRECT) { + 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, @@ -85,12 +150,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; } // 발신자 이름 조회 @@ -98,11 +171,7 @@ private void sendFcmNotification( // 알림 메시지 생성 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); @@ -113,12 +182,51 @@ 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 = buildNotificationBody(senderName, 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) .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/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 im public GetChatMessages( ChatRoomQueryRepository chatRoomQueryRepository, ChatMessageQueryRepository chatMessageQueryRepository, - ObjectMapper objectMapper + ObjectMapper objectMapper, + ChatRoomMemberQueryRepository chatRoomMemberQueryRepository, + FileQueryRepository fileQueryRepository, + FileUrlService fileUrlService ) { - super(chatRoomQueryRepository, chatMessageQueryRepository, objectMapper); + super( + chatRoomQueryRepository, + chatMessageQueryRepository, + objectMapper, + chatRoomMemberQueryRepository, + fileQueryRepository, + fileUrlService + ); } @Override 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/application/chat/usecase/ManagerCreateOrGetChatRoom.java b/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerCreateOrGetChatRoom.java index 3609bf4f..32784dbd 100644 --- a/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerCreateOrGetChatRoom.java +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/ManagerCreateOrGetChatRoom.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.ManagerCreateOrGetChatRoomUseCase; +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.ManagerActor; @@ -14,9 +15,10 @@ public class ManagerCreateOrGetChatRoom extends AbstractCreateOrGetChatRoomUseCa public ManagerCreateOrGetChatRoom( ChatRoomQueryRepository chatRoomQueryRepository, - ChatRoomRepository chatRoomRepository + ChatRoomRepository chatRoomRepository, + ChatRoomMemberRepository chatRoomMemberRepository ) { - super(chatRoomQueryRepository, chatRoomRepository); + super(chatRoomQueryRepository, chatRoomRepository, chatRoomMemberRepository); } @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..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,10 +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; @@ -18,9 +21,19 @@ public class ManagerGetChatMessages extends AbstractGetChatMessagesUseCase new CustomException(ErrorCode.NOT_FOUND, "채팅방 멤버가 아닙니다.")); + member.updateLastRead(lastReadMessageId); + chatRoomMemberRepository.save(member); + } +} 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..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 @@ -3,9 +3,14 @@ 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; 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; @@ -20,11 +25,17 @@ public SendChatMessage( ChatMessageRepository chatMessageRepository, UserQueryRepository userQueryRepository, NotificationService notificationService, - SimpMessagingTemplate messagingTemplate + SimpMessagingTemplate messagingTemplate, + ChatRoomMemberQueryRepository chatRoomMemberQueryRepository, + ChatPresenceStore chatPresenceStore, + AttachFilesUseCase attachFilesUseCase, + FileQueryRepository fileQueryRepository, + FileUrlService fileUrlService ) { super( chatRoomQueryRepository, chatRoomRepository, chatMessageRepository, userQueryRepository, - notificationService, messagingTemplate + notificationService, messagingTemplate, chatRoomMemberQueryRepository, chatPresenceStore, + attachFilesUseCase, fileQueryRepository, fileUrlService ); } 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..bf144aba --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembership.java @@ -0,0 +1,59 @@ +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.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) { + // 레거시 업장 등 단톡방이 아직 없으면 여기서 생성 (self-healing, 절대 throw하지 않음) + Long roomId = createGroupRoom(workspaceId); + Optional existing = + chatRoomMemberQueryRepository.findByRoomAndMember(roomId, memberId, scope); + if (existing.isPresent()) { + ChatRoomMember member = existing.get(); + if (!member.isActive()) { + member.rejoin(); + chatRoomMemberRepository.save(member); + } + return; + } + chatRoomMemberRepository.save(ChatRoomMember.create(roomId, memberId, scope)); + } + + @Override + public void leave(Long workspaceId, Long memberId, TokenScope scope) { + // 단톡방이 없으면(레거시 업장 등) 정리할 멤버십도 없으므로 조용히 무시 (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/main/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspace.java b/src/main/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspace.java index e3c23f6e..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,7 +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.user.entity.User; import com.dreamteam.alter.domain.workspace.entity.Workspace; import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; @@ -9,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; @@ -19,6 +22,7 @@ public class AddWorkerToWorkspace implements CreateWorkspaceWorkerUseCase { private final WorkspaceWorkerRepository workspaceWorkerRepository; private final WorkspaceWorkerQueryRepository workspaceWorkerQueryRepository; + private final ApplicationEventPublisher eventPublisher; @Override public void execute(Workspace workspace, User user) { @@ -28,6 +32,8 @@ public void execute(Workspace workspace, User user) { } workspaceWorkerRepository.save(WorkspaceWorker.create(workspace, user)); + + 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 e93f173c..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,12 +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.file.entity.File; import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; import com.dreamteam.alter.domain.file.type.FileTargetType; @@ -49,6 +52,7 @@ public class UpdateWorkspaceRequestStatus implements UpdateWorkspaceRequestStatu private final WorkspaceRequestImageQueryRepository workspaceRequestImageQueryRepository; private final FileQueryRepository fileQueryRepository; private final FileDeleteService fileDeleteService; + private final ApplicationEventPublisher eventPublisher; @Override public void execute(Long workspaceRequestId, WorkspaceRequestStatus status) { @@ -90,6 +94,9 @@ private void approve(WorkspaceRequest workspaceRequest, Long workspaceRequestId) workspaceRepository.save(workspace); + eventPublisher.publishEvent( + new ChatMembershipJoinedEvent(workspace.getId(), managerUser.getUser().getId(), TokenScope.MANAGER)); + attachRepresentativeImages(workspaceRequestId, workspace); Optional file = fileQueryRepository.findByTargetTypeAndTargetId( 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..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,5 +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.workspace.entity.SubstituteRequest; import com.dreamteam.alter.domain.workspace.entity.WorkspaceShift; import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; @@ -9,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; @@ -23,6 +26,7 @@ public class WorkerResignationServiceImpl implements WorkerResignationService { private final WorkspaceShiftQueryRepository workspaceShiftQueryRepository; private final WorkspaceWorkerScheduleQueryRepository workspaceWorkerScheduleQueryRepository; private final SubstituteRequestQueryRepository substituteRequestQueryRepository; + private final ApplicationEventPublisher eventPublisher; @Override public void resign(WorkspaceWorker worker) { @@ -45,5 +49,8 @@ public void resign(WorkspaceWorker worker) { ); worker.resign(); + + eventPublisher.publishEvent( + new ChatMembershipLeftEvent(worker.getWorkspace().getId(), worker.getUser().getId(), TokenScope.APP)); } } 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/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..ef6d71c2 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/common/config/WebSocketEventListener.java @@ -0,0 +1,53 @@ +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.messaging.simp.stomp.StompHeaderAccessor; +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) { + String sessionId = StompHeaderAccessor.wrap(event.getMessage()).getSessionId(); + if (sessionId == null) { + return; + } + resolve(event.getUser()) + .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(), sessionId)); + } + + 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/main/java/com/dreamteam/alter/common/exception/ErrorCode.java b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java index 442b9c8d..30fcd2cf 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,9 @@ public enum ErrorCode { INVALID_FILE_TYPE(400, "B023", "허용되지 않는 파일 형식입니다."), 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", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), 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/main/java/com/dreamteam/alter/domain/chat/entity/ChatMessage.java b/src/main/java/com/dreamteam/alter/domain/chat/entity/ChatMessage.java index 58a8069f..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 @@ -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; @@ -32,9 +33,13 @@ 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) + @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..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 @@ -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,19 @@ public static ChatRoom create( .participant1Scope(participant1Scope) .participant2Id(participant2Id) .participant2Scope(participant2Scope) + .type(ChatRoomType.DIRECT) + .build(); + } + + public static ChatRoom createGroup(Long workspaceId) { + // GROUP 방은 반드시 업장과 연관되어야 한다 (도메인 불변식) + if (workspaceId == null) { + throw new IllegalArgumentException("GROUP 채팅방은 workspaceId가 필수입니다."); + } + return ChatRoom.builder() + .type(ChatRoomType.GROUP) + .workspaceId(workspaceId) + // participant 컬럼은 DIRECT 전용: 그룹은 멤버 테이블로 관리하므로 사용 안 함 .build(); } 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/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/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/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/ChatPresenceStore.java b/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatPresenceStore.java new file mode 100644 index 00000000..357e0fe0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/chat/port/outbound/ChatPresenceStore.java @@ -0,0 +1,12 @@ +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, String sessionId); + + void markOffline(TokenScope scope, Long memberId, String sessionId); + + boolean isOnline(TokenScope scope, Long memberId); +} 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/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/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 +} 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/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; 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; 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' + ); 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'; diff --git a/src/test/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImplTests.java b/src/test/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImplTests.java new file mode 100644 index 00000000..b323fd1d --- /dev/null +++ b/src/test/java/com/dreamteam/alter/adapter/outbound/chat/persistence/ChatRoomMemberRepositoryImplTests.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(); + } +} diff --git a/src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTests.java b/src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTests.java new file mode 100644 index 00000000..c5814fc5 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/adapter/outbound/chat/redis/ChatPresenceStoreRepositoryImplTests.java @@ -0,0 +1,110 @@ +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.SetOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +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:sessions:APP:1"; + + @Test + @DisplayName("markOnline 시 세션 id를 SET에 추가하고 TTL 60초로 갱신한다") + void markOnline_세션추가() { + // given + @SuppressWarnings("unchecked") + SetOperations setOperations = mock(SetOperations.class); + given(redisTemplate.opsForSet()).willReturn(setOperations); + + // when + chatPresenceStoreRepository.markOnline(TokenScope.APP, 1L, "sess1"); + + // then + then(setOperations).should().add(KEY, "sess1"); + then(redisTemplate).should().expire(KEY, Duration.ofSeconds(60)); + } + + @Test + @DisplayName("markOffline 시 세션 id만 SET에서 제거한다") + void markOffline_세션제거() { + // given + @SuppressWarnings("unchecked") + SetOperations setOperations = mock(SetOperations.class); + given(redisTemplate.opsForSet()).willReturn(setOperations); + + // when + chatPresenceStoreRepository.markOffline(TokenScope.APP, 1L, "sess1"); + + // then + then(setOperations).should().remove(KEY, "sess1"); + } + + @Test + @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 + boolean result = chatPresenceStoreRepository.isOnline(TokenScope.APP, 1L); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("isOnline은 SET 크기가 0이면 false") + void isOnline_false_크기0() { + // given + @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).isFalse(); + } + + @Test + @DisplayName("isOnline은 SET이 없어(size=null) 오프라인이면 false") + void isOnline_false_null() { + // given + @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); + + // then + assertThat(result).isFalse(); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListenerTests.java b/src/test/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListenerTests.java new file mode 100644 index 00000000..d365bf76 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/event/ChatMembershipSyncEventListenerTests.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/chat/usecase/CreateOrGetChatRoomTests.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/CreateOrGetChatRoomTests.java new file mode 100644 index 00000000..0c0309fb --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/CreateOrGetChatRoomTests.java @@ -0,0 +1,101 @@ +package com.dreamteam.alter.application.chat.usecase; + +import com.dreamteam.alter.adapter.inbound.general.chat.dto.CreateChatRoomResponseDto; +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 com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +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.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CreateOrGetChatRoom 테스트") +class CreateOrGetChatRoomTest { + + @Mock + private ChatRoomQueryRepository chatRoomQueryRepository; + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private ChatRoomMemberRepository chatRoomMemberRepository; + + @InjectMocks + private CreateOrGetChatRoom sut; + + @Test + @DisplayName("기존 채팅방이 없으면 새로 생성하고 참여자 2명의 chat_room_members 행을 생성한다") + void execute_신규방생성_멤버행2개생성() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + AppActor actor = AppActor.from(user, List.of()); + + given(chatRoomQueryRepository.findExistingChatRoom(1L, TokenScope.APP, 2L, TokenScope.APP)) + .willReturn(Optional.empty()); + + ChatRoom savedRoom = mock(ChatRoom.class); + given(savedRoom.getId()).willReturn(99L); + given(chatRoomRepository.save(any(ChatRoom.class))).willReturn(savedRoom); + + // when + CreateChatRoomResponseDto response = sut.execute(actor, 2L, TokenScope.APP); + + // then + assertThat(response.getChatRoomId()).isEqualTo(99L); + + ArgumentCaptor> 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()); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTests.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTests.java new file mode 100644 index 00000000..f140da57 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/GetChatMessagesTests.java @@ -0,0 +1,240 @@ +package com.dreamteam.alter.application.chat.usecase; + +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.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; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +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.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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GetChatMessages 테스트") +class GetChatMessagesTest { + + @Mock + private ChatRoomQueryRepository chatRoomQueryRepository; + + @Mock + private ChatMessageQueryRepository chatMessageQueryRepository; + + @Mock + private ChatRoomMemberQueryRepository chatRoomMemberQueryRepository; + + @Mock + private FileQueryRepository fileQueryRepository; + + @Mock + private FileUrlService fileUrlService; + + private GetChatMessages sut; + + @BeforeEach + void setUp() { + sut = new GetChatMessages( + chatRoomQueryRepository, + chatMessageQueryRepository, + new ObjectMapper().registerModule(new JavaTimeModule()), + chatRoomMemberQueryRepository, + fileQueryRepository, + fileUrlService + ); + } + + @Test + @DisplayName("메시지별 안 읽은 사람 수를 계산한다 (읽은 사람/발신자 본인 제외)") + void execute_메시지별_unreadCount_계산() { + // given + Long chatRoomId = 1L; + 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(directRoom)); + + ChatMessageResponse message = new ChatMessageResponse( + 8L, chatRoomId, senderId, TokenScope.APP, "hello", LocalDateTime.now() + ); + given(chatMessageQueryRepository.getChatMessagesWithCursor(any(), any())) + .willReturn(List.of(message)); + + ChatRoomMember memberA = ChatRoomMember.create(chatRoomId, 200L, TokenScope.APP); // 발신자 아님 + memberA.updateLastRead(10L); // 읽음 (10 >= 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); + } + + @Test + @DisplayName("메시지 목록 조회 시 첨부 파일을 일괄 조회하여 각 메시지에 매핑한다 (N+1 방지)") + void execute_첨부파일_일괄조회_매핑() { + // given + Long chatRoomId = 1L; + 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(directRoom)); + + 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()); + } + + @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()); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoomTests.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoomTests.java new file mode 100644 index 00000000..9ae7a779 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/GetWorkspaceGroupChatRoomTests.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); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTests.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTests.java new file mode 100644 index 00000000..04b67e1e --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/ManagerSendChatMessageTests.java @@ -0,0 +1,298 @@ +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; +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; +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; +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.List; +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("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; + + @Mock + private ChatPresenceStore chatPresenceStore; + + @Mock + private AttachFilesUseCase attachFilesUseCase; + + @Mock + private FileQueryRepository fileQueryRepository; + + @Mock + private FileUrlService fileUrlService; + + @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); + given(chatRoomMemberQueryRepository.findActiveByRoom(5L)).willReturn(List.of()); + + 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 방에서 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() { + // 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()); + } + + @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/MarkChatRoomReadTests.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomReadTests.java new file mode 100644 index 00000000..a36c803f --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/MarkChatRoomReadTests.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); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTests.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTests.java new file mode 100644 index 00000000..14a58ad1 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/SendChatMessageTests.java @@ -0,0 +1,489 @@ +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; +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; +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; + +import java.util.List; +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; + + @Mock + private ChatPresenceStore chatPresenceStore; + + @Mock + private AttachFilesUseCase attachFilesUseCase; + + @Mock + private FileQueryRepository fileQueryRepository; + + @Mock + private FileUrlService fileUrlService; + + @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(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(userQueryRepository.findById(1L)).willReturn(Optional.empty()); + given(chatPresenceStore.isOnline(TokenScope.APP, 2L)).willReturn(false); + + // 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("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() { + // 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 방에 발신자 외 멤버가 없으면 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); + 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); + + // 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); + } + + @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()); + } + + @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("이미지만 있는 메시지 전송시 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_포함() { + // 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); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTests.java b/src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTests.java new file mode 100644 index 00000000..7b84f598 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/chat/usecase/SyncWorkspaceChatMembershipTests.java @@ -0,0 +1,133 @@ +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.assertj.core.api.Assertions.assertThatCode; +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); + } + + @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()); + } + } +} 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 new file mode 100644 index 00000000..90366b1d --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/AddWorkerToWorkspaceTests.java @@ -0,0 +1,90 @@ +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.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.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.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AddWorkerToWorkspace 테스트") +class AddWorkerToWorkspaceTest { + + @Mock + private WorkspaceWorkerRepository workspaceWorkerRepository; + + @Mock + private WorkspaceWorkerQueryRepository workspaceWorkerQueryRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private AddWorkerToWorkspace addWorkerToWorkspace; + + @Captor + private ArgumentCaptor eventCaptor; + + @Test + @DisplayName("신규 근무자 추가 시 업장 단톡 join 이벤트를 발행한다") + void execute_신규추가_단톡join이벤트발행() { + // given + Workspace workspace = mock(Workspace.class); + given(workspace.getId()).willReturn(1L); + User user = mock(User.class); + given(user.getId()).willReturn(2L); + + given(workspaceWorkerQueryRepository.findActiveWorkerByWorkspaceAndUser(workspace, user)) + .willReturn(Optional.empty()); + + // when + addWorkerToWorkspace.execute(workspace, user); + + // then + 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이벤트미발행() { + // given + Workspace workspace = mock(Workspace.class); + User user = mock(User.class); + WorkspaceWorker existingWorker = mock(WorkspaceWorker.class); + + given(workspaceWorkerQueryRepository.findActiveWorkerByWorkspaceAndUser(workspace, user)) + .willReturn(Optional.of(existingWorker)); + + // when & then + assertThatThrownBy(() -> addWorkerToWorkspace.execute(workspace, user)) + .isInstanceOf(CustomException.class); + + 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 0f3b294a..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,8 +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.file.entity.File; import com.dreamteam.alter.domain.file.port.outbound.FileQueryRepository; import com.dreamteam.alter.domain.file.type.FileTargetType; @@ -28,6 +30,7 @@ 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; @@ -69,12 +72,18 @@ class UpdateWorkspaceRequestStatusTests { @Mock private FileDeleteService fileDeleteService; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private UpdateWorkspaceRequestStatus updateWorkspaceRequestStatus; @Captor private ArgumentCaptor> imagesCaptor; + @Captor + private ArgumentCaptor eventCaptor; + @Nested @DisplayName("execute") class ExecuteTests { @@ -118,9 +127,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()); @@ -143,10 +155,13 @@ 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(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); given(workspaceRequestImageQueryRepository.findAllByWorkspaceRequestId(1L)).willReturn(List.of()); given(fileQueryRepository.findByTargetTypeAndTargetId(FileTargetType.WORKSPACE_OWN_IDENTITY, "1")) .willReturn(Optional.empty()); @@ -161,6 +176,36 @@ class ExecuteTests { then(fileDeleteService).should(never()).delete(any()); } + @Test + @DisplayName("ACTIVATED 면 업장 저장 후 채팅 멤버십 join 이벤트를 발행한다") + void execute_ACTIVATED_채팅멤버십join이벤트발행() { + // given + 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.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()); + given(fileQueryRepository.findByTargetTypeAndTargetId(FileTargetType.WORKSPACE_OWN_IDENTITY, "1")) + .willReturn(Optional.empty()); + + // when + updateWorkspaceRequestStatus.execute(1L, WorkspaceRequestStatus.ACTIVATED); + + // then + then(workspaceRepository).should().save(any(Workspace.class)); + then(eventPublisher).should().publishEvent(eventCaptor.capture()); + ChatMembershipJoinedEvent event = eventCaptor.getValue(); + // 참여자 id는 ManagerUser 엔티티 id가 아닌, 채팅 도메인 기준 식별자인 User(계정) id 여야 한다 + assertThat(event.getMemberId()).isEqualTo(42L); + assertThat(event.getScope()).isEqualTo(TokenScope.MANAGER); + } + @Test @DisplayName("REVOKED 면 reject 만 처리하고 업장/매니저는 생성하지 않는다") void execute_REVOKED_반려() { @@ -176,6 +221,7 @@ class ExecuteTests { then(workspaceRepository).should(never()).save(any()); then(managerUserRepository).should(never()).save(any()); then(fileDeleteService).should(never()).delete(any()); + then(eventPublisher).should(never()).publishEvent(any()); } @Test @@ -252,7 +298,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; } 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 deleted file mode 100644 index 83f82224..00000000 --- a/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.dreamteam.alter.application.workspace.usecase; - -import com.dreamteam.alter.domain.workspace.entity.SubstituteRequest; -import com.dreamteam.alter.domain.workspace.entity.WorkspaceShift; -import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorker; -import com.dreamteam.alter.domain.workspace.entity.WorkspaceWorkerSchedule; -import com.dreamteam.alter.domain.workspace.port.outbound.SubstituteRequestQueryRepository; -import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceShiftQueryRepository; -import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceWorkerScheduleQueryRepository; -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.time.LocalDateTime; -import java.util.List; - -import static org.mockito.ArgumentMatchers.any; -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 테스트") -class WorkerResignationServiceImplTest { - - @Mock - private WorkspaceShiftQueryRepository workspaceShiftQueryRepository; - - @Mock - private WorkspaceWorkerScheduleQueryRepository workspaceWorkerScheduleQueryRepository; - - @Mock - private SubstituteRequestQueryRepository substituteRequestQueryRepository; - - @InjectMocks - private WorkerResignationServiceImpl workerResignationService; - - @Test - @DisplayName("퇴직 정리 대상을 일괄 정리하고 worker를 퇴직 처리한다") - void resign_정리대상존재_일괄정리() { - // given - WorkspaceWorker worker = mock(WorkspaceWorker.class); - when(worker.getId()).thenReturn(10L); - - WorkspaceShift futureShift = mock(WorkspaceShift.class); - when(workspaceShiftQueryRepository.findFutureShiftsByAssignedWorker(any(), any(LocalDateTime.class))) - .thenReturn(List.of(futureShift)); - - WorkspaceWorkerSchedule fixedSchedule = mock(WorkspaceWorkerSchedule.class); - when(workspaceWorkerScheduleQueryRepository.getByWorkspaceWorker(worker)) - .thenReturn(List.of(fixedSchedule)); - - SubstituteRequest requesterRequest = mock(SubstituteRequest.class); - when(substituteRequestQueryRepository.findAllActiveByRequesterWorkerId(10L)) - .thenReturn(List.of(requesterRequest)); - - SubstituteRequest targetRequest = mock(SubstituteRequest.class); - when(substituteRequestQueryRepository.findAllPendingTargetRequestsByTargetWorkerId(10L)) - .thenReturn(List.of(targetRequest)); - - // when - workerResignationService.resign(worker); - - // then - verify(futureShift, times(1)).unassignWorker(); - verify(fixedSchedule, times(1)).delete(); - verify(requesterRequest, times(1)).cancel(); - verify(targetRequest, times(1)).cancelPendingTargetAndCancelIfNoPendingTargets(10L); - verify(worker, times(1)).resign(); - } - - @Test - @DisplayName("정리 대상이 없어도 worker를 퇴직 처리한다") - void resign_정리대상없음_퇴직처리() { - // given - WorkspaceWorker worker = mock(WorkspaceWorker.class); - when(worker.getId()).thenReturn(20L); - 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()); - - // when - workerResignationService.resign(worker); - - // then - verify(worker, times(1)).resign(); - } -} 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 new file mode 100644 index 00000000..9304ffa1 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/workspace/usecase/WorkerResignationServiceImplTests.java @@ -0,0 +1,130 @@ +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.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; +import com.dreamteam.alter.domain.workspace.port.outbound.SubstituteRequestQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceShiftQueryRepository; +import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceWorkerScheduleQueryRepository; +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.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WorkerResignationServiceImpl 테스트") +class WorkerResignationServiceImplTest { + + @Mock + private WorkspaceShiftQueryRepository workspaceShiftQueryRepository; + + @Mock + private WorkspaceWorkerScheduleQueryRepository workspaceWorkerScheduleQueryRepository; + + @Mock + private SubstituteRequestQueryRepository substituteRequestQueryRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private WorkerResignationServiceImpl workerResignationService; + + @Captor + private ArgumentCaptor eventCaptor; + + @Test + @DisplayName("퇴직 정리 대상을 일괄 정리하고 worker를 퇴직 처리한다") + void resign_정리대상존재_일괄정리() { + // given + WorkspaceWorker worker = mock(WorkspaceWorker.class); + given(worker.getId()).willReturn(10L); + Workspace workspace = mock(Workspace.class); + given(workspace.getId()).willReturn(100L); + User user = mock(User.class); + given(user.getId()).willReturn(200L); + given(worker.getWorkspace()).willReturn(workspace); + given(worker.getUser()).willReturn(user); + + WorkspaceShift futureShift = mock(WorkspaceShift.class); + given(workspaceShiftQueryRepository.findFutureShiftsByAssignedWorker(any(), any(LocalDateTime.class))) + .willReturn(List.of(futureShift)); + + WorkspaceWorkerSchedule fixedSchedule = mock(WorkspaceWorkerSchedule.class); + given(workspaceWorkerScheduleQueryRepository.getByWorkspaceWorker(worker)) + .willReturn(List.of(fixedSchedule)); + + SubstituteRequest requesterRequest = mock(SubstituteRequest.class); + given(substituteRequestQueryRepository.findAllActiveByRequesterWorkerId(10L)) + .willReturn(List.of(requesterRequest)); + + SubstituteRequest targetRequest = mock(SubstituteRequest.class); + given(substituteRequestQueryRepository.findAllPendingTargetRequestsByTargetWorkerId(10L)) + .willReturn(List.of(targetRequest)); + + // when + workerResignationService.resign(worker); + + // then + verify(futureShift, times(1)).unassignWorker(); + verify(fixedSchedule, times(1)).delete(); + verify(requesterRequest, times(1)).cancel(); + verify(targetRequest, times(1)).cancelPendingTargetAndCancelIfNoPendingTargets(10L); + verify(worker, times(1)).resign(); + 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 + @DisplayName("정리 대상이 없어도 worker를 퇴직 처리한다") + void resign_정리대상없음_퇴직처리() { + // given + WorkspaceWorker worker = mock(WorkspaceWorker.class); + given(worker.getId()).willReturn(20L); + Workspace workspace = mock(Workspace.class); + given(workspace.getId()).willReturn(300L); + User user = mock(User.class); + 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); + + // then + verify(worker, times(1)).resign(); + 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); + } +} diff --git a/src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTests.java b/src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTests.java new file mode 100644 index 00000000..dbe7436c --- /dev/null +++ b/src/test/java/com/dreamteam/alter/common/config/WebSocketEventListenerTests.java @@ -0,0 +1,167 @@ +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.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; +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.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; + + 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); + lenient().when(dto.getScope()).thenReturn(scope); + lenient().when(dto.getId()).thenReturn(memberId); + return new AccessTokenAuthentication("token", dto, Collections.emptyList()); + } + + @Test + @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, "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(), any()); + } + + @Test + @DisplayName("연결 시 LoginUserDto가 아닌 principal이면 markOnline 호출 안함") + 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(), any()); + } + + @Test + @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, "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(), 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"); + } +} diff --git a/src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTests.java b/src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTests.java new file mode 100644 index 00000000..ea7465d7 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/domain/chat/entity/ChatRoomTests.java @@ -0,0 +1,24 @@ +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; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ChatRoomTest { + + @Test + void createGroup_업장_그룹방_생성() { + ChatRoom room = ChatRoom.createGroup(100L); + + assertThat(room.getType()).isEqualTo(ChatRoomType.GROUP); + assertThat(room.getWorkspaceId()).isEqualTo(100L); + } + + @Test + void createGroup_workspaceId_null이면_예외() { + assertThatThrownBy(() -> ChatRoom.createGroup(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} 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