diff --git a/.coderabbit.yml b/.coderabbit.yml new file mode 100644 index 00000000..2f7e97c4 --- /dev/null +++ b/.coderabbit.yml @@ -0,0 +1,80 @@ +language: "ko-KR" + +tone_instructions: | + 존댓말을 사용해주세요. 코드 리뷰는 친절하고 건설적인 어조로 작성해주세요. + 문제점을 지적할 때는 이유와 개선 방향을 함께 제시해주세요. + +reviews: + auto_review: + enabled: true + drafts: false + profile: "assertive" + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + collapse_walkthrough: false + path_filters: + - "!**/Q*.java" + - "!**/*MapperImpl.java" + - "!**/generated/**" + - "!**/.claude/**" + - "!**/build/**" + - "!**/*.md" + + path_instructions: + - path: "BE/src/main/java/**/ui/**" + instructions: | + - Controller 네이밍: `{동사}{도메인}Controller` (예: `CreateRoomController`) + - Controller에 `@Transactional` 금지. 트랜잭션은 UseCase 계층에서 처리. + - 응답은 반드시 `BaseResponse.onSuccess(data)` 또는 `BaseResponse.onSuccess()` 사용. + - 인증이 필요한 엔드포인트는 `@CurrentUser` 파라미터 존재 여부 확인. + - 입력값 검증에 `@Valid` 적용 여부 확인. + + - path: "BE/src/main/java/**/application/usecase/**" + instructions: | + - UseCase 네이밍: `{동사}{도메인}UseCase` (예: `CreateRoomUseCase`) + - 메서드명은 `execute()` 고정. 단, 이벤트 리스너 역할을 겸하는 경우 `handle()` 허용. + - 트랜잭션은 UseCase 단위에서 `@Transactional` 적용. + - Cross-domain 의존 시 이벤트 발행 방식 사용. 직접 도메인 서비스 호출 지양. + - `@TransactionalEventListener(phase = BEFORE_COMMIT)` 사용 시 부모 트랜잭션과 원자성 보장 여부 확인. + + - path: "BE/src/main/java/**/domain/**" + instructions: | + - Service 네이밍: `{도메인}Service` (예: `RoomService`) + - 도메인 레이어는 다른 도메인에 직접 의존하면 안 됨. 이벤트/인터페이스로 분리. + - 엔티티 상태 변경은 도메인 메서드로 캡슐화. setter 직접 호출 지양. + + - path: "BE/src/main/java/**/application/dto/**" + instructions: | + - DTO는 `record` 타입 사용. + - 응답 DTO 이름: `{도메인}{동작}Response` (예: `RoomDetailResponse`) + - 요청 DTO 이름: `{동작}{도메인}Request` (예: `CreateRoomRequest`) + + - path: "BE/src/main/java/**/infra/**" + instructions: | + - infra 레이어는 domain 인터페이스 구현체. + - domain 레이어의 Repository 인터페이스를 구현할 때는 `Impl` 접미사 사용. + - QueryDSL 사용 시 Q클래스는 자동생성이므로 직접 편집 금지. + + - path: "BE/src/main/java/**/global/exception/**" + instructions: | + - 에러 발생 시 `throw new RestApiException({Domain}ErrorStatus.XXX)` 형식만 사용. + - `RuntimeException` 직접 throw 금지. + - ErrorStatus enum은 해당 도메인 패키지의 `code/status/` 하위에 위치. + + - path: "BE/src/main/java/**/global/alert/**" + instructions: | + - Discord 알림은 `SystemAlertPublisher.publish(...)` 통해서만 발행. + - CRITICAL: 즉각 대응 필요한 서비스 장애. ERROR: 기능 실패. WARN: 주의 필요. INFO: 참고 정보. + + - path: "BE/src/test/**" + instructions: | + - 테스트는 성공/실패/경계값 케이스 모두 작성. + - UseCase 단위 테스트: `src/test/java/.../unit/usecase/` + - Service 단위 테스트: `src/test/java/.../unit/service/` + - `@Transactional` 롤백 사용 시 의도 명시. + - Mock 남용 지양. 도메인 로직 테스트는 실제 객체 사용 권장. + +chat: + auto_reply: true diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 70fdf152..620ddb46 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -33,6 +33,11 @@ jobs: - name: Setup Gradle cache uses: gradle/actions/setup-gradle@v4 + - name: Ensure Docker is available (Testcontainers) + run: | + echo ">>> [CI] Checking Docker daemon..." + docker info + - name: Run tests run: | echo ">>> [CI] Running tests..." diff --git a/.gitignore b/.gitignore index 7f75572c..71cc691d 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,8 @@ pinpoint-agent/tools/ .claude ### env ### -.env \ No newline at end of file +.env +/analysis/ + +# macOS artefacts +.DS_Store diff --git a/src/main/java/com/project/dorumdorum/domain/calendar/application/usecase/LoadCalendarEventsUseCase.java b/src/main/java/com/project/dorumdorum/domain/calendar/application/usecase/LoadCalendarEventsUseCase.java index 298fa7a6..0f345027 100644 --- a/src/main/java/com/project/dorumdorum/domain/calendar/application/usecase/LoadCalendarEventsUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/calendar/application/usecase/LoadCalendarEventsUseCase.java @@ -19,6 +19,11 @@ public class LoadCalendarEventsUseCase { private final CalendarEventService calendarEventService; private final CalendarEventMapper calendarEventMapper; + /** + * 기간 내 캘린더 일정 조회 + * - 시작일~종료일 범위의 일정을 조회 + * - 응답 DTO 목록으로 변환해 반환 + */ public List execute(LocalDate startDate, LocalDate endDate) { List events = calendarEventService.loadBetween(startDate, endDate); return calendarEventMapper.toResponseList(events); diff --git a/src/main/java/com/project/dorumdorum/domain/chat/application/dto/response/ChatMessageResponse.java b/src/main/java/com/project/dorumdorum/domain/chat/application/dto/response/ChatMessageResponse.java index 9eb5976e..4531ecb7 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/application/dto/response/ChatMessageResponse.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/application/dto/response/ChatMessageResponse.java @@ -9,5 +9,6 @@ public record ChatMessageResponse( String senderNickname, String content, String messageType, - LocalDateTime sentAt + LocalDateTime sentAt, + int unreadCount ) {} diff --git a/src/main/java/com/project/dorumdorum/domain/chat/application/dto/response/ChatReadReceiptResponse.java b/src/main/java/com/project/dorumdorum/domain/chat/application/dto/response/ChatReadReceiptResponse.java new file mode 100644 index 00000000..343c9112 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/chat/application/dto/response/ChatReadReceiptResponse.java @@ -0,0 +1,9 @@ +package com.project.dorumdorum.domain.chat.application.dto.response; + +import java.time.LocalDateTime; + +public record ChatReadReceiptResponse( + String chatRoomNo, + String readerUserNo, + LocalDateTime readAt +) {} diff --git a/src/main/java/com/project/dorumdorum/domain/chat/application/event/RoommateKickedEventListener.java b/src/main/java/com/project/dorumdorum/domain/chat/application/event/RoommateKickedEventListener.java index c9374f8d..c92bab29 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/application/event/RoommateKickedEventListener.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/application/event/RoommateKickedEventListener.java @@ -3,6 +3,7 @@ import com.project.dorumdorum.domain.chat.application.dto.response.ChatMessageResponse; import com.project.dorumdorum.domain.chat.domain.entity.ChatMessage; import com.project.dorumdorum.domain.chat.domain.entity.ChatRoom; +import com.project.dorumdorum.domain.chat.domain.entity.ChatRoomMember; import com.project.dorumdorum.domain.chat.domain.entity.MessageType; import com.project.dorumdorum.domain.chat.domain.service.ChatMessageService; import com.project.dorumdorum.domain.chat.domain.service.ChatRoomMemberService; @@ -11,13 +12,15 @@ import com.project.dorumdorum.domain.user.domain.entity.User; import com.project.dorumdorum.domain.user.domain.service.UserService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import java.time.LocalDateTime; + +@Slf4j @Component @RequiredArgsConstructor public class RoommateKickedEventListener { @@ -31,15 +34,21 @@ public class RoommateKickedEventListener { /** * 룸메이트 강퇴(RoommateKickedEvent) → 채팅방에서 퇴장 처리 * 발행: KickRoommateUseCase (room 도메인 담당자) + * + * BEFORE_COMMIT: 부모 트랜잭션(KickRoommateUseCase)에 참여하여 방 강퇴 + 채팅방 퇴장을 하나의 트랜잭션으로 처리. + * 채팅방 퇴장 실패 시 방 강퇴도 롤백되어 데이터 정합성 보장. + * WebSocket 브로드캐스트는 트랜잭션 외부 작업이므로 실패가 TX에 영향을 주지 않도록 독립 처리. */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void handle(RoommateKickedEvent event) { chatRoomService.findByRoomNo(event.roomNo()).ifPresent(chatRoom -> { if (chatRoomMemberService.isMember(chatRoom, event.kickedUserNo())) { - chatRoomMemberService.leave( - chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, event.kickedUserNo()) - ); + ChatRoomMember member = chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, event.kickedUserNo()); + LocalDateTime fromTime = member.getLastReadAt() != null + ? member.getLastReadAt() + : member.getJoinedAt(); + chatMessageService.decreaseUnreadCount(chatRoom.getChatRoomNo(), fromTime, event.kickedUserNo()); + chatRoomMemberService.leave(member); User kicked = userService.findById(event.kickedUserNo()); String displayName = (kicked.getNickname() != null && !kicked.getNickname().isBlank()) ? kicked.getNickname() : kicked.getName(); @@ -47,9 +56,17 @@ public void handle(RoommateKickedEvent event) { ChatMessage message = chatMessageService.save(chatRoom, "SYSTEM", content, MessageType.SYSTEM, 0); ChatMessageResponse response = new ChatMessageResponse( message.getMessageNo(), chatRoom.getChatRoomNo(), - "SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt()); - messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoom.getChatRoomNo(), response); + "SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount()); + broadcastSafely(chatRoom, response); } }); } + + private void broadcastSafely(ChatRoom chatRoom, ChatMessageResponse response) { + try { + messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoom.getChatRoomNo(), response); + } catch (Exception e) { + log.warn("[Chat] WebSocket 브로드캐스트 실패. chatRoomNo={}", chatRoom.getChatRoomNo(), e); + } + } } diff --git a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/CreateChatRoomUseCase.java b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/CreateChatRoomUseCase.java index 57c1f2a9..ccfbb2c7 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/CreateChatRoomUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/CreateChatRoomUseCase.java @@ -5,12 +5,13 @@ import com.project.dorumdorum.domain.chat.domain.service.ChatRoomService; import com.project.dorumdorum.domain.room.application.event.RoomConfirmedEvent; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +@Slf4j @Service @RequiredArgsConstructor public class CreateChatRoomUseCase { @@ -22,15 +23,38 @@ public class CreateChatRoomUseCase { * 방 전원 확정 완료(RoomConfirmedEvent) 수신 → 채팅방 생성 및 전원 입장 * - 채팅방 없으면 생성 후 미입장 멤버 전원 입장 * - 채팅방 있으면 아직 미입장인 멤버만 추가 (RoommateAcceptedEvent로 일부 이미 입장했을 수 있음) + * + * BEFORE_COMMIT: 부모 트랜잭션(ConfirmRoomAssignmentUseCase)에 참여. + * 채팅방 생성 실패 시 방 확정도 롤백 → 원자성 보장. + * DataIntegrityViolationException: 동시 요청으로 인한 중복 생성/입장 시 재조회로 처리. */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void handle(RoomConfirmedEvent event) { - ChatRoom chatRoom = chatRoomService.findByRoomNo(event.roomNo()) - .orElseGet(() -> chatRoomService.create(event.roomNo())); + ChatRoom chatRoom = findOrCreate(event.roomNo()); event.allMemberUserNos().stream() .filter(userNo -> !chatRoomMemberService.isMember(chatRoom, userNo)) - .forEach(userNo -> chatRoomMemberService.join(chatRoom, userNo)); + .forEach(userNo -> joinSafely(chatRoom, userNo)); + } + + private ChatRoom findOrCreate(String roomNo) { + return chatRoomService.findByRoomNo(roomNo).orElseGet(() -> { + try { + return chatRoomService.create(roomNo); + } catch (DataIntegrityViolationException e) { + log.debug("[Chat] 채팅방 중복 생성 (동시 요청). roomNo={}", roomNo); + return chatRoomService.findByRoomNo(roomNo) + .orElseThrow(() -> e); + } + }); + } + + private void joinSafely(ChatRoom chatRoom, String userNo) { + try { + chatRoomMemberService.join(chatRoom, userNo); + } catch (DataIntegrityViolationException e) { + log.debug("[Chat] 채팅방 중복 입장 (동시 요청). chatRoomNo={}, userNo={}", + chatRoom.getChatRoomNo(), userNo); + } } } diff --git a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/GetChatRoomMembersUseCase.java b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/GetChatRoomMembersUseCase.java index acb81711..207100f5 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/GetChatRoomMembersUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/GetChatRoomMembersUseCase.java @@ -24,6 +24,12 @@ public class GetChatRoomMembersUseCase { private final RoommateService roommateService; private final UserService userService; + /** + * 채팅방 멤버 목록 조회 + * - 요청 사용자가 채팅방 멤버인지 검증 + * - 멤버별 닉네임과 방장 여부를 조회 + * - 방장이 먼저 보이도록 정렬해 반환 + */ @Transactional(readOnly = true) public List execute(String chatRoomNo, String requestingUserNo) { ChatRoom chatRoom = chatRoomService.findByChatRoomNo(chatRoomNo); diff --git a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/GetOrCreateDirectChatRoomUseCase.java b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/GetOrCreateDirectChatRoomUseCase.java index bf2ad127..7459e2fb 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/GetOrCreateDirectChatRoomUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/GetOrCreateDirectChatRoomUseCase.java @@ -23,9 +23,9 @@ public class GetOrCreateDirectChatRoomUseCase { /** * 지원자-방장 1:1 채팅방 조회 또는 생성 - * - 이미 존재하면 기존 채팅방 반환 - * - 없으면 생성 후 반환 - * - 호출자는 방장 또는 지원자여야 함 + * - 호출자가 방장 또는 지원자인지 검증 + * - 기존 채팅방이 있으면 그대로 반환 + * - 없으면 생성 후 양쪽 사용자를 입장시켜 반환 */ public String execute(String callerUserNo, String roomNo, String applicantUserNo) { Room room = roomService.findById(roomNo); @@ -46,7 +46,10 @@ public String execute(String callerUserNo, String roomNo, String applicantUserNo } /** - * 이벤트 리스너에서 사용 — 인증 없이 채팅방 생성 (방 지원 시 자동 생성) + * 지원자-방장 1:1 채팅방 선생성 + * - 이벤트 리스너에서 인증 없이 호출 + * - 기존 채팅방이 있으면 그대로 반환 + * - 없으면 생성 후 양쪽 사용자를 입장시켜 반환 */ public String createIfAbsent(String roomNo, String applicantUserNo, String hostUserNo) { return chatRoomService.findDirectChatRoom(roomNo, applicantUserNo) diff --git a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/JoinChatRoomUseCase.java b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/JoinChatRoomUseCase.java index 695e0bf3..a8126dd1 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/JoinChatRoomUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/JoinChatRoomUseCase.java @@ -11,13 +11,14 @@ import com.project.dorumdorum.domain.user.domain.entity.User; import com.project.dorumdorum.domain.user.domain.service.UserService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +@Slf4j @Service @RequiredArgsConstructor public class JoinChatRoomUseCase { @@ -33,19 +34,33 @@ public class JoinChatRoomUseCase { * - 채팅방 없으면 생성 후 방장 + 신규 멤버 입장 * - 채팅방 있으면 신규 멤버만 입장 (중복 방지) * - 입장 시스템 메시지 저장 + * + * BEFORE_COMMIT: 부모 트랜잭션(DecideApplicationRequestUseCase.approve)에 참여. + * 채팅방 입장 실패 시 룸메이트 승인도 롤백 → 원자성 보장. + * WebSocket 브로드캐스트는 트랜잭션 외부 작업이므로 실패가 TX에 영향을 주지 않도록 독립 처리. */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void handle(RoommateAcceptedEvent event) { ChatRoom chatRoom = chatRoomService.findByRoomNo(event.roomNo()) .orElseGet(() -> { ChatRoom created = chatRoomService.create(event.roomNo()); - chatRoomMemberService.join(created, event.hostUserNo()); + try { + chatRoomMemberService.join(created, event.hostUserNo()); + } catch (DataIntegrityViolationException e) { + log.debug("[Chat] 방장 중복 입장 (동시 요청). roomNo={}, hostUserNo={}", + event.roomNo(), event.hostUserNo()); + } return created; }); if (!chatRoomMemberService.isMember(chatRoom, event.acceptedUserNo())) { - chatRoomMemberService.join(chatRoom, event.acceptedUserNo()); + try { + chatRoomMemberService.join(chatRoom, event.acceptedUserNo()); + } catch (DataIntegrityViolationException e) { + log.debug("[Chat] 신규 멤버 중복 입장 (동시 요청). roomNo={}, userNo={}", + event.roomNo(), event.acceptedUserNo()); + return; + } User accepted = userService.findById(event.acceptedUserNo()); String displayName = (accepted.getNickname() != null && !accepted.getNickname().isBlank()) ? accepted.getNickname() : accepted.getName(); @@ -53,8 +68,16 @@ public void handle(RoommateAcceptedEvent event) { ChatMessage message = chatMessageService.save(chatRoom, "SYSTEM", content, MessageType.SYSTEM, 0); ChatMessageResponse response = new ChatMessageResponse( message.getMessageNo(), chatRoom.getChatRoomNo(), - "SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt()); + "SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount()); + broadcastSafely(chatRoom, response); + } + } + + private void broadcastSafely(ChatRoom chatRoom, ChatMessageResponse response) { + try { messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoom.getChatRoomNo(), response); + } catch (Exception e) { + log.warn("[Chat] WebSocket 브로드캐스트 실패. chatRoomNo={}", chatRoom.getChatRoomNo(), e); } } } diff --git a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/LeaveChatRoomUseCase.java b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/LeaveChatRoomUseCase.java index 9a447bdd..5bc123c5 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/LeaveChatRoomUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/LeaveChatRoomUseCase.java @@ -20,6 +20,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + import static com.project.dorumdorum.global.exception.code.status.ChatErrorStatus.HOST_CANNOT_LEAVE; @Service @@ -34,42 +36,86 @@ public class LeaveChatRoomUseCase { private final UserService userService; private final SimpMessagingTemplate messagingTemplate; + /** + * 채팅방 퇴장 처리 + * - 채팅방 멤버를 조회하고 방장 퇴장 가능 여부를 검증 + * - 그룹 채팅방이면 룸메이트와 방 인원수를 함께 갱신 + * - 마지막 멤버면 방을 삭제하고, 아니면 퇴장 메시지를 전송 + */ @Transactional public void execute(String chatRoomNo, String userNo) { ChatRoom chatRoom = chatRoomService.findByChatRoomNo(chatRoomNo); ChatRoomMember member = chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, userNo); long memberCount = chatRoomMemberService.countByChatRoom(chatRoom); + if (ChatRoomType.GROUP.equals(chatRoom.getChatRoomType())) { + leaveGroupChatRoom(chatRoom, member, userNo, memberCount); + return; + } + + leaveDirectChatRoom(chatRoom, member, userNo, memberCount); + } + + private void leaveGroupChatRoom(ChatRoom chatRoom, ChatRoomMember member, String userNo, long memberCount) { + Room room = roomService.findByIdForUpdate(chatRoom.getRoomNo()); + + if (memberCount > 1 && roommateService.isHost(userNo, room)) { + throw new RestApiException(HOST_CANNOT_LEAVE); + } + if (memberCount > 1) { - boolean isHost = roommateService.isHostOfRoom(userNo, chatRoom.getRoomNo()); - if (isHost) { - throw new RestApiException(HOST_CANNOT_LEAVE); - } + LocalDateTime fromTime = member.getLastReadAt() != null + ? member.getLastReadAt() + : member.getJoinedAt(); + chatMessageService.decreaseUnreadCount(chatRoom.getChatRoomNo(), fromTime, userNo); } chatRoomMemberService.leave(member); + roommateService.leaveRoom(userNo, chatRoom.getRoomNo()); + room.minusCurrentMate(); - // GROUP 채팅방인 경우에만 룸메이트 삭제 및 방 인원수 감소 - if (ChatRoomType.GROUP.equals(chatRoom.getChatRoomType())) { - roommateService.leaveRoom(userNo, chatRoom.getRoomNo()); - Room room = roomService.findById(chatRoom.getRoomNo()); - room.minusCurrentMate(); + if (memberCount == 1) { + chatMessageService.deleteAllByChatRoom(chatRoom.getChatRoomNo()); + chatRoomService.delete(chatRoom); + return; } + sendLeaveSystemMessage(chatRoom, userNo); + } + + private void leaveDirectChatRoom(ChatRoom chatRoom, ChatRoomMember member, String userNo, long memberCount) { + if (memberCount > 1 && roommateService.isHostOfRoom(userNo, chatRoom.getRoomNo())) { + throw new RestApiException(HOST_CANNOT_LEAVE); + } + + if (memberCount > 1) { + LocalDateTime fromTime = member.getLastReadAt() != null + ? member.getLastReadAt() + : member.getJoinedAt(); + chatMessageService.decreaseUnreadCount(chatRoom.getChatRoomNo(), fromTime, userNo); + } + + chatRoomMemberService.leave(member); + if (memberCount == 1) { - // 마지막 멤버가 나가는 경우: 메시지 먼저 삭제 후 채팅방 삭제 (FK 제약 위반 방지) chatMessageService.deleteAllByChatRoom(chatRoom.getChatRoomNo()); chatRoomService.delete(chatRoom); - } else { - User leavingUser = userService.findById(userNo); - String displayName = (leavingUser.getNickname() != null && !leavingUser.getNickname().isBlank()) - ? leavingUser.getNickname() : leavingUser.getName(); - String content = displayName + "가 퇴장했습니다."; - ChatMessage message = chatMessageService.save(chatRoom, "SYSTEM", content, MessageType.SYSTEM, 0); - ChatMessageResponse response = new ChatMessageResponse( - message.getMessageNo(), chatRoomNo, - "SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt()); - messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoomNo, response); + return; } + + sendLeaveSystemMessage(chatRoom, userNo); + } + + private void sendLeaveSystemMessage(ChatRoom chatRoom, String userNo) { + String chatRoomNo = chatRoom.getChatRoomNo(); + User leavingUser = userService.findById(userNo); + String displayName = (leavingUser.getNickname() != null && !leavingUser.getNickname().isBlank()) + ? leavingUser.getNickname() : leavingUser.getName(); + String content = displayName + "가 퇴장했습니다."; + ChatMessage message = chatMessageService.save(chatRoom, "SYSTEM", content, MessageType.SYSTEM, 0); + ChatMessageResponse response = new ChatMessageResponse( + message.getMessageNo(), chatRoomNo, + "SYSTEM", null, content, MessageType.SYSTEM.name(), message.getCreatedAt(), message.getUnreadCount()); + messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoomNo, response); } } diff --git a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/LoadChatMessagesUseCase.java b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/LoadChatMessagesUseCase.java index d8241f67..49339b40 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/LoadChatMessagesUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/LoadChatMessagesUseCase.java @@ -33,6 +33,12 @@ public class LoadChatMessagesUseCase { private final UserService userService; private static final int LIMIT = 30; + /** + * 채팅 메시지 목록 커서 조회 + * - 요청 사용자의 채팅방 참여 여부와 입장 시점을 확인 + * - 커서 기준으로 메시지를 조회 + * - 발신자 표시명을 보강해 다음 커서와 함께 반환 + */ @Transactional(readOnly = true) public CursorPage execute(String chatRoomNo, String userNo, String cursor) { ChatRoom chatRoom = chatRoomService.findByChatRoomNo(chatRoomNo); diff --git a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/LoadMyChatRoomsUseCase.java b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/LoadMyChatRoomsUseCase.java index 1afbc0e4..00eb71ed 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/LoadMyChatRoomsUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/LoadMyChatRoomsUseCase.java @@ -14,6 +14,11 @@ public class LoadMyChatRoomsUseCase { private final ChatRoomService chatRoomService; + /** + * 내 채팅방 목록 조회 + * - 사용자가 속한 채팅방 목록을 조회 + * - 최근 메시지 기준 요약 정보로 반환 + */ @Transactional(readOnly = true) public List execute(String userNo) { return chatRoomService.findMyChatRooms(userNo); diff --git a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/MarkChatRoomReadUseCase.java b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/MarkChatRoomReadUseCase.java index 750b92fe..b7275e18 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/MarkChatRoomReadUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/MarkChatRoomReadUseCase.java @@ -1,12 +1,12 @@ package com.project.dorumdorum.domain.chat.application.usecase; -import com.project.dorumdorum.domain.chat.domain.entity.ChatRoom; +import com.project.dorumdorum.domain.chat.application.dto.response.ChatReadReceiptResponse; import com.project.dorumdorum.domain.chat.domain.entity.ChatRoomMember; import com.project.dorumdorum.domain.chat.domain.service.ChatMessageService; import com.project.dorumdorum.domain.chat.domain.service.ChatRoomMemberService; -import com.project.dorumdorum.domain.chat.domain.service.ChatRoomService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -15,20 +15,31 @@ @RequiredArgsConstructor public class MarkChatRoomReadUseCase { - private final ChatRoomService chatRoomService; private final ChatRoomMemberService chatRoomMemberService; private final ChatMessageService chatMessageService; + private final SimpMessagingTemplate messagingTemplate; + /** + * 채팅방 읽음 처리 + * - 사용자의 채팅방 멤버 정보를 조회 + * - 마지막 읽은 시점 이후 메시지의 안 읽은 수를 감소 + * - 현재 시각으로 마지막 읽은 시점을 갱신 + */ @Transactional public void execute(String chatRoomNo, String userNo) { - ChatRoom chatRoom = chatRoomService.findByChatRoomNo(chatRoomNo); - ChatRoomMember member = chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, userNo); + ChatRoomMember member = chatRoomMemberService.findByChatRoomNoAndUserNoForUpdate(chatRoomNo, userNo); LocalDateTime fromTime = member.getLastReadAt() != null ? member.getLastReadAt() : member.getJoinedAt(); chatMessageService.decreaseUnreadCount(chatRoomNo, fromTime, userNo); - chatRoomMemberService.updateLastReadAt(member, LocalDateTime.now()); + LocalDateTime readAt = LocalDateTime.now(); + chatRoomMemberService.updateLastReadAt(member, readAt); + + messagingTemplate.convertAndSend( + "/topic/chat-room/" + chatRoomNo + "/read", + new ChatReadReceiptResponse(chatRoomNo, userNo, readAt) + ); } } diff --git a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/SendGroupChatMessageUseCase.java b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/SendGroupChatMessageUseCase.java index ba990418..ad4923c1 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/SendGroupChatMessageUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/application/usecase/SendGroupChatMessageUseCase.java @@ -31,6 +31,12 @@ public class SendGroupChatMessageUseCase { private final NotificationRequestPublisher notificationRequestPublisher; private final UserService userService; + /** + * 그룹 채팅 메시지 전송 + * - 발신자가 채팅방 멤버인지 검증 + * - 메시지를 저장하고 채팅방 최근 메시지를 갱신 + * - 웹소켓으로 메시지를 전송하고 다른 멤버에게 알림을 발행 + */ @RateLimited(tag = "chat-message", key = "#senderNo + ':' + #chatRoomNo") @Transactional public void send(String chatRoomNo, String senderNo, String content) { @@ -65,7 +71,8 @@ public void send(String chatRoomNo, String senderNo, String content) { senderNickname, content, MessageType.TEXT.name(), - message.getCreatedAt() + message.getCreatedAt(), + message.getUnreadCount() ); messagingTemplate.convertAndSend("/topic/chat-room/" + chatRoomNo, response); diff --git a/src/main/java/com/project/dorumdorum/domain/chat/domain/entity/ChatRoom.java b/src/main/java/com/project/dorumdorum/domain/chat/domain/entity/ChatRoom.java index 990e54c7..687c0342 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/domain/entity/ChatRoom.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/domain/entity/ChatRoom.java @@ -9,7 +9,6 @@ import jakarta.persistence.Id; import jakarta.persistence.Index; import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; import lombok.*; import java.time.LocalDateTime; @@ -18,11 +17,7 @@ @Table( indexes = { @Index(name = "idx_chat_room_last_message_at", columnList = "last_message_at") - }, - uniqueConstraints = @UniqueConstraint( - name = "uk_chat_room_direct", - columnNames = {"room_no", "chat_room_type", "applicant_user_no"} - ) + } ) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/com/project/dorumdorum/domain/chat/domain/repository/ChatRoomMemberRepository.java b/src/main/java/com/project/dorumdorum/domain/chat/domain/repository/ChatRoomMemberRepository.java index e88b8a31..6f41047d 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/domain/repository/ChatRoomMemberRepository.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/domain/repository/ChatRoomMemberRepository.java @@ -2,7 +2,11 @@ import com.project.dorumdorum.domain.chat.domain.entity.ChatRoom; import com.project.dorumdorum.domain.chat.domain.entity.ChatRoomMember; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -11,6 +15,18 @@ public interface ChatRoomMemberRepository extends JpaRepository findByChatRoomAndUserNo(ChatRoom chatRoom, String userNo); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select member + from ChatRoomMember member + where member.chatRoom.chatRoomNo = :chatRoomNo + and member.userNo = :userNo + """) + Optional findByChatRoomNoAndUserNoForUpdate( + @Param("chatRoomNo") String chatRoomNo, + @Param("userNo") String userNo + ); + boolean existsByChatRoomAndUserNo(ChatRoom chatRoom, String userNo); List findByChatRoom(ChatRoom chatRoom); diff --git a/src/main/java/com/project/dorumdorum/domain/chat/domain/service/ChatRoomMemberService.java b/src/main/java/com/project/dorumdorum/domain/chat/domain/service/ChatRoomMemberService.java index d25f9946..db15fd56 100644 --- a/src/main/java/com/project/dorumdorum/domain/chat/domain/service/ChatRoomMemberService.java +++ b/src/main/java/com/project/dorumdorum/domain/chat/domain/service/ChatRoomMemberService.java @@ -5,7 +5,6 @@ import com.project.dorumdorum.domain.chat.domain.repository.ChatRoomMemberRepository; import com.project.dorumdorum.global.exception.RestApiException; import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -27,12 +26,7 @@ public ChatRoomMember join(ChatRoom chatRoom, String userNo) { .chatRoom(chatRoom) .userNo(userNo) .build(); - try { - return chatRoomMemberRepository.save(member); - } catch (DataIntegrityViolationException e) { - // 동시 입장 요청으로 UNIQUE 제약 위반 시 graceful 처리 - throw new RestApiException(ALREADY_CHAT_ROOM_MEMBER); - } + return chatRoomMemberRepository.save(member); } public ChatRoomMember findByChatRoomAndUserNo(ChatRoom chatRoom, String userNo) { @@ -40,6 +34,11 @@ public ChatRoomMember findByChatRoomAndUserNo(ChatRoom chatRoom, String userNo) .orElseThrow(() -> new RestApiException(CHAT_ROOM_MEMBER_NOT_FOUND)); } + public ChatRoomMember findByChatRoomNoAndUserNoForUpdate(String chatRoomNo, String userNo) { + return chatRoomMemberRepository.findByChatRoomNoAndUserNoForUpdate(chatRoomNo, userNo) + .orElseThrow(() -> new RestApiException(CHAT_ROOM_MEMBER_NOT_FOUND)); + } + public boolean isMember(ChatRoom chatRoom, String userNo) { return chatRoomMemberRepository.existsByChatRoomAndUserNo(chatRoom, userNo); } diff --git a/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/CreateUserChecklistUseCase.java b/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/CreateUserChecklistUseCase.java index 6fa91d5b..b2d81771 100644 --- a/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/CreateUserChecklistUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/CreateUserChecklistUseCase.java @@ -16,6 +16,11 @@ public class CreateUserChecklistUseCase { private final UserChecklistService userChecklistService; private final UserChecklistMapper userChecklistMapper; + /** + * 사용자 체크리스트 생성 + * - 요청값을 체크리스트 엔티티로 변환 + * - 저장 후 생성된 체크리스트 번호를 반환 + */ public String execute(String userNo, CreateUserChecklistRequest request) { UserChecklist checklist = userChecklistMapper.toUserChecklist(userNo, request); UserChecklist savedChecklist = userChecklistService.save(checklist); diff --git a/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/LoadMyRoomRuleUseCase.java b/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/LoadMyRoomRuleUseCase.java index 9b69a061..7a23afca 100644 --- a/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/LoadMyRoomRuleUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/LoadMyRoomRuleUseCase.java @@ -14,6 +14,11 @@ public class LoadMyRoomRuleUseCase { private final RoomRuleService roomRuleService; private final RoomRuleMapper roomRuleMapper; + /** + * 방 체크리스트 규칙 조회 + * - 방 번호로 규칙 정보를 조회 + * - 응답 DTO로 변환해 반환 + */ public MyRoomRuleResponse execute(String roomNo) { RoomRule roomRule = roomRuleService.findByRoomNo(roomNo); return roomRuleMapper.toResponse(roomRule); diff --git a/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/LoadUserChecklistUseCase.java b/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/LoadUserChecklistUseCase.java index 120cfb60..f8ed492a 100644 --- a/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/LoadUserChecklistUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/LoadUserChecklistUseCase.java @@ -14,6 +14,11 @@ public class LoadUserChecklistUseCase { private final UserChecklistService userChecklistService; private final UserChecklistMapper userChecklistMapper; + /** + * 사용자 체크리스트 조회 + * - 사용자 번호로 체크리스트를 조회 + * - 응답 DTO로 변환해 반환 + */ public UserChecklistResponse execute(String userNo) { UserChecklist checklist = userChecklistService.findByUserNo(userNo); return userChecklistMapper.toResponse(checklist); diff --git a/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/UpdateRoomRuleUseCase.java b/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/UpdateRoomRuleUseCase.java index e143ee3b..c9881f05 100644 --- a/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/UpdateRoomRuleUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/UpdateRoomRuleUseCase.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.INVALID_ROOM_CAPACITY; import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.NO_PERMISSION_ON_ROOM; @Service @@ -24,14 +25,23 @@ public class UpdateRoomRuleUseCase { private final RoomRuleService roomRuleService; private final RoomRuleMapper roomRuleMapper; + /** + * 방 규칙 및 모집 조건 수정 + * - 방장을 검증하고 방/규칙 정보를 조회 + * - 방 기본 정보와 방 규칙을 함께 갱신 + * - 변경된 규칙을 저장 + */ public void execute(String userNo, String roomNo, UpdateRoomRuleRequest request) { - Room room = roomService.findById(roomNo); + Room room = roomService.findByIdForUpdate(roomNo); if (!roommateService.isHost(userNo, room)) throw new RestApiException(NO_PERMISSION_ON_ROOM); RoomRule roomRule = roomRuleService.findByRoomNo(roomNo); + if (!room.isValidCapacity(request.capacity())) { + throw new RestApiException(INVALID_ROOM_CAPACITY); + } room.updateCapacity(request.capacity()); room.updateRoomType(request.roomType()); room.updateResidencePeriod(request.residencePeriod()); diff --git a/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/UpdateUserChecklistUseCase.java b/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/UpdateUserChecklistUseCase.java index a6d1d59f..267d4ea7 100644 --- a/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/UpdateUserChecklistUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/checklist/application/usecase/UpdateUserChecklistUseCase.java @@ -16,6 +16,11 @@ public class UpdateUserChecklistUseCase { private final UserChecklistService userChecklistService; private final UserChecklistMapper userChecklistMapper; + /** + * 사용자 체크리스트 수정 + * - 기존 체크리스트를 조회 + * - 요청값으로 항목을 갱신한 뒤 저장 + */ public void execute(String userNo, UpdateUserChecklistRequest request) { UserChecklist checklist = userChecklistService.findByUserNo(userNo); userChecklistMapper.updateUserChecklist(request, checklist); diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/LoadNoticesUseCase.java b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/LoadNoticesUseCase.java index 4f3541fc..2a4fda87 100644 --- a/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/LoadNoticesUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/LoadNoticesUseCase.java @@ -18,6 +18,11 @@ public class LoadNoticesUseCase { private final NoticeService noticeService; private final NoticeMapper noticeMapper; + /** + * 공지사항 목록 조회 + * - 작성일 내림차순으로 공지사항을 조회 + * - 응답 DTO 목록으로 변환해 반환 + */ public List execute() { List notices = noticeService.loadAllByWrittenDateDesc(); return noticeMapper.toResponseList(notices); diff --git a/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/LoadMyNotificationsUseCase.java b/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/LoadMyNotificationsUseCase.java index 95cbbafc..7c64dfb1 100644 --- a/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/LoadMyNotificationsUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/LoadMyNotificationsUseCase.java @@ -21,6 +21,12 @@ public class LoadMyNotificationsUseCase { private final NotificationMapper notificationMapper; private static final int LIMIT = 20; + /** + * 내 알림 목록 커서 조회 + * - 커서 기반 조회 조건을 생성 + * - 알림 목록을 조회해 응답 DTO로 변환 + * - 다음 페이지 커서를 포함해 반환 + */ @Transactional(readOnly = true) public CursorPage execute(String userNo, String cursor) { CursorQueryParams params = PaginationHelper.prepareCursorQuery(cursor, LIMIT); diff --git a/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/MarkAsReadNotificationUseCase.java b/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/MarkAsReadNotificationUseCase.java index 08d26cdc..288c850d 100644 --- a/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/MarkAsReadNotificationUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/MarkAsReadNotificationUseCase.java @@ -11,6 +11,11 @@ public class MarkAsReadNotificationUseCase { private final NotificationService notificationService; + /** + * 알림 읽음 처리 + * - 사용자 소유 알림인지 확인 + * - 해당 알림을 읽음 상태로 변경 + */ @Transactional public void execute(String userNo, String notificationNo) { notificationService.markAsRead(notificationNo, userNo); diff --git a/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/RegisterDeviceTokenUseCase.java b/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/RegisterDeviceTokenUseCase.java index 829e78fc..503c769c 100644 --- a/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/RegisterDeviceTokenUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/RegisterDeviceTokenUseCase.java @@ -11,6 +11,11 @@ public class RegisterDeviceTokenUseCase { private final NotificationDeviceService notificationDeviceService; + /** + * 푸시 알림 디바이스 토큰 등록 또는 갱신 + * - 사용자/디바이스 기준으로 기존 토큰을 확인 + * - 없으면 등록하고 있으면 최신 토큰으로 갱신 + */ @Transactional public void execute(String userNo, String deviceId, String fcmToken) { notificationDeviceService.registerOrUpdateToken(userNo, deviceId, fcmToken); diff --git a/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/SendTestNotificationUseCase.java b/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/SendTestNotificationUseCase.java index c01472bb..10592f13 100644 --- a/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/SendTestNotificationUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/notification/application/usecase/SendTestNotificationUseCase.java @@ -14,6 +14,11 @@ public class SendTestNotificationUseCase { private final NotificationRequestPublisher notificationRequestPublisher; + /** + * 테스트 푸시 알림 발행 + * - 랜덤 식별값으로 테스트 메시지를 생성 + * - 지정한 사용자에게 알림 발행 이벤트를 전송 + */ @Transactional public void execute(String receiverNo) { int random = ThreadLocalRandom.current().nextInt(1000, 99999); diff --git a/src/main/java/com/project/dorumdorum/domain/notification/domain/entity/Device.java b/src/main/java/com/project/dorumdorum/domain/notification/domain/entity/Device.java index d44c5a68..dd1f39c5 100644 --- a/src/main/java/com/project/dorumdorum/domain/notification/domain/entity/Device.java +++ b/src/main/java/com/project/dorumdorum/domain/notification/domain/entity/Device.java @@ -2,11 +2,7 @@ import com.project.dorumdorum.global.common.BaseEntity; import io.hypersistence.utils.hibernate.id.Tsid; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Index; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -20,10 +16,12 @@ @Builder @Table( name = "devices", + uniqueConstraints = { + @UniqueConstraint(name = "uk_device_user_device", columnNames = {"user_no", "device_id"}) + }, indexes = { @Index(name = "idx_device_user_no", columnList = "user_no"), - @Index(name = "idx_device_fcm_token", columnList = "fcm_token"), - @Index(name = "uk_device_user_device", columnList = "user_no, device_id", unique = true) + @Index(name = "idx_device_fcm_token", columnList = "fcm_token") } ) public class Device extends BaseEntity { diff --git a/src/main/java/com/project/dorumdorum/domain/notification/domain/repository/UserPresenceRepository.java b/src/main/java/com/project/dorumdorum/domain/notification/domain/repository/UserPresenceRepository.java index 3b8ccf7a..66bc0d52 100644 --- a/src/main/java/com/project/dorumdorum/domain/notification/domain/repository/UserPresenceRepository.java +++ b/src/main/java/com/project/dorumdorum/domain/notification/domain/repository/UserPresenceRepository.java @@ -6,6 +6,8 @@ public interface UserPresenceRepository { void setOnline(String userNo); + void refreshPresence(String userNo); + void setOffline(String userNo); void setInChatroom(String userNo, String messageRoomNo); diff --git a/src/main/java/com/project/dorumdorum/domain/notification/infra/repository/RedisUserPresenceRepository.java b/src/main/java/com/project/dorumdorum/domain/notification/infra/repository/RedisUserPresenceRepository.java index 739d9b8f..b9fec079 100644 --- a/src/main/java/com/project/dorumdorum/domain/notification/infra/repository/RedisUserPresenceRepository.java +++ b/src/main/java/com/project/dorumdorum/domain/notification/infra/repository/RedisUserPresenceRepository.java @@ -19,13 +19,22 @@ public class RedisUserPresenceRepository implements UserPresenceRepository { @Override public void setOnline(String userNo) { + redisTemplate.opsForValue().set( + KEY_PREFIX + userNo, + UserPresence.online().toRedisValue(), + TTL + ); + } + + @Override + public void refreshPresence(String userNo) { String key = KEY_PREFIX + userNo; String current = redisTemplate.opsForValue().get(key); UserPresence presence = UserPresence.fromRedisValue(current); - UserPresence newPresence = presence.kind() == UserPresence.PresenceKind.IN_CHATROOM - ? presence - : UserPresence.online(); + UserPresence newPresence = presence.kind() == UserPresence.PresenceKind.OFFLINE + ? UserPresence.online() + : presence; redisTemplate.opsForValue().set(key, newPresence.toRedisValue(), TTL); } diff --git a/src/main/java/com/project/dorumdorum/domain/notification/infra/sse/ChatRoomPresenceListener.java b/src/main/java/com/project/dorumdorum/domain/notification/infra/sse/ChatRoomPresenceListener.java new file mode 100644 index 00000000..bf91963a --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notification/infra/sse/ChatRoomPresenceListener.java @@ -0,0 +1,76 @@ +package com.project.dorumdorum.domain.notification.infra.sse; + +import com.project.dorumdorum.domain.notification.domain.repository.UserPresenceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@RequiredArgsConstructor +public class ChatRoomPresenceListener { + + private static final String CHAT_ROOM_TOPIC_PREFIX = "/topic/chat-room/"; + + private final UserPresenceRepository userPresenceRepository; + private final SseEmitterRegistry sseEmitterRegistry; + + private final Map chatRoomSessions = new ConcurrentHashMap<>(); + + @EventListener + public void handleSubscribe(SessionSubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String destination = accessor.getDestination(); + String sessionId = accessor.getSessionId(); + String userNo = resolveUserNo(accessor); + + if (sessionId == null || userNo == null || destination == null || !destination.startsWith(CHAT_ROOM_TOPIC_PREFIX)) { + return; + } + + String chatRoomNo = destination.substring(CHAT_ROOM_TOPIC_PREFIX.length()); + if (chatRoomNo.isBlank()) { + return; + } + + chatRoomSessions.put(sessionId, new ChatRoomSession(userNo, chatRoomNo)); + userPresenceRepository.setInChatroom(userNo, chatRoomNo); + } + + @EventListener + public void handleDisconnect(SessionDisconnectEvent event) { + String sessionId = event.getSessionId(); + if (sessionId == null) { + return; + } + + ChatRoomSession session = chatRoomSessions.remove(sessionId); + if (session == null) { + return; + } + + if (sseEmitterRegistry.hasConnection(session.userNo())) { + userPresenceRepository.setOnline(session.userNo()); + return; + } + + userPresenceRepository.setOffline(session.userNo()); + } + + private String resolveUserNo(StompHeaderAccessor accessor) { + Map sessionAttributes = accessor.getSessionAttributes(); + if (sessionAttributes == null) { + return null; + } + Object userNo = sessionAttributes.get("userNo"); + return userNo instanceof String value ? value : null; + } + + private record ChatRoomSession(String userNo, String chatRoomNo) { + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notification/infra/sse/SseEmitterRegistry.java b/src/main/java/com/project/dorumdorum/domain/notification/infra/sse/SseEmitterRegistry.java index 6f26f4cd..4ef2a5b9 100644 --- a/src/main/java/com/project/dorumdorum/domain/notification/infra/sse/SseEmitterRegistry.java +++ b/src/main/java/com/project/dorumdorum/domain/notification/infra/sse/SseEmitterRegistry.java @@ -40,7 +40,7 @@ public SseEmitter register(String userNo, String deviceId) { } if (emitters.size() == 1) - userPresenceRepository.setOnline(userNo); + userPresenceRepository.refreshPresence(userNo); // remove() -> removeIfCurrent() emitter.onCompletion(() -> removeIfCurrent(userNo, key, emitter)); diff --git a/src/main/java/com/project/dorumdorum/domain/notification/infra/sse/SseHeartbeatScheduler.java b/src/main/java/com/project/dorumdorum/domain/notification/infra/sse/SseHeartbeatScheduler.java index f6f905f4..80782ffc 100644 --- a/src/main/java/com/project/dorumdorum/domain/notification/infra/sse/SseHeartbeatScheduler.java +++ b/src/main/java/com/project/dorumdorum/domain/notification/infra/sse/SseHeartbeatScheduler.java @@ -20,7 +20,7 @@ public class SseHeartbeatScheduler { public void run() { for (String userNo : sseEmitterRegistry.getConnectedUsers()) { try { - userPresenceRepository.setOnline(userNo); + userPresenceRepository.refreshPresence(userNo); sseEmitterRegistry.sendHeartbeatToUser(userNo); } catch (Exception e) { log.warn("[SSE] heartbeat failed userNo={}", userNo, e); diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/FindRoomsResponse.java b/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/FindRoomsResponse.java index 775d88d5..df468f70 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/FindRoomsResponse.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/FindRoomsResponse.java @@ -16,5 +16,6 @@ public record FindRoomsResponse( String title, String hostNickname, RoomStatus roomStatus, - String residencePeriod // 거주기간 (예: "학기(16주)", "반기(24주)", "계절학기") + String residencePeriod, // 거주기간 (예: "학기(16주)", "반기(24주)", "계절학기") + Integer remaining ) {} \ No newline at end of file diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/ApplyRoomUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/ApplyRoomUseCase.java index b81d891c..57566142 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/ApplyRoomUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/ApplyRoomUseCase.java @@ -27,6 +27,12 @@ public class ApplyRoomUseCase { private final RoommateService roommateService; private final ApplicationEventPublisher eventPublisher; + /** + * 방 지원 요청 생성 + * - 사용자/방 존재 여부와 지원 가능 상태를 검증 + * - 중복 지원과 이미 방에 속한 상태를 차단 + * - 지원 요청을 생성하고 1:1 채팅방 생성 이벤트를 발행 + */ public String execute(String userNo, String roomNo, JoinRoomRequest request) { userService.validateExistsById(userNo); diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/CancelRoomApplicationUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/CancelRoomApplicationUseCase.java index dcc030e1..b47a2e83 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/CancelRoomApplicationUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/CancelRoomApplicationUseCase.java @@ -15,9 +15,13 @@ public class CancelRoomApplicationUseCase { private final RoomService roomService; private final RoomRequestService roomRequestService; + /** + * 방 지원 취소 + * - 방 정보를 조회 + * - 사용자의 해당 방 지원 요청을 취소 + */ public void execute(String userNo, String roomNo) { Room room = roomService.findById(roomNo); roomRequestService.cancelJoinRequest(userNo, room); } } - diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/CheckMyRoomUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/CheckMyRoomUseCase.java index 4789dbe7..91cc1088 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/CheckMyRoomUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/CheckMyRoomUseCase.java @@ -11,6 +11,11 @@ public class CheckMyRoomUseCase { private final RoommateService roommateService; + /** + * 내가 속한 방 여부 조회 + * - 사용자 룸메이트 정보를 조회 + * - 방 소속 여부와 방 번호를 함께 반환 + */ public CheckMyRoomResponse execute(String userNo) { CheckMyRoomResponse checkMyRoomResponse = roommateService.findByUserNo(userNo) .map(roommate -> new CheckMyRoomResponse( diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/ConfirmRoomAssignmentUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/ConfirmRoomAssignmentUseCase.java index 031b1e7b..9c020214 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/ConfirmRoomAssignmentUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/ConfirmRoomAssignmentUseCase.java @@ -15,6 +15,7 @@ import java.util.List; +import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.ALREADY_CONFIRM_REQUEST; import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.NO_PERMISSION_ON_ROOM; @Service @@ -26,8 +27,14 @@ public class ConfirmRoomAssignmentUseCase { private final RoommateService roommateService; private final ApplicationEventPublisher eventPublisher; + /** + * 방 배정 확정 처리 + * - 현재 사용자가 해당 방 룸메이트인지 검증 + * - 본인 확정 상태를 승인으로 변경 + * - 방이 가득 찼고 전원이 승인했으면 방 배정을 완료하고 이벤트를 발행 + */ public void execute(String userNo, String roomNo) { - Room room = roomService.findById(roomNo); + Room room = roomService.findByIdForUpdate(roomNo); List allRoommates = roommateService.findByRoom(room); @@ -36,6 +43,10 @@ public void execute(String userNo, String roomNo) { .findFirst() .orElseThrow(() -> new RestApiException(NO_PERMISSION_ON_ROOM)); + if (room.isCompleted()) { + throw new RestApiException(ALREADY_CONFIRM_REQUEST); + } + currentRoommate.accept(); if(room.isFull()) { diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/CreateRoomUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/CreateRoomUseCase.java index 6569681f..d6c9ff3a 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/CreateRoomUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/CreateRoomUseCase.java @@ -22,6 +22,12 @@ public class CreateRoomUseCase { private final RoomRuleService roomRuleService; private final RoomRuleMapper roomRuleMapper; + /** + * 방 생성 + * - 방 정보를 생성하고 생성자를 방장으로 등록 + * - 요청의 규칙 정보를 방 규칙으로 저장 + * - 생성된 방 번호를 반환 + */ public String execute(String userNo, RoomCreateRequest request) { Room room = roomService.create(userNo, request); roommateService.create(userNo, room, RoomRole.HOST); diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/DecideApplicationRequestUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/DecideApplicationRequestUseCase.java index bd638e06..f8ed2dc4 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/DecideApplicationRequestUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/DecideApplicationRequestUseCase.java @@ -27,17 +27,28 @@ public class DecideApplicationRequestUseCase { private final RoommateService roommateService; private final ApplicationEventPublisher eventPublisher; + /** + * 지원 요청 승인 + * - 요청자와 지원자의 상태를 검증 + * - 방장 권한과 정원 초과 여부를 확인 + * - 룸메이트를 추가하고 승인 이벤트를 발행한 뒤 요청을 삭제 + */ public void approve(String userNo, String roomNo, String roomRequestNo) { // 유저 존재 유무 검증 userService.validateExistsById(userNo); - // 지원자가 이미 속한 방이 있는지 검증 + // 원하는 방의 요청이 맞는지 확인 RoomRequest roomRequest = roomRequestService.findById(roomRequestNo); + if (!roomRequest.getRoom().getRoomNo().equals(roomNo)) { + throw new RestApiException(ROOM_REQUEST_NOT_FOUND); + } + + // 지원자가 이미 속한 방이 있는지 검증 if(roommateService.existsByUserNo(roomRequest.getUserNo())) throw new RestApiException(ALREADY_JOINED_USER); - // 방장인지 확인 - Room room = roomService.findById(roomNo); + // 요청이 path roomNo에 속한 요청인지 검증한 뒤, 해당 방 기준으로 락과 권한 검증 수행 + Room room = roomService.findByIdForUpdate(roomNo); if(!roommateService.isHost(userNo, room)) throw new RestApiException(NO_PERMISSION_ON_ROOM); @@ -59,6 +70,11 @@ public void approve(String userNo, String roomNo, String roomRequestNo) { roomRequestService.delete(roomRequest); } + /** + * 지원 요청 거절 + * - 요청자 존재 여부와 방장 권한을 검증 + * - 지원 요청을 삭제해 거절 처리 + */ public void reject(String userNo, String roomRequestNo) { // 유저 존재 유무 검증 userService.validateExistsById(userNo); diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/FindRoomsUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/FindRoomsUseCase.java index 3549aee4..155152a7 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/FindRoomsUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/FindRoomsUseCase.java @@ -9,16 +9,23 @@ import com.project.dorumdorum.global.pagination.PaginationHelper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class FindRoomsUseCase { private final RoomService roomService; private static final int LIMIT = 50; + /** + * 방 목록 커서 조회 + * - 필터와 커서 기준으로 방 목록을 조회 + * - 다음 페이지 커서를 포함한 결과를 반환 + */ public CursorPage execute(ChecklistFilterRequest request) { CursorQueryParams params = PaginationHelper.prepareCursorQuery(request.cursor(), LIMIT); @@ -26,13 +33,16 @@ public CursorPage execute(ChecklistFilterRequest request) { request, params.cursorCreatedAt(), params.cursorId(), + params.cursorRemaining(), params.limitPlusOne() ); return PaginationHelper.buildCursorPage( responses, LIMIT, - last -> CursorCodec.encode(last.createdAt(), last.roomNo()) + last -> request.sortType() == ChecklistFilterRequest.SortType.REMAINING + ? CursorCodec.encode(last.remaining(), last.createdAt(), last.roomNo()) + : CursorCodec.encode(last.createdAt(), last.roomNo()) ); } } diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/KickRoommateUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/KickRoommateUseCase.java index 8a7f0f06..70bab830 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/KickRoommateUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/KickRoommateUseCase.java @@ -22,8 +22,14 @@ public class KickRoommateUseCase { private final RoommateService roommateService; private final ApplicationEventPublisher eventPublisher; + /** + * 룸메이트 강퇴 처리 + * - 요청 사용자가 방장인지 검증 + * - 자기 자신 강퇴를 차단 + * - 대상 사용자를 방에서 제외하고 강퇴 이벤트를 발행 + */ public void execute(String requesterNo, String roomNo, String kickedUserNo) { - Room room = roomService.findById(roomNo); + Room room = roomService.findByIdForUpdate(roomNo); if (!roommateService.isHost(requesterNo, room)) { throw new RestApiException(NO_PERMISSION_ON_ROOM); diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadMyAppliedRoomsUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadMyAppliedRoomsUseCase.java index 922e612a..b7af1156 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadMyAppliedRoomsUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadMyAppliedRoomsUseCase.java @@ -15,8 +15,12 @@ public class LoadMyAppliedRoomsUseCase { private final RoomService roomService; + /** + * 내가 지원한 방 목록 조회 + * - 사용자가 지원한 방 목록을 조회 + * - 방 목록 응답 DTO로 반환 + */ public List execute(String userNo) { return roomService.findAppliedRooms(userNo); } } - diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadMyLikedRoomsUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadMyLikedRoomsUseCase.java index 46380207..2b9b682f 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadMyLikedRoomsUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadMyLikedRoomsUseCase.java @@ -15,8 +15,12 @@ public class LoadMyLikedRoomsUseCase { private final RoomService roomService; + /** + * 내가 찜한 방 목록 조회 + * - 사용자가 찜한 방 목록을 조회 + * - 방 목록 응답 DTO로 반환 + */ public List execute(String userNo) { return roomService.findLikedRooms(userNo); } } - diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadMyRoomsUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadMyRoomsUseCase.java index f38ff6b2..25ae4fbe 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadMyRoomsUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadMyRoomsUseCase.java @@ -11,6 +11,11 @@ public class LoadMyRoomsUseCase { private final RoomService roomService; + /** + * 내가 속한 방 조회 + * - 사용자가 현재 속한 방 정보를 조회 + * - 방 상세 요약 응답으로 반환 + */ public FindRoomsResponse execute(String userNo) { return roomService.findMyRoom(userNo); } diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadRoomApplicationsUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadRoomApplicationsUseCase.java index 46fd8846..bea8829b 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadRoomApplicationsUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadRoomApplicationsUseCase.java @@ -23,6 +23,11 @@ public class LoadRoomApplicationsUseCase { private final RoomService roomService; private final RoommateService roommateService; + /** + * 방 지원자 목록 조회 + * - 방 정보를 조회하고 요청 사용자가 해당 방 멤버인지 검증 + * - 해당 방의 지원 요청 목록을 반환 + */ public List execute(String userNo, String roomNo) { Room room = roomService.findById(roomNo); diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/RoomLikeUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/RoomLikeUseCase.java index 59665904..c3f5cfe9 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/RoomLikeUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/RoomLikeUseCase.java @@ -17,6 +17,11 @@ public class RoomLikeUseCase { private final RoomService roomService; private final RoomLikeService roomLikeService; + /** + * 방 찜 등록 + * - 사용자와 방 존재 여부를 검증 + * - 해당 방을 찜 목록에 추가 + */ public void like(String userNo, String roomNo) { userService.validateExistsById(userNo); Room room = roomService.findById(roomNo); @@ -24,6 +29,11 @@ public void like(String userNo, String roomNo) { roomLikeService.like(userNo, room); } + /** + * 방 찜 해제 + * - 사용자와 방 존재 여부를 검증 + * - 해당 방을 찜 목록에서 제거 + */ public void unlike(String userNo, String roomNo) { userService.validateExistsById(userNo); Room room = roomService.findById(roomNo); @@ -31,4 +41,3 @@ public void unlike(String userNo, String roomNo) { roomLikeService.unlike(userNo, room); } } - diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/UpdateRoomTitleUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/UpdateRoomTitleUseCase.java index 6d99f98e..e09b283b 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/UpdateRoomTitleUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/UpdateRoomTitleUseCase.java @@ -19,6 +19,11 @@ public class UpdateRoomTitleUseCase { private final RoomService roomService; private final RoommateService roommateService; + /** + * 방 제목 수정 + * - 방 정보를 조회하고 방장 권한을 검증 + * - 공백을 정리한 새 제목으로 변경 + */ public void execute(String userNo, String roomNo, UpdateRoomTitleRequest request) { Room room = roomService.findById(roomNo); diff --git a/src/main/java/com/project/dorumdorum/domain/room/domain/entity/Room.java b/src/main/java/com/project/dorumdorum/domain/room/domain/entity/Room.java index c29f1297..dcf50087 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/domain/entity/Room.java +++ b/src/main/java/com/project/dorumdorum/domain/room/domain/entity/Room.java @@ -13,9 +13,7 @@ @Table( indexes = { @Index(name = "idx_room_status_created", columnList = "room_status, created_at, room_no"), - @Index(name = "idx_room_status_remaining_created", columnList = "room_status, remaining, created_at, room_no"), - @Index(name = "idx_room_type", columnList = "room_type"), - @Index(name = "idx_room_capacity", columnList = "capacity"), + @Index(name = "idx_room_status_remaining_created", columnList = "room_status, remaining, created_at DESC, room_no DESC"), @Index(name = "idx_room_residence_period", columnList = "residence_period"), @Index(name = "idx_room_host_user_no", columnList = "host_user_no") } @@ -92,6 +90,14 @@ public boolean isPending() { return RoomStatus.CONFIRM_PENDING.equals(this.roomStatus); } + public boolean isCompleted() { + return RoomStatus.COMPLETED.equals(this.roomStatus); + } + + public boolean isValidCapacity(Integer capacity) { + return capacity >= currentMateCount; + } + public void updateStatus(RoomStatus roomStatus) { this.roomStatus = roomStatus; } diff --git a/src/main/java/com/project/dorumdorum/domain/room/domain/entity/RoomLike.java b/src/main/java/com/project/dorumdorum/domain/room/domain/entity/RoomLike.java index 8740d94f..bb492fbb 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/domain/entity/RoomLike.java +++ b/src/main/java/com/project/dorumdorum/domain/room/domain/entity/RoomLike.java @@ -11,8 +11,10 @@ @AllArgsConstructor(access = AccessLevel.PROTECTED) @Builder @Table( + uniqueConstraints = { + @UniqueConstraint(name = "uk_room_like_user_room", columnNames = {"user_no", "room_no"}) + }, indexes = { - @Index(name = "uk_room_like_user_room", columnList = "user_no, room_no", unique = true) } ) public class RoomLike extends BaseEntity { diff --git a/src/main/java/com/project/dorumdorum/domain/room/domain/entity/RoomRequest.java b/src/main/java/com/project/dorumdorum/domain/room/domain/entity/RoomRequest.java index c0bc6bb8..0f2745c3 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/domain/entity/RoomRequest.java +++ b/src/main/java/com/project/dorumdorum/domain/room/domain/entity/RoomRequest.java @@ -11,9 +11,11 @@ @AllArgsConstructor(access = AccessLevel.PROTECTED) @Builder @Table( + uniqueConstraints = { + @UniqueConstraint(name = "uk_room_request_user_room_direction", columnNames = {"user_no", "room_no", "direction"}) + }, indexes = { - @Index(name = "idx_room_request_room_created", columnList = "room_no, created_at"), - @Index(name = "idx_room_request_user_room_direction", columnList = "user_no, room_no, direction") + @Index(name = "idx_room_request_room_created", columnList = "room_no, created_at") } ) public class RoomRequest extends BaseEntity { diff --git a/src/main/java/com/project/dorumdorum/domain/room/domain/repository/RoomQueryRepository.java b/src/main/java/com/project/dorumdorum/domain/room/domain/repository/RoomQueryRepository.java index e4ebfd18..eff78adb 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/domain/repository/RoomQueryRepository.java +++ b/src/main/java/com/project/dorumdorum/domain/room/domain/repository/RoomQueryRepository.java @@ -13,6 +13,7 @@ List findByCursor( ChecklistFilterRequest request, LocalDateTime cursorCreatedAt, String cursorId, + Integer cursorRemaining, int limitPlusOne ); diff --git a/src/main/java/com/project/dorumdorum/domain/room/domain/repository/RoomRepository.java b/src/main/java/com/project/dorumdorum/domain/room/domain/repository/RoomRepository.java index be8cd378..063c674d 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/domain/repository/RoomRepository.java +++ b/src/main/java/com/project/dorumdorum/domain/room/domain/repository/RoomRepository.java @@ -1,8 +1,18 @@ package com.project.dorumdorum.domain.room.domain.repository; import com.project.dorumdorum.domain.room.domain.entity.Room; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; public interface RoomRepository extends JpaRepository, RoomQueryRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select room from Room room where room.roomNo = :roomNo") + Optional findByRoomNoForUpdate(@Param("roomNo") String roomNo); + } diff --git a/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomLikeService.java b/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomLikeService.java index 4bf029d6..6d4140f3 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomLikeService.java +++ b/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomLikeService.java @@ -36,4 +36,3 @@ public void unlike(String userNo, Room room) { roomLikeRepository.deleteByUserNoAndRoom(userNo, room); } } - diff --git a/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomService.java b/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomService.java index bd8e159a..6a69f417 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomService.java +++ b/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomService.java @@ -38,13 +38,19 @@ public Room findById(String roomNo) { .orElseThrow(() -> new RestApiException(_NOT_FOUND)); } + public Room findByIdForUpdate(String roomNo) { + return roomRepository.findByRoomNoForUpdate(roomNo) + .orElseThrow(() -> new RestApiException(_NOT_FOUND)); + } + public List searchByCursor( ChecklistFilterRequest request, LocalDateTime cursorCreatedAt, String cursorId, + Integer cursorRemaining, int limitPlusOne ) { - return roomRepository.findByCursor(request, cursorCreatedAt, cursorId, limitPlusOne); + return roomRepository.findByCursor(request, cursorCreatedAt, cursorId, cursorRemaining, limitPlusOne); } public FindRoomsResponse findMyRoom(String userNo) { diff --git a/src/main/java/com/project/dorumdorum/domain/room/infra/repository/RoomRepositoryImpl.java b/src/main/java/com/project/dorumdorum/domain/room/infra/repository/RoomRepositoryImpl.java index e870103a..5f687fa2 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/infra/repository/RoomRepositoryImpl.java +++ b/src/main/java/com/project/dorumdorum/domain/room/infra/repository/RoomRepositoryImpl.java @@ -1,20 +1,25 @@ package com.project.dorumdorum.domain.room.infra.repository; -import com.project.dorumdorum.domain.checklist.domain.entity.QRoomRule; import com.project.dorumdorum.domain.room.application.dto.request.ChecklistFilterRequest; import com.project.dorumdorum.domain.room.application.dto.response.FindRoomsResponse; import com.project.dorumdorum.domain.room.domain.entity.Direction; +import com.project.dorumdorum.domain.room.domain.entity.RoomType; import com.project.dorumdorum.domain.room.domain.entity.RoomStatus; import com.project.dorumdorum.domain.room.domain.repository.RoomQueryRepository; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.sql.Timestamp; import java.time.LocalDateTime; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import static com.project.dorumdorum.domain.room.domain.entity.QRoom.room; @@ -29,15 +34,20 @@ public class RoomRepositoryImpl implements RoomQueryRepository { private final JPAQueryFactory query; - private static final QRoomRule roomRule = QRoomRule.roomRule; + private final EntityManager entityManager; @Override public List findByCursor( ChecklistFilterRequest request, LocalDateTime cursorCreatedAt, String cursorId, + Integer cursorRemaining, int limitPlusOne ) { + if (hasChecklistFilters(request)) { + return findByCursorWithLateral(request, cursorCreatedAt, cursorId, cursorRemaining, limitPlusOne); + } + JPAQuery q = query .select( constructor(FindRoomsResponse.class, @@ -49,42 +59,20 @@ public List findByCursor( room.title, user.nickname, room.roomStatus, - room.residencePeriod.stringValue() + room.residencePeriod.stringValue(), + room.remaining ) ) .from(room) - .leftJoin(roomRule).on(roomRule.room.eq(room)) .leftJoin(user).on(user.userNo.eq(room.hostUserNo)) .where( room.roomStatus.eq(RoomStatus.CONFIRM_PENDING), eqRoomType(request), eqResidencePeriod(request), eqCapacity(request), - eqBedtime(request), - eqWakeUp(request), - eqReturnHome(request), - eqReturnHomeTime(request), - eqCleaning(request), - eqPhoneCall(request), - eqSleepLight(request), - eqSleepHabit(request), - eqSnoring(request), - eqShowerTime(request), - eqEating(request), - eqLightsOut(request), - eqLightsOutTime(request), - eqHomeVisit(request), - eqSmoking(request), - eqRefrigerator(request), - eqHairDryer(request), - eqAlarm(request), - eqEarphone(request), - eqKeyskin(request), - eqHeat(request), - eqCold(request), - eqStudy(request), - eqTrashCan(request), - cursorPredicate(cursorCreatedAt, cursorId) + request.sortType() == ChecklistFilterRequest.SortType.REMAINING + ? remainingCursorPredicate(cursorRemaining, cursorCreatedAt, cursorId) + : cursorPredicate(cursorCreatedAt, cursorId) ); if (request.sortType() == ChecklistFilterRequest.SortType.REMAINING) { @@ -96,6 +84,89 @@ public List findByCursor( return q.limit(limitPlusOne).fetch(); } + private List findByCursorWithLateral( + ChecklistFilterRequest request, + LocalDateTime cursorCreatedAt, + String cursorId, + Integer cursorRemaining, + int limitPlusOne + ) { + StringBuilder sql = new StringBuilder(""" + SELECT + r.room_no, + r.room_type, + r.capacity, + r.current_mate_count, + r.created_at, + r.title, + u.nickname, + r.room_status, + r.residence_period::text, + r.remaining + FROM room r + JOIN LATERAL ( + SELECT 1 + FROM room_rule rr + WHERE rr.room_no = r.room_no + """); + + Map params = new LinkedHashMap<>(); + + appendCondition(sql, params, "rr.bedtime", "bedtime", request.bedtime()); + appendCondition(sql, params, "rr.wake_up", "wakeUp", request.wakeUp()); + appendCondition(sql, params, "rr.return_home", "returnHome", enumName(request.returnHome())); + appendCondition(sql, params, "rr.return_home_time", "returnHomeTime", request.returnHomeTime()); + appendCondition(sql, params, "rr.cleaning", "cleaning", enumName(request.cleaning())); + appendCondition(sql, params, "rr.phone_call", "phoneCall", enumName(request.phoneCall())); + appendCondition(sql, params, "rr.sleep_light", "sleepLight", enumName(request.sleepLight())); + appendCondition(sql, params, "rr.sleep_habit", "sleepHabit", enumName(request.sleepHabit())); + appendCondition(sql, params, "rr.snoring", "snoring", enumName(request.snoring())); + appendCondition(sql, params, "rr.shower_time", "showerTime", enumName(request.showerTime())); + appendCondition(sql, params, "rr.eating", "eating", enumName(request.eating())); + appendCondition(sql, params, "rr.lights_out", "lightsOut", enumName(request.lightsOut())); + appendCondition(sql, params, "rr.lights_out_time", "lightsOutTime", request.lightsOutTime()); + appendCondition(sql, params, "rr.home_visit", "homeVisit", enumName(request.homeVisit())); + appendCondition(sql, params, "rr.smoking", "smoking", enumName(request.smoking())); + appendCondition(sql, params, "rr.refrigerator", "refrigerator", enumName(request.refrigerator())); + appendCondition(sql, params, "rr.hair_dryer", "hairDryer", request.hairDryer()); + appendCondition(sql, params, "rr.alarm", "alarm", enumName(request.alarm())); + appendCondition(sql, params, "rr.earphone", "earphone", enumName(request.earphone())); + appendCondition(sql, params, "rr.keyskin", "keyskin", enumName(request.keyskin())); + appendCondition(sql, params, "rr.heat", "heat", enumName(request.heat())); + appendCondition(sql, params, "rr.cold", "cold", enumName(request.cold())); + appendCondition(sql, params, "rr.study", "study", enumName(request.study())); + appendCondition(sql, params, "rr.trash_can", "trashCan", enumName(request.trashCan())); + + sql.append(""" + LIMIT 1 + ) rule_match ON TRUE + LEFT JOIN users u + ON u.user_no = r.host_user_no + WHERE r.room_status = :roomStatus + """); + params.put("roomStatus", RoomStatus.CONFIRM_PENDING.name()); + + appendCondition(sql, params, "r.room_type", "roomType", enumName(request.roomType())); + appendCondition(sql, params, "r.residence_period", "residencePeriod", enumName(request.residencePeriod())); + appendCondition(sql, params, "r.capacity", "capacity", request.capacity()); + appendCursorCondition(sql, params, request.sortType(), cursorCreatedAt, cursorId, cursorRemaining); + + sql.append("\n ORDER BY "); + appendOrderBy(sql, "r", request.sortType()); + sql.append("\n LIMIT :limitPlusOne"); + params.put("limitPlusOne", limitPlusOne); + + Query nativeQuery = entityManager.createNativeQuery(sql.toString()); + params.forEach(nativeQuery::setParameter); + + @SuppressWarnings("unchecked") + List rows = nativeQuery.getResultList(); + + return rows.stream() + .map(this::toFindRoomsResponse) + .toList(); + } + @Override public Optional findMyRoom(String userNo) { FindRoomsResponse result = query @@ -109,7 +180,8 @@ public Optional findMyRoom(String userNo) { room.title, user.nickname, room.roomStatus, - room.residencePeriod.stringValue() + room.residencePeriod.stringValue(), + room.remaining ) ) .from(room) @@ -139,7 +211,8 @@ public List findLikedRooms(String userNo) { room.title, user.nickname, room.roomStatus, - room.residencePeriod.stringValue() + room.residencePeriod.stringValue(), + room.remaining ) ) .from(roomLike) @@ -166,7 +239,8 @@ public List findAppliedRooms(String userNo) { room.title, user.nickname, room.roomStatus, - room.residencePeriod.stringValue() + room.residencePeriod.stringValue(), + room.remaining ) ) .from(roomRequest) @@ -181,13 +255,19 @@ public List findAppliedRooms(String userNo) { .fetch(); } - /** 커서는 항상 (createdAt, pk). 정렬은 sort 파라미터로 ORDER BY에서만 적용 */ private BooleanExpression cursorPredicate(LocalDateTime cursorCreatedAt, String cursorId) { if (cursorCreatedAt == null || cursorId == null) return null; return room.createdAt.lt(cursorCreatedAt) .or(room.createdAt.eq(cursorCreatedAt).and(room.roomNo.lt(cursorId))); } + private BooleanExpression remainingCursorPredicate(Integer cursorRemaining, LocalDateTime cursorCreatedAt, String cursorId) { + if (cursorRemaining == null || cursorCreatedAt == null || cursorId == null) return null; + return room.remaining.gt(cursorRemaining) + .or(room.remaining.eq(cursorRemaining).and(room.createdAt.lt(cursorCreatedAt))) + .or(room.remaining.eq(cursorRemaining).and(room.createdAt.eq(cursorCreatedAt)).and(room.roomNo.lt(cursorId))); + } + private BooleanExpression eqRoomType(ChecklistFilterRequest request) { return request.roomType() == null ? null : room.roomType.eq(request.roomType()); } @@ -200,100 +280,144 @@ private BooleanExpression eqCapacity(ChecklistFilterRequest request) { return request.capacity() == null ? null : room.capacity.eq(request.capacity()); } - private BooleanExpression eqBedtime(ChecklistFilterRequest request) { - return isBlank(request.bedtime()) ? null : roomRule.bedtime.eq(request.bedtime()); - } - - private BooleanExpression eqWakeUp(ChecklistFilterRequest request) { - return isBlank(request.wakeUp()) ? null : roomRule.wakeUp.eq(request.wakeUp()); - } - - private BooleanExpression eqReturnHome(ChecklistFilterRequest request) { - return request.returnHome() == null ? null : roomRule.returnHome.eq(request.returnHome()); - } - - private BooleanExpression eqReturnHomeTime(ChecklistFilterRequest request) { - return isBlank(request.returnHomeTime()) ? null : roomRule.returnHomeTime.eq(request.returnHomeTime()); - } - - private BooleanExpression eqCleaning(ChecklistFilterRequest request) { - return request.cleaning() == null ? null : roomRule.cleaning.eq(request.cleaning()); - } - - private BooleanExpression eqPhoneCall(ChecklistFilterRequest request) { - return request.phoneCall() == null ? null : roomRule.phoneCall.eq(request.phoneCall()); - } - - private BooleanExpression eqSleepLight(ChecklistFilterRequest request) { - return request.sleepLight() == null ? null : roomRule.sleepLight.eq(request.sleepLight()); - } - - private BooleanExpression eqSleepHabit(ChecklistFilterRequest request) { - return request.sleepHabit() == null ? null : roomRule.sleepHabit.eq(request.sleepHabit()); - } - - private BooleanExpression eqSnoring(ChecklistFilterRequest request) { - return request.snoring() == null ? null : roomRule.snoring.eq(request.snoring()); - } - - private BooleanExpression eqShowerTime(ChecklistFilterRequest request) { - return request.showerTime() == null ? null : roomRule.showerTime.eq(request.showerTime()); - } - - private BooleanExpression eqEating(ChecklistFilterRequest request) { - return request.eating() == null ? null : roomRule.eating.eq(request.eating()); - } - - private BooleanExpression eqLightsOut(ChecklistFilterRequest request) { - return request.lightsOut() == null ? null : roomRule.lightsOut.eq(request.lightsOut()); - } - - private BooleanExpression eqLightsOutTime(ChecklistFilterRequest request) { - return isBlank(request.lightsOutTime()) ? null : roomRule.lightsOutTime.eq(request.lightsOutTime()); - } + private boolean hasChecklistFilters(ChecklistFilterRequest request) { + return !isBlank(request.bedtime()) + || !isBlank(request.wakeUp()) + || request.returnHome() != null + || !isBlank(request.returnHomeTime()) + || request.cleaning() != null + || request.phoneCall() != null + || request.sleepLight() != null + || request.sleepHabit() != null + || request.snoring() != null + || request.showerTime() != null + || request.eating() != null + || request.lightsOut() != null + || !isBlank(request.lightsOutTime()) + || request.homeVisit() != null + || request.smoking() != null + || request.refrigerator() != null + || !isBlank(request.hairDryer()) + || request.alarm() != null + || request.earphone() != null + || request.keyskin() != null + || request.heat() != null + || request.cold() != null + || request.study() != null + || request.trashCan() != null; + } + + private void appendCondition( + StringBuilder sql, + Map params, + String column, + String parameterName, + Object value + ) { + if (value == null || (value instanceof String stringValue && isBlank(stringValue))) { + return; + } - private BooleanExpression eqHomeVisit(ChecklistFilterRequest request) { - return request.homeVisit() == null ? null : roomRule.homeVisit.eq(request.homeVisit()); + sql.append("\n AND ").append(column).append(" = :").append(parameterName); + params.put(parameterName, value); } - private BooleanExpression eqSmoking(ChecklistFilterRequest request) { - return request.smoking() == null ? null : roomRule.smoking.eq(request.smoking()); - } + private void appendCursorCondition( + StringBuilder sql, + Map params, + ChecklistFilterRequest.SortType sortType, + LocalDateTime cursorCreatedAt, + String cursorId, + Integer cursorRemaining + ) { + if (sortType == ChecklistFilterRequest.SortType.REMAINING) { + if (cursorRemaining == null || cursorCreatedAt == null || cursorId == null) { + return; + } + + sql.append(""" + + AND ( + r.remaining > :cursorRemaining + OR (r.remaining = :cursorRemaining AND r.created_at < :cursorCreatedAt) + OR (r.remaining = :cursorRemaining AND r.created_at = :cursorCreatedAt AND r.room_no < :cursorId) + ) + """); + params.put("cursorRemaining", cursorRemaining); + params.put("cursorCreatedAt", cursorCreatedAt); + params.put("cursorId", cursorId); + return; + } - private BooleanExpression eqRefrigerator(ChecklistFilterRequest request) { - return request.refrigerator() == null ? null : roomRule.refrigerator.eq(request.refrigerator()); - } + if (cursorCreatedAt == null || cursorId == null) { + return; + } - private BooleanExpression eqHairDryer(ChecklistFilterRequest request) { - return isBlank(request.hairDryer()) ? null : roomRule.hairDryer.eq(request.hairDryer()); - } + sql.append(""" + + AND ( + r.created_at < :cursorCreatedAt + OR (r.created_at = :cursorCreatedAt AND r.room_no < :cursorId) + ) + """); + params.put("cursorCreatedAt", cursorCreatedAt); + params.put("cursorId", cursorId); + } + + private void appendOrderBy(StringBuilder sql, String alias, ChecklistFilterRequest.SortType sortType) { + if (sortType == ChecklistFilterRequest.SortType.REMAINING) { + sql.append(alias).append(".remaining ASC, ") + .append(alias).append(".created_at DESC, ") + .append(alias).append(".room_no DESC"); + return; + } - private BooleanExpression eqAlarm(ChecklistFilterRequest request) { - return request.alarm() == null ? null : roomRule.alarm.eq(request.alarm()); + sql.append(alias).append(".created_at DESC, ") + .append(alias).append(".room_no DESC"); } - private BooleanExpression eqEarphone(ChecklistFilterRequest request) { - return request.earphone() == null ? null : roomRule.earphone.eq(request.earphone()); + private FindRoomsResponse toFindRoomsResponse(Object[] row) { + return new FindRoomsResponse( + stringValue(row[0]), + enumValue(RoomType.class, row[1]), + intValue(row[2]), + intValue(row[3]), + localDateTimeValue(row[4]), + stringValue(row[5]), + stringValue(row[6]), + enumValue(RoomStatus.class, row[7]), + stringValue(row[8]), + intValue(row[9]) + ); } - private BooleanExpression eqKeyskin(ChecklistFilterRequest request) { - return request.keyskin() == null ? null : roomRule.keyskin.eq(request.keyskin()); + private > T enumValue(Class enumType, Object value) { + return value == null ? null : Enum.valueOf(enumType, value.toString()); } - private BooleanExpression eqHeat(ChecklistFilterRequest request) { - return request.heat() == null ? null : roomRule.heat.eq(request.heat()); + private String enumName(Enum value) { + return value == null ? null : value.name(); } - private BooleanExpression eqCold(ChecklistFilterRequest request) { - return request.cold() == null ? null : roomRule.cold.eq(request.cold()); + private String stringValue(Object value) { + return value == null ? null : value.toString(); } - private BooleanExpression eqStudy(ChecklistFilterRequest request) { - return request.study() == null ? null : roomRule.study.eq(request.study()); + private Integer intValue(Object value) { + return value == null ? null : ((Number) value).intValue(); } - private BooleanExpression eqTrashCan(ChecklistFilterRequest request) { - return request.trashCan() == null ? null : roomRule.trashCan.eq(request.trashCan()); + private LocalDateTime localDateTimeValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime; + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime(); + } + return LocalDateTime.parse(value.toString().replace(' ', 'T')); } private boolean isBlank(String value) { diff --git a/src/main/java/com/project/dorumdorum/domain/roommate/application/usecase/LoadMyRoommatesUseCase.java b/src/main/java/com/project/dorumdorum/domain/roommate/application/usecase/LoadMyRoommatesUseCase.java index b5f8f7b8..027238bc 100644 --- a/src/main/java/com/project/dorumdorum/domain/roommate/application/usecase/LoadMyRoommatesUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/roommate/application/usecase/LoadMyRoommatesUseCase.java @@ -13,6 +13,11 @@ public class LoadMyRoommatesUseCase { private final RoommateService roommateService; + /** + * 내 룸메이트 목록 조회 + * - 사용자가 속한 방 기준 룸메이트 목록을 조회 + * - 응답 DTO 목록으로 반환 + */ public List execute(String userNo) { return roommateService.findMyRoommates(userNo); } diff --git a/src/main/java/com/project/dorumdorum/domain/roommate/domain/entity/Roommate.java b/src/main/java/com/project/dorumdorum/domain/roommate/domain/entity/Roommate.java index 98111d44..2f5c485f 100644 --- a/src/main/java/com/project/dorumdorum/domain/roommate/domain/entity/Roommate.java +++ b/src/main/java/com/project/dorumdorum/domain/roommate/domain/entity/Roommate.java @@ -12,10 +12,11 @@ @AllArgsConstructor(access = AccessLevel.PROTECTED) @Builder @Table( + uniqueConstraints = { + @UniqueConstraint(name = "uk_roommate_user_no", columnNames = "user_no") + }, indexes = { - @Index(name = "idx_roommate_room_no", columnList = "room_no"), - @Index(name = "idx_roommate_user_no", columnList = "user_no"), - @Index(name = "uk_roommate_user_room", columnList = "user_no, room_no", unique = true) + @Index(name = "idx_roommate_room_no", columnList = "room_no") } ) public class Roommate extends BaseEntity { diff --git a/src/main/java/com/project/dorumdorum/domain/roommate/domain/service/RoommateService.java b/src/main/java/com/project/dorumdorum/domain/roommate/domain/service/RoommateService.java index 9b7688a8..5511af3a 100644 --- a/src/main/java/com/project/dorumdorum/domain/roommate/domain/service/RoommateService.java +++ b/src/main/java/com/project/dorumdorum/domain/roommate/domain/service/RoommateService.java @@ -14,7 +14,6 @@ import java.util.Optional; import static com.project.dorumdorum.global.exception.code.status.CommonErrorStatus._NOT_FOUND; - @Service @RequiredArgsConstructor public class RoommateService { @@ -68,6 +67,7 @@ public void leaveRoom(String userNo, String roomNo) { Roommate roommate = roommateRepository.findByUserNoAndRoomNo(userNo, roomNo) .orElseThrow(() -> new RestApiException(_NOT_FOUND)); roommateRepository.delete(roommate); + roommateRepository.flush(); } public List findMyRoommates(String userNo) { diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/LoadUserProfileUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/LoadUserProfileUseCase.java index f28012bc..d12ef5fb 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/LoadUserProfileUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/LoadUserProfileUseCase.java @@ -11,6 +11,11 @@ public class LoadUserProfileUseCase { private final UserProfileService userProfileService; + /** + * 사용자 프로필 조회 + * - 사용자 프로필 정보를 조회 + * - 화면 표시용 응답으로 반환 + */ public ProfileResponse execute(String userNo) { return userProfileService.getProfile(userNo); } diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/LoginUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/LoginUseCase.java index 4595a049..04fa2db8 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/LoginUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/LoginUseCase.java @@ -24,6 +24,12 @@ public class LoginUseCase { private final TokenProvider tokenProvider; private final RefreshTokenService refreshTokenService; + /** + * 로그인 처리 + * - 이메일로 사용자를 조회하고 비밀번호를 검증 + * - 액세스 토큰과 리프레시 토큰을 발급 + * - 리프레시 토큰을 저장한 뒤 응답으로 반환 + */ public LoginResponse execute(LoginRequest request) { User user = userService.findByEmail(request.email()); diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/LogoutUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/LogoutUseCase.java index df572389..7532ab87 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/LogoutUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/LogoutUseCase.java @@ -23,6 +23,12 @@ public class LogoutUseCase { private final TokenBlacklistService tokenBlacklistService; private final TokenWhitelistService tokenWhitelistService; + /** + * 로그아웃 처리 + * - 액세스 토큰에서 사용자 식별값과 만료 시간을 추출 + * - 화이트리스트와 리프레시 토큰을 제거 + * - 남은 만료 시간만큼 블랙리스트에 등록 + */ public void execute(String accessToken) { String userNo = tokenProvider.getId(accessToken) .orElseThrow(() -> new RestApiException(INVALID_ID_TOKEN)); diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SendVerificationEmailUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SendVerificationEmailUseCase.java index e7d34ad8..08d23085 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SendVerificationEmailUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SendVerificationEmailUseCase.java @@ -18,6 +18,12 @@ public class SendVerificationEmailUseCase { private final EmailVerificationService emailVerificationService; private final SecureRandomGenerator secureRandomGenerator; + /** + * 이메일 인증 코드 발송 + * - 이미 가입된 이메일인지 확인 + * - 허용된 대학 이메일 도메인인지 검증 + * - 인증 코드를 생성해 메일로 발송 + */ public void send(String email) { if (userService.isAlreadyRegistered(email)) { throw new RestApiException(DUPLICATE_EMAIL); diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SignUpUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SignUpUseCase.java index 86ed972b..8a0c82ff 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SignUpUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SignUpUseCase.java @@ -8,7 +8,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import static com.project.dorumdorum.global.exception.code.status.AuthErrorStatus.ALREADY_REGISTERED_EMAIL; +import static com.project.dorumdorum.global.exception.code.status.UserErrorStatus.DUPLICATE_SIGN_UP_INFO; import static com.project.dorumdorum.global.exception.code.status.UserErrorStatus._PASSWORD_NOT_MATCHES; @Service @@ -18,13 +18,20 @@ public class SignUpUseCase { private final UserService userService; + /** + * 회원가입 처리 + * - 비밀번호 확인 여부를 검증 + * - 중복 이메일 가입을 차단 + * - 사용자를 저장하고 생성된 사용자 번호를 반환 + */ public String execute(SignUpRequest request) { if(!request.isCheckedPassword()) { throw new RestApiException(_PASSWORD_NOT_MATCHES); } - if (userService.isAlreadyRegistered(request.email())) { - throw new RestApiException(ALREADY_REGISTERED_EMAIL); + if (userService.isAlreadyRegistered(request.email()) + || userService.isAlreadyRegisteredStudentNo(request.studentNo())) { + throw new RestApiException(DUPLICATE_SIGN_UP_INFO); } User savedUser = userService.save(request); diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/TokenReissueUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/TokenReissueUseCase.java index 6de50ef3..34521786 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/TokenReissueUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/TokenReissueUseCase.java @@ -16,6 +16,11 @@ public class TokenReissueUseCase { private final TokenReissueService tokenReissueService; private final TokenProvider tokenProvider; + /** + * 토큰 재발급 + * - 리프레시 토큰에서 사용자 식별값을 추출 + * - 유효한 토큰이면 새 토큰 묶음을 재발급 + */ public TokenReissueResponse execute(String refreshToken) { String userNo = tokenProvider.getId(refreshToken) .orElseThrow(() -> new RestApiException(INVALID_REFRESH_TOKEN)); diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/UpdateUserProfileUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/UpdateUserProfileUseCase.java index 9417ed6a..1b0fbbb2 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/UpdateUserProfileUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/UpdateUserProfileUseCase.java @@ -14,6 +14,11 @@ public class UpdateUserProfileUseCase { private final UserProfileService userProfileService; + /** + * 사용자 프로필 수정 + * - 요청 사용자 프로필을 수정 + * - 갱신된 프로필 정보를 반환 + */ public ProfileResponse execute(String userNo, UpdateProfileRequest updateProfileRequest) { return userProfileService.updateProfile(userNo, updateProfileRequest); } diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyEmailUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyEmailUseCase.java index 028a825b..4369bde2 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyEmailUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyEmailUseCase.java @@ -10,6 +10,11 @@ public class VerifyEmailUseCase { private final EmailVerificationService emailVerificationService; + /** + * 이메일 인증 코드 검증 + * - 이메일과 인증 코드의 일치 여부를 확인 + * - 유효하지 않으면 예외를 발생 + */ public void execute(String email, String code) { emailVerificationService.verifyCode(email, code); } diff --git a/src/main/java/com/project/dorumdorum/domain/user/domain/entity/User.java b/src/main/java/com/project/dorumdorum/domain/user/domain/entity/User.java index a3ea34c2..77ed3687 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/domain/entity/User.java +++ b/src/main/java/com/project/dorumdorum/domain/user/domain/entity/User.java @@ -13,8 +13,9 @@ @Getter @Table( name = "users", - indexes = { - @Index(name = "idx_user_email", columnList = "email") + uniqueConstraints = { + @UniqueConstraint(name = "uk_user_email", columnNames = "email"), + @UniqueConstraint(name = "uk_user_student_no", columnNames = "student_no") } ) @AllArgsConstructor diff --git a/src/main/java/com/project/dorumdorum/domain/user/domain/repository/UserRepository.java b/src/main/java/com/project/dorumdorum/domain/user/domain/repository/UserRepository.java index fdb961c5..4a640e35 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/domain/repository/UserRepository.java +++ b/src/main/java/com/project/dorumdorum/domain/user/domain/repository/UserRepository.java @@ -12,6 +12,9 @@ public interface UserRepository extends JpaRepository { @Query("select count(u) > 0 from User u where u.email = :email") Boolean existsByEmail(@Param("email") String email); + @Query("select count(u) > 0 from User u where u.studentNo = :studentNo") + Boolean existsByStudentNo(@Param("studentNo") String studentNo); + @Query("select u from User u where u.email = :email") Optional findByEmail(@Param("email") String email); } diff --git a/src/main/java/com/project/dorumdorum/domain/user/domain/service/UserService.java b/src/main/java/com/project/dorumdorum/domain/user/domain/service/UserService.java index 5fb60bfc..e481df9a 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/domain/service/UserService.java +++ b/src/main/java/com/project/dorumdorum/domain/user/domain/service/UserService.java @@ -28,6 +28,10 @@ public boolean isAlreadyRegistered(String email) { return userRepository.existsByEmail(email); } + public boolean isAlreadyRegisteredStudentNo(String studentNo) { + return userRepository.existsByStudentNo(studentNo); + } + public User save(SignUpRequest request) { return userRepository.save( User.builder() diff --git a/src/main/java/com/project/dorumdorum/global/alert/AlertDeduplicationService.java b/src/main/java/com/project/dorumdorum/global/alert/AlertDeduplicationService.java new file mode 100644 index 00000000..718086cc --- /dev/null +++ b/src/main/java/com/project/dorumdorum/global/alert/AlertDeduplicationService.java @@ -0,0 +1,45 @@ +package com.project.dorumdorum.global.alert; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +/** + * 동일한 알림이 짧은 시간 내에 반복 발송되는 것을 방지하는 서비스. + * Redis TTL 기반으로 중복 여부를 판단한다. + */ +@Service +@RequiredArgsConstructor +public class AlertDeduplicationService { + + private static final String KEY_PREFIX = "alert:dedup:"; + + private final StringRedisTemplate redisTemplate; + + /** + * 해당 알림이 중복인지 확인하고, 중복이 아니면 TTL을 설정한다. + * + * @return true이면 중복 — 발송 건너뜀. false이면 신규 — 발송 진행. + */ + public boolean isDuplicate(SystemAlert alert) { + String key = buildKey(alert); + Duration ttl = resolveTtl(alert.severity()); + Boolean isNew = redisTemplate.opsForValue().setIfAbsent(key, "1", ttl); + return !Boolean.TRUE.equals(isNew); + } + + private String buildKey(SystemAlert alert) { + return KEY_PREFIX + alert.severity() + ":" + Math.abs(alert.title().hashCode()); + } + + private Duration resolveTtl(AlertSeverity severity) { + return switch (severity) { + case CRITICAL -> Duration.ofSeconds(60); + case ERROR -> Duration.ofSeconds(300); + case WARN -> Duration.ofSeconds(900); + case INFO -> Duration.ofSeconds(1800); + }; + } +} diff --git a/src/main/java/com/project/dorumdorum/global/alert/ApplicationLifecycleAlertListener.java b/src/main/java/com/project/dorumdorum/global/alert/ApplicationLifecycleAlertListener.java new file mode 100644 index 00000000..c9901be7 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/global/alert/ApplicationLifecycleAlertListener.java @@ -0,0 +1,34 @@ +package com.project.dorumdorum.global.alert; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ApplicationLifecycleAlertListener { + + private final SystemAlertPublisher systemAlertPublisher; + + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + systemAlertPublisher.publish( + AlertSeverity.INFO, + AlertType.DEPLOYMENT, + "[배포] 서버 시작 완료", + "dorumdorum 서버가 정상 시작되었습니다." + ); + } + + @EventListener(ContextClosedEvent.class) + public void onContextClosed() { + systemAlertPublisher.publish( + AlertSeverity.WARN, + AlertType.DEPLOYMENT, + "[배포] 서버 종료", + "dorumdorum 서버가 종료되고 있습니다." + ); + } +} diff --git a/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertEventListener.java b/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertEventListener.java index d48a794a..52d09e2a 100644 --- a/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertEventListener.java +++ b/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertEventListener.java @@ -1,11 +1,13 @@ package com.project.dorumdorum.global.alert; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +@Slf4j @Component @RequiredArgsConstructor public class DiscordAlertEventListener { @@ -18,6 +20,10 @@ public class DiscordAlertEventListener { fallbackExecution = true ) public void handle(SystemAlertEvent event) { - discordAlertSender.send(event.alert()); + try { + discordAlertSender.send(event.alert()); + } catch (Exception e) { + log.error("[Alert] Discord 전송 실패 (Async). title={}", event.alert().title(), e); + } } } diff --git a/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertProperties.java b/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertProperties.java index 5bbf5857..e3f5bc1c 100644 --- a/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertProperties.java +++ b/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertProperties.java @@ -9,6 +9,27 @@ @ConfigurationProperties(prefix = "alert.discord") public class DiscordAlertProperties { + /** 공통 fallback webhook URL */ private String webhookUrl; -} + /** CRITICAL / ERROR 전용 채널 webhook URL */ + private String errorWebhookUrl; + + /** WARN / INFO 전용 채널 webhook URL */ + private String infoWebhookUrl; + + /** + * severity에 맞는 webhook URL 반환. + * error/info URL이 미설정이면 공통 webhookUrl로 fallback. + */ + public String resolveWebhookUrl(AlertSeverity severity) { + return switch (severity) { + case CRITICAL, ERROR -> nonBlankOr(errorWebhookUrl, webhookUrl); + case WARN, INFO -> nonBlankOr(infoWebhookUrl, webhookUrl); + }; + } + + private static String nonBlankOr(String preferred, String fallback) { + return (preferred != null && !preferred.isBlank()) ? preferred : fallback; + } +} diff --git a/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertSender.java b/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertSender.java index 18caa43b..15284713 100644 --- a/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertSender.java +++ b/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertSender.java @@ -9,6 +9,11 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; @Slf4j @@ -17,51 +22,91 @@ public class DiscordAlertSender { private final DiscordAlertProperties properties; + private final AlertDeduplicationService deduplicationService; private final ObjectMapper objectMapper; private final HttpClient httpClient = HttpClient.newHttpClient(); public void send(SystemAlert alert) { - String webhookUrl = properties.getWebhookUrl(); + if (deduplicationService.isDuplicate(alert)) { + log.debug("[Alert] 중복 알림 건너뜀. title={}", alert.title()); + return; + } + + String webhookUrl = properties.resolveWebhookUrl(alert.severity()); if (webhookUrl == null || webhookUrl.isBlank()) { - log.debug("Discord webhook URL is not configured. Skip alert. title={}", alert.title()); + log.debug("[Alert] Discord webhook URL 미설정. title={}", alert.title()); return; } - String content = buildContent(alert); - Map payload = Map.of("content", content); + Map payload = buildEmbedPayload(alert); try { String body = objectMapper.writeValueAsString(payload); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(webhookUrl)) + .timeout(Duration.ofSeconds(10)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); httpClient.send(request, HttpResponse.BodyHandlers.discarding()); } catch (Exception e) { - log.warn("Failed to send Discord alert. title={}", alert.title(), e); + log.warn("[Alert] Discord 전송 실패. title={}", alert.title(), e); } } - private String buildContent(SystemAlert alert) { - StringBuilder sb = new StringBuilder(); + private Map buildEmbedPayload(SystemAlert alert) { + Map embed = new LinkedHashMap<>(); + embed.put("title", severityPrefix(alert.severity()) + " " + alert.title()); + embed.put("description", alert.message()); + embed.put("color", resolveColor(alert.severity())); + embed.put("timestamp", Instant.now().toString()); - sb.append("[") - .append(alert.severity()) - .append("] ") - .append(alert.title()) - .append("\n") - .append(alert.message()); + List> fields = new ArrayList<>(); + fields.add(inlineField("심각도", alert.severity().name())); + fields.add(inlineField("유형", alert.type().name())); if (alert.data() != null && !alert.data().isEmpty()) { - sb.append("\n\n") - .append("details: ") - .append(alert.data()); + fields.add(blockField("상세 정보", formatData(alert.data()))); } + embed.put("fields", fields); + embed.put("footer", Map.of("text", "dorumdorum")); + + return Map.of("embeds", List.of(embed)); + } + + private String severityPrefix(AlertSeverity severity) { + return switch (severity) { + case CRITICAL -> "\uD83D\uDD34"; + case ERROR -> "\uD83D\uDFE0"; + case WARN -> "\uD83D\uDFE1"; + case INFO -> "\uD83D\uDD35"; + }; + } + + private int resolveColor(AlertSeverity severity) { + return switch (severity) { + case CRITICAL -> 15158332; // #E74C3C 빨강 + case ERROR -> 15105570; // #E67E22 주황 + case WARN -> 16312092; // #F9CA24 노랑 + case INFO -> 5793266; // #5865F2 블루퍼플 + }; + } + + private Map inlineField(String name, String value) { + return Map.of("name", name, "value", value, "inline", true); + } + + private Map blockField(String name, String value) { + return Map.of("name", name, "value", value, "inline", false); + } + + private String formatData(Map data) { + StringBuilder sb = new StringBuilder("```\n"); + data.forEach((k, v) -> sb.append(k).append(": ").append(v).append("\n")); + sb.append("```"); return sb.toString(); } } - diff --git a/src/main/java/com/project/dorumdorum/global/exception/ExceptionAdvice.java b/src/main/java/com/project/dorumdorum/global/exception/ExceptionAdvice.java index d7663918..b52f028e 100644 --- a/src/main/java/com/project/dorumdorum/global/exception/ExceptionAdvice.java +++ b/src/main/java/com/project/dorumdorum/global/exception/ExceptionAdvice.java @@ -4,6 +4,7 @@ import com.project.dorumdorum.global.alert.AlertType; import com.project.dorumdorum.global.alert.SystemAlertPublisher; import com.project.dorumdorum.global.exception.code.BaseCode; +import com.project.dorumdorum.global.exception.code.BaseCodeInterface; import com.project.dorumdorum.global.exception.code.status.CommonErrorStatus; import com.project.dorumdorum.global.logging.LogRedactor; import com.project.dorumdorum.global.logging.RequestLogContext; @@ -11,11 +12,12 @@ import com.project.dorumdorum.global.logging.StructuredLogFactory; import com.project.dorumdorum.global.properties.LoggingPolicyProperties; import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.ConstraintViolationException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; @@ -34,6 +36,10 @@ import java.util.Map; import java.util.Optional; +import static com.project.dorumdorum.global.exception.code.status.ChatErrorStatus.ALREADY_CHAT_ROOM_MEMBER; +import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.ALREADY_JOINED_USER; +import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.DUPLICATE_JOIN_REQUEST; +import static com.project.dorumdorum.global.exception.code.status.UserErrorStatus.DUPLICATE_SIGN_UP_INFO; import static net.logstash.logback.argument.StructuredArguments.entries; @Slf4j @@ -42,6 +48,15 @@ @RequiredArgsConstructor public class ExceptionAdvice extends ResponseEntityExceptionHandler { + private static final String DUPLICATE_KEY_SQL_STATE = "23505"; + private static final Map DATA_INTEGRITY_ERROR_CODES = Map.of( + "uk_room_request_user_room_direction", DUPLICATE_JOIN_REQUEST, + "uk_roommate_user_no", ALREADY_JOINED_USER, + "uk_user_email", DUPLICATE_SIGN_UP_INFO, + "uk_user_student_no", DUPLICATE_SIGN_UP_INFO, + "uk_chat_room_member_room_user", ALREADY_CHAT_ROOM_MEMBER + ); + private final RequestLogContextResolver requestLogContextResolver; private final StructuredLogFactory structuredLogFactory; private final LogRedactor logRedactor; @@ -102,6 +117,17 @@ public ResponseEntity handleConstraintViolationException(Constrai return handleExceptionInternal(CommonErrorStatus._VALIDATION_ERROR.getCode()); } + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException e) { + BaseCode mappedErrorCode = resolveDataIntegrityErrorCode(e); + if (mappedErrorCode != null) { + logError(e, mappedErrorCode.getHttpStatus().value()); + return handleExceptionInternal(mappedErrorCode); + } + + return handle500Exception(e); + } + /* * MethodArgumentTypeMismatchException 발생 시 예외 처리 * 메서드의 인자 타입이 예상과 다른 경우 @@ -144,6 +170,19 @@ private ResponseEntity handleExceptionInternal(BaseCode errorCode .body(ErrorResponse.of(errorCode.getCode(), errorCode.getMessage())); } + private BaseCode resolveDataIntegrityErrorCode(DataIntegrityViolationException e) { + Throwable current = e; + while (current != null) { + if (current instanceof org.hibernate.exception.ConstraintViolationException constraintViolationException + && DUPLICATE_KEY_SQL_STATE.equals(constraintViolationException.getSQLException().getSQLState())) { + BaseCodeInterface mapped = DATA_INTEGRITY_ERROR_CODES.get(constraintViolationException.getConstraintName()); + return mapped != null ? mapped.getCode() : null; + } + current = current.getCause(); + } + return null; + } + private void logError(Exception e, int fallbackStatus) { HttpServletRequest request = getCurrentRequest(); HttpServletResponse response = getCurrentResponse(); diff --git a/src/main/java/com/project/dorumdorum/global/exception/code/status/RoomErrorStatus.java b/src/main/java/com/project/dorumdorum/global/exception/code/status/RoomErrorStatus.java index 9cf90363..6b027bbc 100644 --- a/src/main/java/com/project/dorumdorum/global/exception/code/status/RoomErrorStatus.java +++ b/src/main/java/com/project/dorumdorum/global/exception/code/status/RoomErrorStatus.java @@ -23,6 +23,7 @@ public enum RoomErrorStatus implements BaseCodeInterface { CANNOT_APPLY_TO_OWN_ROOM(HttpStatus.BAD_REQUEST, "ROOM011", "자신이 만든 방에는 지원할 수 없습니다."), CANNOT_KICK_SELF(HttpStatus.BAD_REQUEST, "ROOM012", "자기 자신은 강퇴할 수 없습니다."), ROOM_FULL(HttpStatus.BAD_REQUEST, "ROOM014", "방 정원이 가득 찼습니다."), + INVALID_ROOM_CAPACITY(HttpStatus.BAD_REQUEST, "ROOM015", "현재 인원보다 적은 정원으로 변경할 수 없습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/project/dorumdorum/global/exception/code/status/UserErrorStatus.java b/src/main/java/com/project/dorumdorum/global/exception/code/status/UserErrorStatus.java index 1fd71c0e..9c52f86f 100644 --- a/src/main/java/com/project/dorumdorum/global/exception/code/status/UserErrorStatus.java +++ b/src/main/java/com/project/dorumdorum/global/exception/code/status/UserErrorStatus.java @@ -11,6 +11,7 @@ public enum UserErrorStatus implements BaseCodeInterface { _PASSWORD_NOT_MATCHES(HttpStatus.BAD_REQUEST, "COMMON400", "비밀번호가 일치하지 않습니다."), DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "ROOM002", "이미 가입된 이메일입니다."), + DUPLICATE_SIGN_UP_INFO(HttpStatus.BAD_REQUEST, "USER001", "이미 가입된 회원 정보입니다."), EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "ROOM002", "가입되지 않은 이메일입니다."), AGE_PARSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON400", "나이 계산 파싱 오류입니다."), FAILED_SEND_VERIFY_CODE(HttpStatus.INTERNAL_SERVER_ERROR, "MAIL001", "인증번호 전송에 실패하였습니다."), diff --git a/src/main/java/com/project/dorumdorum/global/logging/DiscordLogbackAppender.java b/src/main/java/com/project/dorumdorum/global/logging/DiscordLogbackAppender.java new file mode 100644 index 00000000..70e37572 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/global/logging/DiscordLogbackAppender.java @@ -0,0 +1,210 @@ +package com.project.dorumdorum.global.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.StackTraceElementProxy; +import ch.qos.logback.core.UnsynchronizedAppenderBase; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * WARN 이상 로그를 Discord webhook으로 전송하는 Logback Appender. + * + *

인메모리 TTL 기반 중복 제거를 통해 동일 로그의 반복 전송을 방지한다. + * Redis 의존 없이 동작하며, AlertDeduplicationService(구조적 알림용)와 독립적으로 운영된다. + * + *

logback-spring.xml에서 반드시 AsyncAppender로 감싸서 사용해야 한다. + * 직접 사용하면 HTTP 호출이 애플리케이션 스레드를 블로킹한다. + * + *

중복 TTL (AlertDeduplicationService와 동일하게 맞춤): + *

    + *
  • ERROR: 300초 (5분)
  • + *
  • WARN: 900초 (15분)
  • + *
+ */ +public class DiscordLogbackAppender extends UnsynchronizedAppenderBase { + + private static final long ERROR_DEDUP_MILLIS = 300_000L; + private static final long WARN_DEDUP_MILLIS = 900_000L; + private static final int MAX_MESSAGE_LEN = 1_000; + private static final int MAX_STACKTRACE_LEN = 800; + private static final int CLEANUP_INTERVAL = 200; + + /** logback-spring.xml의 태그로 주입 */ + private String webhookUrl; + + private HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + /** + * key = "{level}:{shortLoggerName}:{messageHash}" + * value = expiry timestamp (millis) + */ + private final ConcurrentHashMap dedupCache = new ConcurrentHashMap<>(); + private final AtomicInteger appendCount = new AtomicInteger(0); + + // ------------------------------------------------------------------------- + // 핵심 append 메서드 + // ------------------------------------------------------------------------- + + @Override + protected void append(ILoggingEvent event) { + if (webhookUrl == null || webhookUrl.isBlank()) return; + if (!event.getLevel().isGreaterOrEqual(Level.WARN)) return; + + periodicCleanup(); + + if (isDuplicate(event)) { + addInfo("[DiscordLogbackAppender] 중복 로그 건너뜀: " + buildDedupKey(event)); + return; + } + + sendToDiscord(event); + } + + // ------------------------------------------------------------------------- + // 중복 제거 + // ------------------------------------------------------------------------- + + /** + * TTL 내 동일 키가 존재하면 true(중복), 없거나 만료됐으면 false(신규). + * ConcurrentHashMap.compute()로 원자적 처리. + */ + private boolean isDuplicate(ILoggingEvent event) { + String key = buildDedupKey(event); + long now = System.currentTimeMillis(); + long ttl = event.getLevel() == Level.ERROR ? ERROR_DEDUP_MILLIS : WARN_DEDUP_MILLIS; + + boolean[] isDup = {false}; + dedupCache.compute(key, (k, existingExpiry) -> { + if (existingExpiry != null && existingExpiry > now) { + isDup[0] = true; + return existingExpiry; + } + return now + ttl; + }); + return isDup[0]; + } + + private String buildDedupKey(ILoggingEvent event) { + int msgHash = Math.abs(event.getFormattedMessage().hashCode()); + return event.getLevel().levelStr + ":" + shortLogger(event.getLoggerName()) + ":" + msgHash; + } + + /** 200번마다 만료된 캐시 항목을 정리해 메모리 누수를 방지 */ + private void periodicCleanup() { + if (appendCount.incrementAndGet() % CLEANUP_INTERVAL == 0) { + long now = System.currentTimeMillis(); + dedupCache.entrySet().removeIf(e -> e.getValue() <= now); + } + } + + // ------------------------------------------------------------------------- + // Discord 전송 + // ------------------------------------------------------------------------- + + private void sendToDiscord(ILoggingEvent event) { + try { + String payload = buildEmbedPayload(event); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(webhookUrl)) + .timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + httpClient.send(request, HttpResponse.BodyHandlers.discarding()); + } catch (Exception e) { + // addWarn()을 사용해 일반 로거를 우회 → 무한 루프 방지 + addWarn("[DiscordLogbackAppender] Discord 전송 실패: " + e.getMessage()); + } + } + + // ------------------------------------------------------------------------- + // Discord Embed 페이로드 구성 + // ------------------------------------------------------------------------- + + private String buildEmbedPayload(ILoggingEvent event) { + boolean isError = event.getLevel() == Level.ERROR; + String emoji = isError ? "\uD83D\uDFE0" : "\uD83D\uDFE1"; // 🟠 / 🟡 + int color = isError ? 15105570 : 16312092; // orange / yellow + + String title = emoji + " " + event.getLevel().levelStr + " | " + shortLogger(event.getLoggerName()); + String description = buildDescription(event); + + return "{\"embeds\":[{" + + "\"title\":\"" + escapeJson(title) + "\"," + + "\"description\":\"" + escapeJson(description) + "\"," + + "\"color\":" + color + "," + + "\"timestamp\":\"" + Instant.ofEpochMilli(event.getTimeStamp()) + "\"," + + "\"footer\":{\"text\":\"dorumdorum\"}" + + "}]}"; + } + + private String buildDescription(ILoggingEvent event) { + String message = truncate(event.getFormattedMessage(), MAX_MESSAGE_LEN); + StringBuilder sb = new StringBuilder("```\n").append(message).append("\n```"); + + IThrowableProxy tp = event.getThrowableProxy(); + if (tp != null) { + sb.append("\n```\n").append(extractStackTrace(tp)).append("\n```"); + } + return sb.toString(); + } + + private String extractStackTrace(IThrowableProxy tp) { + StringBuilder sb = new StringBuilder(tp.getClassName()) + .append(": ").append(tp.getMessage()).append("\n"); + + for (StackTraceElementProxy step : tp.getStackTraceElementProxyArray()) { + sb.append("\tat ").append(step.getSTEAsString()).append("\n"); + if (sb.length() >= MAX_STACKTRACE_LEN) { + sb.append("... (truncated)"); + break; + } + } + return sb.toString().trim(); + } + + // ------------------------------------------------------------------------- + // 유틸 + // ------------------------------------------------------------------------- + + private String shortLogger(String loggerName) { + if (loggerName == null) return "unknown"; + int idx = loggerName.lastIndexOf('.'); + return idx >= 0 ? loggerName.substring(idx + 1) : loggerName; + } + + private String truncate(String s, int maxLen) { + if (s == null) return ""; + return s.length() > maxLen ? s.substring(0, maxLen) + "..." : s; + } + + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + // Logback XML 바인딩용 setter + public void setWebhookUrl(String webhookUrl) { + this.webhookUrl = webhookUrl; + } + + /** 테스트 전용 — 프로덕션 코드에서 호출 금지 */ + void setHttpClientForTest(HttpClient client) { + this.httpClient = client; + } +} diff --git a/src/main/java/com/project/dorumdorum/global/pagination/CursorCodec.java b/src/main/java/com/project/dorumdorum/global/pagination/CursorCodec.java index 20735746..cddeb164 100644 --- a/src/main/java/com/project/dorumdorum/global/pagination/CursorCodec.java +++ b/src/main/java/com/project/dorumdorum/global/pagination/CursorCodec.java @@ -12,6 +12,12 @@ public static String encode(LocalDateTime createdAt, String id) { .encodeToString(raw.getBytes(StandardCharsets.UTF_8)); } + public static String encode(int remaining, LocalDateTime createdAt, String id) { + String raw = remaining + "|" + createdAt + "|" + id; + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + public static DecodedCursor decode(String cursor) { if (cursor == null || cursor.isBlank()) return null; @@ -24,8 +30,9 @@ public static DecodedCursor decode(String cursor) { LocalDateTime createdAt = LocalDateTime.parse(parts[parts.length - 2]); String id = parts[parts.length - 1]; + Integer remaining = parts.length >= 3 ? Integer.parseInt(parts[0]) : null; - return new DecodedCursor(createdAt, id); + return new DecodedCursor(createdAt, id, remaining); } } diff --git a/src/main/java/com/project/dorumdorum/global/pagination/CursorQueryParams.java b/src/main/java/com/project/dorumdorum/global/pagination/CursorQueryParams.java index 4e6cfd46..64383e4b 100644 --- a/src/main/java/com/project/dorumdorum/global/pagination/CursorQueryParams.java +++ b/src/main/java/com/project/dorumdorum/global/pagination/CursorQueryParams.java @@ -5,5 +5,6 @@ public record CursorQueryParams( LocalDateTime cursorCreatedAt, String cursorId, + Integer cursorRemaining, int limitPlusOne ) {} diff --git a/src/main/java/com/project/dorumdorum/global/pagination/DecodedCursor.java b/src/main/java/com/project/dorumdorum/global/pagination/DecodedCursor.java index da80a292..bfbb2d2f 100644 --- a/src/main/java/com/project/dorumdorum/global/pagination/DecodedCursor.java +++ b/src/main/java/com/project/dorumdorum/global/pagination/DecodedCursor.java @@ -4,5 +4,6 @@ public record DecodedCursor( LocalDateTime createdAt, - String pk + String pk, + Integer remaining ) {} \ No newline at end of file diff --git a/src/main/java/com/project/dorumdorum/global/pagination/PaginationHelper.java b/src/main/java/com/project/dorumdorum/global/pagination/PaginationHelper.java index df3573b6..aa85b39b 100644 --- a/src/main/java/com/project/dorumdorum/global/pagination/PaginationHelper.java +++ b/src/main/java/com/project/dorumdorum/global/pagination/PaginationHelper.java @@ -14,10 +14,10 @@ public static int limitPlusOne(int limit) { public static CursorQueryParams prepareCursorQuery(String cursor, int limit) { int limitPlusOne = limitPlusOne(limit); if (cursor == null || cursor.isBlank()) { - return new CursorQueryParams(null, null, limitPlusOne); + return new CursorQueryParams(null, null, null, limitPlusOne); } DecodedCursor decoded = CursorCodec.decode(cursor); - return new CursorQueryParams(decoded.createdAt(), decoded.pk(), limitPlusOne); + return new CursorQueryParams(decoded.createdAt(), decoded.pk(), decoded.remaining(), limitPlusOne); } public static CursorPage buildCursorPage(List results, int limit, Function nextCursorEncoder) { diff --git a/src/main/java/com/project/dorumdorum/global/warmer/WarmupScheduler.java b/src/main/java/com/project/dorumdorum/global/warmer/WarmupScheduler.java index e3dca0a6..84b17fc6 100644 --- a/src/main/java/com/project/dorumdorum/global/warmer/WarmupScheduler.java +++ b/src/main/java/com/project/dorumdorum/global/warmer/WarmupScheduler.java @@ -1,5 +1,8 @@ package com.project.dorumdorum.global.warmer; +import com.project.dorumdorum.global.alert.AlertSeverity; +import com.project.dorumdorum.global.alert.AlertType; +import com.project.dorumdorum.global.alert.SystemAlertPublisher; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -18,6 +21,7 @@ public class WarmupScheduler { private final RequestActivityTrackingFilter requestActivityTrackingFilter; private final WarmupService warmupService; + private final SystemAlertPublisher systemAlertPublisher; private final AtomicLong lastWarmupTime = new AtomicLong(0L); @@ -35,8 +39,18 @@ public void warmIfIdle() { } log.info("[Warmup] Service idle for {} ms, running warmup", now - lastRequest); - warmupService.warm(); - lastWarmupTime.set(now); + try { + warmupService.warm(); + lastWarmupTime.set(now); + } catch (Exception e) { + log.error("[Warmup] Warmup 실패", e); + systemAlertPublisher.publish( + AlertSeverity.WARN, + AlertType.SCHEDULED_JOB, + "[Warmup] Warmup 작업 실패", + e.getMessage() + ); + } } private boolean isIdle(long now, long lastRequest) { @@ -47,4 +61,3 @@ private boolean isCooldownPassed(long now, long lastWarm) { return now - lastWarm >= COOLDOWN.toMillis(); } } - diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 43477eba..ce445a36 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1,4 +1,7 @@ spring: + devtools: + restart: + enabled: false datasource: username: ${RDB_USERNAME:postgres} url: ${RDB_URL:jdbc:postgresql://localhost:5432/dorumdorum} @@ -10,7 +13,7 @@ spring: port: 6379 jpa: hibernate: - ddl-auto: update + ddl-auto: validate show-sql: true jwt: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 81c2b1ef..8e9c973e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -177,6 +177,12 @@ management: health: show-details: when-authorized +alert: + discord: + webhook-url: ${DISCORD_WEBHOOK_URL:} + error-webhook-url: ${DISCORD_ERROR_WEBHOOK_URL:} + info-webhook-url: ${DISCORD_INFO_WEBHOOK_URL:} + rate-limit: rules: fcm: diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 01384398..f73c3b86 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -2,8 +2,9 @@ - - + + + @@ -41,15 +42,51 @@ - + + + + ${DISCORD_WEBHOOK_URL} + + + + + + 512 + 0 + true + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 00000000..a9d49e00 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,33 @@ +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_email + ON users (email); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_student_no + ON users (student_no); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_roommate_user_no + ON roommate (user_no); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_room_like_user_room + ON room_like (user_no, room_no); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_device_user_device + ON devices (user_no, device_id); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_room_request_user_room_direction + ON room_request (user_no, room_no, direction); + +DROP INDEX IF EXISTS idx_room_status_remaining_created; + +CREATE INDEX IF NOT EXISTS idx_room_status_remaining_created + ON room (room_status, remaining, created_at DESC, room_no DESC); + +ALTER TABLE IF EXISTS chat_room + DROP CONSTRAINT IF EXISTS uk_chat_room_direct; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_chat_room_group + ON chat_room (room_no) + WHERE chat_room_type = 'GROUP'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_chat_room_direct + ON chat_room (room_no, applicant_user_no) + WHERE chat_room_type = 'DIRECT'; diff --git a/src/test/java/com/project/dorumdorum/domain/chat/integration/ChatRoomSchemaInitializationTest.java b/src/test/java/com/project/dorumdorum/domain/chat/integration/ChatRoomSchemaInitializationTest.java new file mode 100644 index 00000000..3a8981c8 --- /dev/null +++ b/src/test/java/com/project/dorumdorum/domain/chat/integration/ChatRoomSchemaInitializationTest.java @@ -0,0 +1,68 @@ +package com.project.dorumdorum.domain.chat.integration; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.messaging.FirebaseMessaging; +import com.project.dorumdorum.testsupport.TestcontainersSupport; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("ChatRoom schema 초기화 테스트") +class ChatRoomSchemaInitializationTest { + + @BeforeAll + static void requireDocker() { + Assumptions.assumeTrue( + TestcontainersSupport.requireDockerOrSkip("ChatRoomSchemaInitializationTest"), + "Docker is required for ChatRoomSchemaInitializationTest" + ); + } + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockitoBean + private FirebaseApp firebaseApp; + + @MockitoBean + private FirebaseMessaging firebaseMessaging; + + @Test + @DisplayName("테스트 DB에 DIRECT/GROUP partial unique index가 생성된다") + void testIndexesAreCreatedForChatRoom() { + List indexDefinitions = jdbcTemplate.query( + """ + SELECT indexdef + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'chat_room' + AND indexname IN ('uk_chat_room_group', 'uk_chat_room_direct') + ORDER BY indexname + """, + (rs, rowNum) -> rs.getString("indexdef") + ); + + assertThat(indexDefinitions).hasSize(2); + assertThat(indexDefinitions) + .anySatisfy(indexDef -> { + assertThat(indexDef).contains("UNIQUE INDEX uk_chat_room_direct"); + assertThat(indexDef).contains("DIRECT"); + }) + .anySatisfy(indexDef -> { + assertThat(indexDef).contains("UNIQUE INDEX uk_chat_room_group"); + assertThat(indexDef).contains("GROUP"); + }); + } +} diff --git a/src/test/java/com/project/dorumdorum/domain/chat/integration/ChatTransactionAtomicityIntegrationTest.java b/src/test/java/com/project/dorumdorum/domain/chat/integration/ChatTransactionAtomicityIntegrationTest.java new file mode 100644 index 00000000..3f7d7c18 --- /dev/null +++ b/src/test/java/com/project/dorumdorum/domain/chat/integration/ChatTransactionAtomicityIntegrationTest.java @@ -0,0 +1,346 @@ +package com.project.dorumdorum.domain.chat.integration; + +import com.project.dorumdorum.domain.chat.domain.entity.ChatMessage; +import com.project.dorumdorum.domain.chat.domain.entity.ChatRoom; +import com.project.dorumdorum.domain.chat.domain.entity.ChatRoomMember; +import com.project.dorumdorum.domain.chat.domain.repository.ChatRoomMemberRepository; +import com.project.dorumdorum.domain.chat.domain.repository.ChatRoomRepository; +import com.project.dorumdorum.domain.chat.domain.service.ChatMessageService; +import com.project.dorumdorum.domain.chat.domain.service.ChatRoomMemberService; +import com.project.dorumdorum.domain.room.application.usecase.KickRoommateUseCase; +import com.project.dorumdorum.domain.room.domain.entity.ResidencePeriod; +import com.project.dorumdorum.domain.room.domain.entity.Room; +import com.project.dorumdorum.domain.room.domain.entity.RoomType; +import com.project.dorumdorum.domain.room.domain.repository.RoomRepository; +import com.project.dorumdorum.domain.roommate.domain.entity.ConfirmStatus; +import com.project.dorumdorum.domain.roommate.domain.entity.RoomRole; +import com.project.dorumdorum.domain.roommate.domain.entity.Roommate; +import com.project.dorumdorum.domain.roommate.domain.repository.RoommateRepository; +import com.project.dorumdorum.domain.user.domain.entity.Role; +import com.project.dorumdorum.domain.user.domain.entity.User; +import com.project.dorumdorum.domain.user.domain.service.UserService; +import com.project.dorumdorum.testsupport.TestcontainersSupport; +import com.google.firebase.FirebaseApp; +import com.google.firebase.messaging.FirebaseMessaging; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 채팅 트랜잭션 원자성 통합 테스트. + * + *

검증 대상: {@code @TransactionalEventListener(BEFORE_COMMIT)} 리스너가 + * 부모 트랜잭션(KickRoommateUseCase)에 참여하여 아래 두 연산을 단일 트랜잭션으로 처리하는지 확인한다. + *

    + *
  • 방 도메인: roommateService.leaveRoom() — Roommate 삭제
  • + *
  • 채팅 도메인: chatRoomMemberService.leave() — ChatRoomMember 삭제
  • + *
+ * + *

주의: 테스트 메서드에 {@code @Transactional}을 붙이면 부모 트랜잭션이 실제로 커밋되지 않아 + * BEFORE_COMMIT 이벤트가 발화되지 않는다. 따라서 의도적으로 {@code @Transactional}을 생략하고 + * {@code @AfterEach}에서 수동 cleanup을 수행한다. + */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("채팅 트랜잭션 원자성 통합 테스트 (BEFORE_COMMIT)") +class ChatTransactionAtomicityIntegrationTest { + + @BeforeAll + static void requireDocker() { + Assumptions.assumeTrue( + TestcontainersSupport.requireDockerOrSkip("ChatTransactionAtomicityIntegrationTest"), + "Docker is required for ChatTransactionAtomicityIntegrationTest" + ); + } + + // ─── Firebase 목 (FileInputStream 오류 방지 — 테스트에서 FCM 사용 안 함) ── + @MockitoBean @SuppressWarnings("unused") private FirebaseApp firebaseApp; + @MockitoBean @SuppressWarnings("unused") private FirebaseMessaging firebaseMessaging; + + // ─── UseCase ────────────────────────────────────────────────────────────── + @Autowired private KickRoommateUseCase kickRoommateUseCase; + + // ─── Repositories (DB 상태 직접 검증용) ───────────────────────────────── + @Autowired private RoomRepository roomRepository; + @Autowired private RoommateRepository roommateRepository; + @Autowired private ChatRoomRepository chatRoomRepository; + @Autowired private ChatRoomMemberRepository chatRoomMemberRepository; + + // ─── External Services (Mocking to isolate behavior) ───────────────────── + @MockitoBean private SimpMessagingTemplate messagingTemplate; + + // ─── SpyBean (특정 메서드만 실패 강제 또는 외부 의존 우회) ───────────── + @MockitoSpyBean private ChatRoomMemberService chatRoomMemberService; + @MockitoSpyBean private UserService userService; + @MockitoSpyBean private ChatMessageService chatMessageService; + + // ─── 테스트 픽스처 ──────────────────────────────────────────────────────── + private Room room; + private ChatRoom chatRoom; + + /** + * userNo에 FK 제약이 없으므로 실제 User 엔티티 없이 임의 ID 사용 가능. + * '_atomicity_' 접두사로 다른 테스트 데이터와 충돌 방지. + */ + private static final String HOST_NO = "test_atomicity_host_001"; + private static final String MEMBER_NO = "test_atomicity_member_001"; + + @BeforeEach + void setUp() { + // 1. Room 저장 (@PrePersist → currentMateCount=1, roomStatus=CONFIRM_PENDING 자동 설정) + room = roomRepository.save( + Room.builder() + .roomType(RoomType.TYPE_1) + .capacity(2) + .title("원자성 테스트 방") + .hostUserNo(HOST_NO) + .residencePeriod(ResidencePeriod.SEMESTER) + .build() + ); + + // 2. Roommate 저장 (HOST, MATE) + // @PrePersist가 confirmStatus를 PENDING으로 덮어쓰지만, + // KickRoommateUseCase는 RoomRole만 확인하므로 테스트에 영향 없음 + roommateRepository.save( + Roommate.builder() + .room(room) + .userNo(HOST_NO) + .roomRole(RoomRole.HOST) + .confirmStatus(ConfirmStatus.COMPLETED) + .build() + ); + roommateRepository.save( + Roommate.builder() + .room(room) + .userNo(MEMBER_NO) + .roomRole(RoomRole.MEMBER) + .confirmStatus(ConfirmStatus.COMPLETED) + .build() + ); + + // 3. ChatRoom + ChatRoomMember 저장 + // ChatRoom.builder()의 chatRoomType @Builder.Default = GROUP → findByRoomNo() 조회 가능 + chatRoom = chatRoomRepository.save( + ChatRoom.builder() + .roomNo(room.getRoomNo()) + .build() + ); + chatRoomMemberRepository.save( + ChatRoomMember.builder().chatRoom(chatRoom).userNo(HOST_NO).build() + ); + chatRoomMemberRepository.save( + ChatRoomMember.builder().chatRoom(chatRoom).userNo(MEMBER_NO).build() + ); + + // 각 테스트 시작 전 spy 상태 초기화 + Mockito.reset(chatRoomMemberService, userService, chatMessageService); + } + + @AfterEach + void tearDown() { + // FK 순서: ChatRoomMember → ChatRoom → Roommate → Room + chatRoomMemberRepository.deleteAll(); + chatRoomRepository.deleteAll(); + roommateRepository.deleteAll(); + roomRepository.deleteAll(); + } + + // ========================================================================= + // Test 1: 성공 경로 (원자적 커밋) + // ========================================================================= + + @Test + @DisplayName("강퇴 성공: Roommate 삭제 + ChatRoomMember 삭제가 단일 트랜잭션으로 커밋된다") + void kick_Success_RoommateAndChatMemberBothRemovedAtomically() { + // given: 외부 의존 서비스 스텁 (User 엔티티 불필요, ChatMessage DB 저장 불필요) + stubUserService(); + stubChatMessageService(); + + // when + kickRoommateUseCase.execute(HOST_NO, room.getRoomNo(), MEMBER_NO); + + // then-1: Roommate 삭제 확인 (방 도메인 변경) + assertThat(roommateRepository.existsByUserNoAndRoomNo(MEMBER_NO, room.getRoomNo())) + .as("강퇴된 멤버의 Roommate 레코드가 삭제되어야 한다") + .isFalse(); + + // then-2: ChatRoomMember 삭제 확인 (채팅 도메인 변경 — BEFORE_COMMIT 리스너에서 처리) + assertThat(chatRoomMemberRepository.existsByChatRoomAndUserNo(chatRoom, MEMBER_NO)) + .as("강퇴된 멤버의 ChatRoomMember 레코드가 같은 TX 내에서 삭제되어야 한다") + .isFalse(); + } + + // ========================================================================= + // Test 2: 원자성 검증 (채팅 퇴장 실패 → 전체 롤백) + // ========================================================================= + + @Test + @DisplayName("원자성: 채팅방 퇴장 실패 시 룸메이트 강퇴도 롤백된다 (BEFORE_COMMIT 단일 TX)") + void kick_WhenChatRoomLeaveFails_RoommateKickAlsoRolledBack() { + // given: BEFORE_COMMIT 리스너 내 chatRoomMemberService.leave() 강제 실패 + // → 부모 TX(KickRoommateUseCase)에 예외가 전파되어 전체 롤백 + doThrow(new RuntimeException("채팅방 퇴장 DB 오류 (강제 실패)")) + .when(chatRoomMemberService).leave(any()); + + // when: 강퇴 실행 → BEFORE_COMMIT 예외 → 부모 TX 롤백 + assertThatThrownBy(() -> kickRoommateUseCase.execute(HOST_NO, room.getRoomNo(), MEMBER_NO)) + .isInstanceOf(RuntimeException.class); + + // then-1: Roommate 삭제가 롤백되어 여전히 존재해야 한다 + assertThat(roommateRepository.existsByUserNoAndRoomNo(MEMBER_NO, room.getRoomNo())) + .as("BEFORE_COMMIT 실패로 TX 롤백 → Roommate 삭제 취소") + .isTrue(); + + // then-2: ChatRoomMember 변경 없이 그대로 존재해야 한다 + assertThat(chatRoomMemberRepository.existsByChatRoomAndUserNo(chatRoom, MEMBER_NO)) + .as("TX 롤백으로 ChatRoomMember는 변경되지 않아야 한다") + .isTrue(); + } + + // ========================================================================= + // Test 3: 채팅방 없는 경우 강퇴 — Roommate만 삭제, TX 정상 커밋 + // ========================================================================= + + @Test + @DisplayName("채팅방 없는 경우: Roommate만 삭제되고 TX가 정상 커밋된다") + void kick_WhenNoChatRoom_OnlyRoommateRemovedAndTxCommits() { + // given: ChatRoomMember + ChatRoom 제거 → 채팅방이 없는 상태 시뮬레이션 + chatRoomMemberRepository.deleteAll(); + chatRoomRepository.deleteAll(); + stubUserService(); + stubChatMessageService(); + + // when: 채팅방 없어도 강퇴 성공해야 한다 (채팅 이벤트 리스너가 gracefully 처리) + kickRoommateUseCase.execute(HOST_NO, room.getRoomNo(), MEMBER_NO); + + // then: Roommate 삭제 확인 + assertThat(roommateRepository.existsByUserNoAndRoomNo(MEMBER_NO, room.getRoomNo())) + .as("채팅방이 없어도 Roommate는 삭제되어야 한다") + .isFalse(); + } + + // ========================================================================= + // Test 4: chatMessageService.save() 실패 → 전체 TX 롤백 + // ========================================================================= + + @Test + @DisplayName("원자성: chatMessageService.save() 실패 시 Roommate 강퇴도 롤백된다") + void kick_WhenChatMessageSaveFails_RoommateKickAlsoRolledBack() { + // given: BEFORE_COMMIT 리스너 내 chatMessageService.save() 강제 실패 + stubUserService(); + doThrow(new RuntimeException("메시지 저장 DB 오류 (강제 실패)")) + .when(chatMessageService).save(any(), anyString(), anyString(), any(), anyInt()); + + // when + assertThatThrownBy(() -> kickRoommateUseCase.execute(HOST_NO, room.getRoomNo(), MEMBER_NO)) + .isInstanceOf(RuntimeException.class); + + // then-1: Roommate 삭제가 롤백되어 여전히 존재해야 한다 + assertThat(roommateRepository.existsByUserNoAndRoomNo(MEMBER_NO, room.getRoomNo())) + .as("chatMessageService 실패로 TX 롤백 → Roommate 삭제 취소") + .isTrue(); + + // then-2: ChatRoomMember 변경 없이 그대로 존재해야 한다 + assertThat(chatRoomMemberRepository.existsByChatRoomAndUserNo(chatRoom, MEMBER_NO)) + .as("TX 롤백으로 ChatRoomMember는 변경되지 않아야 한다") + .isTrue(); + } + + // ========================================================================= + // Test 5: WebSocket 브로드캐스트 실패 (P1 케이스: 외부 요인 예외 격리) + // ========================================================================= + + @Test + @DisplayName("P1 케이스: WebSocket 메시지 전송 실패 시 TX는 롤백되지 않고 정상 커밋된다 (디커플링 보장)") + void kick_WhenWebSocketBroadcastFails_TxCommitsSuccessfully() { + // given: 메시지 전송 시 강제 예외 발생 + stubUserService(); + stubChatMessageService(); + doThrow(new RuntimeException("WebSocket 전송 실패 (강제 에러)")) + .when(messagingTemplate).convertAndSend(anyString(), any(Object.class)); + + // when: 강퇴 실행 + kickRoommateUseCase.execute(HOST_NO, room.getRoomNo(), MEMBER_NO); + + // then: Roommate 및 ChatRoomMember는 정상 삭제되어야 한다 (TX 커밋) + assertThat(roommateRepository.existsByUserNoAndRoomNo(MEMBER_NO, room.getRoomNo())) + .as("WebSocket 실패가 DB 트랜잭션 롤백으로 이어지지 않아야 한다") + .isFalse(); + assertThat(chatRoomMemberRepository.existsByChatRoomAndUserNo(chatRoom, MEMBER_NO)) + .as("채팅방 멤버 레코드도 삭제된 상태로 커밋되어야 한다") + .isFalse(); + } + + // ========================================================================= + // Test 6: 사용자가 채팅방에 이미 없는 경우 (P1 케이스: 멱등성 보장) + // ========================================================================= + + @Test + @DisplayName("P1 케이스: 강퇴 대상이 이미 채팅방 멤버가 아니어도 강퇴 트랜잭션은 성공한다") + void kick_WhenAlreadyLeftChatRoom_TxCommitsSuccessfully() { + // given: 예외적으로 ChatRoomMember 레코드만 미리 삭제된 상태 + chatRoomMemberService.leave( + chatRoomMemberRepository.findByChatRoomAndUserNo(chatRoom, MEMBER_NO) + .orElseThrow() + ); + chatRoomMemberRepository.flush(); // 영속성 컨텍스트 비우기 + stubUserService(); + stubChatMessageService(); + + // when: 방 강퇴 + kickRoommateUseCase.execute(HOST_NO, room.getRoomNo(), MEMBER_NO); + + // then: 트랜잭션 정상 수행됨 (방에서는 삭제됨) + assertThat(roommateRepository.existsByUserNoAndRoomNo(MEMBER_NO, room.getRoomNo())) + .isFalse(); + } + + // ========================================================================= + // 헬퍼 + // ========================================================================= + + /** RoommateKickedEventListener 내 userService.findById() 우회 (User 엔티티 저장 불필요) */ + private void stubUserService() { + User fakeUser = User.builder() + .nickname("테스트멤버") + .name("홍길동") + .email("member-atomicity@test.com") + .password("pw") + .role(Role.USER) + .studentNo("202400001") + .build(); + doReturn(fakeUser).when(userService).findById(MEMBER_NO); + } + + /** chatMessageService.save() 우회 — ChatMessage DB 저장 없이 테스트 (cleanup 단순화) */ + private void stubChatMessageService() { + ChatMessage fakeMessage = mock(ChatMessage.class); + when(fakeMessage.getMessageNo()).thenReturn("fake-msg-001"); + when(fakeMessage.getCreatedAt()).thenReturn(LocalDateTime.now()); + doReturn(fakeMessage) + .when(chatMessageService) + .save(any(), anyString(), anyString(), any(), anyInt()); + } +} diff --git a/src/test/java/com/project/dorumdorum/domain/chat/unit/service/ChatRoomMemberServiceTest.java b/src/test/java/com/project/dorumdorum/domain/chat/unit/service/ChatRoomMemberServiceTest.java index 7874eeca..b3ad6add 100644 --- a/src/test/java/com/project/dorumdorum/domain/chat/unit/service/ChatRoomMemberServiceTest.java +++ b/src/test/java/com/project/dorumdorum/domain/chat/unit/service/ChatRoomMemberServiceTest.java @@ -11,7 +11,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.dao.DataIntegrityViolationException; import java.time.LocalDateTime; import java.util.List; @@ -54,17 +53,6 @@ void join_WhenDuplicate_Throws() { .isInstanceOf(RestApiException.class); } - @Test - @DisplayName("join: 동시 입장으로 save 중 DataIntegrityViolationException 발생 시 RestApiException으로 변환") - void join_WhenSaveThrowsDataIntegrityViolation_ThrowsRestApiException() { - when(chatRoomMemberRepository.existsByChatRoomAndUserNo(chatRoom, "user-1")).thenReturn(false); - when(chatRoomMemberRepository.save(any(ChatRoomMember.class))) - .thenThrow(new DataIntegrityViolationException("UK violation")); - - assertThatThrownBy(() -> service.join(chatRoom, "user-1")) - .isInstanceOf(RestApiException.class); - } - @Test @DisplayName("findByChatRoomAndUserNo: 존재하면 반환") void findByChatRoomAndUserNo_WhenExists_Returns() { @@ -85,6 +73,28 @@ void findByChatRoomAndUserNo_WhenMissing_Throws() { .isInstanceOf(RestApiException.class); } + @Test + @DisplayName("findByChatRoomNoAndUserNoForUpdate: 존재하면 반환") + void findByChatRoomNoAndUserNoForUpdate_WhenExists_Returns() { + ChatRoomMember member = ChatRoomMember.builder().chatRoom(chatRoom).userNo("user-1").build(); + when(chatRoomMemberRepository.findByChatRoomNoAndUserNoForUpdate("cr-1", "user-1")) + .thenReturn(Optional.of(member)); + + ChatRoomMember result = service.findByChatRoomNoAndUserNoForUpdate("cr-1", "user-1"); + + assertThat(result).isEqualTo(member); + } + + @Test + @DisplayName("findByChatRoomNoAndUserNoForUpdate: 존재하지 않으면 RestApiException") + void findByChatRoomNoAndUserNoForUpdate_WhenMissing_Throws() { + when(chatRoomMemberRepository.findByChatRoomNoAndUserNoForUpdate("cr-1", "user-1")) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.findByChatRoomNoAndUserNoForUpdate("cr-1", "user-1")) + .isInstanceOf(RestApiException.class); + } + @Test @DisplayName("validateMembership: 멤버가 아니면 RestApiException") void validateMembership_WhenNotMember_Throws() { diff --git a/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/CreateChatRoomUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/CreateChatRoomUseCaseTest.java index 3bab7783..a4976acb 100644 --- a/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/CreateChatRoomUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/CreateChatRoomUseCaseTest.java @@ -11,6 +11,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; import java.util.List; import java.util.Optional; @@ -56,4 +57,34 @@ void handle_WhenAlreadyExists_JoinsOnlyNonMembers() { verify(chatRoomMemberService, never()).join(chatRoom, "user-1"); verify(chatRoomMemberService).join(chatRoom, "user-2"); } + + @Test + @DisplayName("채팅방 생성 중 DataIntegrityViolationException 발생 시 재조회하여 처리") + void handle_WhenDuplicateOnCreate_RetriesWithExistingRoom() { + ChatRoom chatRoom = ChatRoom.builder().roomNo("room-1").build(); + when(chatRoomService.findByRoomNo("room-1")) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(chatRoom)); + when(chatRoomService.create("room-1")).thenThrow(new DataIntegrityViolationException("duplicate")); + when(chatRoomMemberService.isMember(chatRoom, "user-1")).thenReturn(false); + + useCase.handle(new RoomConfirmedEvent("room-1", List.of("user-1"))); + + verify(chatRoomMemberService).join(chatRoom, "user-1"); + } + + @Test + @DisplayName("멤버 입장 중 DataIntegrityViolationException 발생 시 해당 멤버만 스킵하고 나머지 계속 진행") + void handle_WhenDuplicateOnJoin_SkipsAndContinues() { + ChatRoom chatRoom = ChatRoom.builder().roomNo("room-1").build(); + when(chatRoomService.findByRoomNo("room-1")).thenReturn(Optional.of(chatRoom)); + when(chatRoomMemberService.isMember(chatRoom, "user-1")).thenReturn(false); + when(chatRoomMemberService.isMember(chatRoom, "user-2")).thenReturn(false); + doThrow(new DataIntegrityViolationException("duplicate")).when(chatRoomMemberService).join(chatRoom, "user-1"); + + useCase.handle(new RoomConfirmedEvent("room-1", List.of("user-1", "user-2"))); + + verify(chatRoomMemberService).join(chatRoom, "user-1"); + verify(chatRoomMemberService).join(chatRoom, "user-2"); + } } diff --git a/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/JoinChatRoomUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/JoinChatRoomUseCaseTest.java index 57ad6977..e0c0eeb2 100644 --- a/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/JoinChatRoomUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/JoinChatRoomUseCaseTest.java @@ -10,6 +10,7 @@ import com.project.dorumdorum.domain.room.application.event.RoommateAcceptedEvent; import com.project.dorumdorum.domain.user.domain.entity.User; import com.project.dorumdorum.domain.user.domain.service.UserService; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.messaging.simp.SimpMessagingTemplate; import java.time.LocalDateTime; @@ -92,4 +93,75 @@ void handle_WhenAlreadyMember_DoesNothing() { verify(chatRoomMemberService, never()).join(any(), any()); verify(chatMessageService, never()).save(any(), any(), any(), any(), anyInt()); } + + @Test + @DisplayName("방장 입장 중 DataIntegrityViolationException 발생 시 무시하고 계속 진행") + void handle_WhenHostJoinDuplicate_IgnoresAndContinues() { + User mockUser = mock(User.class); + ChatMessage mockMessage = mock(ChatMessage.class); + when(chatRoomService.findByRoomNo("room-1")).thenReturn(Optional.empty()); + when(chatRoomService.create("room-1")).thenReturn(chatRoom); + doThrow(new DataIntegrityViolationException("duplicate")).when(chatRoomMemberService).join(chatRoom, "user-host"); + when(chatRoomMemberService.isMember(chatRoom, "user-new")).thenReturn(false); + when(userService.findById("user-new")).thenReturn(mockUser); + when(mockUser.getNickname()).thenReturn("nick"); + when(chatMessageService.save(any(), any(), any(), any(), anyInt())).thenReturn(mockMessage); + when(mockMessage.getMessageNo()).thenReturn("msg-1"); + when(mockMessage.getCreatedAt()).thenReturn(LocalDateTime.now()); + + useCase.handle(new RoommateAcceptedEvent("room-1", "user-new", "user-host")); + + verify(chatRoomMemberService).join(chatRoom, "user-new"); + } + + @Test + @DisplayName("신규 멤버 입장 중 DataIntegrityViolationException 발생 시 시스템 메시지 없이 조용히 종료") + void handle_WhenNewMemberJoinDuplicate_SkipsMessageAndReturns() { + when(chatRoomService.findByRoomNo("room-1")).thenReturn(Optional.of(chatRoom)); + when(chatRoomMemberService.isMember(chatRoom, "user-new")).thenReturn(false); + doThrow(new DataIntegrityViolationException("duplicate")).when(chatRoomMemberService).join(chatRoom, "user-new"); + + useCase.handle(new RoommateAcceptedEvent("room-1", "user-new", "user-host")); + + verify(chatMessageService, never()).save(any(), any(), any(), any(), anyInt()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + } + + @Test + @DisplayName("닉네임이 없으면 이름으로 시스템 메시지 생성") + void handle_WhenNicknameBlank_UsesName() { + User mockUser = mock(User.class); + ChatMessage mockMessage = mock(ChatMessage.class); + when(chatRoomService.findByRoomNo("room-1")).thenReturn(Optional.of(chatRoom)); + when(chatRoomMemberService.isMember(chatRoom, "user-new")).thenReturn(false); + when(userService.findById("user-new")).thenReturn(mockUser); + when(mockUser.getNickname()).thenReturn(""); + when(mockUser.getName()).thenReturn("홍길동"); + when(chatMessageService.save(any(), eq("SYSTEM"), contains("홍길동"), eq(MessageType.SYSTEM), eq(0))).thenReturn(mockMessage); + when(mockMessage.getMessageNo()).thenReturn("msg-1"); + when(mockMessage.getCreatedAt()).thenReturn(LocalDateTime.now()); + + useCase.handle(new RoommateAcceptedEvent("room-1", "user-new", "user-host")); + + verify(chatMessageService).save(eq(chatRoom), eq("SYSTEM"), contains("홍길동"), eq(MessageType.SYSTEM), eq(0)); + } + + @Test + @DisplayName("WebSocket 브로드캐스트 실패 시 예외를 무시하고 정상 종료") + void handle_WhenWebSocketFails_LogsAndContinues() { + User mockUser = mock(User.class); + ChatMessage mockMessage = mock(ChatMessage.class); + when(chatRoomService.findByRoomNo("room-1")).thenReturn(Optional.of(chatRoom)); + when(chatRoomMemberService.isMember(chatRoom, "user-new")).thenReturn(false); + when(userService.findById("user-new")).thenReturn(mockUser); + when(mockUser.getNickname()).thenReturn("nick"); + when(chatMessageService.save(any(), any(), any(), any(), anyInt())).thenReturn(mockMessage); + when(mockMessage.getMessageNo()).thenReturn("msg-1"); + when(mockMessage.getCreatedAt()).thenReturn(LocalDateTime.now()); + doThrow(new RuntimeException("WebSocket error")).when(messagingTemplate).convertAndSend(anyString(), any(Object.class)); + + useCase.handle(new RoommateAcceptedEvent("room-1", "user-new", "user-host")); + + verify(chatMessageService).save(any(), any(), any(), any(), anyInt()); + } } diff --git a/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/LeaveChatRoomUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/LeaveChatRoomUseCaseTest.java index 38536313..f207a2ee 100644 --- a/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/LeaveChatRoomUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/LeaveChatRoomUseCaseTest.java @@ -29,6 +29,8 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import org.mockito.InOrder; + @ExtendWith(MockitoExtension.class) @DisplayName("LeaveChatRoomUseCase Unit Tests") class LeaveChatRoomUseCaseTest { @@ -56,8 +58,8 @@ void execute_WhenNonHost_LeavesAndSavesSystemMessage() { when(chatRoomMemberService.countByChatRoom(chatRoom)).thenReturn(2L); when(chatRoom.getRoomNo()).thenReturn("room-1"); when(chatRoom.getChatRoomType()).thenReturn(ChatRoomType.GROUP); - when(roommateService.isHostOfRoom("u1", "room-1")).thenReturn(false); - when(roomService.findById("room-1")).thenReturn(room); + when(roomService.findByIdForUpdate("room-1")).thenReturn(room); + when(roommateService.isHost("u1", room)).thenReturn(false); when(userService.findById("u1")).thenReturn(mockUser); when(mockUser.getNickname()).thenReturn("nick1"); when(chatMessageService.save(any(), eq("SYSTEM"), anyString(), eq(MessageType.SYSTEM), eq(0))).thenReturn(mockMessage); @@ -83,7 +85,9 @@ void execute_WhenHostWithOtherMembers_ThrowsHostCannotLeave() { when(chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, "u1")).thenReturn(member); when(chatRoomMemberService.countByChatRoom(chatRoom)).thenReturn(2L); when(chatRoom.getRoomNo()).thenReturn("room-1"); - when(roommateService.isHostOfRoom("u1", "room-1")).thenReturn(true); + when(chatRoom.getChatRoomType()).thenReturn(ChatRoomType.GROUP); + when(roomService.findByIdForUpdate("room-1")).thenReturn(mock(Room.class)); + when(roommateService.isHost(eq("u1"), any(Room.class))).thenReturn(true); assertThatThrownBy(() -> useCase.execute("cr-1", "u1")) .isInstanceOf(RestApiException.class); @@ -104,7 +108,7 @@ void execute_WhenLastMember_LeavesAndDeletesChatRoom() { when(chatRoomMemberService.countByChatRoom(chatRoom)).thenReturn(1L); when(chatRoom.getRoomNo()).thenReturn("room-1"); when(chatRoom.getChatRoomType()).thenReturn(ChatRoomType.GROUP); - when(roomService.findById("room-1")).thenReturn(room); + when(roomService.findByIdForUpdate("room-1")).thenReturn(room); useCase.execute("cr-1", "u1"); @@ -115,6 +119,90 @@ void execute_WhenLastMember_LeavesAndDeletesChatRoom() { verify(chatMessageService, never()).save(any(), any(), any(), any(), anyInt()); } + @Test + @DisplayName("비방장이 퇴장할 때 lastReadAt이 없으면 joinedAt 기준으로 decreaseUnreadCount가 leave() 이전에 호출된다") + void execute_WhenNonHostLeaves_DecreasesUnreadCountBeforeLeave() { + ChatRoom chatRoom = mock(ChatRoom.class); + ChatRoomMember member = mock(ChatRoomMember.class); + Room room = mock(Room.class); + User mockUser = mock(User.class); + ChatMessage mockMessage = mock(ChatMessage.class); + + LocalDateTime joinedAt = LocalDateTime.of(2026, 1, 1, 0, 0); + when(chatRoomService.findByChatRoomNo("cr-1")).thenReturn(chatRoom); + when(chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, "u1")).thenReturn(member); + when(chatRoomMemberService.countByChatRoom(chatRoom)).thenReturn(2L); + when(chatRoom.getRoomNo()).thenReturn("room-1"); + when(chatRoom.getChatRoomType()).thenReturn(ChatRoomType.GROUP); + when(chatRoom.getChatRoomNo()).thenReturn("cr-1"); + when(roomService.findByIdForUpdate("room-1")).thenReturn(room); + when(roommateService.isHost("u1", room)).thenReturn(false); + when(member.getLastReadAt()).thenReturn(null); + when(member.getJoinedAt()).thenReturn(joinedAt); + when(userService.findById("u1")).thenReturn(mockUser); + when(mockUser.getNickname()).thenReturn("nick1"); + when(chatMessageService.save(any(), eq("SYSTEM"), anyString(), eq(MessageType.SYSTEM), eq(0))).thenReturn(mockMessage); + when(mockMessage.getMessageNo()).thenReturn("msg-1"); + when(mockMessage.getCreatedAt()).thenReturn(LocalDateTime.now()); + + useCase.execute("cr-1", "u1"); + + verify(chatMessageService).decreaseUnreadCount("cr-1", LocalDateTime.of(2026, 1, 1, 0, 0), "u1"); + + InOrder inOrder = inOrder(chatMessageService, chatRoomMemberService); + inOrder.verify(chatMessageService).decreaseUnreadCount("cr-1", LocalDateTime.of(2026, 1, 1, 0, 0), "u1"); + inOrder.verify(chatRoomMemberService).leave(member); + } + + @Test + @DisplayName("마지막 멤버가 퇴장할 때는 decreaseUnreadCount가 호출되지 않는다") + void execute_WhenLastMemberLeaves_DoesNotDecreaseUnreadCount() { + ChatRoom chatRoom = mock(ChatRoom.class); + ChatRoomMember member = mock(ChatRoomMember.class); + Room room = mock(Room.class); + + when(chatRoomService.findByChatRoomNo("cr-1")).thenReturn(chatRoom); + when(chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, "u1")).thenReturn(member); + when(chatRoomMemberService.countByChatRoom(chatRoom)).thenReturn(1L); + when(chatRoom.getRoomNo()).thenReturn("room-1"); + when(chatRoom.getChatRoomType()).thenReturn(ChatRoomType.GROUP); + when(roomService.findByIdForUpdate("room-1")).thenReturn(room); + + useCase.execute("cr-1", "u1"); + + verify(chatMessageService, never()).decreaseUnreadCount(any(), any(), any()); + } + + @Test + @DisplayName("lastReadAt이 있으면 lastReadAt을 fromTime으로 decreaseUnreadCount가 호출된다") + void execute_WhenLastReadAtExists_UsesLastReadAtAsFromTime() { + ChatRoom chatRoom = mock(ChatRoom.class); + ChatRoomMember member = mock(ChatRoomMember.class); + Room room = mock(Room.class); + User mockUser = mock(User.class); + ChatMessage mockMessage = mock(ChatMessage.class); + + LocalDateTime lastReadAt = LocalDateTime.of(2026, 3, 1, 12, 0); + when(chatRoomService.findByChatRoomNo("cr-1")).thenReturn(chatRoom); + when(chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, "u1")).thenReturn(member); + when(chatRoomMemberService.countByChatRoom(chatRoom)).thenReturn(2L); + when(chatRoom.getRoomNo()).thenReturn("room-1"); + when(chatRoom.getChatRoomType()).thenReturn(ChatRoomType.GROUP); + when(chatRoom.getChatRoomNo()).thenReturn("cr-1"); + when(roomService.findByIdForUpdate("room-1")).thenReturn(room); + when(roommateService.isHost("u1", room)).thenReturn(false); + when(member.getLastReadAt()).thenReturn(lastReadAt); + when(userService.findById("u1")).thenReturn(mockUser); + when(mockUser.getNickname()).thenReturn("nick1"); + when(chatMessageService.save(any(), eq("SYSTEM"), anyString(), eq(MessageType.SYSTEM), eq(0))).thenReturn(mockMessage); + when(mockMessage.getMessageNo()).thenReturn("msg-1"); + when(mockMessage.getCreatedAt()).thenReturn(LocalDateTime.now()); + + useCase.execute("cr-1", "u1"); + + verify(chatMessageService).decreaseUnreadCount("cr-1", LocalDateTime.of(2026, 3, 1, 12, 0), "u1"); + } + @Test @DisplayName("DIRECT 채팅방 퇴장 시 룸메이트 삭제와 방 인원수 감소가 일어나지 않는다") void execute_WhenDirectChatRoom_DoesNotUpdateRoomCount() { @@ -130,7 +218,56 @@ void execute_WhenDirectChatRoom_DoesNotUpdateRoomCount() { verify(chatRoomMemberService).leave(member); verify(roommateService, never()).leaveRoom(any(), any()); - verify(roomService, never()).findById(any()); + verify(roomService, never()).findByIdForUpdate(any()); verify(chatRoomService).delete(chatRoom); } + + @Test + @DisplayName("DIRECT 채팅방에서 다른 멤버가 있을 때는 unreadCount를 감소시키고 나간다") + void execute_WhenDirectChatHasOtherMember_DecreasesUnreadCountBeforeLeave() { + ChatRoom chatRoom = mock(ChatRoom.class); + ChatRoomMember member = mock(ChatRoomMember.class); + User user = mock(User.class); + ChatMessage message = mock(ChatMessage.class); + + LocalDateTime lastReadAt = LocalDateTime.of(2026, 5, 1, 10, 0); + when(chatRoomService.findByChatRoomNo("cr-1")).thenReturn(chatRoom); + when(chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, "u1")).thenReturn(member); + when(chatRoomMemberService.countByChatRoom(chatRoom)).thenReturn(2L); + when(chatRoom.getChatRoomType()).thenReturn(ChatRoomType.DIRECT); + when(chatRoom.getChatRoomNo()).thenReturn("cr-1"); + when(member.getLastReadAt()).thenReturn(lastReadAt); + when(userService.findById("u1")).thenReturn(user); + when(user.getNickname()).thenReturn("nick"); + when(chatMessageService.save(any(), eq("SYSTEM"), anyString(), eq(MessageType.SYSTEM), eq(0))).thenReturn(message); + when(message.getMessageNo()).thenReturn("msg-1"); + when(message.getCreatedAt()).thenReturn(LocalDateTime.now()); + + useCase.execute("cr-1", "u1"); + + verify(chatMessageService).decreaseUnreadCount("cr-1", lastReadAt, "u1"); + InOrder inOrder = inOrder(chatMessageService, chatRoomMemberService); + inOrder.verify(chatMessageService).decreaseUnreadCount("cr-1", lastReadAt, "u1"); + inOrder.verify(chatRoomMemberService).leave(member); + } + + @Test + @DisplayName("DIRECT 채팅방에서 방장은 다른 멤버가 있으면 퇴장할 수 없다") + void execute_WhenDirectHostHasOtherMember_ThrowsHostCannotLeave() { + ChatRoom chatRoom = mock(ChatRoom.class); + ChatRoomMember member = mock(ChatRoomMember.class); + + when(chatRoomService.findByChatRoomNo("cr-1")).thenReturn(chatRoom); + when(chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, "u1")).thenReturn(member); + when(chatRoomMemberService.countByChatRoom(chatRoom)).thenReturn(2L); + when(chatRoom.getChatRoomType()).thenReturn(ChatRoomType.DIRECT); + when(chatRoom.getRoomNo()).thenReturn("room-1"); + when(roommateService.isHostOfRoom("u1", "room-1")).thenReturn(true); + + assertThatThrownBy(() -> useCase.execute("cr-1", "u1")) + .isInstanceOf(RestApiException.class); + + verify(chatMessageService, never()).decreaseUnreadCount(any(), any(), any()); + verify(chatRoomMemberService, never()).leave(any()); + } } diff --git a/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/MarkChatRoomReadUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/MarkChatRoomReadUseCaseTest.java index 18ad5df7..42d7ad26 100644 --- a/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/MarkChatRoomReadUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/MarkChatRoomReadUseCaseTest.java @@ -1,17 +1,17 @@ package com.project.dorumdorum.domain.chat.unit.usecase; +import com.project.dorumdorum.domain.chat.application.dto.response.ChatReadReceiptResponse; import com.project.dorumdorum.domain.chat.application.usecase.MarkChatRoomReadUseCase; -import com.project.dorumdorum.domain.chat.domain.entity.ChatRoom; import com.project.dorumdorum.domain.chat.domain.entity.ChatRoomMember; import com.project.dorumdorum.domain.chat.domain.service.ChatMessageService; import com.project.dorumdorum.domain.chat.domain.service.ChatRoomMemberService; -import com.project.dorumdorum.domain.chat.domain.service.ChatRoomService; 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.simp.SimpMessagingTemplate; import java.time.LocalDateTime; @@ -23,37 +23,34 @@ @DisplayName("MarkChatRoomReadUseCase Unit Tests") class MarkChatRoomReadUseCaseTest { - @Mock private ChatRoomService chatRoomService; @Mock private ChatRoomMemberService chatRoomMemberService; @Mock private ChatMessageService chatMessageService; + @Mock private SimpMessagingTemplate messagingTemplate; @InjectMocks private MarkChatRoomReadUseCase useCase; @Test @DisplayName("lastReadAt이 있으면 해당 시각 이후 메시지의 unread를 감소시키고 lastReadAt을 갱신한다") void execute_WhenLastReadAtExists_DecreasesFromLastReadAt() { - ChatRoom chatRoom = mock(ChatRoom.class); ChatRoomMember member = mock(ChatRoomMember.class); LocalDateTime lastReadAt = LocalDateTime.now().minusHours(1); - when(chatRoomService.findByChatRoomNo("cr-1")).thenReturn(chatRoom); - when(chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, "u1")).thenReturn(member); + when(chatRoomMemberService.findByChatRoomNoAndUserNoForUpdate("cr-1", "u1")).thenReturn(member); when(member.getLastReadAt()).thenReturn(lastReadAt); useCase.execute("cr-1", "u1"); verify(chatMessageService).decreaseUnreadCount("cr-1", lastReadAt, "u1"); verify(chatRoomMemberService).updateLastReadAt(eq(member), any(LocalDateTime.class)); + verify(messagingTemplate).convertAndSend(eq("/topic/chat-room/cr-1/read"), any(ChatReadReceiptResponse.class)); } @Test @DisplayName("lastReadAt이 null이면 joinedAt 이후 메시지의 unread를 감소시킨다") void execute_WhenLastReadAtIsNull_DecreasesFromJoinedAt() { - ChatRoom chatRoom = mock(ChatRoom.class); ChatRoomMember member = mock(ChatRoomMember.class); LocalDateTime joinedAt = LocalDateTime.now().minusDays(1); - when(chatRoomService.findByChatRoomNo("cr-1")).thenReturn(chatRoom); - when(chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, "u1")).thenReturn(member); + when(chatRoomMemberService.findByChatRoomNoAndUserNoForUpdate("cr-1", "u1")).thenReturn(member); when(member.getLastReadAt()).thenReturn(null); when(member.getJoinedAt()).thenReturn(joinedAt); @@ -61,5 +58,6 @@ void execute_WhenLastReadAtIsNull_DecreasesFromJoinedAt() { verify(chatMessageService).decreaseUnreadCount("cr-1", joinedAt, "u1"); verify(chatRoomMemberService).updateLastReadAt(eq(member), any(LocalDateTime.class)); + verify(messagingTemplate).convertAndSend(eq("/topic/chat-room/cr-1/read"), any(ChatReadReceiptResponse.class)); } } diff --git a/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/RoommateKickedEventListenerTest.java b/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/RoommateKickedEventListenerTest.java index 05170c56..b43789aa 100644 --- a/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/RoommateKickedEventListenerTest.java +++ b/src/test/java/com/project/dorumdorum/domain/chat/unit/usecase/RoommateKickedEventListenerTest.java @@ -26,6 +26,8 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import org.mockito.InOrder; + @ExtendWith(MockitoExtension.class) @DisplayName("RoommateKickedEventListener Unit Tests") class RoommateKickedEventListenerTest { @@ -82,4 +84,71 @@ void handle_WhenNotMember_DoesNothing() { verify(chatRoomMemberService, never()).leave(any()); verify(chatMessageService, never()).save(any(), any(), any(), any(), anyInt()); } + + @Test + @DisplayName("닉네임이 없으면 이름으로 시스템 메시지 생성") + void handle_WhenNicknameNull_UsesName() { + ChatRoomMember member = ChatRoomMember.builder().chatRoom(chatRoom).userNo("user-kicked").build(); + User mockUser = mock(User.class); + ChatMessage mockMessage = mock(ChatMessage.class); + when(chatRoomService.findByRoomNo("room-1")).thenReturn(Optional.of(chatRoom)); + when(chatRoomMemberService.isMember(chatRoom, "user-kicked")).thenReturn(true); + when(chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, "user-kicked")).thenReturn(member); + when(userService.findById("user-kicked")).thenReturn(mockUser); + when(mockUser.getNickname()).thenReturn(null); + when(mockUser.getName()).thenReturn("홍길동"); + when(chatMessageService.save(any(), eq("SYSTEM"), contains("홍길동"), eq(MessageType.SYSTEM), eq(0))).thenReturn(mockMessage); + when(mockMessage.getMessageNo()).thenReturn("msg-1"); + when(mockMessage.getCreatedAt()).thenReturn(LocalDateTime.now()); + + listener.handle(new RoommateKickedEvent("room-1", "user-kicked")); + + verify(chatMessageService).save(eq(chatRoom), eq("SYSTEM"), contains("홍길동"), eq(MessageType.SYSTEM), eq(0)); + } + + @Test + @DisplayName("멤버가 존재하면 decreaseUnreadCount가 leave() 이전에 호출된다") + void handle_WhenMemberExists_DecreasesUnreadCountBeforeLeave() { + ChatRoomMember member = ChatRoomMember.builder().chatRoom(chatRoom).userNo("user-kicked").build(); + User mockUser = mock(User.class); + ChatMessage mockMessage = mock(ChatMessage.class); + when(chatRoomService.findByRoomNo("room-1")).thenReturn(Optional.of(chatRoom)); + when(chatRoomMemberService.isMember(chatRoom, "user-kicked")).thenReturn(true); + when(chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, "user-kicked")).thenReturn(member); + when(userService.findById("user-kicked")).thenReturn(mockUser); + when(mockUser.getNickname()).thenReturn("kickedNick"); + when(chatMessageService.save(any(), eq("SYSTEM"), anyString(), eq(MessageType.SYSTEM), eq(0))).thenReturn(mockMessage); + when(mockMessage.getMessageNo()).thenReturn("msg-1"); + when(mockMessage.getCreatedAt()).thenReturn(LocalDateTime.now()); + + listener.handle(new RoommateKickedEvent("room-1", "user-kicked")); + + verify(chatMessageService).decreaseUnreadCount(any(), any(), eq("user-kicked")); + + InOrder inOrder = inOrder(chatMessageService, chatRoomMemberService); + inOrder.verify(chatMessageService).decreaseUnreadCount(any(), any(), eq("user-kicked")); + inOrder.verify(chatRoomMemberService).leave(member); + } + + @Test + @DisplayName("WebSocket 브로드캐스트 실패 시 예외를 무시하고 정상 종료") + void handle_WhenWebSocketFails_LogsAndContinues() { + ChatRoomMember member = ChatRoomMember.builder().chatRoom(chatRoom).userNo("user-kicked").build(); + User mockUser = mock(User.class); + ChatMessage mockMessage = mock(ChatMessage.class); + when(chatRoomService.findByRoomNo("room-1")).thenReturn(Optional.of(chatRoom)); + when(chatRoomMemberService.isMember(chatRoom, "user-kicked")).thenReturn(true); + when(chatRoomMemberService.findByChatRoomAndUserNo(chatRoom, "user-kicked")).thenReturn(member); + when(userService.findById("user-kicked")).thenReturn(mockUser); + when(mockUser.getNickname()).thenReturn("nick"); + when(chatMessageService.save(any(), any(), any(), any(), anyInt())).thenReturn(mockMessage); + when(mockMessage.getMessageNo()).thenReturn("msg-1"); + when(mockMessage.getCreatedAt()).thenReturn(LocalDateTime.now()); + doThrow(new RuntimeException("WebSocket error")).when(messagingTemplate).convertAndSend(anyString(), any(Object.class)); + + listener.handle(new RoommateKickedEvent("room-1", "user-kicked")); + + verify(chatRoomMemberService).leave(member); + verify(chatMessageService).save(any(), any(), any(), any(), anyInt()); + } } diff --git a/src/test/java/com/project/dorumdorum/domain/checklist/unit/usecase/UpdateRoomRuleUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/checklist/unit/usecase/UpdateRoomRuleUseCaseTest.java index 32921196..c29128f0 100644 --- a/src/test/java/com/project/dorumdorum/domain/checklist/unit/usecase/UpdateRoomRuleUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/checklist/unit/usecase/UpdateRoomRuleUseCaseTest.java @@ -18,6 +18,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.INVALID_ROOM_CAPACITY; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -35,9 +36,10 @@ void execute_WhenHost_UpdatesRoomAndRule() { UpdateRoomRuleRequest request = ChecklistFixtures.updateRoomRuleRequest(); Room room = mock(Room.class); RoomRule roomRule = RoomRule.builder().room(Room.builder().roomNo("r1").build()).build(); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.isHost("u1", room)).thenReturn(true); when(roomRuleService.findByRoomNo("r1")).thenReturn(roomRule); + when(room.isValidCapacity(request.capacity())).thenReturn(true); useCase.execute("u1", "r1", request); @@ -51,7 +53,7 @@ void execute_WhenHost_UpdatesRoomAndRule() { @Test void execute_WhenNotHost_Throws() { Room room = Room.builder().roomNo("r1").build(); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.isHost("u1", room)).thenReturn(false); assertThatThrownBy(() -> useCase.execute("u1", "r1", ChecklistFixtures.updateRoomRuleRequest())) @@ -59,4 +61,62 @@ void execute_WhenNotHost_Throws() { verify(roomRuleService, never()).findByRoomNo(anyString()); } + + @Test + void execute_WhenCapacityLessThanCurrentMateCount_Throws() { + UpdateRoomRuleRequest request = ChecklistFixtures.updateRoomRuleRequest(); + Room room = spy(Room.builder() + .roomNo("r1") + .capacity(3) + .title("title") + .hostUserNo("host") + .roomType(request.roomType()) + .residencePeriod(request.residencePeriod()) + .build()); + room.init(); + room.plusCurrentMate(); + RoomRule roomRule = RoomRule.builder().room(room).build(); + UpdateRoomRuleRequest invalidRequest = new UpdateRoomRuleRequest( + request.bedtime(), + request.wakeUp(), + request.returnHome(), + request.returnHomeTime(), + request.cleaning(), + request.phoneCall(), + request.sleepLight(), + request.sleepHabit(), + request.snoring(), + request.showerTime(), + request.eating(), + request.lightsOut(), + request.lightsOutTime(), + request.homeVisit(), + request.smoking(), + request.refrigerator(), + request.hairDryer(), + request.alarm(), + request.earphone(), + request.keyskin(), + request.heat(), + request.cold(), + request.study(), + request.trashCan(), + request.otherNotes(), + request.roomType(), + 1, + request.residencePeriod() + ); + + when(roomService.findByIdForUpdate("r1")).thenReturn(room); + when(roommateService.isHost("u1", room)).thenReturn(true); + when(roomRuleService.findByRoomNo("r1")).thenReturn(roomRule); + + assertThatThrownBy(() -> useCase.execute("u1", "r1", invalidRequest)) + .isInstanceOf(RestApiException.class) + .extracting("errorCode.code") + .isEqualTo(INVALID_ROOM_CAPACITY.getCode().getCode()); + + verify(roomRuleMapper, never()).updateRoomRule(any(), any()); + verify(roomRuleService, never()).save(any()); + } } diff --git a/src/test/java/com/project/dorumdorum/domain/notification/infra/repository/RedisUserPresenceRepositoryTest.java b/src/test/java/com/project/dorumdorum/domain/notification/infra/repository/RedisUserPresenceRepositoryTest.java index 8ba8d1a8..a7538b33 100644 --- a/src/test/java/com/project/dorumdorum/domain/notification/infra/repository/RedisUserPresenceRepositoryTest.java +++ b/src/test/java/com/project/dorumdorum/domain/notification/infra/repository/RedisUserPresenceRepositoryTest.java @@ -38,8 +38,8 @@ void setUp() { } @Test - @DisplayName("setOnline은 현재 IN_CHATROOM 상태이면 상태를 유지하고 TTL만 갱신한다") - void setOnline_PreservesInChatroomPresence() { + @DisplayName("refreshPresence는 현재 IN_CHATROOM 상태이면 상태를 유지하고 TTL만 갱신한다") + void refreshPresence_PreservesInChatroomPresence() { // given String userNo = "user-1"; String key = "notification:presence:" + userNo; @@ -47,26 +47,36 @@ void setOnline_PreservesInChatroomPresence() { when(valueOperations.get(key)).thenReturn(inChatroom.toRedisValue()); // when - assertThatCode(() -> repository.setOnline(userNo)).doesNotThrowAnyException(); + assertThatCode(() -> repository.refreshPresence(userNo)).doesNotThrowAnyException(); // then verify(valueOperations).set(eq(key), eq(inChatroom.toRedisValue()), any(Duration.class)); } @Test - @DisplayName("setOnline은 IN_CHATROOM이 아닐 때 ONLINE 상태로 설정한다") - void setOnline_SetsOnlineWhenNotInChatroom() { + @DisplayName("refreshPresence는 OFFLINE일 때 ONLINE 상태로 설정한다") + void refreshPresence_SetsOnlineWhenOffline() { // given String userNo = "user-2"; String key = "notification:presence:" + userNo; when(valueOperations.get(key)).thenReturn(null); // OFFLINE // when - repository.setOnline(userNo); + repository.refreshPresence(userNo); // then String expected = UserPresence.online().toRedisValue(); // "ONLINE" verify(valueOperations).set(eq(key), eq(expected), any(Duration.class)); } -} + @Test + @DisplayName("setOnline은 현재 상태와 무관하게 ONLINE으로 전환한다") + void setOnline_SetsOnline() { + String userNo = "user-3"; + String key = "notification:presence:" + userNo; + + repository.setOnline(userNo); + + verify(valueOperations).set(eq(key), eq(UserPresence.online().toRedisValue()), any(Duration.class)); + } +} diff --git a/src/test/java/com/project/dorumdorum/domain/notification/infra/sse/ChatRoomPresenceListenerTest.java b/src/test/java/com/project/dorumdorum/domain/notification/infra/sse/ChatRoomPresenceListenerTest.java new file mode 100644 index 00000000..3385e6ad --- /dev/null +++ b/src/test/java/com/project/dorumdorum/domain/notification/infra/sse/ChatRoomPresenceListenerTest.java @@ -0,0 +1,97 @@ +package com.project.dorumdorum.domain.notification.infra.sse; + +import com.project.dorumdorum.domain.notification.domain.repository.UserPresenceRepository; +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.web.socket.CloseStatus; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; + +import java.util.HashMap; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChatRoomPresenceListener 단위 테스트") +class ChatRoomPresenceListenerTest { + + @Mock + private UserPresenceRepository userPresenceRepository; + + @Mock + private SseEmitterRegistry sseEmitterRegistry; + + @InjectMocks + private ChatRoomPresenceListener listener; + + @Test + @DisplayName("채팅방 topic 구독 시 IN_CHATROOM 상태로 전환한다") + void handleSubscribe_WhenChatRoomTopic_SetsInChatroom() { + SessionSubscribeEvent event = new SessionSubscribeEvent(this, createSubscribeMessage("session-1", "user-1", "/topic/chat-room/cr-1")); + + listener.handleSubscribe(event); + + verify(userPresenceRepository).setInChatroom("user-1", "cr-1"); + } + + @Test + @DisplayName("채팅방 topic이 아닌 구독은 무시한다") + void handleSubscribe_WhenOtherTopic_Ignores() { + SessionSubscribeEvent event = new SessionSubscribeEvent(this, createSubscribeMessage("session-1", "user-1", "/topic/notice")); + + listener.handleSubscribe(event); + + verify(userPresenceRepository, never()).setInChatroom(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("채팅방 구독 세션이 disconnect 되고 SSE 연결이 남아 있으면 ONLINE으로 복귀한다") + void handleDisconnect_WhenSseConnected_SetsOnline() { + listener.handleSubscribe(new SessionSubscribeEvent(this, createSubscribeMessage("session-1", "user-1", "/topic/chat-room/cr-1"))); + when(sseEmitterRegistry.hasConnection("user-1")).thenReturn(true); + + listener.handleDisconnect(new SessionDisconnectEvent(this, createDisconnectMessage("session-1"), "session-1", CloseStatus.NORMAL)); + + verify(userPresenceRepository).setOnline("user-1"); + verify(userPresenceRepository, never()).setOffline("user-1"); + } + + @Test + @DisplayName("채팅방 구독 세션이 disconnect 되고 SSE 연결도 없으면 OFFLINE으로 전환한다") + void handleDisconnect_WhenNoSseConnection_SetsOffline() { + listener.handleSubscribe(new SessionSubscribeEvent(this, createSubscribeMessage("session-1", "user-1", "/topic/chat-room/cr-1"))); + when(sseEmitterRegistry.hasConnection("user-1")).thenReturn(false); + + listener.handleDisconnect(new SessionDisconnectEvent(this, createDisconnectMessage("session-1"), "session-1", CloseStatus.NORMAL)); + + verify(userPresenceRepository).setOffline("user-1"); + verify(userPresenceRepository, never()).setOnline("user-1"); + } + + private Message createSubscribeMessage(String sessionId, String userNo, String destination) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); + accessor.setSessionId(sessionId); + accessor.setDestination(destination); + accessor.setSessionAttributes(new HashMap<>()); + accessor.getSessionAttributes().put("userNo", userNo); + accessor.setLeaveMutable(true); + return MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + } + + private Message createDisconnectMessage(String sessionId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.DISCONNECT); + accessor.setSessionId(sessionId); + accessor.setLeaveMutable(true); + return MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + } +} diff --git a/src/test/java/com/project/dorumdorum/domain/notification/infra/sse/SseEmitterRegistryTest.java b/src/test/java/com/project/dorumdorum/domain/notification/infra/sse/SseEmitterRegistryTest.java index 4e25ebe9..24f935e3 100644 --- a/src/test/java/com/project/dorumdorum/domain/notification/infra/sse/SseEmitterRegistryTest.java +++ b/src/test/java/com/project/dorumdorum/domain/notification/infra/sse/SseEmitterRegistryTest.java @@ -35,10 +35,9 @@ void register_FirstConnection_SetsOnlineAndHasConnection() { SseEmitter emitter = registry.register("user-1", "device-1"); assertThat(emitter).isNotNull(); - verify(userPresenceRepository).setOnline("user-1"); + verify(userPresenceRepository).refreshPresence("user-1"); assertThat(registry.hasConnection("user-1", "device-1")).isTrue(); assertThat(registry.getConnectedUsers()).contains("user-1"); } } - diff --git a/src/test/java/com/project/dorumdorum/domain/notification/infra/sse/SseHeartbeatSchedulerTest.java b/src/test/java/com/project/dorumdorum/domain/notification/infra/sse/SseHeartbeatSchedulerTest.java index 4bc7bdbf..a2d3b623 100644 --- a/src/test/java/com/project/dorumdorum/domain/notification/infra/sse/SseHeartbeatSchedulerTest.java +++ b/src/test/java/com/project/dorumdorum/domain/notification/infra/sse/SseHeartbeatSchedulerTest.java @@ -36,10 +36,9 @@ void run_SendsHeartbeatAndKeepsOnline() { scheduler.run(); // then - verify(userPresenceRepository).setOnline("user-1"); - verify(userPresenceRepository).setOnline("user-2"); + verify(userPresenceRepository).refreshPresence("user-1"); + verify(userPresenceRepository).refreshPresence("user-2"); verify(sseEmitterRegistry).sendHeartbeatToUser("user-1"); verify(sseEmitterRegistry).sendHeartbeatToUser("user-2"); } } - diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/entity/RoomTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/entity/RoomTest.java index 5df33241..70f66f72 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/entity/RoomTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/entity/RoomTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @DisplayName("Room Entity Unit Tests") class RoomTest { @@ -77,4 +78,15 @@ void mutationMethods_UpdateState() { assertThat(room.getResidencePeriod()).isEqualTo(ResidencePeriod.HALF_YEAR); assertThat(room.getTitle()).isEqualTo("new title"); } + + @Test + @DisplayName("Should validate capacity is not lower than current mate count") + void isValidCapacity_WhenLowerThanCurrentMateCount_ReturnsFalse() { + Room room = newRoom(); + room.init(); + room.plusCurrentMate(); + + assertThat(room.isValidCapacity(1)).isFalse(); + assertThat(room.isValidCapacity(2)).isTrue(); + } } diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/infra/repository/RoomRepositoryImplTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/infra/repository/RoomRepositoryImplTest.java index 898f8824..55c3c9cc 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/infra/repository/RoomRepositoryImplTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/infra/repository/RoomRepositoryImplTest.java @@ -1,8 +1,8 @@ package com.project.dorumdorum.domain.room.unit.infra.repository; import com.project.dorumdorum.domain.room.application.dto.request.ChecklistFilterRequest; -import com.project.dorumdorum.domain.room.application.dto.request.RoomSort; import com.project.dorumdorum.domain.room.application.dto.response.FindRoomsResponse; +import com.project.dorumdorum.domain.checklist.domain.entity.enums.SmokingType; import com.project.dorumdorum.domain.room.domain.entity.ResidencePeriod; import com.project.dorumdorum.domain.room.domain.entity.RoomStatus; import com.project.dorumdorum.domain.room.domain.entity.RoomType; @@ -10,6 +10,8 @@ import com.querydsl.core.types.Expression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,6 +19,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -30,6 +33,7 @@ class RoomRepositoryImplTest { @Mock private JPAQueryFactory queryFactory; + @Mock private EntityManager entityManager; private ChecklistFilterRequest request(ChecklistFilterRequest.SortType sortType) { return new ChecklistFilterRequest( @@ -44,11 +48,11 @@ private ChecklistFilterRequest request(ChecklistFilterRequest.SortType sortType) @Test @DisplayName("Should fetch room list for recruiting relation") void findByCursor_WhenRecruiting_FetchesWithLimit() { - RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory); + RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory, entityManager); JPAQuery jpaQuery = mock(JPAQuery.class, RETURNS_SELF); List expected = List.of( new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, LocalDateTime.now(), - "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name()) + "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) ); when(queryFactory.select(org.mockito.ArgumentMatchers.>any())).thenReturn(jpaQuery); @@ -63,7 +67,7 @@ void findByCursor_WhenRecruiting_FetchesWithLimit() { ); List result = repository.findByCursor( - request, cursorCreatedAt, "r1", 51 + request, cursorCreatedAt, "r1", 1, 51 ); assertThat(result).isEqualTo(expected); @@ -75,7 +79,7 @@ void findByCursor_WhenRecruiting_FetchesWithLimit() { @Test @DisplayName("Should fetch room list with created-at sort cursor") void findByCursor_WithCreatedAtSortCursor_Fetches() { - RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory); + RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory, entityManager); JPAQuery jpaQuery = mock(JPAQuery.class, RETURNS_SELF); when(queryFactory.select(org.mockito.ArgumentMatchers.>any())).thenReturn(jpaQuery); @@ -85,7 +89,7 @@ void findByCursor_WithCreatedAtSortCursor_Fetches() { ChecklistFilterRequest request = request(ChecklistFilterRequest.SortType.LATEST); List result = repository.findByCursor( - request, cursorCreatedAt, "r9", 11 + request, cursorCreatedAt, "r9", null, 11 ); assertThat(result).isEmpty(); @@ -96,7 +100,7 @@ void findByCursor_WithCreatedAtSortCursor_Fetches() { @Test @DisplayName("Should fetch room list with null cursor and empty filters") void findByCursor_WithNullCursorAndEmptyFilters_Fetches() { - RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory); + RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory, entityManager); JPAQuery jpaQuery = mock(JPAQuery.class, RETURNS_SELF); when(queryFactory.select(org.mockito.ArgumentMatchers.>any())).thenReturn(jpaQuery); @@ -104,7 +108,7 @@ void findByCursor_WithNullCursorAndEmptyFilters_Fetches() { ChecklistFilterRequest request = request(ChecklistFilterRequest.SortType.LATEST); List result = repository.findByCursor( - request, null, null, 7 + request, null, null, null, 7 ); assertThat(result).isEmpty(); @@ -116,7 +120,7 @@ void findByCursor_WithNullCursorAndEmptyFilters_Fetches() { @Test @DisplayName("Should fetch room list with null sort using default branch") void findByCursor_WithNullSort_UsesDefaultOrderBranch() { - RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory); + RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory, entityManager); JPAQuery jpaQuery = mock(JPAQuery.class, RETURNS_SELF); when(queryFactory.select(org.mockito.ArgumentMatchers.>any())).thenReturn(jpaQuery); @@ -126,21 +130,89 @@ void findByCursor_WithNullSort_UsesDefaultOrderBranch() { ChecklistFilterRequest request = request(null); List result = repository.findByCursor( - request, cursorCreatedAt, "r3", 5 + request, cursorCreatedAt, "r3", null, 5 ); assertThat(result).isEmpty(); verify(jpaQuery).limit(5L); } + @Test + @DisplayName("Should use native lateral query when checklist filters exist") + void findByCursor_WithChecklistFilters_UsesNativeLateralQuery() { + RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory, entityManager); + Query nativeQuery = mock(Query.class); + LocalDateTime createdAt = LocalDateTime.now(); + ChecklistFilterRequest request = new ChecklistFilterRequest( + ChecklistFilterRequest.SortType.REMAINING, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + SmokingType.NON_SMOKER, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); + + Object[] row = { + "r1", + RoomType.TYPE_1.name(), + 2, + 1, + createdAt, + "title", + "host", + RoomStatus.CONFIRM_PENDING.name(), + ResidencePeriod.SEMESTER.name(), + 1 + }; + + when(entityManager.createNativeQuery(anyString())).thenReturn(nativeQuery); + when(nativeQuery.setParameter(anyString(), any())).thenReturn(nativeQuery); + List rows = new ArrayList<>(); + rows.add(row); + when(nativeQuery.getResultList()).thenReturn(rows); + + List result = repository.findByCursor(request, createdAt, "r1", 1, 51); + + assertThat(result).containsExactly( + new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, createdAt, + "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) + ); + verify(entityManager).createNativeQuery(anyString()); + verify(nativeQuery).getResultList(); + verifyNoInteractions(queryFactory); + } + @SuppressWarnings("unchecked") @Test @DisplayName("Should return optional result for my room query") void findMyRoom_ReturnsOptional() { - RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory); + RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory, entityManager); JPAQuery jpaQuery = mock(JPAQuery.class, RETURNS_SELF); FindRoomsResponse response = new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, LocalDateTime.now(), - "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name()); + "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1); when(queryFactory.select(org.mockito.ArgumentMatchers.>any())).thenReturn(jpaQuery); when(jpaQuery.fetchOne()).thenReturn(response); @@ -155,7 +227,7 @@ void findMyRoom_ReturnsOptional() { @Test @DisplayName("Should return empty optional when my room does not exist") void findMyRoom_WhenMissing_ReturnsEmpty() { - RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory); + RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory, entityManager); JPAQuery jpaQuery = mock(JPAQuery.class, RETURNS_SELF); when(queryFactory.select(org.mockito.ArgumentMatchers.>any())).thenReturn(jpaQuery); when(jpaQuery.fetchOne()).thenReturn(null); diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/service/RoomServiceTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/service/RoomServiceTest.java index 1df7e7e5..2759b33f 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/service/RoomServiceTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/service/RoomServiceTest.java @@ -60,7 +60,7 @@ void findById_WhenMissing_Throws() { void searchByCursor_DelegatesToRepository() { List expected = List.of( new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, LocalDateTime.now(), - "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name()) + "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) ); ChecklistFilterRequest request = new ChecklistFilterRequest( ChecklistFilterRequest.SortType.LATEST, null, null, null, null, @@ -68,11 +68,11 @@ void searchByCursor_DelegatesToRepository() { null, null, null, null, null, null, null, null, null, null, null, null, null, null ); - when(roomRepository.findByCursor(any(), any(), any(), anyInt())) + when(roomRepository.findByCursor(any(), any(), any(), any(), anyInt())) .thenReturn(expected); List result = service.searchByCursor( - request, null, null, 10 + request, null, null, null, 10 ); assertThat(result).isEqualTo(expected); diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/ConfirmRoomAssignmentUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/ConfirmRoomAssignmentUseCaseTest.java index f24ab7af..502798b7 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/ConfirmRoomAssignmentUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/ConfirmRoomAssignmentUseCaseTest.java @@ -20,6 +20,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static com.project.dorumdorum.global.exception.code.status.RoomErrorStatus.ALREADY_CONFIRM_REQUEST; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -36,10 +37,11 @@ class ConfirmRoomAssignmentUseCaseTest { void execute_WhenUserInRoom_AcceptsCurrentRoommate() { Room room = mock(Room.class); Roommate me = mock(Roommate.class); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.findByRoom(room)).thenReturn(List.of(me)); when(me.getUserNo()).thenReturn("u1"); when(room.isFull()).thenReturn(false); + when(room.isCompleted()).thenReturn(false); useCase.execute("u1", "r1"); @@ -53,10 +55,11 @@ void execute_WhenFullAndAllAccepted_CompletesRoom() { Room room = mock(Room.class); Roommate me = mock(Roommate.class); Roommate other = mock(Roommate.class); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.findByRoom(room)).thenReturn(List.of(me, other)); when(me.getUserNo()).thenReturn("u1"); when(room.isFull()).thenReturn(true); + when(room.isCompleted()).thenReturn(false); when(me.getConfirmStatus()).thenReturn(ConfirmStatus.ACCEPTED); when(other.getConfirmStatus()).thenReturn(ConfirmStatus.ACCEPTED); @@ -72,7 +75,7 @@ void execute_WhenFullAndAllAccepted_CompletesRoom() { void execute_WhenUserNotInRoom_Throws() { Room room = Room.builder().roomNo("r1").build(); Roommate other = Roommate.builder().userNo("u2").confirmStatus(ConfirmStatus.PENDING).roomRole(RoomRole.MEMBER).build(); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.findByRoom(room)).thenReturn(List.of(other)); assertThatThrownBy(() -> useCase.execute("u1", "r1")) @@ -85,10 +88,11 @@ void execute_WhenFullButNotAllAccepted_DoesNotCompleteRoom() { Room room = mock(Room.class); Roommate me = mock(Roommate.class); Roommate other = mock(Roommate.class); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.findByRoom(room)).thenReturn(List.of(me, other)); when(me.getUserNo()).thenReturn("u1"); when(room.isFull()).thenReturn(true); + when(room.isCompleted()).thenReturn(false); when(me.getConfirmStatus()).thenReturn(ConfirmStatus.ACCEPTED); when(other.getConfirmStatus()).thenReturn(ConfirmStatus.PENDING); @@ -99,4 +103,23 @@ void execute_WhenFullButNotAllAccepted_DoesNotCompleteRoom() { verify(other, never()).complete(); verify(room, never()).updateStatus(RoomStatus.COMPLETED); } + + @Test + @DisplayName("Should throw when room assignment was already completed") + void execute_WhenAlreadyCompleted_Throws() { + Room room = mock(Room.class); + Roommate me = mock(Roommate.class); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); + when(roommateService.findByRoom(room)).thenReturn(List.of(me)); + when(me.getUserNo()).thenReturn("u1"); + when(room.isCompleted()).thenReturn(true); + + assertThatThrownBy(() -> useCase.execute("u1", "r1")) + .isInstanceOf(RestApiException.class) + .extracting("errorCode.code") + .isEqualTo(ALREADY_CONFIRM_REQUEST.getCode().getCode()); + + verify(me, never()).accept(); + verify(room, never()).updateStatus(any()); + } } diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/DecideApplicationRequestUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/DecideApplicationRequestUseCaseTest.java index 4b15d056..3e776fca 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/DecideApplicationRequestUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/DecideApplicationRequestUseCaseTest.java @@ -35,10 +35,11 @@ class DecideApplicationRequestUseCaseTest { @DisplayName("Should approve request when host and applicant not joined") void approve_WhenValid_ApprovesAndDeletesRequest() { Room room = mock(Room.class); - RoomRequest request = RoomRequest.builder().roomRequestNo("rq1").userNo("applicant").build(); + Room requestedRoom = Room.builder().roomNo("r1").build(); + RoomRequest request = RoomRequest.builder().roomRequestNo("rq1").room(requestedRoom).userNo("applicant").build(); when(roomRequestService.findById("rq1")).thenReturn(request); when(roommateService.existsByUserNo("applicant")).thenReturn(false); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.isHost("host", room)).thenReturn(true); when(room.isFull()).thenReturn(false); @@ -54,10 +55,11 @@ void approve_WhenValid_ApprovesAndDeletesRequest() { @DisplayName("Should throw when room is already full") void approve_WhenRoomFull_Throws() { Room room = mock(Room.class); - RoomRequest request = RoomRequest.builder().roomRequestNo("rq1").userNo("applicant").build(); + Room requestedRoom = Room.builder().roomNo("r1").build(); + RoomRequest request = RoomRequest.builder().roomRequestNo("rq1").room(requestedRoom).userNo("applicant").build(); when(roomRequestService.findById("rq1")).thenReturn(request); when(roommateService.existsByUserNo("applicant")).thenReturn(false); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.isHost("host", room)).thenReturn(true); when(room.isFull()).thenReturn(true); @@ -71,14 +73,15 @@ void approve_WhenRoomFull_Throws() { @Test @DisplayName("Should throw when applicant already joined another room") void approve_WhenApplicantAlreadyJoined_Throws() { - RoomRequest request = RoomRequest.builder().roomRequestNo("rq1").userNo("applicant").build(); + Room requestedRoom = Room.builder().roomNo("r1").build(); + RoomRequest request = RoomRequest.builder().roomRequestNo("rq1").room(requestedRoom).userNo("applicant").build(); when(roomRequestService.findById("rq1")).thenReturn(request); when(roommateService.existsByUserNo("applicant")).thenReturn(true); assertThatThrownBy(() -> useCase.approve("host", "r1", "rq1")) .isInstanceOf(RestApiException.class); - verify(roomService, never()).findById(anyString()); + verify(roomService, never()).findByIdForUpdate(anyString()); verify(roomRequestService, never()).delete(any(RoomRequest.class)); } @@ -86,10 +89,11 @@ void approve_WhenApplicantAlreadyJoined_Throws() { @DisplayName("Should throw when approver is not host") void approve_WhenNotHost_Throws() { Room room = Room.builder().roomNo("r1").build(); - RoomRequest request = RoomRequest.builder().roomRequestNo("rq1").userNo("applicant").build(); + Room requestedRoom = Room.builder().roomNo("r1").build(); + RoomRequest request = RoomRequest.builder().roomRequestNo("rq1").room(requestedRoom).userNo("applicant").build(); when(roomRequestService.findById("rq1")).thenReturn(request); when(roommateService.existsByUserNo("applicant")).thenReturn(false); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.isHost("host", room)).thenReturn(false); assertThatThrownBy(() -> useCase.approve("host", "r1", "rq1")) @@ -98,6 +102,21 @@ void approve_WhenNotHost_Throws() { verify(roomRequestService, never()).delete(any(RoomRequest.class)); } + @Test + @DisplayName("Should throw when request does not belong to room") + void approve_WhenRoomRequestBelongsToDifferentRoom_Throws() { + Room requestedRoom = Room.builder().roomNo("r2").build(); + RoomRequest request = RoomRequest.builder().roomRequestNo("rq1").room(requestedRoom).userNo("applicant").build(); + when(roomRequestService.findById("rq1")).thenReturn(request); + + assertThatThrownBy(() -> useCase.approve("host", "r1", "rq1")) + .isInstanceOf(RestApiException.class); + + verify(roomService, never()).findByIdForUpdate(anyString()); + verify(roommateService, never()).create(anyString(), any(Room.class), any(RoomRole.class)); + verify(roomRequestService, never()).delete(any(RoomRequest.class)); + } + @Test @DisplayName("Should reject request when user is host") void reject_WhenHost_DeletesRequest() { diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/FindRoomsUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/FindRoomsUseCaseTest.java index 50e5319a..8a6225ec 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/FindRoomsUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/FindRoomsUseCaseTest.java @@ -34,7 +34,7 @@ class FindRoomsUseCaseTest { private FindRoomsResponse room(String roomNo, int capacity, int current, LocalDateTime createdAt) { return new FindRoomsResponse(roomNo, RoomType.TYPE_1, capacity, current, createdAt, "title", - "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name()); + "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), capacity - current); } private ChecklistFilterRequest request(ChecklistFilterRequest.SortType sortType, String cursor) { @@ -54,7 +54,7 @@ void execute_WhenResponsesExceedLimit_ReturnsHasNextTrue() { .mapToObj(i -> room("r" + i, 2, 1, now.minusMinutes(i))) .toList(); ChecklistFilterRequest request = request(ChecklistFilterRequest.SortType.LATEST, null); - when(roomService.searchByCursor(any(), any(), any(), anyInt())) + when(roomService.searchByCursor(any(), any(), any(), any(), anyInt())) .thenReturn(responses); CursorPage result = useCase.execute(request); @@ -62,7 +62,7 @@ void execute_WhenResponsesExceedLimit_ReturnsHasNextTrue() { assertThat(result.items()).hasSize(50); assertThat(result.hasNext()).isTrue(); assertThat(result.nextCursor()).isNotBlank(); - verify(roomService).searchByCursor(any(), any(), any(), anyInt()); + verify(roomService).searchByCursor(any(), any(), any(), any(), anyInt()); } @Test @@ -70,7 +70,7 @@ void execute_WhenResponsesExceedLimit_ReturnsHasNextTrue() { void execute_WhenResponsesWithinLimit_ReturnsHasNextFalse() { List responses = List.of(room("r1", 2, 1, LocalDateTime.now())); ChecklistFilterRequest request = request(ChecklistFilterRequest.SortType.REMAINING, null); - when(roomService.searchByCursor(any(), any(), any(), anyInt())) + when(roomService.searchByCursor(any(), any(), any(), any(), anyInt())) .thenReturn(responses); CursorPage result = useCase.execute(request); @@ -84,7 +84,7 @@ void execute_WhenResponsesWithinLimit_ReturnsHasNextFalse() { @DisplayName("Should return null nextCursor when no response") void execute_WhenNoResponse_ReturnsNullCursor() { ChecklistFilterRequest request = request(ChecklistFilterRequest.SortType.LATEST, null); - when(roomService.searchByCursor(any(), any(), any(), anyInt())) + when(roomService.searchByCursor(any(), any(), any(), any(), anyInt())) .thenReturn(List.of()); CursorPage result = useCase.execute(request); @@ -100,12 +100,12 @@ void execute_WithCursor_UsesDecodedCursorPath() { List responses = List.of(room("r1", 2, 1, LocalDateTime.now())); String cursor = com.project.dorumdorum.global.pagination.CursorCodec.encode(LocalDateTime.now(), "r1"); ChecklistFilterRequest request = request(ChecklistFilterRequest.SortType.LATEST, cursor); - when(roomService.searchByCursor(any(), any(), any(), anyInt())) + when(roomService.searchByCursor(any(), any(), any(), any(), anyInt())) .thenReturn(responses); CursorPage result = useCase.execute(request); assertThat(result.items()).hasSize(1); - verify(roomService).searchByCursor(any(), any(), any(), anyInt()); + verify(roomService).searchByCursor(any(), any(), any(), any(), anyInt()); } } diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/KickRoommateUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/KickRoommateUseCaseTest.java index 61771e5f..f4396221 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/KickRoommateUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/KickRoommateUseCaseTest.java @@ -34,7 +34,7 @@ class KickRoommateUseCaseTest { @DisplayName("방장이 다른 멤버를 강퇴하면 Roommate 삭제, 인원 감소, 이벤트 발행") void execute_WhenHostKicksOther_Success() { Room room = mock(Room.class); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.isHost("host", room)).thenReturn(true); useCase.execute("host", "r1", "member1"); @@ -47,7 +47,7 @@ void execute_WhenHostKicksOther_Success() { @Test @DisplayName("존재하지 않는 방 ID로 요청 시 RestApiException 발생") void execute_WhenRoomNotFound_ThrowsException() { - when(roomService.findById("nonexistent")).thenThrow(new RestApiException(NO_PERMISSION_ON_ROOM)); + when(roomService.findByIdForUpdate("nonexistent")).thenThrow(new RestApiException(NO_PERMISSION_ON_ROOM)); assertThatThrownBy(() -> useCase.execute("host", "nonexistent", "member1")) .isInstanceOf(RestApiException.class); @@ -61,7 +61,7 @@ void execute_WhenRoomNotFound_ThrowsException() { @DisplayName("강퇴 대상이 해당 방에 없으면 leaveRoom에서 RestApiException 발생") void execute_WhenKickedUserNotInRoom_ThrowsException() { Room room = mock(Room.class); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.isHost("host", room)).thenReturn(true); doThrow(new RestApiException(_NOT_FOUND)) .when(roommateService).leaveRoom("ghost", "r1"); @@ -77,7 +77,7 @@ void execute_WhenKickedUserNotInRoom_ThrowsException() { @DisplayName("방장이 아닌 사람이 강퇴 시도 시 NO_PERMISSION_ON_ROOM 예외") void execute_WhenNotHost_ThrowsNoPermission() { Room room = mock(Room.class); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.isHost("member1", room)).thenReturn(false); assertThatThrownBy(() -> useCase.execute("member1", "r1", "member2")) @@ -91,7 +91,7 @@ void execute_WhenNotHost_ThrowsNoPermission() { @DisplayName("방장이 자기 자신을 강퇴 시도 시 CANNOT_KICK_SELF 예외") void execute_WhenKickSelf_ThrowsCannotKickSelf() { Room room = mock(Room.class); - when(roomService.findById("r1")).thenReturn(room); + when(roomService.findByIdForUpdate("r1")).thenReturn(room); when(roommateService.isHost("host", room)).thenReturn(true); assertThatThrownBy(() -> useCase.execute("host", "r1", "host")) diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java index 65bf3417..5f7d0b5e 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java @@ -30,7 +30,7 @@ class LoadMyRoomsUseCaseTest { @DisplayName("Should return my room from room service") void execute_ReturnsMyRoom() { FindRoomsResponse expected = new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, LocalDateTime.now(), - "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name()); + "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1); when(roomService.findMyRoom("u1")).thenReturn(expected); FindRoomsResponse result = useCase.execute("u1"); diff --git a/src/test/java/com/project/dorumdorum/global/alert/AlertDeduplicationServiceTest.java b/src/test/java/com/project/dorumdorum/global/alert/AlertDeduplicationServiceTest.java new file mode 100644 index 00000000..11015799 --- /dev/null +++ b/src/test/java/com/project/dorumdorum/global/alert/AlertDeduplicationServiceTest.java @@ -0,0 +1,104 @@ +package com.project.dorumdorum.global.alert; + +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 org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AlertDeduplicationService Unit Tests") +class AlertDeduplicationServiceTest { + + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOps; + @InjectMocks private AlertDeduplicationService service; + + private SystemAlert alert(AlertSeverity severity, String title) { + return new SystemAlert(severity, AlertType.SYSTEM_HEALTH, title, "msg", Map.of()); + } + + @Test + @DisplayName("신규 알림이면 false 반환 (Redis setIfAbsent = true)") + void isDuplicate_WhenNew_ReturnsFalse() { + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true); + + boolean result = service.isDuplicate(alert(AlertSeverity.CRITICAL, "서버 다운")); + + assertThat(result).isFalse(); + } + + @Test + @DisplayName("중복 알림이면 true 반환 (Redis setIfAbsent = false)") + void isDuplicate_WhenDuplicate_ReturnsTrue() { + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false); + + boolean result = service.isDuplicate(alert(AlertSeverity.ERROR, "에러 발생")); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("Redis setIfAbsent가 null 반환 시 중복으로 처리 (방어 로직)") + void isDuplicate_WhenRedisReturnsNull_ReturnsTrueAsSafe() { + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(null); + + boolean result = service.isDuplicate(alert(AlertSeverity.WARN, "경고")); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("CRITICAL severity는 60초 TTL 사용") + void isDuplicate_CriticalSeverity_Uses60SecondTtl() { + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true); + + service.isDuplicate(alert(AlertSeverity.CRITICAL, "critical")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Duration.class); + verify(valueOps).setIfAbsent(anyString(), anyString(), captor.capture()); + assertThat(captor.getValue()).isEqualTo(Duration.ofSeconds(60)); + } + + @Test + @DisplayName("ERROR severity는 300초 TTL 사용") + void isDuplicate_ErrorSeverity_Uses300SecondTtl() { + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true); + + service.isDuplicate(alert(AlertSeverity.ERROR, "error")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Duration.class); + verify(valueOps).setIfAbsent(anyString(), anyString(), captor.capture()); + assertThat(captor.getValue()).isEqualTo(Duration.ofSeconds(300)); + } + + @Test + @DisplayName("동일 제목 + 다른 severity는 다른 Redis key 사용") + void isDuplicate_SameTitleDifferentSeverity_UsesDifferentKeys() { + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true); + + service.isDuplicate(alert(AlertSeverity.CRITICAL, "동일제목")); + service.isDuplicate(alert(AlertSeverity.WARN, "동일제목")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(valueOps, times(2)).setIfAbsent(captor.capture(), anyString(), any()); + assertThat(captor.getAllValues().get(0)).isNotEqualTo(captor.getAllValues().get(1)); + } +} diff --git a/src/test/java/com/project/dorumdorum/global/alert/DiscordAlertPropertiesTest.java b/src/test/java/com/project/dorumdorum/global/alert/DiscordAlertPropertiesTest.java new file mode 100644 index 00000000..961d3fdc --- /dev/null +++ b/src/test/java/com/project/dorumdorum/global/alert/DiscordAlertPropertiesTest.java @@ -0,0 +1,74 @@ +package com.project.dorumdorum.global.alert; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("DiscordAlertProperties Unit Tests") +class DiscordAlertPropertiesTest { + + @Test + @DisplayName("CRITICAL severity는 errorWebhookUrl 반환") + void resolveWebhookUrl_Critical_ReturnsErrorUrl() { + DiscordAlertProperties props = propsWithAll(); + assertThat(props.resolveWebhookUrl(AlertSeverity.CRITICAL)).isEqualTo("http://error"); + } + + @Test + @DisplayName("ERROR severity는 errorWebhookUrl 반환") + void resolveWebhookUrl_Error_ReturnsErrorUrl() { + DiscordAlertProperties props = propsWithAll(); + assertThat(props.resolveWebhookUrl(AlertSeverity.ERROR)).isEqualTo("http://error"); + } + + @Test + @DisplayName("WARN severity는 infoWebhookUrl 반환") + void resolveWebhookUrl_Warn_ReturnsInfoUrl() { + DiscordAlertProperties props = propsWithAll(); + assertThat(props.resolveWebhookUrl(AlertSeverity.WARN)).isEqualTo("http://info"); + } + + @Test + @DisplayName("INFO severity는 infoWebhookUrl 반환") + void resolveWebhookUrl_Info_ReturnsInfoUrl() { + DiscordAlertProperties props = propsWithAll(); + assertThat(props.resolveWebhookUrl(AlertSeverity.INFO)).isEqualTo("http://info"); + } + + @Test + @DisplayName("errorWebhookUrl 미설정 시 webhookUrl로 fallback") + void resolveWebhookUrl_NoErrorUrl_FallbackToDefault() { + DiscordAlertProperties props = new DiscordAlertProperties(); + props.setWebhookUrl("http://default"); + props.setInfoWebhookUrl("http://info"); + + assertThat(props.resolveWebhookUrl(AlertSeverity.CRITICAL)).isEqualTo("http://default"); + } + + @Test + @DisplayName("infoWebhookUrl 미설정 시 webhookUrl로 fallback") + void resolveWebhookUrl_NoInfoUrl_FallbackToDefault() { + DiscordAlertProperties props = new DiscordAlertProperties(); + props.setWebhookUrl("http://default"); + props.setErrorWebhookUrl("http://error"); + + assertThat(props.resolveWebhookUrl(AlertSeverity.WARN)).isEqualTo("http://default"); + } + + @Test + @DisplayName("모든 URL 미설정 시 null 반환") + void resolveWebhookUrl_AllNull_ReturnsNull() { + DiscordAlertProperties props = new DiscordAlertProperties(); + + assertThat(props.resolveWebhookUrl(AlertSeverity.CRITICAL)).isNull(); + } + + private DiscordAlertProperties propsWithAll() { + DiscordAlertProperties props = new DiscordAlertProperties(); + props.setWebhookUrl("http://default"); + props.setErrorWebhookUrl("http://error"); + props.setInfoWebhookUrl("http://info"); + return props; + } +} diff --git a/src/test/java/com/project/dorumdorum/global/exception/ExceptionAdviceTest.java b/src/test/java/com/project/dorumdorum/global/exception/ExceptionAdviceTest.java new file mode 100644 index 00000000..6e833ea5 --- /dev/null +++ b/src/test/java/com/project/dorumdorum/global/exception/ExceptionAdviceTest.java @@ -0,0 +1,161 @@ +package com.project.dorumdorum.global.exception; + +import com.project.dorumdorum.global.alert.SystemAlertPublisher; +import com.project.dorumdorum.global.exception.code.status.CommonErrorStatus; +import com.project.dorumdorum.global.logging.LogRedactor; +import com.project.dorumdorum.global.logging.RequestLogContext; +import com.project.dorumdorum.global.logging.RequestLogContextResolver; +import com.project.dorumdorum.global.logging.StructuredLogFactory; +import com.project.dorumdorum.global.properties.LoggingPolicyProperties; +import org.hibernate.exception.ConstraintViolationException; +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 org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ResponseEntity; + +import java.sql.SQLException; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ExceptionAdvice Unit Tests") +class ExceptionAdviceTest { + + @Mock + private RequestLogContextResolver requestLogContextResolver; + + @Mock + private StructuredLogFactory structuredLogFactory; + + @Mock + private LogRedactor logRedactor; + + @Mock + private LoggingPolicyProperties loggingPolicyProperties; + + @Mock + private SystemAlertPublisher systemAlertPublisher; + + private ExceptionAdvice exceptionAdvice; + + @BeforeEach + void setUp() { + exceptionAdvice = new ExceptionAdvice( + requestLogContextResolver, + structuredLogFactory, + logRedactor, + loggingPolicyProperties, + systemAlertPublisher + ); + + lenient().when(requestLogContextResolver.resolve(any(), any(), anyInt())) + .thenReturn(new RequestLogContext("trace-1", "GET", "/api/test", null, null, 500)); + lenient().when(logRedactor.redactText(anyString())).thenAnswer(invocation -> invocation.getArgument(0)); + lenient().when(structuredLogFactory.requestFailed(any(), any(), anyLong(), anyString())) + .thenReturn(Map.of("event", "request_failed")); + lenient().when(loggingPolicyProperties.includeStackTrace()).thenReturn(false); + } + + @Test + @DisplayName("room request unique 제약 위반은 DUPLICATE_JOIN_REQUEST로 변환한다") + void handleDataIntegrityViolationException_WhenDuplicateRoomRequest_ReturnsBadRequest() { + SQLException sqlException = new SQLException("duplicate key", "23505"); + ConstraintViolationException cause = new ConstraintViolationException( + "duplicate", + sqlException, + "uk_room_request_user_room_direction" + ); + DataIntegrityViolationException exception = new DataIntegrityViolationException("duplicate", cause); + + ResponseEntity response = exceptionAdvice.handleDataIntegrityViolationException(exception); + + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getCode()).isEqualTo("ROOM002"); + verify(systemAlertPublisher, never()).publish(any(), any(), any(), any()); + } + + @Test + @DisplayName("roommate unique 제약 위반은 ALREADY_JOINED_USER로 변환한다") + void handleDataIntegrityViolationException_WhenDuplicateRoommate_ReturnsBadRequest() { + SQLException sqlException = new SQLException("duplicate key", "23505"); + ConstraintViolationException cause = new ConstraintViolationException( + "duplicate", + sqlException, + "uk_roommate_user_no" + ); + DataIntegrityViolationException exception = new DataIntegrityViolationException("duplicate", cause); + + ResponseEntity response = exceptionAdvice.handleDataIntegrityViolationException(exception); + + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getCode()).isEqualTo("ROOM013"); + verify(systemAlertPublisher, never()).publish(any(), any(), any(), any()); + } + + @Test + @DisplayName("user unique 제약 위반은 DUPLICATE_SIGN_UP_INFO로 변환한다") + void handleDataIntegrityViolationException_WhenDuplicateUserSignUp_ReturnsBadRequest() { + SQLException sqlException = new SQLException("duplicate key", "23505"); + ConstraintViolationException cause = new ConstraintViolationException( + "duplicate", + sqlException, + "uk_user_email" + ); + DataIntegrityViolationException exception = new DataIntegrityViolationException("duplicate", cause); + + ResponseEntity response = exceptionAdvice.handleDataIntegrityViolationException(exception); + + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getCode()).isEqualTo("USER001"); + verify(systemAlertPublisher, never()).publish(any(), any(), any(), any()); + } + + @Test + @DisplayName("chat room member unique 제약 위반은 ALREADY_CHAT_ROOM_MEMBER로 변환한다") + void handleDataIntegrityViolationException_WhenDuplicateChatRoomMember_ReturnsBadRequest() { + SQLException sqlException = new SQLException("duplicate key", "23505"); + ConstraintViolationException cause = new ConstraintViolationException( + "duplicate", + sqlException, + "uk_chat_room_member_room_user" + ); + DataIntegrityViolationException exception = new DataIntegrityViolationException("duplicate", cause); + + ResponseEntity response = exceptionAdvice.handleDataIntegrityViolationException(exception); + + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getCode()).isEqualTo("CHAT004"); + verify(systemAlertPublisher, never()).publish(any(), any(), any(), any()); + } + + @Test + @DisplayName("알 수 없는 DataIntegrityViolationException은 500으로 처리한다") + void handleDataIntegrityViolationException_WhenUnknown_ReturnsInternalServerError() { + DataIntegrityViolationException exception = new DataIntegrityViolationException("unexpected"); + + ResponseEntity response = exceptionAdvice.handleDataIntegrityViolationException(exception); + + assertThat(response.getStatusCode().value()).isEqualTo(500); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getCode()) + .isEqualTo(CommonErrorStatus._INTERNAL_SERVER_ERROR.getCode().getCode()); + verify(systemAlertPublisher).publish(any(), any(), any(), any()); + } +} diff --git a/src/test/java/com/project/dorumdorum/global/logging/DiscordLogbackAppenderTest.java b/src/test/java/com/project/dorumdorum/global/logging/DiscordLogbackAppenderTest.java new file mode 100644 index 00000000..98dc7aaf --- /dev/null +++ b/src/test/java/com/project/dorumdorum/global/logging/DiscordLogbackAppenderTest.java @@ -0,0 +1,234 @@ +package com.project.dorumdorum.global.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.lang.reflect.Field; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * DiscordLogbackAppender 단위 테스트. + * Spring Context 없이 순수 JUnit으로 실행하여 빠른 피드백을 제공한다. + */ +@DisplayName("DiscordLogbackAppender Unit Tests") +class DiscordLogbackAppenderTest { + + private DiscordLogbackAppender appender; + private HttpClient mockHttpClient; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() throws Exception { + appender = new DiscordLogbackAppender(); + appender.setWebhookUrl("https://discord.com/api/webhooks/test"); + + // HttpClient mock 주입 (네트워크 호출 방지) + mockHttpClient = mock(HttpClient.class); + @SuppressWarnings("unchecked") + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockHttpClient.send(any(HttpRequest.class), any())).thenReturn(mockResponse); + appender.setHttpClientForTest(mockHttpClient); + + appender.start(); + } + + // ========================================================================= + // webhookUrl 미설정 시 skip + // ========================================================================= + + @ParameterizedTest(name = "webhookUrl={0}") + @ValueSource(strings = {"", " "}) + @DisplayName("webhookUrl이 빈 값이면 HTTP 전송을 건너뛴다") + void append_WhenWebhookUrlBlank_SkipsHttpCall(String blankUrl) throws Exception { + appender.setWebhookUrl(blankUrl); + + appender.append(mockWarnEvent("any message")); + + verify(mockHttpClient, never()).send(any(), any()); + } + + @Test + @DisplayName("webhookUrl이 null이면 HTTP 전송을 건너뛴다") + void append_WhenWebhookUrlNull_SkipsHttpCall() throws Exception { + appender.setWebhookUrl(null); + + assertThatCode(() -> appender.append(mockWarnEvent("any message"))) + .doesNotThrowAnyException(); + verify(mockHttpClient, never()).send(any(), any()); + } + + // ========================================================================= + // 레벨 필터링 + // ========================================================================= + + @Test + @DisplayName("INFO 레벨 로그는 Discord로 전송하지 않는다") + void append_WhenLevelInfo_SkipsHttpCall() throws Exception { + ILoggingEvent event = mockEventWithLevel(Level.INFO, "info message"); + + appender.append(event); + + verify(mockHttpClient, never()).send(any(), any()); + } + + @Test + @DisplayName("DEBUG 레벨 로그는 Discord로 전송하지 않는다") + void append_WhenLevelDebug_SkipsHttpCall() throws Exception { + ILoggingEvent event = mockEventWithLevel(Level.DEBUG, "debug message"); + + appender.append(event); + + verify(mockHttpClient, never()).send(any(), any()); + } + + @Test + @DisplayName("WARN 레벨 로그는 Discord로 전송한다") + void append_WhenLevelWarn_SendsToDiscord() throws Exception { + ILoggingEvent event = mockWarnEvent("warn message"); + + appender.append(event); + + verify(mockHttpClient, times(1)).send(any(), any()); + } + + @Test + @DisplayName("ERROR 레벨 로그는 Discord로 전송한다") + void append_WhenLevelError_SendsToDiscord() throws Exception { + ILoggingEvent event = mockErrorEvent("error message"); + + appender.append(event); + + verify(mockHttpClient, times(1)).send(any(), any()); + } + + // ========================================================================= + // 중복 제거 (dedup) — TTL 기반 + // ========================================================================= + + @Test + @DisplayName("동일 로그를 TTL 내에 두 번 append 하면 첫 번째만 전송된다") + void append_WhenSameMessageWithinTtl_SendsOnlyOnce() throws Exception { + ILoggingEvent event1 = mockWarnEvent("duplicate warn message"); + ILoggingEvent event2 = mockWarnEvent("duplicate warn message"); + + appender.append(event1); + appender.append(event2); + + // TTL 내 두 번째 호출은 중복으로 차단 + verify(mockHttpClient, times(1)).send(any(), any()); + } + + @Test + @DisplayName("TTL 만료 후 동일 로그를 append 하면 다시 전송된다") + void append_WhenSameMessageAfterTtlExpiry_SendsAgain() throws Exception { + ILoggingEvent event = mockWarnEvent("expired warn message"); + + // 만료된 엔트리를 dedupCache에 직접 주입 + ConcurrentHashMap dedupCache = getDedupCache(); + String key = buildExpectedKey(event); + dedupCache.put(key, System.currentTimeMillis() - 1L); // 이미 만료된 엔트리 + + appender.append(event); + + // 만료 후 → 새 전송 + verify(mockHttpClient, times(1)).send(any(), any()); + } + + @Test + @DisplayName("다른 메시지는 TTL과 무관하게 각각 전송된다") + void append_WhenDifferentMessages_BothSent() throws Exception { + ILoggingEvent event1 = mockWarnEvent("first message"); + ILoggingEvent event2 = mockWarnEvent("second message"); + + appender.append(event1); + appender.append(event2); + + verify(mockHttpClient, times(2)).send(any(), any()); + } + + // ========================================================================= + // HTTP 전송 실패 — 예외 전파 없음 + // ========================================================================= + + @Test + @DisplayName("Discord HTTP 전송 실패 시 예외를 전파하지 않는다 (Logback 스레드 보호)") + void append_WhenHttpFails_DoesNotPropagateException() throws Exception { + when(mockHttpClient.send(any(), any())).thenThrow(new RuntimeException("Network error")); + + assertThatCode(() -> appender.append(mockErrorEvent("error message"))) + .doesNotThrowAnyException(); + } + + // ========================================================================= + // escapeJson 유틸 검증 + // ========================================================================= + + @Test + @DisplayName("로그 메시지에 특수문자가 포함되어도 예외 없이 전송된다") + void append_WhenMessageHasSpecialChars_SendsWithoutException() throws Exception { + // 개행, 따옴표, 백슬래시가 포함된 메시지 + ILoggingEvent event = mockErrorEvent("line1\nline2\t\"quoted\" \\ backslash"); + + assertThatCode(() -> appender.append(event)).doesNotThrowAnyException(); + verify(mockHttpClient, times(1)).send(any(), any()); + } + + @Test + @DisplayName("스택트레이스가 없는 로그도 정상 처리된다") + void append_WhenNoThrowable_HandlesGracefully() throws Exception { + ILoggingEvent event = mockWarnEvent("warn without exception"); + + assertThatCode(() -> appender.append(event)).doesNotThrowAnyException(); + verify(mockHttpClient, times(1)).send(any(), any()); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + private ILoggingEvent mockWarnEvent(String message) { + return mockEventWithLevel(Level.WARN, message); + } + + private ILoggingEvent mockErrorEvent(String message) { + return mockEventWithLevel(Level.ERROR, message); + } + + private ILoggingEvent mockEventWithLevel(Level level, String message) { + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getLevel()).thenReturn(level); + when(event.getFormattedMessage()).thenReturn(message); + when(event.getLoggerName()).thenReturn("com.example.TestClass"); + when(event.getTimeStamp()).thenReturn(System.currentTimeMillis()); + when(event.getThrowableProxy()).thenReturn(null); + return event; + } + + /** dedupCache 필드에 리플렉션으로 접근 */ + @SuppressWarnings("unchecked") + private ConcurrentHashMap getDedupCache() throws Exception { + Field field = DiscordLogbackAppender.class.getDeclaredField("dedupCache"); + field.setAccessible(true); + return (ConcurrentHashMap) field.get(appender); + } + + /** buildDedupKey()와 동일한 로직으로 키 생성 (화이트박스 검증용) */ + private String buildExpectedKey(ILoggingEvent event) { + String shortLogger = "TestClass"; // com.example.TestClass → TestClass + int msgHash = Math.abs(event.getFormattedMessage().hashCode()); + return event.getLevel().levelStr + ":" + shortLogger + ":" + msgHash; + } +} diff --git a/src/test/java/com/project/dorumdorum/testsupport/TestcontainersSupport.java b/src/test/java/com/project/dorumdorum/testsupport/TestcontainersSupport.java new file mode 100644 index 00000000..7854aaf9 --- /dev/null +++ b/src/test/java/com/project/dorumdorum/testsupport/TestcontainersSupport.java @@ -0,0 +1,51 @@ +package com.project.dorumdorum.testsupport; + +import org.testcontainers.DockerClientFactory; + +/** + * Utility helpers for integration tests that rely on Testcontainers. + */ +public final class TestcontainersSupport { + + private static final boolean DOCKER_AVAILABLE = checkDockerAvailability(); + private static final boolean CI = detectCiEnvironment(); + + private TestcontainersSupport() { + } + + public static boolean isDockerAvailable() { + return DOCKER_AVAILABLE; + } + + public static boolean isCiEnvironment() { + return CI; + } + + /** + * Returns {@code true} when Docker is ready for use. If Docker is unavailable in CI, throws an + * {@link IllegalStateException} so the build fails instead of silently skipping tests. + */ + public static boolean requireDockerOrSkip(String testName) { + if (DOCKER_AVAILABLE) { + return true; + } + if (CI) { + throw new IllegalStateException("Docker is required for " + testName + " in CI"); + } + return false; + } + + private static boolean checkDockerAvailability() { + try { + DockerClientFactory.instance().client(); + return true; + } catch (Throwable ignored) { + return false; + } + } + + private static boolean detectCiEnvironment() { + String ciEnv = System.getenv("CI"); + return ciEnv != null && Boolean.parseBoolean(ciEnv); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index cb010c9a..1b7f1d07 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,6 +1,11 @@ spring: application: name: dorumdorum + + sql: + init: + mode: always + schema-locations: classpath:schema.sql datasource: driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver @@ -9,6 +14,7 @@ spring: password: test jpa: + defer-datasource-initialization: true hibernate: ddl-auto: create-drop properties: