diff --git a/src/main/java/com/example/solidconnection/chat/controller/ChatController.java b/src/main/java/com/example/solidconnection/chat/controller/ChatController.java new file mode 100644 index 000000000..1c3b1a5d1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/controller/ChatController.java @@ -0,0 +1,52 @@ +package com.example.solidconnection.chat.controller; + +import com.example.solidconnection.chat.dto.ChatMessageResponse; +import com.example.solidconnection.chat.dto.ChatRoomListResponse; +import com.example.solidconnection.chat.service.ChatService; +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.resolver.AuthorizedUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/chats") +public class ChatController { + + private final ChatService chatService; + + @GetMapping("/rooms") + public ResponseEntity getChatRooms( + @AuthorizedUser long siteUserId + ) { + ChatRoomListResponse chatRoomListResponse = chatService.getChatRooms(siteUserId); + return ResponseEntity.ok(chatRoomListResponse); + } + + @GetMapping("/rooms/{room-id}") + public ResponseEntity> getChatMessages( + @AuthorizedUser long siteUserId, + @PathVariable("room-id") Long roomId, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + SliceResponse response = chatService.getChatMessages(siteUserId, roomId, pageable); + return ResponseEntity.ok(response); + } + + @PutMapping("/rooms/{room-id}/read") + public ResponseEntity markChatMessagesAsRead( + @AuthorizedUser long siteUserId, + @PathVariable("room-id") Long roomId + ) { + chatService.markChatMessagesAsRead(siteUserId, roomId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java b/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java index 5c0f5e651..def9263c8 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatAttachment.java @@ -32,4 +32,14 @@ public class ChatAttachment extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) private ChatMessage chatMessage; + + public ChatAttachment(boolean isImage, String url, String thumbnailUrl, ChatMessage chatMessage) { + this.isImage = isImage; + this.url = url; + this.thumbnailUrl = thumbnailUrl; + this.chatMessage = chatMessage; + if (chatMessage != null) { + chatMessage.getChatAttachments().add(this); + } + } } diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java index 8d513c5a7..07fc99131 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatMessage.java @@ -34,5 +34,14 @@ public class ChatMessage extends BaseEntity { private ChatRoom chatRoom; @OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL) - private List chatAttachments = new ArrayList<>(); + private final List chatAttachments = new ArrayList<>(); + + public ChatMessage(String content, long senderId, ChatRoom chatRoom) { + this.content = content; + this.senderId = senderId; + this.chatRoom = chatRoom; + if (chatRoom != null) { + chatRoom.getChatMessages().add(this); + } + } } diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatParticipant.java b/src/main/java/com/example/solidconnection/chat/domain/ChatParticipant.java index 169e1dd06..60fc6b795 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatParticipant.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatParticipant.java @@ -34,4 +34,12 @@ public class ChatParticipant extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) private ChatRoom chatRoom; + + public ChatParticipant(long siteUserId, ChatRoom chatRoom) { + this.siteUserId = siteUserId; + this.chatRoom = chatRoom; + if (chatRoom != null) { + chatRoom.getChatParticipants().add(this); + } + } } diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatReadStatus.java b/src/main/java/com/example/solidconnection/chat/domain/ChatReadStatus.java index 13d4ac646..8c731738c 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatReadStatus.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatReadStatus.java @@ -32,4 +32,9 @@ public class ChatReadStatus extends BaseEntity { @Column(name = "chat_participant_id") private long chatParticipantId; + + public ChatReadStatus(long chatRoomId, long chatParticipantId) { + this.chatRoomId = chatRoomId; + this.chatParticipantId = chatParticipantId; + } } diff --git a/src/main/java/com/example/solidconnection/chat/domain/ChatRoom.java b/src/main/java/com/example/solidconnection/chat/domain/ChatRoom.java index 020befe5f..e8e7a3ebb 100644 --- a/src/main/java/com/example/solidconnection/chat/domain/ChatRoom.java +++ b/src/main/java/com/example/solidconnection/chat/domain/ChatRoom.java @@ -12,6 +12,7 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; @Entity @Getter @@ -25,8 +26,13 @@ public class ChatRoom extends BaseEntity { private boolean isGroup = false; @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL) - private List chatParticipants = new ArrayList<>(); + @BatchSize(size = 10) + private final List chatParticipants = new ArrayList<>(); @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL) - private List chatMessages = new ArrayList<>(); + private final List chatMessages = new ArrayList<>(); + + public ChatRoom(boolean isGroup) { + this.isGroup = isGroup; + } } diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatAttachmentResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatAttachmentResponse.java new file mode 100644 index 000000000..44c11246b --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatAttachmentResponse.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.chat.dto; + +import java.time.ZonedDateTime; + +public record ChatAttachmentResponse( + long id, + boolean isImage, + String url, + String thumbnailUrl, + ZonedDateTime createdAt +) { + + public static ChatAttachmentResponse of(long id, boolean isImage, String url, + String thumbnailUrl, ZonedDateTime createdAt) { + return new ChatAttachmentResponse(id, isImage, url, thumbnailUrl, createdAt); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatMessageResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageResponse.java new file mode 100644 index 000000000..a3728b7fd --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatMessageResponse.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.chat.dto; + +import java.time.ZonedDateTime; +import java.util.List; + +public record ChatMessageResponse( + long id, + String content, + long senderId, + ZonedDateTime createdAt, + List attachments +) { + + public static ChatMessageResponse of(long id, String content, long senderId, + ZonedDateTime createdAt, List attachments) { + return new ChatMessageResponse(id, content, senderId, createdAt, attachments); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatParticipantResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatParticipantResponse.java new file mode 100644 index 000000000..ffa6b9b8c --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatParticipantResponse.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.chat.dto; + +public record ChatParticipantResponse( + long partnerId, + String nickname, + String profileUrl +) { + + public static ChatParticipantResponse of(long partnerId, String nickname, String profileUrl) { + return new ChatParticipantResponse(partnerId, nickname, profileUrl); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatRoomListResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatRoomListResponse.java new file mode 100644 index 000000000..add17f1d1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatRoomListResponse.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.chat.dto; + +import java.util.List; + +public record ChatRoomListResponse( + List chatRooms +) { + + public static ChatRoomListResponse of(List chatRooms) { + return new ChatRoomListResponse(chatRooms); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/dto/ChatRoomResponse.java b/src/main/java/com/example/solidconnection/chat/dto/ChatRoomResponse.java new file mode 100644 index 000000000..69ec047fb --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/dto/ChatRoomResponse.java @@ -0,0 +1,22 @@ +package com.example.solidconnection.chat.dto; + +import java.time.ZonedDateTime; + +public record ChatRoomResponse( + long id, + String lastChatMessage, + ZonedDateTime lastReceivedTime, + ChatParticipantResponse partner, + long unReadCount +) { + + public static ChatRoomResponse of( + long id, + String lastChatMessage, + ZonedDateTime lastReceivedTime, + ChatParticipantResponse partner, + long unReadCount + ) { + return new ChatRoomResponse(id, lastChatMessage, lastReceivedTime, partner, unReadCount); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatAttachmentRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatAttachmentRepository.java new file mode 100644 index 000000000..0d2dd3051 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatAttachmentRepository.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatAttachment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatAttachmentRepository extends JpaRepository { + +} diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java new file mode 100644 index 000000000..ad0f15630 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatMessageRepository.java @@ -0,0 +1,22 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatMessage; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ChatMessageRepository extends JpaRepository { + + @Query(""" + SELECT cm FROM ChatMessage cm + LEFT JOIN FETCH cm.chatAttachments + WHERE cm.chatRoom.id = :roomId + ORDER BY cm.createdAt DESC + """) + Slice findByRoomIdWithPaging(@Param("roomId") long roomId, Pageable pageable); + + Optional findFirstByChatRoomIdOrderByCreatedAtDesc(long chatRoomId); +} diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java new file mode 100644 index 000000000..4bce2d08c --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatParticipantRepository.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatParticipant; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatParticipantRepository extends JpaRepository { + + boolean existsByChatRoomIdAndSiteUserId(long chatRoomId, long siteUserId); + + Optional findByChatRoomIdAndSiteUserId(long chatRoomId, long siteUserId); +} diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java new file mode 100644 index 000000000..5ff82a75b --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatReadStatusRepository.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatReadStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ChatReadStatusRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + INSERT INTO chat_read_status (chat_room_id, chat_participant_id, created_at, updated_at) + VALUES (:chatRoomId, :chatParticipantId, NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE updated_at = NOW(6) + """, nativeQuery = true) + void upsertReadStatus(@Param("chatRoomId") long chatRoomId, @Param("chatParticipantId") long chatParticipantId); +} diff --git a/src/main/java/com/example/solidconnection/chat/repository/ChatRoomRepository.java b/src/main/java/com/example/solidconnection/chat/repository/ChatRoomRepository.java new file mode 100644 index 000000000..dd5193abf --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/repository/ChatRoomRepository.java @@ -0,0 +1,36 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatRoom; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ChatRoomRepository extends JpaRepository { + + @Query(""" + SELECT cr FROM ChatRoom cr + JOIN cr.chatParticipants cp + WHERE cp.siteUserId = :userId AND cr.isGroup = false + ORDER BY ( + SELECT MAX(cm.createdAt) + FROM ChatMessage cm + WHERE cm.chatRoom = cr + ) DESC NULLS LAST + """) + List findOneOnOneChatRoomsByUserId(@Param("userId") long userId); + + @Query(""" + SELECT COUNT(cm) FROM ChatMessage cm + LEFT JOIN ChatReadStatus crs ON crs.chatRoomId = cm.chatRoom.id + AND crs.chatParticipantId = ( + SELECT cp.id FROM ChatParticipant cp + WHERE cp.chatRoom.id = :chatRoomId + AND cp.siteUserId = :userId + ) + WHERE cm.chatRoom.id = :chatRoomId + AND cm.senderId != :userId + AND (crs.updatedAt IS NULL OR cm.createdAt > crs.updatedAt) + """) + long countUnreadMessages(@Param("chatRoomId") long chatRoomId, @Param("userId") long userId); +} diff --git a/src/main/java/com/example/solidconnection/chat/service/ChatService.java b/src/main/java/com/example/solidconnection/chat/service/ChatService.java new file mode 100644 index 000000000..c378f6b50 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/service/ChatService.java @@ -0,0 +1,127 @@ +package com.example.solidconnection.chat.service; + +import static com.example.solidconnection.common.exception.ErrorCode.CHAT_PARTICIPANT_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.CHAT_PARTNER_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_CHAT_ROOM_STATE; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatParticipant; +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.dto.ChatAttachmentResponse; +import com.example.solidconnection.chat.dto.ChatMessageResponse; +import com.example.solidconnection.chat.dto.ChatParticipantResponse; +import com.example.solidconnection.chat.dto.ChatRoomListResponse; +import com.example.solidconnection.chat.dto.ChatRoomResponse; +import com.example.solidconnection.chat.repository.ChatMessageRepository; +import com.example.solidconnection.chat.repository.ChatParticipantRepository; +import com.example.solidconnection.chat.repository.ChatReadStatusRepository; +import com.example.solidconnection.chat.repository.ChatRoomRepository; +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class ChatService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; + private final ChatParticipantRepository chatParticipantRepository; + private final ChatReadStatusRepository chatReadStatusRepository; + private final SiteUserRepository siteUserRepository; + + @Transactional(readOnly = true) + public ChatRoomListResponse getChatRooms(long siteUserId) { + // todo : n + 1 문제 해결 필요! + List chatRooms = chatRoomRepository.findOneOnOneChatRoomsByUserId(siteUserId); + List chatRoomInfos = chatRooms.stream() + .map(chatRoom -> toChatRoomResponse(chatRoom, siteUserId)) + .toList(); + return ChatRoomListResponse.of(chatRoomInfos); + } + + private ChatRoomResponse toChatRoomResponse(ChatRoom chatRoom, long siteUserId) { + Optional latestMessage = chatMessageRepository.findFirstByChatRoomIdOrderByCreatedAtDesc(chatRoom.getId()); + String lastChatMessage = latestMessage.map(ChatMessage::getContent).orElse(""); + ZonedDateTime lastReceivedTime = latestMessage.map(ChatMessage::getCreatedAt).orElse(null); + + ChatParticipant partnerParticipant = findPartner(chatRoom, siteUserId); + + SiteUser siteUser = siteUserRepository.findById(partnerParticipant.getSiteUserId()) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + ChatParticipantResponse partner = ChatParticipantResponse.of(siteUser.getId(), siteUser.getNickname(), siteUser.getProfileImageUrl()); + + long unReadCount = chatRoomRepository.countUnreadMessages(chatRoom.getId(), siteUserId); + + return ChatRoomResponse.of(chatRoom.getId(), lastChatMessage, lastReceivedTime, partner, unReadCount); + } + + private ChatParticipant findPartner(ChatRoom chatRoom, long siteUserId) { + if (chatRoom.isGroup()) { + throw new CustomException(INVALID_CHAT_ROOM_STATE); + } + return chatRoom.getChatParticipants().stream() + .filter(participant -> participant.getSiteUserId() != siteUserId) + .findFirst() + .orElseThrow(() -> new CustomException(CHAT_PARTNER_NOT_FOUND)); + } + + @Transactional(readOnly = true) + public SliceResponse getChatMessages(long siteUserId, long roomId, Pageable pageable) { + validateChatRoomParticipant(siteUserId, roomId); + + Slice chatMessages = chatMessageRepository.findByRoomIdWithPaging(roomId, pageable); + + List content = chatMessages.getContent().stream() + .map(this::toChatMessageResponse) + .toList(); + + return SliceResponse.of(content, chatMessages); + } + + private ChatMessageResponse toChatMessageResponse(ChatMessage message) { + List attachments = message.getChatAttachments().stream() + .map(attachment -> ChatAttachmentResponse.of( + attachment.getId(), + attachment.getIsImage(), + attachment.getUrl(), + attachment.getThumbnailUrl(), + attachment.getCreatedAt() + )) + .toList(); + + return ChatMessageResponse.of( + message.getId(), + message.getContent(), + message.getSenderId(), + message.getCreatedAt(), + attachments + ); + } + + private void validateChatRoomParticipant(long siteUserId, long roomId) { + boolean isParticipant = chatParticipantRepository.existsByChatRoomIdAndSiteUserId(roomId, siteUserId); + if (!isParticipant) { + throw new CustomException(CHAT_PARTICIPANT_NOT_FOUND); + } + } + + @Transactional + public void markChatMessagesAsRead(long siteUserId, long roomId) { + ChatParticipant participant = chatParticipantRepository + .findByChatRoomIdAndSiteUserId(roomId, siteUserId) + .orElseThrow(() -> new CustomException(CHAT_PARTICIPANT_NOT_FOUND)); + + chatReadStatusRepository.upsertReadStatus(roomId, participant.getId()); + } +} diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 824c531f6..47f5af097 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -45,6 +45,8 @@ public enum ErrorCode { NEWS_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 소식지입니다."), MENTOR_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 멘토입니다."), REPORT_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 신고 대상입니다."), + CHAT_PARTNER_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "채팅 상대를 찾을 수 없습니다."), + CHAT_PARTICIPANT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "채팅 참여자를 찾을 수 없습니다."), // auth USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."), @@ -115,6 +117,9 @@ public enum ErrorCode { // report ALREADY_REPORTED_BY_CURRENT_USER(HttpStatus.BAD_REQUEST.value(), "이미 신고한 상태입니다."), + // chat + INVALID_CHAT_ROOM_STATE(HttpStatus.BAD_REQUEST.value(), "잘못된 채팅방 상태입니다."), + // database DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT.value(), "데이터베이스 무결성 제약조건 위반이 발생했습니다."), diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatAttachmentFixture.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatAttachmentFixture.java new file mode 100644 index 000000000..37f85c6e9 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatAttachmentFixture.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatAttachment; +import com.example.solidconnection.chat.domain.ChatMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatAttachmentFixture { + + private final ChatAttachmentFixtureBuilder chatAttachmentFixtureBuilder; + + public ChatAttachment 첨부파일(boolean isImage, String url, String thumbnailUrl, ChatMessage chatMessage) { + return chatAttachmentFixtureBuilder.chatAttachment() + .isImage(isImage) + .url(url) + .thumbnailUrl(thumbnailUrl) + .chatMessage(chatMessage) + .create(); + } + + public ChatAttachment 이미지(String url, String thumbnailUrl, ChatMessage chatMessage) { + return 첨부파일(true, url, thumbnailUrl, chatMessage); + } + + public ChatAttachment 파일(String url, ChatMessage chatMessage) { + return 첨부파일(false, url, null, chatMessage); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatAttachmentFixtureBuilder.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatAttachmentFixtureBuilder.java new file mode 100644 index 000000000..7db17caf0 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatAttachmentFixtureBuilder.java @@ -0,0 +1,48 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatAttachment; +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.repository.ChatAttachmentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatAttachmentFixtureBuilder { + + private final ChatAttachmentRepository chatAttachmentRepository; + + private boolean isImage; + private String url; + private String thumbnailUrl; + private ChatMessage chatMessage; + + public ChatAttachmentFixtureBuilder chatAttachment() { + return new ChatAttachmentFixtureBuilder(chatAttachmentRepository); + } + + public ChatAttachmentFixtureBuilder isImage(boolean isImage) { + this.isImage = isImage; + return this; + } + + public ChatAttachmentFixtureBuilder url(String url) { + this.url = url; + return this; + } + + public ChatAttachmentFixtureBuilder thumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + return this; + } + + public ChatAttachmentFixtureBuilder chatMessage(ChatMessage chatMessage) { + this.chatMessage = chatMessage; + return this; + } + + public ChatAttachment create() { + ChatAttachment attachment = new ChatAttachment(isImage, url, thumbnailUrl, chatMessage); + return chatAttachmentRepository.save(attachment); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatMessageFixture.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatMessageFixture.java new file mode 100644 index 000000000..f5a30cec8 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatMessageFixture.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatRoom; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatMessageFixture { + + private final ChatMessageFixtureBuilder chatMessageFixtureBuilder; + + public ChatMessage 메시지(String content, long senderId, ChatRoom chatRoom) { + return chatMessageFixtureBuilder.chatMessage() + .content(content) + .senderId(senderId) + .chatRoom(chatRoom) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatMessageFixtureBuilder.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatMessageFixtureBuilder.java new file mode 100644 index 000000000..8b30718cb --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatMessageFixtureBuilder.java @@ -0,0 +1,42 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.repository.ChatMessageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatMessageFixtureBuilder { + + private final ChatMessageRepository chatMessageRepository; + + private String content; + private long senderId; + private ChatRoom chatRoom; + + public ChatMessageFixtureBuilder chatMessage() { + return new ChatMessageFixtureBuilder(chatMessageRepository); + } + + public ChatMessageFixtureBuilder content(String content) { + this.content = content; + return this; + } + + public ChatMessageFixtureBuilder senderId(long senderId) { + this.senderId = senderId; + return this; + } + + public ChatMessageFixtureBuilder chatRoom(ChatRoom chatRoom) { + this.chatRoom = chatRoom; + return this; + } + + public ChatMessage create() { + ChatMessage chatMessage = new ChatMessage(content, senderId, chatRoom); + return chatMessageRepository.save(chatMessage); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatParticipantFixture.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatParticipantFixture.java new file mode 100644 index 000000000..20825919d --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatParticipantFixture.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatParticipant; +import com.example.solidconnection.chat.domain.ChatRoom; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatParticipantFixture { + + private final ChatParticipantFixtureBuilder chatParticipantFixtureBuilder; + + public ChatParticipant 참여자(long siteUserId, ChatRoom chatRoom) { + return chatParticipantFixtureBuilder.chatParticipant() + .siteUserId(siteUserId) + .chatRoom(chatRoom) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatParticipantFixtureBuilder.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatParticipantFixtureBuilder.java new file mode 100644 index 000000000..8514ce77e --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatParticipantFixtureBuilder.java @@ -0,0 +1,36 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatParticipant; +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.repository.ChatParticipantRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatParticipantFixtureBuilder { + + private final ChatParticipantRepository chatParticipantRepository; + + private long siteUserId; + private ChatRoom chatRoom; + + public ChatParticipantFixtureBuilder chatParticipant() { + return new ChatParticipantFixtureBuilder(chatParticipantRepository); + } + + public ChatParticipantFixtureBuilder siteUserId(long siteUserId) { + this.siteUserId = siteUserId; + return this; + } + + public ChatParticipantFixtureBuilder chatRoom(ChatRoom chatRoom) { + this.chatRoom = chatRoom; + return this; + } + + public ChatParticipant create() { + ChatParticipant chatParticipant = new ChatParticipant(siteUserId, chatRoom); + return chatParticipantRepository.save(chatParticipant); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatReadStatusFixture.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatReadStatusFixture.java new file mode 100644 index 000000000..f254faaf3 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatReadStatusFixture.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatReadStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatReadStatusFixture { + + private final ChatReadStatusFixtureBuilder chatReadStatusFixtureBuilder; + + public ChatReadStatus 읽음상태(long chatRoomId, long chatParticipantId) { + return chatReadStatusFixtureBuilder.chatReadStatus() + .chatRoomId(chatRoomId) + .chatParticipantId(chatParticipantId) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatReadStatusFixtureBuilder.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatReadStatusFixtureBuilder.java new file mode 100644 index 000000000..6f42c7d13 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatReadStatusFixtureBuilder.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatReadStatus; +import com.example.solidconnection.chat.repository.ChatReadStatusRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatReadStatusFixtureBuilder { + + private final ChatReadStatusRepository chatReadStatusRepository; + + private long chatRoomId; + private long chatParticipantId; + + public ChatReadStatusFixtureBuilder chatReadStatus() { + return new ChatReadStatusFixtureBuilder(chatReadStatusRepository); + } + + public ChatReadStatusFixtureBuilder chatRoomId(long chatRoomId) { + this.chatRoomId = chatRoomId; + return this; + } + + public ChatReadStatusFixtureBuilder chatParticipantId(long chatParticipantId) { + this.chatParticipantId = chatParticipantId; + return this; + } + + public ChatReadStatus create() { + ChatReadStatus chatReadStatus = new ChatReadStatus(chatRoomId, chatParticipantId); + return chatReadStatusRepository.save(chatReadStatus); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatRoomFixture.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatRoomFixture.java new file mode 100644 index 000000000..cf80313bc --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatRoomFixture.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatRoom; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatRoomFixture { + + private final ChatRoomFixtureBuilder chatRoomFixtureBuilder; + + public ChatRoom 채팅방(boolean isGroup) { + return chatRoomFixtureBuilder.chatRoom() + .isGroup(isGroup) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/fixture/ChatRoomFixtureBuilder.java b/src/test/java/com/example/solidconnection/chat/fixture/ChatRoomFixtureBuilder.java new file mode 100644 index 000000000..bf7ed3387 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/fixture/ChatRoomFixtureBuilder.java @@ -0,0 +1,29 @@ +package com.example.solidconnection.chat.fixture; + +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.repository.ChatRoomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ChatRoomFixtureBuilder { + + private final ChatRoomRepository chatRoomRepository; + + private boolean isGroup; + + public ChatRoomFixtureBuilder chatRoom() { + return new ChatRoomFixtureBuilder(chatRoomRepository); + } + + public ChatRoomFixtureBuilder isGroup(boolean isGroup) { + this.isGroup = isGroup; + return this; + } + + public ChatRoom create() { + ChatRoom chatRoom = new ChatRoom(isGroup); + return chatRoomRepository.save(chatRoom); + } +} diff --git a/src/test/java/com/example/solidconnection/chat/repository/ChatReadStatusRepositoryForTest.java b/src/test/java/com/example/solidconnection/chat/repository/ChatReadStatusRepositoryForTest.java new file mode 100644 index 000000000..894276b78 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/repository/ChatReadStatusRepositoryForTest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.chat.repository; + +import com.example.solidconnection.chat.domain.ChatReadStatus; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatReadStatusRepositoryForTest extends JpaRepository { + + Optional findByChatRoomIdAndChatParticipantId(long chatRoomId, long chatParticipantId); +} diff --git a/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java b/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java new file mode 100644 index 000000000..77120e884 --- /dev/null +++ b/src/test/java/com/example/solidconnection/chat/service/ChatServiceTest.java @@ -0,0 +1,366 @@ +package com.example.solidconnection.chat.service; + +import static com.example.solidconnection.common.exception.ErrorCode.CHAT_PARTICIPANT_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.CHAT_PARTNER_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.chat.domain.ChatAttachment; +import com.example.solidconnection.chat.domain.ChatMessage; +import com.example.solidconnection.chat.domain.ChatParticipant; +import com.example.solidconnection.chat.domain.ChatReadStatus; +import com.example.solidconnection.chat.domain.ChatRoom; +import com.example.solidconnection.chat.dto.ChatMessageResponse; +import com.example.solidconnection.chat.dto.ChatRoomListResponse; +import com.example.solidconnection.chat.fixture.ChatAttachmentFixture; +import com.example.solidconnection.chat.fixture.ChatMessageFixture; +import com.example.solidconnection.chat.fixture.ChatParticipantFixture; +import com.example.solidconnection.chat.fixture.ChatReadStatusFixture; +import com.example.solidconnection.chat.fixture.ChatRoomFixture; +import com.example.solidconnection.chat.repository.ChatReadStatusRepositoryForTest; +import com.example.solidconnection.common.dto.SliceResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +@TestContainerSpringBootTest +@DisplayName("채팅 서비스 테스트") +class ChatServiceTest { + + @Autowired + private ChatService chatService; + + @Autowired + private ChatReadStatusRepositoryForTest chatReadStatusRepositoryForTest; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private ChatRoomFixture chatRoomFixture; + + @Autowired + private ChatParticipantFixture chatParticipantFixture; + + @Autowired + private ChatMessageFixture chatMessageFixture; + + @Autowired + private ChatReadStatusFixture chatReadStatusFixture; + + @Autowired + private ChatAttachmentFixture chatAttachmentFixture; + + private SiteUser user; + private SiteUser mentor1; + private SiteUser mentor2; + + @BeforeEach + void setUp() { + user = siteUserFixture.사용자(); + mentor1 = siteUserFixture.사용자(1, "mentor1"); + mentor2 = siteUserFixture.사용자(2, "mentor2"); + } + + @Nested + class 채팅방_목록을_조회한다 { + + @Test + void 채팅방이_없으면_빈_목록을_반환한다() { + // when + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // then + assertThat(response.chatRooms()).isEmpty(); + } + + @Test + void 최신_메시지_순으로_정렬되어_조회한다() { + // given + ChatRoom chatRoom1 = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), chatRoom1); + chatParticipantFixture.참여자(mentor1.getId(), chatRoom1); + ChatMessage oldMessage = chatMessageFixture.메시지("오래된 메시지", mentor1.getId(), chatRoom1); + + ChatRoom chatRoom2 = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), chatRoom2); + chatParticipantFixture.참여자(mentor2.getId(), chatRoom2); + ChatMessage newMessage = chatMessageFixture.메시지("최신 메시지", mentor2.getId(), chatRoom2); + + // when + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // then + assertAll( + () -> assertThat(response.chatRooms()).hasSize(2), + () -> assertThat(response.chatRooms().get(0).partner().partnerId()).isEqualTo(mentor2.getId()), + () -> assertThat(response.chatRooms().get(0).lastChatMessage()).isEqualTo(newMessage.getContent()), + () -> assertThat(response.chatRooms().get(1).partner().partnerId()).isEqualTo(mentor1.getId()), + () -> assertThat(response.chatRooms().get(1).lastChatMessage()).isEqualTo(oldMessage.getContent()) + ); + } + + @Test + void 그룹_채팅방은_제외하고_1대1_채팅방만_조회한다() { + // given + ChatRoom oneOnOneRoom = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), oneOnOneRoom); + chatParticipantFixture.참여자(mentor1.getId(), oneOnOneRoom); + + ChatRoom groupRoom = chatRoomFixture.채팅방(true); + chatParticipantFixture.참여자(user.getId(), groupRoom); + chatParticipantFixture.참여자(mentor1.getId(), groupRoom); + chatParticipantFixture.참여자(mentor2.getId(), groupRoom); + + // when + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // then + assertAll( + () -> assertThat(response.chatRooms()).hasSize(1), + () -> assertThat(response.chatRooms().get(0).id()).isEqualTo(oneOnOneRoom.getId()) + ); + } + + @Test + void 채팅_상대방이_없으면_예외가_발생한다() { + // given + ChatRoom chatRoom = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), chatRoom); + + // when & then + assertThatCode(() -> chatService.getChatRooms(user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(CHAT_PARTNER_NOT_FOUND.getMessage()); + } + } + + @Nested + class 읽지_않은_메시지_수를_조회한다 { + + private ChatRoom chatRoom; + private ChatParticipant participant; + + @BeforeEach + void setUp() { + chatRoom = chatRoomFixture.채팅방(false); + participant = chatParticipantFixture.참여자(user.getId(), chatRoom); + chatParticipantFixture.참여자(mentor1.getId(), chatRoom); + } + + @Test + void 읽음_상태가_없으면_모든_상대방_메시지를_카운팅한다() { + // given + chatMessageFixture.메시지("메시지1", mentor1.getId(), chatRoom); + chatMessageFixture.메시지("메시지2", mentor1.getId(), chatRoom); + + // when + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // then + assertThat(response.chatRooms().get(0).unReadCount()).isEqualTo(2); + } + + @Test + void 읽음_상태_이후_메시지만_읽지_않은_메시지로_카운팅한다() { + // given + chatMessageFixture.메시지("읽은 메시지", mentor1.getId(), chatRoom); + chatReadStatusFixture.읽음상태(chatRoom.getId(), participant.getId()); + + chatMessageFixture.메시지("읽지 않은 메시지1", mentor1.getId(), chatRoom); + chatMessageFixture.메시지("읽지 않은 메시지2", mentor1.getId(), chatRoom); + + // when + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // then + assertThat(response.chatRooms().get(0).unReadCount()).isEqualTo(2); + } + } + + @Nested + class 채팅_메시지를_조회한다 { + + private static final int NO_NEXT_PAGE_NUMBER = -1; + + private ChatRoom chatRoom; + private Pageable pageable; + + @BeforeEach + void setUp() { + chatRoom = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), chatRoom); + chatParticipantFixture.참여자(mentor1.getId(), chatRoom); + + pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + } + + @Test + void 메시지가_없는_채팅방에서_빈_목록을_반환한다() { + // when + SliceResponse response = chatService.getChatMessages(user.getId(), chatRoom.getId(), pageable); + + // then + assertAll( + () -> assertThat(response.content()).isEmpty(), + () -> assertThat(response.nextPageNumber()).isEqualTo(NO_NEXT_PAGE_NUMBER) + ); + } + + @Test + void 첨부파일이_없는_메시지들을_정상_조회한다() { + // given + ChatMessage message1 = chatMessageFixture.메시지("메시지1", mentor1.getId(), chatRoom); + ChatMessage message2 = chatMessageFixture.메시지("메시지2", user.getId(), chatRoom); + + // when + SliceResponse response = chatService.getChatMessages(user.getId(), chatRoom.getId(), pageable); + + // then + assertAll( + () -> assertThat(response.content()).hasSize(2), + () -> assertThat(response.content().get(0).content()).isEqualTo(message2.getContent()), + () -> assertThat(response.content().get(0).senderId()).isEqualTo(user.getId()), + () -> assertThat(response.content().get(1).content()).isEqualTo(message1.getContent()), + () -> assertThat(response.content().get(1).senderId()).isEqualTo(mentor1.getId()) + ); + } + + @Test + void 첨부파일이_있는_메시지를_정상_조회한다() { + // given + ChatMessage messageWithImage = chatMessageFixture.메시지("이미지", mentor1.getId(), chatRoom); + ChatAttachment imageAttachment = chatAttachmentFixture.첨부파일( + true, + "https://example.com/image.png", + "https://example.com/thumb.png", + messageWithImage + ); + + // when + SliceResponse response = chatService.getChatMessages(user.getId(), chatRoom.getId(), pageable); + + // then + assertAll( + () -> assertThat(response.content()).hasSize(1), + () -> assertThat(response.content().get(0).content()).isEqualTo(messageWithImage.getContent()), + () -> assertThat(response.content().get(0).attachments()).hasSize(1), + () -> assertThat(response.content().get(0).attachments().get(0).id()).isEqualTo(imageAttachment.getId()) + ); + } + + @Test + void 페이징이_정상_작동한다() { + for (int i = 1; i <= 25; i++) { + chatMessageFixture.메시지("메시지" + i, (i % 2 == 0) ? user.getId() : mentor1.getId(), chatRoom); + } + + Pageable firstPage = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + Pageable secondPage = PageRequest.of(1, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + SliceResponse firstResponse = chatService.getChatMessages(user.getId(), chatRoom.getId(), firstPage); + SliceResponse secondResponse = chatService.getChatMessages(user.getId(), chatRoom.getId(), secondPage); + + // then + assertAll( + () -> assertThat(firstResponse.nextPageNumber()).isEqualTo(2), + () -> assertThat(secondResponse.nextPageNumber()).isEqualTo(NO_NEXT_PAGE_NUMBER) + ); + } + + @Test + void 채팅방_참여자가_아니면_예외가_발생한다() { + // when & then + assertThatCode(() -> chatService.getChatMessages(mentor2.getId(), chatRoom.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(CHAT_PARTICIPANT_NOT_FOUND.getMessage()); + } + + @Test + void 존재하지_않는_채팅방에_접근하면_예외가_발생한다() { + // given + long nonExistentRoomId = 999L; + + // when & then + assertThatCode(() -> chatService.getChatMessages(user.getId(), nonExistentRoomId, pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(CHAT_PARTICIPANT_NOT_FOUND.getMessage()); + } + } + + @Nested + class 채팅_메시지_읽음을_처리한다 { + + private ChatRoom chatRoom; + private ChatParticipant participant; + + @BeforeEach + void setUp() { + chatRoom = chatRoomFixture.채팅방(false); + participant = chatParticipantFixture.참여자(user.getId(), chatRoom); + chatParticipantFixture.참여자(mentor1.getId(), chatRoom); + } + + @Test + void 처음_읽음_처리_시_새로운_읽음_상태를_생성한다() { + // given + chatMessageFixture.메시지("읽지 않은 메시지1", mentor1.getId(), chatRoom); + chatMessageFixture.메시지("읽지 않은 메시지2", mentor1.getId(), chatRoom); + + // when + chatService.markChatMessagesAsRead(user.getId(), chatRoom.getId()); + + // then + ChatReadStatus afterStatus = chatReadStatusRepositoryForTest + .findByChatRoomIdAndChatParticipantId(chatRoom.getId(), participant.getId()) + .orElseThrow(); + + assertThat(afterStatus.getChatRoomId()).isEqualTo(chatRoom.getId()); + } + + @Test + void 기존_읽음_상태가_있으면_updatedAt을_갱신한다() { + // given + ChatReadStatus chatReadStatus = chatReadStatusFixture.읽음상태(chatRoom.getId(), participant.getId()); + ZonedDateTime updatedAt = chatReadStatus.getUpdatedAt(); + chatMessageFixture.메시지("새로운 메시지", mentor1.getId(), chatRoom); + + // when + chatService.markChatMessagesAsRead(user.getId(), chatRoom.getId()); + + // then + ChatReadStatus updatedStatus = chatReadStatusRepositoryForTest + .findByChatRoomIdAndChatParticipantId(chatRoom.getId(), participant.getId()) + .orElseThrow(); + assertAll( + () -> assertThat(updatedStatus.getId()).isEqualTo(chatReadStatus.getId()), + () -> assertThat(updatedStatus.getUpdatedAt()).isAfter(updatedAt) + ); + } + + @Test + void 채팅방_참여자가_아니면_예외가_발생한다() { + // given + ChatRoom chatRoom = chatRoomFixture.채팅방(false); + chatParticipantFixture.참여자(user.getId(), chatRoom); + chatParticipantFixture.참여자(mentor1.getId(), chatRoom); + + // when & then + assertThatCode(() -> chatService.markChatMessagesAsRead(mentor2.getId(), chatRoom.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(CHAT_PARTICIPANT_NOT_FOUND.getMessage()); + } + } +}