From b18e97d8419852cfa66663ccff0a3ec02bd1a95b Mon Sep 17 00:00:00 2001 From: Jansoon Date: Sun, 21 Dec 2025 03:40:02 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20Message,=20Link=20=EC=97=B0?= =?UTF-8?q?=EA=B4=80=EA=B4=80=EA=B3=84=20=EC=B6=94=EA=B0=80=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/controller/ChatApi.java | 21 +- .../chat/controller/ChatController.java | 20 +- .../domain/chat/dto/response/MessagesRes.java | 89 ++++++++ .../linkiving/domain/chat/entity/Message.java | 18 +- .../domain/chat/facade/ChatFacade.java | 2 + .../chat/service/MessageQueryService.java | 15 +- .../domain/chat/service/MessageService.java | 6 +- .../domain/chat/entity/FeedbackTest.java | 2 +- .../domain/chat/entity/MessageTest.java | 23 +- .../domain/chat/facade/ChatFacadeTest.java | 38 ++++ .../chat/integration/ChatIntegrationTest.java | 59 +++++- .../repository/MessageRepositoryTest.java | 197 ++++++++++++++++++ .../chat/service/MessageQueryServiceTest.java | 51 ++++- .../chat/service/MessageServiceTest.java | 28 +-- .../integration/LinkApiIntegrationTest.java | 5 +- .../LongListToStringConverterTest.java | 90 ++++++++ 16 files changed, 612 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/sofa/linkiving/domain/chat/dto/response/MessagesRes.java create mode 100644 src/test/java/com/sofa/linkiving/domain/chat/repository/MessageRepositoryTest.java create mode 100644 src/test/java/com/sofa/linkiving/global/converter/LongListToStringConverterTest.java diff --git a/src/main/java/com/sofa/linkiving/domain/chat/controller/ChatApi.java b/src/main/java/com/sofa/linkiving/domain/chat/controller/ChatApi.java index 3a249841..1ae07939 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/controller/ChatApi.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/controller/ChatApi.java @@ -1,15 +1,22 @@ package com.sofa.linkiving.domain.chat.controller; +import org.springframework.validation.annotation.Validated; + import com.sofa.linkiving.domain.chat.dto.request.CreateChatReq; import com.sofa.linkiving.domain.chat.dto.response.ChatsRes; import com.sofa.linkiving.domain.chat.dto.response.CreateChatRes; +import com.sofa.linkiving.domain.chat.dto.response.MessagesRes; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.global.common.BaseResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +@Validated @Tag(name = "Chat", description = """ AI 채팅 통합 명세 (HTTP + WebSocket) @@ -27,12 +34,24 @@ """) public interface ChatApi { + @Operation(summary = "채팅 기록 조회", description = "채팅 기록을 최신순으로 조회합니다. 무한 스크롤 방식으로 제공됩니다.") + BaseResponse getMessages( + Member member, + @Parameter(description = "채팅방 ID") Long chatId, + @Parameter(description = "페이징을 위한 마지막 메시지 ID, 첫 조회 시 null") Long lastId, + + @Parameter(description = "페이지 크기") + @Min(value = 1, message = "최소 1개 이상 조회해야 합니다.") + @Max(value = 50, message = "한 번에 최대 50개까지만 조회할 수 있습니다.") + int size + ); + @Operation(summary = "채팅방 목록 조회", description = "사용자의 채팅방 목록 정보(채팅방 Id, 제목)을 조회합니다.") BaseResponse getChats(Member member); @Operation(summary = "새로운 채팅 생성", description = "새로운 채팅을 생성합니다.") BaseResponse createChat( - CreateChatReq req, + @Valid CreateChatReq req, Member member ); diff --git a/src/main/java/com/sofa/linkiving/domain/chat/controller/ChatController.java b/src/main/java/com/sofa/linkiving/domain/chat/controller/ChatController.java index 972da2dd..25747196 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/controller/ChatController.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/controller/ChatController.java @@ -9,17 +9,18 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.sofa.linkiving.domain.chat.dto.request.CreateChatReq; import com.sofa.linkiving.domain.chat.dto.response.ChatsRes; import com.sofa.linkiving.domain.chat.dto.response.CreateChatRes; +import com.sofa.linkiving.domain.chat.dto.response.MessagesRes; import com.sofa.linkiving.domain.chat.facade.ChatFacade; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.global.common.BaseResponse; import com.sofa.linkiving.security.annotation.AuthMember; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @@ -37,7 +38,7 @@ public BaseResponse getChats(@AuthMember Member member) { @Override @PostMapping - public BaseResponse createChat(@RequestBody @Valid CreateChatReq req, @AuthMember Member member) { + public BaseResponse createChat(@RequestBody CreateChatReq req, @AuthMember Member member) { CreateChatRes res = chatFacade.createChat(req.firstChat(), member); return BaseResponse.success(res, "채팅방 생성 완료"); } @@ -60,4 +61,19 @@ public void sendMessage(@DestinationVariable Long chatId, @Payload String messag public void cancelMessage(@DestinationVariable Long chatId, @AuthMember Member member) { chatFacade.cancelAnswer(chatId, member); } + + @Override + @GetMapping("/{chatId}") + public BaseResponse getMessages( + @AuthMember Member member, + @PathVariable Long chatId, + @RequestParam(required = false) Long lastId, + + @RequestParam(defaultValue = "20") + + int size + ) { + MessagesRes res = chatFacade.getMessages(member, chatId, lastId, size); + return BaseResponse.success(res, "채팅 기록을 가져오는데 성공했습니다."); + } } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/dto/response/MessagesRes.java b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/MessagesRes.java new file mode 100644 index 00000000..8b99ec59 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/MessagesRes.java @@ -0,0 +1,89 @@ +package com.sofa.linkiving.domain.chat.dto.response; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.sofa.linkiving.domain.chat.entity.Message; +import com.sofa.linkiving.domain.chat.enums.Sentiment; +import com.sofa.linkiving.domain.chat.enums.Type; +import com.sofa.linkiving.domain.link.entity.Link; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MessagesRes( + @Schema(description = "메시지 목록") + List messages, + + @Schema(description = "다음 페이지 존재 여부") + boolean hasNext, + + @Schema(description = "마지막 메시지 ID (다음 요청 커서용)") + Long lastId +) { + public static MessagesRes of(List messages, boolean hasNext) { + List messageDtos = messages.stream() + .map(MessageDto::from) + .toList(); + + Long lastId = messages.isEmpty() ? null : messages.get(messages.size() - 1).getId(); + + return new MessagesRes(messageDtos, hasNext, lastId); + } + + public record MessageDto( + @Schema(description = "메시지 ID") + Long id, + + @Schema(description = "메시지 내용") + String content, + + @Schema(description = "발신자 타입 (USER / AI)") + Type type, + + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "피드백 상태 (AI 메시지인 경우만 포함: LIKE, DISLIKE, NONE)") + Sentiment feedback, + + @Schema(description = "첨부된 링크 목록") + List links + ) { + public static MessageDto from(Message message) { + Sentiment feedbackStatus = null; + + if (message.getType() == Type.AI) { + feedbackStatus = (message.getFeedback() != null) + ? message.getFeedback().getSentiment() + : Sentiment.NONE; + } + + // [변경] 엔티티에서 링크 목록을 바로 변환 + List linkDtos = message.getLinks().stream() + .map(LinkPreviewDto::from) + .toList(); + + return new MessageDto( + message.getId(), + message.getContent(), + message.getType(), + feedbackStatus, + linkDtos + ); + } + } + + public record LinkPreviewDto( + Long id, + String title, + String url, + String imageUrl + ) { + public static LinkPreviewDto from(Link link) { + return new LinkPreviewDto( + link.getId(), + link.getTitle(), + link.getUrl(), + link.getImageUrl() + ); + } + } +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/entity/Message.java b/src/main/java/com/sofa/linkiving/domain/chat/entity/Message.java index 9ccde652..416167df 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/entity/Message.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/entity/Message.java @@ -4,14 +4,15 @@ import java.util.List; import com.sofa.linkiving.domain.chat.enums.Type; +import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.global.common.BaseEntity; -import com.sofa.linkiving.global.converter.LongListToStringConverter; import jakarta.persistence.Column; -import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; @@ -36,17 +37,22 @@ public class Message extends BaseEntity { @Column(columnDefinition = "text", nullable = false) private String content; - @Convert(converter = LongListToStringConverter.class) - private List linkIds = new ArrayList<>(); + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "message_link", + joinColumns = @JoinColumn(name = "message_id"), + inverseJoinColumns = @JoinColumn(name = "link_id") + ) + private List links = new ArrayList<>(); @OneToOne(mappedBy = "message") private Feedback feedback; @Builder - public Message(Chat chat, Type type, String content, List linkIds) { + public Message(Chat chat, Type type, String content, List links) { this.chat = chat; this.type = type; this.content = content; - this.linkIds = linkIds; + this.links = (links != null) ? links : new ArrayList<>(); } } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java b/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java index 5def52e1..42318756 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java @@ -6,8 +6,10 @@ import org.springframework.transaction.annotation.Transactional; import com.sofa.linkiving.domain.chat.ai.AiTitleClient; +import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto; import com.sofa.linkiving.domain.chat.dto.response.ChatsRes; import com.sofa.linkiving.domain.chat.dto.response.CreateChatRes; +import com.sofa.linkiving.domain.chat.dto.response.MessagesRes; import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.service.ChatService; import com.sofa.linkiving.domain.chat.service.FeedbackService; diff --git a/src/main/java/com/sofa/linkiving/domain/chat/service/MessageQueryService.java b/src/main/java/com/sofa/linkiving/domain/chat/service/MessageQueryService.java index 5ce23bf9..21acc704 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/service/MessageQueryService.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/service/MessageQueryService.java @@ -2,8 +2,10 @@ import java.util.List; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto; import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.repository.MessageRepository; @@ -15,7 +17,16 @@ public class MessageQueryService { private final MessageRepository messageRepository; - public List findAllByChat(Chat chat) { - return messageRepository.findAllByChat(chat); + public MessagesDto findAllByChatAndCursor(Chat chat, Long lastId, int size) { + PageRequest pageRequest = PageRequest.of(0, size + 1); + List messages = messageRepository.findAllByChatAndCursor(chat, lastId, pageRequest); + + boolean hasNext = false; + if (size < messages.size()) { + hasNext = true; + messages.remove(size); + } + + return new MessagesDto(messages, hasNext); } } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/service/MessageService.java b/src/main/java/com/sofa/linkiving/domain/chat/service/MessageService.java index 93767217..8540be0c 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/service/MessageService.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/service/MessageService.java @@ -1,6 +1,5 @@ package com.sofa.linkiving.domain.chat.service; -import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -8,6 +7,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; +import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto; import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.enums.Type; @@ -32,8 +32,8 @@ public void deleteAll(Chat chat) { messageCommandService.deleteAllByChat(chat); } - public List getMessagesByChat(Chat chat) { - return messageQueryService.findAllByChat(chat); + public MessagesDto getMessages(Chat chat, Long lastId, int size) { + return messageQueryService.findAllByChatAndCursor(chat, lastId, size); } public void generateAnswer(Chat chat, String userMessage) { diff --git a/src/test/java/com/sofa/linkiving/domain/chat/entity/FeedbackTest.java b/src/test/java/com/sofa/linkiving/domain/chat/entity/FeedbackTest.java index ce112f40..91baad58 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/entity/FeedbackTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/entity/FeedbackTest.java @@ -43,7 +43,7 @@ void shouldSaveFeedbackWithSentiment() { .chat(chat) .type(Type.USER) .content("답변입니다.") - .linkIds(Collections.emptyList()) + .links(Collections.emptyList()) .build(); em.persist(message); diff --git a/src/test/java/com/sofa/linkiving/domain/chat/entity/MessageTest.java b/src/test/java/com/sofa/linkiving/domain/chat/entity/MessageTest.java index 668f2f5e..cb439359 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/entity/MessageTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/entity/MessageTest.java @@ -12,6 +12,7 @@ import org.springframework.test.context.ActiveProfiles; import com.sofa.linkiving.domain.chat.enums.Type; +import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.member.entity.Member; @DataJpaTest @@ -38,14 +39,27 @@ void shouldSaveMessageWithContentAndLinkIds() { .build(); em.persist(chat); - List linkIds = List.of(1L, 100L, 500L); + Link link1 = Link.builder() + .member(member) + .url("https://example1.com") + .title("Link 1") + .build(); + em.persist(link1); + + Link link2 = Link.builder() + .member(member) + .url("https://example2.com") + .title("Link 2") + .build(); + em.persist(link2); + String content = "테스트 메시지입니다."; Message message = Message.builder() .chat(chat) .type(Type.AI) .content(content) - .linkIds(linkIds) + .links(List.of(link1, link2)) .build(); // when @@ -57,7 +71,8 @@ void shouldSaveMessageWithContentAndLinkIds() { assertThat(savedMessage.getChat()).isEqualTo(chat); // Converter 동작 검증 - assertThat(savedMessage.getLinkIds()).hasSize(3) - .containsExactly(1L, 100L, 500L); + assertThat(savedMessage.getLinks()).hasSize(2) + .extracting("url") + .containsExactlyInAnyOrder("https://example1.com", "https://example2.com"); } } diff --git a/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java b/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java index 35923b42..90c79f8b 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.util.Collections; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -13,9 +14,13 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.sofa.linkiving.domain.chat.ai.AiTitleClient; +import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto; import com.sofa.linkiving.domain.chat.dto.response.ChatsRes; import com.sofa.linkiving.domain.chat.dto.response.CreateChatRes; +import com.sofa.linkiving.domain.chat.dto.response.MessagesRes; import com.sofa.linkiving.domain.chat.entity.Chat; +import com.sofa.linkiving.domain.chat.entity.Message; +import com.sofa.linkiving.domain.chat.enums.Type; import com.sofa.linkiving.domain.chat.service.ChatService; import com.sofa.linkiving.domain.chat.service.FeedbackService; import com.sofa.linkiving.domain.chat.service.MessageService; @@ -41,6 +46,39 @@ public class ChatFacadeTest { @Mock private Member member; + @Test + @DisplayName("메시지 조회 요청 시 ChatService와 MessageService를 호출하여 결과를 반환함") + void shouldReturnMessagesResWhenGetMessages() { + // given + Long chatId = 1L; + Long lastId = 100L; + int size = 20; + + Chat chat = mock(Chat.class); + + // Mock Message 생성 + Message message = mock(Message.class); + given(message.getId()).willReturn(99L); + given(message.getType()).willReturn(Type.USER); + given(message.getLinks()).willReturn(Collections.emptyList()); + + MessagesDto messagesDto = new MessagesDto(List.of(message), true); + + given(chatService.getChat(chatId, member)).willReturn(chat); + given(messageService.getMessages(chat, lastId, size)).willReturn(messagesDto); + + // when + MessagesRes result = chatFacade.getMessages(member, chatId, lastId, size); + + // then + assertThat(result.messages()).hasSize(1); + assertThat(result.hasNext()).isTrue(); + assertThat(result.lastId()).isEqualTo(99L); + + verify(chatService).getChat(chatId, member); + verify(messageService).getMessages(chat, lastId, size); + } + @Test @DisplayName("ChatService.getChats 호출 및 ChatsRes 변환 반환") void shouldReturnChatsResWhenGetChats() { diff --git a/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatIntegrationTest.java b/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatIntegrationTest.java index cd04c6e4..b61d15a4 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatIntegrationTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatIntegrationTest.java @@ -25,10 +25,9 @@ import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.enums.Type; +import com.sofa.linkiving.domain.chat.facade.ChatFacade; import com.sofa.linkiving.domain.chat.repository.ChatRepository; import com.sofa.linkiving.domain.chat.repository.MessageRepository; -import com.sofa.linkiving.domain.chat.service.FeedbackService; -import com.sofa.linkiving.domain.chat.service.MessageService; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.domain.member.enums.Role; import com.sofa.linkiving.domain.member.repository.MemberRepository; @@ -39,7 +38,7 @@ @AutoConfigureMockMvc @Transactional @ActiveProfiles("test") -class ChatIntegrationTest { +public class ChatApiIntegrationTest { private static final String BASE_URL = "/v1/chats"; @@ -61,14 +60,11 @@ class ChatIntegrationTest { @Autowired private AiTitleClient aiTitleClient; - @MockitoBean - private RedisService redisService; - - @MockitoBean - private FeedbackService feedbackService; + @Autowired + private ChatFacade chatFacade; @MockitoBean - private MessageService messageService; + private RedisService redisService; private UserDetails testUserDetails; private Member testMember; @@ -83,6 +79,50 @@ void setUp() { testUserDetails = new CustomMemberDetail(testMember, Role.USER); } + @Test + @DisplayName("메시지 조회 성공 시 200 OK와 데이터 반환") + void shouldReturnMessagesWhenValidRequest() throws Exception { + // given + Chat chat = chatRepository.save(Chat.builder() + .member(testMember) + .title("테스트 채팅방") + .build()); + + messageRepository.save(Message.builder() + .chat(chat) + .content("안녕하세요") + .type(Type.USER) + .build()); + + messageRepository.save(Message.builder() + .chat(chat) + .content("반갑습니다") + .type(Type.AI) + .build()); + + // when & then + mockMvc.perform(get(BASE_URL + "/{chatId}", chat.getId()) + .param("size", "20") + .with(user(testUserDetails))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("조회 개수(size)가 50을 초과하면 400 Bad Request 반환 (Validation)") + void shouldReturn400WhenSizeExceedsLimit() throws Exception { + // given + Long chatId = 1L; + + // when & then + mockMvc.perform(get(BASE_URL + "/{chatId}", chatId) + .param("size", "100") + .with(user(testUserDetails))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + @Test @DisplayName("유효한 요청 시 채팅 생성 및 200 OK 반환") void shouldCreateChatSuccessfullyWhenValidRequest() throws Exception { @@ -151,7 +191,6 @@ void shouldReturnChatListWhenGetChats() throws Exception { .title("Chat 2") .build()); - // 생성 시간 차이를 두기 위해 잠시 대기 (정렬 테스트) Thread.sleep(10); Chat chat1 = chatRepository.save(Chat.builder() diff --git a/src/test/java/com/sofa/linkiving/domain/chat/repository/MessageRepositoryTest.java b/src/test/java/com/sofa/linkiving/domain/chat/repository/MessageRepositoryTest.java new file mode 100644 index 00000000..a6d7ce23 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/repository/MessageRepositoryTest.java @@ -0,0 +1,197 @@ +package com.sofa.linkiving.domain.chat.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +import com.sofa.linkiving.domain.chat.entity.Chat; +import com.sofa.linkiving.domain.chat.entity.Feedback; +import com.sofa.linkiving.domain.chat.entity.Message; +import com.sofa.linkiving.domain.chat.enums.Sentiment; +import com.sofa.linkiving.domain.chat.enums.Type; +import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.member.entity.Member; +import com.sofa.linkiving.domain.member.repository.MemberRepository; + +@DataJpaTest +@ActiveProfiles("test") +class MessageRepositoryTest { + + @Autowired + private MessageRepository messageRepository; + + @Autowired + private ChatRepository chatRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private FeedbackRepository feedbackRepository; + + @Autowired + private TestEntityManager em; + + private Chat chat; + private Member member; + + @BeforeEach + void setUp() { + member = memberRepository.save(Member.builder() + .email("test@repo.com") + .password("password") + .build()); + + chat = chatRepository.save(Chat.builder() + .member(member) + .title("Test Chat") + .build()); + + em.flush(); + em.clear(); + } + + @Test + @DisplayName("채팅방 메시지 커서 기반 조회: 첫 페이지 (lastId가 null일 때)") + void shouldReturnLatestMessagesWhenLastIdIsNull() { + // given + for (int i = 1; i <= 30; i++) { + messageRepository.save(Message.builder() + .chat(chat) + .content("Msg " + i) + .type(Type.USER) + .build()); + } + + // when: lastId = null, size = 10 + List result = messageRepository.findAllByChatAndCursor( + chat, + null, + PageRequest.of(0, 10) + ); + + // then + assertThat(result).hasSize(10); + assertThat(result.get(0).getContent()).isEqualTo("Msg 30"); + assertThat(result.get(9).getContent()).isEqualTo("Msg 21"); + } + + @Test + @DisplayName("채팅방 메시지 커서 기반 조회: 다음 페이지 (lastId 지정 시)") + void shouldReturnMessagesBeforeLastId() { + // given + messageRepository.save(Message.builder() + .chat(chat) + .content("1") + .type(Type.USER) + .build()); + + messageRepository.save(Message.builder() + .chat(chat) + .content("2") + .type(Type.USER) + .build()); + + Message msg3 = messageRepository.save(Message.builder() + .chat(chat) + .content("3") + .type(Type.USER) + .build()); + + List result = messageRepository.findAllByChatAndCursor( + chat, + msg3.getId(), + PageRequest.of(0, 10) + ); + + // then + assertThat(result).hasSize(2); + // 최신 순 정렬 확인 + assertThat(result.get(0).getContent()).isEqualTo("2"); + assertThat(result.get(1).getContent()).isEqualTo("1"); + } + + @Test + @DisplayName("메시지 조회 시 연관된 링크도 정상적으로 조회됨") + void shouldReturnMessageWithLinks() { + // given + Link link1 = Link.builder() + .member(member) + .title("Naver") + .url("https://naver.com") + .imageUrl("img1.png") + .build(); + + Link link2 = Link.builder() + .member(member) + .title("Google") + .url("https://google.com") + .imageUrl("img2.png") + .build(); + + em.persist(link1); + em.persist(link2); + + Message message = Message.builder() + .chat(chat) + .content("Check links") + .type(Type.AI) + .links(List.of(link1, link2)) + .build(); + + messageRepository.save(message); + + em.flush(); + em.clear(); + + // when + List result = messageRepository.findAllByChatAndCursor(chat, null, PageRequest.of(0, 10)); + + // then + assertThat(result).hasSize(1); + Message fetchedMessage = result.get(0); + + // Link 데이터가 정상 로딩 확인 + assertThat(fetchedMessage.getLinks()).hasSize(2); + assertThat(fetchedMessage.getLinks()) + .extracting("title") + .containsExactlyInAnyOrder("Naver", "Google"); + } + + @Test + @DisplayName("메시지 조회 시 연관된 피드백도 함께 조회됨 (Fetch Join)") + void shouldReturnMessageWithFeedback() { + // given + Message message = messageRepository.save(Message.builder() + .chat(chat) + .content("AI Reply") + .type(Type.AI) + .build()); + + feedbackRepository.save(Feedback.builder() + .message(message) + .text("Good") + .sentiment(Sentiment.LIKE) + .build()); + + em.flush(); + em.clear(); + + // when + List result = messageRepository.findAllByChatAndCursor(chat, null, PageRequest.of(0, 10)); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getFeedback()).isNotNull(); + assertThat(result.get(0).getFeedback().getText()).isEqualTo("Good"); + } +} diff --git a/src/test/java/com/sofa/linkiving/domain/chat/service/MessageQueryServiceTest.java b/src/test/java/com/sofa/linkiving/domain/chat/service/MessageQueryServiceTest.java index a76ae158..a08dd15e 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/service/MessageQueryServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/MessageQueryServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -11,7 +12,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto; import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.repository.MessageRepository; @@ -25,20 +28,52 @@ public class MessageQueryServiceTest { @Mock private MessageRepository messageRepository; + @Mock + private Chat chat; + @Test - @DisplayName("MessageRepository.findAllByChat 호출 및 결과 반환") - void shouldReturnMessagesWhenFindAllByChat() { + @DisplayName("요청 개수 초과 데이터가 존재 시 hasNext=true 반환 및 데이터를 잘라서 반환: (요청 개수 :10개 ,데이터 :11개)") + void shouldReturnHasNextTrueWhenMoreDataExists() { // given - Chat chat = mock(Chat.class); - List expectedMessages = List.of(mock(Message.class)); + Long lastId = 100L; + int size = 10; + + List messages = new ArrayList<>(); + for (int i = 0; i < size + 1; i++) { + messages.add(mock(Message.class)); + } + + given(messageRepository.findAllByChatAndCursor(eq(chat), eq(lastId), any(Pageable.class))) + .willReturn(messages); + + // when + MessagesDto result = messageQueryService.findAllByChatAndCursor(chat, lastId, size); + + // then + assertThat(result.hasNext()).isTrue(); + assertThat(result.messages()).hasSize(size); + } + + @Test + @DisplayName("요청 개수 이하로 데이터 존재 시 hasNext=false 반환 (요청 개수 :10개 ,데이터 :10개)") + void shouldReturnHasNextFalseWhenNoMoreData() { + // given + Long lastId = 100L; + int size = 10; + + List messages = new ArrayList<>(); + for (int i = 0; i < size; i++) { + messages.add(mock(Message.class)); + } - given(messageRepository.findAllByChat(chat)).willReturn(expectedMessages); + given(messageRepository.findAllByChatAndCursor(eq(chat), eq(lastId), any(Pageable.class))) + .willReturn(messages); // when - List result = messageQueryService.findAllByChat(chat); + MessagesDto result = messageQueryService.findAllByChatAndCursor(chat, lastId, size); // then - assertThat(result).isEqualTo(expectedMessages); - verify(messageRepository).findAllByChat(chat); + assertThat(result.hasNext()).isFalse(); + assertThat(result.messages()).hasSize(size); } } diff --git a/src/test/java/com/sofa/linkiving/domain/chat/service/MessageServiceTest.java b/src/test/java/com/sofa/linkiving/domain/chat/service/MessageServiceTest.java index 121bd1ad..51d4a630 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/service/MessageServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/MessageServiceTest.java @@ -3,7 +3,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; -import java.util.List; +import java.util.Collections; import java.util.Map; import org.junit.jupiter.api.Assertions; @@ -17,8 +17,8 @@ import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.test.util.ReflectionTestUtils; +import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto; import com.sofa.linkiving.domain.chat.entity.Chat; -import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.manager.SubscriptionManager; @ExtendWith(MockitoExtension.class) @@ -34,17 +34,16 @@ public class MessageServiceTest { private MessageQueryService messageQueryService; @Mock - private SimpMessagingTemplate messagingTemplate; + private Chat chat; @Mock - private SubscriptionManager subscriptionManager; + private SimpMessagingTemplate messagingTemplate; @Mock - private Chat chat; + private SubscriptionManager subscriptionManager; @BeforeEach void setUp() { - // Chat ID Mocking lenient().when(chat.getId()).thenReturn(1L); } @@ -62,20 +61,21 @@ void shouldCallDeleteAllByChatWhenDeleteAll() { } @Test - @DisplayName("MessageQueryService.findAllByChat 호출 및 결과 반환") - void shouldReturnMessagesWhenGetMessagesByChat() { + @DisplayName("메시지 조회 요청 시 QueryService를 호출하여 결과를 반환함") + void shouldDelegateToQueryServiceWhenGetMessages() { // given - Chat chat = mock(Chat.class); - List messages = List.of(mock(Message.class)); + Long lastId = 1L; + int size = 20; + MessagesDto expectedDto = new MessagesDto(Collections.emptyList(), false); - given(messageQueryService.findAllByChat(chat)).willReturn(messages); + given(messageQueryService.findAllByChatAndCursor(chat, lastId, size)).willReturn(expectedDto); // when - List result = messageService.getMessagesByChat(chat); + MessagesDto result = messageService.getMessages(chat, lastId, size); // then - assertThat(result).isEqualTo(messages); - verify(messageQueryService).findAllByChat(chat); + assertThat(result).isEqualTo(expectedDto); + verify(messageQueryService).findAllByChatAndCursor(chat, lastId, size); } @Test diff --git a/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java b/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java index d1a52720..b9556878 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java @@ -355,7 +355,10 @@ void shouldUpdateLinkSuccessfully() throws Exception { .memo("원래 메모") .build()); - LinkUpdateReq req = new LinkUpdateReq("수정된 제목", "수정된 메모"); + LinkUpdateReq req = new LinkUpdateReq( + "수정된 제목", + "수정된 메모" + ); // when & then mockMvc.perform( diff --git a/src/test/java/com/sofa/linkiving/global/converter/LongListToStringConverterTest.java b/src/test/java/com/sofa/linkiving/global/converter/LongListToStringConverterTest.java new file mode 100644 index 00000000..aca37eb9 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/global/converter/LongListToStringConverterTest.java @@ -0,0 +1,90 @@ +package com.sofa.linkiving.global.converter; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class LongListToStringConverterTest { + private final LongListToStringConverter converter = new LongListToStringConverter(); + + @Test + @DisplayName("List을 JSON 문자열로 변환한다 (convertToDatabaseColumn)") + void shouldConvertListToJsonString() { + // given + List attribute = List.of(1L, 20L, 300L); + + // when + String dbData = converter.convertToDatabaseColumn(attribute); + + // then + assertThat(dbData).isEqualTo("[1,20,300]"); + } + + @Test + @DisplayName("빈 리스트는 빈 JSON 배열 문자열 '[]'로 변환된다") + void shouldConvertEmptyListToEmptyJsonArray() { + // given + List attribute = List.of(); + + // when + String dbData = converter.convertToDatabaseColumn(attribute); + + // then + assertThat(dbData).isEqualTo("[]"); + } + + @Test + @DisplayName("null 리스트는 빈 JSON 배열 문자열 '[]'로 변환된다 (Null Safe)") + void shouldConvertNullToEmptyJsonArray() { + // given + List attribute = null; + + // when + String dbData = converter.convertToDatabaseColumn(attribute); + + // then + assertThat(dbData).isEqualTo("[]"); + } + + @Test + @DisplayName("JSON 문자열을 List으로 변환한다 (convertToEntityAttribute)") + void shouldConvertJsonStringToList() { + // given + String dbData = "[1, 100, 500]"; + + // when + List attribute = converter.convertToEntityAttribute(dbData); + + // then + assertThat(attribute).hasSize(3) + .containsExactly(1L, 100L, 500L); + } + + @Test + @DisplayName("빈 JSON 배열 문자열 '[]'은 빈 리스트로 변환된다") + void shouldConvertEmptyJsonArrayToEmptyList() { + // given + String dbData = "[]"; + + // when + List attribute = converter.convertToEntityAttribute(dbData); + + // then + assertThat(attribute).isEmpty(); + } + + @Test + @DisplayName("잘못된 JSON 형식의 문자열 변환 시 IllegalStateException이 발생한다") + void shouldThrowExceptionWhenJsonIsInvalid() { + // given + String invalidJson = "{invalid}"; + + // when & then + assertThatThrownBy(() -> converter.convertToEntityAttribute(invalidJson)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to convert JSON to List"); + } +} From 600999442eb19a30ce8ba0d659aff84f68912656 Mon Sep 17 00:00:00 2001 From: Jansoon Date: Wed, 17 Dec 2025 09:43:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#1?= =?UTF-8?q?40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/controller/ChatController.java | 5 +- .../domain/chat/dto/internal/MessageDto.java | 9 ++ .../domain/chat/dto/internal/MessagesDto.java | 9 ++ .../domain/chat/dto/response/MessageRes.java | 51 ++++++++ .../domain/chat/dto/response/MessagesRes.java | 79 ++---------- .../linkiving/domain/chat/entity/Message.java | 8 ++ .../domain/chat/enums/Sentiment.java | 2 +- .../domain/chat/facade/ChatFacade.java | 6 + .../chat/repository/MessageRepository.java | 16 ++- .../chat/service/MessageQueryService.java | 7 +- .../domain/chat/service/MessageService.java | 43 ++++-- .../link/controller/LinkController.java | 1 - .../domain/link/dto/response/LinkCardRes.java | 38 ++++++ .../link/dto/response/LinkCardsRes.java | 33 ----- .../link/repository/SummaryRepository.java | 7 + .../link/service/SummaryQueryService.java | 21 +++ .../domain/chat/entity/MessageTest.java | 56 ++++++++ .../domain/chat/facade/ChatFacadeTest.java | 23 ++-- ...nTest.java => ChatApiIntegrationTest.java} | 91 ++++++++++--- .../chat/service/ChatQueryServiceTest.java | 1 + .../domain/chat/service/ChatServiceTest.java | 13 -- .../chat/service/MessageQueryServiceTest.java | 22 ++-- .../chat/service/MessageServiceTest.java | 114 ++++++++++++++-- .../repository/SummaryRepositoryTest.java | 122 ++++++++++++++++++ .../domain/link/service/LinkServiceTest.java | 2 - .../link/service/SummaryQueryServiceTest.java | 49 +++++++ 26 files changed, 644 insertions(+), 184 deletions(-) create mode 100644 src/main/java/com/sofa/linkiving/domain/chat/dto/internal/MessageDto.java create mode 100644 src/main/java/com/sofa/linkiving/domain/chat/dto/internal/MessagesDto.java create mode 100644 src/main/java/com/sofa/linkiving/domain/chat/dto/response/MessageRes.java create mode 100644 src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkCardRes.java rename src/test/java/com/sofa/linkiving/domain/chat/integration/{ChatIntegrationTest.java => ChatApiIntegrationTest.java} (70%) create mode 100644 src/test/java/com/sofa/linkiving/domain/link/repository/SummaryRepositoryTest.java diff --git a/src/main/java/com/sofa/linkiving/domain/chat/controller/ChatController.java b/src/main/java/com/sofa/linkiving/domain/chat/controller/ChatController.java index 25747196..b3ccb0b1 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/controller/ChatController.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/controller/ChatController.java @@ -68,10 +68,7 @@ public BaseResponse getMessages( @AuthMember Member member, @PathVariable Long chatId, @RequestParam(required = false) Long lastId, - - @RequestParam(defaultValue = "20") - - int size + @RequestParam(defaultValue = "20") int size ) { MessagesRes res = chatFacade.getMessages(member, chatId, lastId, size); return BaseResponse.success(res, "채팅 기록을 가져오는데 성공했습니다."); diff --git a/src/main/java/com/sofa/linkiving/domain/chat/dto/internal/MessageDto.java b/src/main/java/com/sofa/linkiving/domain/chat/dto/internal/MessageDto.java new file mode 100644 index 00000000..7557862b --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/dto/internal/MessageDto.java @@ -0,0 +1,9 @@ +package com.sofa.linkiving.domain.chat.dto.internal; + +import java.util.List; + +import com.sofa.linkiving.domain.chat.entity.Message; +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; + +public record MessageDto(Message message, List linkDtos) { +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/dto/internal/MessagesDto.java b/src/main/java/com/sofa/linkiving/domain/chat/dto/internal/MessagesDto.java new file mode 100644 index 00000000..4670a78f --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/dto/internal/MessagesDto.java @@ -0,0 +1,9 @@ +package com.sofa.linkiving.domain.chat.dto.internal; + +import java.util.List; + +public record MessagesDto( + List messageDtos, + boolean hasNext +) { +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/dto/response/MessageRes.java b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/MessageRes.java new file mode 100644 index 00000000..c67f773e --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/MessageRes.java @@ -0,0 +1,51 @@ +package com.sofa.linkiving.domain.chat.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.sofa.linkiving.domain.chat.dto.internal.MessageDto; +import com.sofa.linkiving.domain.chat.entity.Message; +import com.sofa.linkiving.domain.chat.enums.Sentiment; +import com.sofa.linkiving.domain.chat.enums.Type; +import com.sofa.linkiving.domain.link.dto.response.LinkCardRes; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MessageRes( + @Schema(description = "메시지 ID") + Long id, + + @Schema(description = "메시지 내용") + String content, + + @Schema(description = "발신자 타입 (USER / AI)") + Type type, + + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "피드백 상태 (AI 메시지인 경우만 포함: LIKE, DISLIKE, NONE)") + Sentiment feedback, + + @Schema(description = "메시지 생성 시간", example = "2024-12-31 14:30:00") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul") + LocalDateTime time, + + @Schema(description = "첨부된 링크 목록") + List links +) { + public static MessageRes from(MessageDto messageDto) { + Message message = messageDto.message(); + + return new MessageRes( + message.getId(), + message.getContent(), + message.getType(), + message.getSentimentOrDefault(), + message.getCreatedAt(), + messageDto.linkDtos().stream() + .map(LinkCardRes::from) + .toList() + ); + } +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/dto/response/MessagesRes.java b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/MessagesRes.java index 8b99ec59..77535a26 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/dto/response/MessagesRes.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/MessagesRes.java @@ -1,18 +1,15 @@ package com.sofa.linkiving.domain.chat.dto.response; +import java.util.Collections; import java.util.List; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.sofa.linkiving.domain.chat.entity.Message; -import com.sofa.linkiving.domain.chat.enums.Sentiment; -import com.sofa.linkiving.domain.chat.enums.Type; -import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.chat.dto.internal.MessageDto; import io.swagger.v3.oas.annotations.media.Schema; public record MessagesRes( @Schema(description = "메시지 목록") - List messages, + List messages, @Schema(description = "다음 페이지 존재 여부") boolean hasNext, @@ -20,70 +17,18 @@ public record MessagesRes( @Schema(description = "마지막 메시지 ID (다음 요청 커서용)") Long lastId ) { - public static MessagesRes of(List messages, boolean hasNext) { - List messageDtos = messages.stream() - .map(MessageDto::from) - .toList(); - - Long lastId = messages.isEmpty() ? null : messages.get(messages.size() - 1).getId(); - - return new MessagesRes(messageDtos, hasNext, lastId); - } - - public record MessageDto( - @Schema(description = "메시지 ID") - Long id, - - @Schema(description = "메시지 내용") - String content, - - @Schema(description = "발신자 타입 (USER / AI)") - Type type, - - @JsonInclude(JsonInclude.Include.NON_NULL) - @Schema(description = "피드백 상태 (AI 메시지인 경우만 포함: LIKE, DISLIKE, NONE)") - Sentiment feedback, - - @Schema(description = "첨부된 링크 목록") - List links - ) { - public static MessageDto from(Message message) { - Sentiment feedbackStatus = null; + public static MessagesRes of(List messageDtos, boolean hasNext) { + if (messageDtos.isEmpty()) { + return new MessagesRes(Collections.emptyList(), hasNext, null); + } - if (message.getType() == Type.AI) { - feedbackStatus = (message.getFeedback() != null) - ? message.getFeedback().getSentiment() - : Sentiment.NONE; - } + List responses = messageDtos.stream() + .map(MessageRes::from) + .toList(); - // [변경] 엔티티에서 링크 목록을 바로 변환 - List linkDtos = message.getLinks().stream() - .map(LinkPreviewDto::from) - .toList(); + Long lastId = messageDtos.get(messageDtos.size() - 1).message().getId(); - return new MessageDto( - message.getId(), - message.getContent(), - message.getType(), - feedbackStatus, - linkDtos - ); - } + return new MessagesRes(responses, hasNext, lastId); } - public record LinkPreviewDto( - Long id, - String title, - String url, - String imageUrl - ) { - public static LinkPreviewDto from(Link link) { - return new LinkPreviewDto( - link.getId(), - link.getTitle(), - link.getUrl(), - link.getImageUrl() - ); - } - } } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/entity/Message.java b/src/main/java/com/sofa/linkiving/domain/chat/entity/Message.java index 416167df..4710b9ed 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/entity/Message.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/entity/Message.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; +import com.sofa.linkiving.domain.chat.enums.Sentiment; import com.sofa.linkiving.domain.chat.enums.Type; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.global.common.BaseEntity; @@ -55,4 +56,11 @@ public Message(Chat chat, Type type, String content, List links) { this.content = content; this.links = (links != null) ? links : new ArrayList<>(); } + + public Sentiment getSentimentOrDefault() { + if (this.type != Type.AI) { + return null; + } + return (this.feedback != null) ? this.feedback.getSentiment() : Sentiment.NONE; + } } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/enums/Sentiment.java b/src/main/java/com/sofa/linkiving/domain/chat/enums/Sentiment.java index d2e3feed..7f84760d 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/enums/Sentiment.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/enums/Sentiment.java @@ -10,7 +10,7 @@ @Getter @RequiredArgsConstructor public enum Sentiment implements CodeEnum { - LIKE(0), DISLIKE(1); + LIKE(0), DISLIKE(1), NONE(2); private final Integer code; @Converter(autoApply = true) diff --git a/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java b/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java index 42318756..d80bccdb 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java @@ -27,6 +27,12 @@ public class ChatFacade { private final FeedbackService feedbackService; private final AiTitleClient aiTitleClient; + public MessagesRes getMessages(Member member, Long chatId, Long lastId, int size) { + Chat chat = chatService.getChat(chatId, member); + MessagesDto result = messageService.getMessages(chat, lastId, size); + return MessagesRes.of(result.messageDtos(), result.hasNext()); + } + @Transactional public CreateChatRes createChat(String firstChat, Member member) { String title = aiTitleClient.generateSummary(firstChat); diff --git a/src/main/java/com/sofa/linkiving/domain/chat/repository/MessageRepository.java b/src/main/java/com/sofa/linkiving/domain/chat/repository/MessageRepository.java index ddc702c3..34c6472d 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/repository/MessageRepository.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/repository/MessageRepository.java @@ -2,9 +2,11 @@ import java.util.List; +import org.springframework.data.domain.Pageable; 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; import org.springframework.stereotype.Repository; import com.sofa.linkiving.domain.chat.entity.Chat; @@ -16,6 +18,18 @@ public interface MessageRepository extends JpaRepository { @Query("DELETE FROM Message m WHERE m.chat = :chat") void deleteAllByChat(Chat chat); - @Query("SELECT m FROM Message m LEFT JOIN FETCH m.feedback WHERE m.chat = :chat") + @Query(""" + SELECT m FROM Message m + LEFT JOIN FETCH m.feedback + WHERE m.chat = :chat + AND (:lastId IS NULL OR m.id < :lastId) + ORDER BY m.id DESC + """) + List findAllByChatAndCursor( + @Param("chat") Chat chat, + @Param("lastId") Long lastId, + Pageable pageable + ); + List findAllByChat(Chat chat); } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/service/MessageQueryService.java b/src/main/java/com/sofa/linkiving/domain/chat/service/MessageQueryService.java index 21acc704..1373eaa1 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/service/MessageQueryService.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/service/MessageQueryService.java @@ -3,9 +3,10 @@ import java.util.List; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Service; -import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto; import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.repository.MessageRepository; @@ -17,7 +18,7 @@ public class MessageQueryService { private final MessageRepository messageRepository; - public MessagesDto findAllByChatAndCursor(Chat chat, Long lastId, int size) { + public Slice findAllByChatAndCursor(Chat chat, Long lastId, int size) { PageRequest pageRequest = PageRequest.of(0, size + 1); List messages = messageRepository.findAllByChatAndCursor(chat, lastId, pageRequest); @@ -27,6 +28,6 @@ public MessagesDto findAllByChatAndCursor(Chat chat, Long lastId, int size) { messages.remove(size); } - return new MessagesDto(messages, hasNext); + return new SliceImpl<>(messages, pageRequest, hasNext); } } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/service/MessageService.java b/src/main/java/com/sofa/linkiving/domain/chat/service/MessageService.java index 8540be0c..8ef29b84 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/service/MessageService.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/service/MessageService.java @@ -1,17 +1,24 @@ package com.sofa.linkiving.domain.chat.service; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import org.springframework.data.domain.Slice; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; +import com.sofa.linkiving.domain.chat.dto.internal.MessageDto; import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto; import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.enums.Type; import com.sofa.linkiving.domain.chat.manager.SubscriptionManager; +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; +import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; +import com.sofa.linkiving.domain.link.service.SummaryQueryService; import lombok.RequiredArgsConstructor; import reactor.core.Disposable; @@ -21,6 +28,7 @@ public class MessageService { private final MessageCommandService messageCommandService; private final MessageQueryService messageQueryService; + private final SummaryQueryService summaryQueryService; private final SimpMessagingTemplate messagingTemplate; private final SubscriptionManager subscriptionManager; @@ -28,14 +36,6 @@ public class MessageService { private final WebClient webClient = WebClient.create("http://localhost:8080/mock/ai"); private final Map messageBuffers = new ConcurrentHashMap<>(); - public void deleteAll(Chat chat) { - messageCommandService.deleteAllByChat(chat); - } - - public MessagesDto getMessages(Chat chat, Long lastId, int size) { - return messageQueryService.findAllByChatAndCursor(chat, lastId, size); - } - public void generateAnswer(Chat chat, String userMessage) { String roomId = chat.getId().toString(); @@ -94,4 +94,31 @@ private void saveMessage(Chat chat, Type type, String content) { messageCommandService.saveMessage(message); } + + public void deleteAll(Chat chat) { + messageCommandService.deleteAllByChat(chat); + } + + public MessagesDto getMessages(Chat chat, Long lastId, int size) { + Slice messageSlice = messageQueryService.findAllByChatAndCursor(chat, lastId, size); + List messages = messageSlice.getContent(); + + List links = messages.stream() + .flatMap(msg -> msg.getLinks().stream()) + .distinct() + .toList(); + + Map summaryMap = summaryQueryService.getSelectedSummariesByLinks(links); + + List messageDtos = messages.stream() + .map(msg -> { + List linkDtos = msg.getLinks().stream() + .map(link -> new LinkDto(link, summaryMap.get(link.getId()))) + .toList(); + return new MessageDto(msg, linkDtos); + }) + .toList(); + + return new MessagesDto(messageDtos, messageSlice.hasNext()); + } } diff --git a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java index a76916a0..408180ae 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java +++ b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java @@ -1,6 +1,5 @@ package com.sofa.linkiving.domain.link.controller; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; diff --git a/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkCardRes.java b/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkCardRes.java new file mode 100644 index 00000000..40e76ec9 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkCardRes.java @@ -0,0 +1,38 @@ +package com.sofa.linkiving.domain.link.dto.response; + +import com.sofa.linkiving.domain.link.dto.internal.LinkDto; +import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record LinkCardRes( + @Schema(description = "링크 ID") + Long id, + + @Schema(description = "링크 URL", example = "https://example.com") + String url, + + @Schema(description = "링크 제목", example = "유용한 개발 자료") + String title, + + @Schema(description = "이미지 URL", example = "https://example.com/image.jpg") + String imageUrl, + + @Schema(description = "요약 정보") + String summary +) { + public static LinkCardRes from(LinkDto dto) { + return of(dto.link(), dto.summary()); + } + + public static LinkCardRes of(Link link, Summary summary) { + return new LinkCardRes( + link.getId(), + link.getUrl(), + link.getTitle(), + link.getImageUrl(), + summary == null ? null : summary.getContent() + ); + } +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkCardsRes.java b/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkCardsRes.java index e04aad77..f472820e 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkCardsRes.java +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/response/LinkCardsRes.java @@ -2,10 +2,7 @@ import java.util.List; -import com.sofa.linkiving.domain.link.dto.internal.LinkDto; import com.sofa.linkiving.domain.link.dto.internal.LinksDto; -import com.sofa.linkiving.domain.link.entity.Link; -import com.sofa.linkiving.domain.link.entity.Summary; import io.swagger.v3.oas.annotations.media.Schema; @@ -24,34 +21,4 @@ public static LinkCardsRes of(LinksDto linksDto) { return new LinkCardsRes(links, linksDto.hasNext(), lastId); } - public record LinkCardRes( - @Schema(description = "링크 ID") - Long id, - - @Schema(description = "링크 URL", example = "https://example.com") - String url, - - @Schema(description = "링크 제목", example = "유용한 개발 자료") - String title, - - @Schema(description = "이미지 URL", example = "https://example.com/image.jpg") - String imageUrl, - - @Schema(description = "요약 정보") - String summary - ) { - public static LinkCardRes from(LinkDto dto) { - return of(dto.link(), dto.summary()); - } - - public static LinkCardRes of(Link link, Summary summary) { - return new LinkCardRes( - link.getId(), - link.getUrl(), - link.getTitle(), - link.getImageUrl(), - summary == null ? null : summary.getContent() - ); - } - } } diff --git a/src/main/java/com/sofa/linkiving/domain/link/repository/SummaryRepository.java b/src/main/java/com/sofa/linkiving/domain/link/repository/SummaryRepository.java index c28d7d57..e893282a 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/repository/SummaryRepository.java +++ b/src/main/java/com/sofa/linkiving/domain/link/repository/SummaryRepository.java @@ -1,10 +1,17 @@ package com.sofa.linkiving.domain.link.repository; +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; import org.springframework.stereotype.Repository; +import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.entity.Summary; @Repository public interface SummaryRepository extends JpaRepository { + @Query("SELECT s FROM Summary s WHERE s.link IN :links AND s.selected = true") + List findAllByLinkInAndSelectedTrue(@Param("links") List links); } diff --git a/src/main/java/com/sofa/linkiving/domain/link/service/SummaryQueryService.java b/src/main/java/com/sofa/linkiving/domain/link/service/SummaryQueryService.java index 85ce153f..6952a4e7 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/service/SummaryQueryService.java +++ b/src/main/java/com/sofa/linkiving/domain/link/service/SummaryQueryService.java @@ -1,7 +1,13 @@ package com.sofa.linkiving.domain.link.service; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + import org.springframework.stereotype.Service; +import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.entity.Summary; import com.sofa.linkiving.domain.link.error.LinkErrorCode; import com.sofa.linkiving.domain.link.repository.SummaryRepository; @@ -19,4 +25,19 @@ public Summary getSummary(Long linkId) { () -> new BusinessException(LinkErrorCode.SUMMARY_NOT_FOUND) ); } + + public Map getSelectedSummariesByLinks(List links) { + if (links.isEmpty()) { + return Collections.emptyMap(); + } + + List summaries = summaryRepository.findAllByLinkInAndSelectedTrue(links); + + return summaries.stream() + .collect(Collectors.toMap( + s -> s.getLink().getId(), + s -> s, + (existing, replacement) -> existing + )); + } } diff --git a/src/test/java/com/sofa/linkiving/domain/chat/entity/MessageTest.java b/src/test/java/com/sofa/linkiving/domain/chat/entity/MessageTest.java index cb439359..04ad811c 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/entity/MessageTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/entity/MessageTest.java @@ -1,6 +1,7 @@ package com.sofa.linkiving.domain.chat.entity; import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; import java.util.List; @@ -10,7 +11,9 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import com.sofa.linkiving.domain.chat.enums.Sentiment; import com.sofa.linkiving.domain.chat.enums.Type; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.member.entity.Member; @@ -75,4 +78,57 @@ void shouldSaveMessageWithContentAndLinkIds() { .extracting("url") .containsExactlyInAnyOrder("https://example1.com", "https://example2.com"); } + + @Test + @DisplayName("메시지 타입이 AI가 아니면 null을 반환한다") + void shouldReturnNullWhenTypeIsNotAi() { + // given + Message userMessage = Message.builder() + .type(Type.USER) + .content("안녕하세요") + .build(); + + // when + Sentiment result = userMessage.getSentimentOrDefault(); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("AI 메시지이지만 피드백이 없으면 Sentiment.NONE을 반환한다") + void shouldReturnNoneWhenFeedbackIsNull() { + // given + Message aiMessage = Message.builder() + .type(Type.AI) + .content("답변입니다") + .build(); + + // when + Sentiment result = aiMessage.getSentimentOrDefault(); + + // then + assertThat(result).isEqualTo(Sentiment.NONE); + } + + @Test + @DisplayName("AI 메시지이고 피드백이 존재하면 해당 피드백의 감정 상태를 반환한다") + void shouldReturnFeedbackSentimentWhenExists() { + // given + Message aiMessage = Message.builder() + .type(Type.AI) + .content("답변입니다") + .build(); + + Feedback feedback = mock(Feedback.class); + given(feedback.getSentiment()).willReturn(Sentiment.LIKE); + + ReflectionTestUtils.setField(aiMessage, "feedback", feedback); + + // when + Sentiment result = aiMessage.getSentimentOrDefault(); + + // then + assertThat(result).isEqualTo(Sentiment.LIKE); + } } diff --git a/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java b/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java index 90c79f8b..9b06f688 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.java @@ -19,8 +19,6 @@ import com.sofa.linkiving.domain.chat.dto.response.CreateChatRes; import com.sofa.linkiving.domain.chat.dto.response.MessagesRes; import com.sofa.linkiving.domain.chat.entity.Chat; -import com.sofa.linkiving.domain.chat.entity.Message; -import com.sofa.linkiving.domain.chat.enums.Type; import com.sofa.linkiving.domain.chat.service.ChatService; import com.sofa.linkiving.domain.chat.service.FeedbackService; import com.sofa.linkiving.domain.chat.service.MessageService; @@ -47,33 +45,28 @@ public class ChatFacadeTest { private Member member; @Test - @DisplayName("메시지 조회 요청 시 ChatService와 MessageService를 호출하여 결과를 반환함") - void shouldReturnMessagesResWhenGetMessages() { + @DisplayName("특정 채팅방의 메시지 목록을 조회한다") + void shouldGetMessages() { // given Long chatId = 1L; Long lastId = 100L; int size = 20; + Member member = mock(Member.class); Chat chat = mock(Chat.class); - // Mock Message 생성 - Message message = mock(Message.class); - given(message.getId()).willReturn(99L); - given(message.getType()).willReturn(Type.USER); - given(message.getLinks()).willReturn(Collections.emptyList()); - - MessagesDto messagesDto = new MessagesDto(List.of(message), true); - given(chatService.getChat(chatId, member)).willReturn(chat); + + MessagesDto messagesDto = new MessagesDto(Collections.emptyList(), false); given(messageService.getMessages(chat, lastId, size)).willReturn(messagesDto); // when MessagesRes result = chatFacade.getMessages(member, chatId, lastId, size); // then - assertThat(result.messages()).hasSize(1); - assertThat(result.hasNext()).isTrue(); - assertThat(result.lastId()).isEqualTo(99L); + assertThat(result).isNotNull(); + assertThat(result.messages()).isEmpty(); + assertThat(result.hasNext()).isFalse(); verify(chatService).getChat(chatId, member); verify(messageService).getMessages(chat, lastId, size); diff --git a/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatIntegrationTest.java b/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatApiIntegrationTest.java similarity index 70% rename from src/test/java/com/sofa/linkiving/domain/chat/integration/ChatIntegrationTest.java rename to src/test/java/com/sofa/linkiving/domain/chat/integration/ChatApiIntegrationTest.java index b61d15a4..766de9eb 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatIntegrationTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatApiIntegrationTest.java @@ -6,6 +6,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import java.util.List; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -22,12 +24,19 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sofa.linkiving.domain.chat.ai.AiTitleClient; import com.sofa.linkiving.domain.chat.dto.request.CreateChatReq; +import com.sofa.linkiving.domain.chat.dto.response.MessageRes; +import com.sofa.linkiving.domain.chat.dto.response.MessagesRes; import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.enums.Type; import com.sofa.linkiving.domain.chat.facade.ChatFacade; import com.sofa.linkiving.domain.chat.repository.ChatRepository; import com.sofa.linkiving.domain.chat.repository.MessageRepository; +import com.sofa.linkiving.domain.link.dto.response.LinkCardRes; +import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; +import com.sofa.linkiving.domain.link.repository.LinkRepository; +import com.sofa.linkiving.domain.link.repository.SummaryRepository; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.domain.member.enums.Role; import com.sofa.linkiving.domain.member.repository.MemberRepository; @@ -57,6 +66,12 @@ public class ChatApiIntegrationTest { @Autowired private MessageRepository messageRepository; + @Autowired + private LinkRepository linkRepository; + + @Autowired + private SummaryRepository summaryRepository; + @Autowired private AiTitleClient aiTitleClient; @@ -80,33 +95,75 @@ void setUp() { } @Test - @DisplayName("메시지 조회 성공 시 200 OK와 데이터 반환") - void shouldReturnMessagesWhenValidRequest() throws Exception { + @DisplayName("메시지 목록 조회 시 링크와 요약 정보가 함께 반환된다") + void shouldGetMessagesWithLinksAndSummaries() { // given - Chat chat = chatRepository.save(Chat.builder() + Link link1 = linkRepository.save(Link.builder() + .member(testMember) + .url("https://example.com/1") + .title("링크1") + .build()); + + Link link2 = linkRepository.save(Link.builder() + .member(testMember) + .url("https://example.com/2") + .title("링크2") + .build()); + + summaryRepository.save(Summary.builder() + .link(link1) + .content("링크1의 핵심 요약입니다.") + .select(true) + .build()); + + Chat chat = chatRepository.save(com.sofa.linkiving.domain.chat.entity.Chat.builder() .member(testMember) .title("테스트 채팅방") .build()); - messageRepository.save(Message.builder() + Message msg1 = Message.builder() .chat(chat) - .content("안녕하세요") .type(Type.USER) - .build()); + .content("첫 번째 메시지") + .links(List.of(link1)) + .build(); - messageRepository.save(Message.builder() + Message msg2 = Message.builder() .chat(chat) - .content("반갑습니다") .type(Type.AI) - .build()); - - // when & then - mockMvc.perform(get(BASE_URL + "/{chatId}", chat.getId()) - .param("size", "20") - .with(user(testUserDetails))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)); + .content("두 번째 메시지") + .links(List.of(link2)) + .build(); + + messageRepository.saveAll(List.of(msg1, msg2)); + + // when + MessagesRes result = chatFacade.getMessages(testMember, chat.getId(), null, 10); + + // then + List messages = result.messages(); + assertThat(messages).hasSize(2); + + // 1. 정렬 순서 확인 (ID 내림차순 -> 최신 메시지가 먼저 와야 함) + MessageRes latestMsg = messages.get(0); + assertThat(latestMsg.id()).isEqualTo(msg2.getId()); + assertThat(latestMsg.content()).isEqualTo("두 번째 메시지"); + + // Index 1: msg1 (과거) + MessageRes oldMsg = messages.get(1); + assertThat(oldMsg.id()).isEqualTo(msg1.getId()); + assertThat(oldMsg.content()).isEqualTo("첫 번째 메시지"); + + // 2.링크 및 요약 매핑 확인 + assertThat(latestMsg.links()).hasSize(1); + LinkCardRes linkCard2 = latestMsg.links().get(0); + assertThat(linkCard2.url()).isEqualTo("https://example.com/2"); + assertThat(linkCard2.summary()).isNull(); + + assertThat(oldMsg.links()).hasSize(1); + LinkCardRes linkCard1 = oldMsg.links().get(0); + assertThat(linkCard1.url()).isEqualTo("https://example.com/1"); + assertThat(linkCard1.summary()).isEqualTo("링크1의 핵심 요약입니다."); } @Test diff --git a/src/test/java/com/sofa/linkiving/domain/chat/service/ChatQueryServiceTest.java b/src/test/java/com/sofa/linkiving/domain/chat/service/ChatQueryServiceTest.java index 4010da97..607c5c50 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/service/ChatQueryServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/ChatQueryServiceTest.java @@ -21,6 +21,7 @@ @ExtendWith(MockitoExtension.class) public class ChatQueryServiceTest { + @InjectMocks private ChatQueryService chatQueryService; diff --git a/src/test/java/com/sofa/linkiving/domain/chat/service/ChatServiceTest.java b/src/test/java/com/sofa/linkiving/domain/chat/service/ChatServiceTest.java index 778199c9..b89a6b19 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/service/ChatServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/ChatServiceTest.java @@ -77,17 +77,4 @@ void shouldReturnChatWhenGetChat() { assertThat(result).isEqualTo(chat); verify(chatQueryService).findChat(chatId, member); } - - @Test - @DisplayName("ChatCommandService.deleteChat 호출 위임") - void shouldCallDeleteChatWhenDelete() { - // given - Chat chat = mock(Chat.class); - - // when - chatService.delete(chat); - - // then - verify(chatCommandService).deleteChat(chat); - } } diff --git a/src/test/java/com/sofa/linkiving/domain/chat/service/MessageQueryServiceTest.java b/src/test/java/com/sofa/linkiving/domain/chat/service/MessageQueryServiceTest.java index a08dd15e..ac0de736 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/service/MessageQueryServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/MessageQueryServiceTest.java @@ -13,8 +13,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; -import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto; import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.repository.MessageRepository; @@ -38,20 +38,20 @@ void shouldReturnHasNextTrueWhenMoreDataExists() { Long lastId = 100L; int size = 10; - List messages = new ArrayList<>(); + List messageDtos = new ArrayList<>(); for (int i = 0; i < size + 1; i++) { - messages.add(mock(Message.class)); + messageDtos.add(mock(Message.class)); } given(messageRepository.findAllByChatAndCursor(eq(chat), eq(lastId), any(Pageable.class))) - .willReturn(messages); + .willReturn(messageDtos); // when - MessagesDto result = messageQueryService.findAllByChatAndCursor(chat, lastId, size); + Slice result = messageQueryService.findAllByChatAndCursor(chat, lastId, size); // then assertThat(result.hasNext()).isTrue(); - assertThat(result.messages()).hasSize(size); + assertThat(result.getContent()).hasSize(size); } @Test @@ -61,19 +61,19 @@ void shouldReturnHasNextFalseWhenNoMoreData() { Long lastId = 100L; int size = 10; - List messages = new ArrayList<>(); + List messageDtos = new ArrayList<>(); for (int i = 0; i < size; i++) { - messages.add(mock(Message.class)); + messageDtos.add(mock(Message.class)); } given(messageRepository.findAllByChatAndCursor(eq(chat), eq(lastId), any(Pageable.class))) - .willReturn(messages); + .willReturn(messageDtos); // when - MessagesDto result = messageQueryService.findAllByChatAndCursor(chat, lastId, size); + Slice result = messageQueryService.findAllByChatAndCursor(chat, lastId, size); // then assertThat(result.hasNext()).isFalse(); - assertThat(result.messages()).hasSize(size); + assertThat(result.getContent()).hasSize(size); } } diff --git a/src/test/java/com/sofa/linkiving/domain/chat/service/MessageServiceTest.java b/src/test/java/com/sofa/linkiving/domain/chat/service/MessageServiceTest.java index 51d4a630..8518b121 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/service/MessageServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/MessageServiceTest.java @@ -4,6 +4,7 @@ import static org.mockito.BDDMockito.*; import java.util.Collections; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Assertions; @@ -14,12 +15,18 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.test.util.ReflectionTestUtils; import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto; import com.sofa.linkiving.domain.chat.entity.Chat; +import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.manager.SubscriptionManager; +import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; +import com.sofa.linkiving.domain.link.service.SummaryQueryService; @ExtendWith(MockitoExtension.class) public class MessageServiceTest { @@ -33,6 +40,9 @@ public class MessageServiceTest { @Mock private MessageQueryService messageQueryService; + @Mock + private SummaryQueryService summaryQueryService; + @Mock private Chat chat; @@ -61,21 +71,109 @@ void shouldCallDeleteAllByChatWhenDeleteAll() { } @Test - @DisplayName("메시지 조회 요청 시 QueryService를 호출하여 결과를 반환함") - void shouldDelegateToQueryServiceWhenGetMessages() { + @DisplayName("메시지 목록과 포함된 링크의 요약을 정상적으로 조회하여 DTO로 반환한다") + void shouldGetMessagesWithLinksAndSummaries() { // given - Long lastId = 1L; - int size = 20; - MessagesDto expectedDto = new MessagesDto(Collections.emptyList(), false); + Chat chat = mock(Chat.class); + Long lastId = 100L; + int size = 10; + + Link link1 = mock(Link.class); + given(link1.getId()).willReturn(1L); + Summary summary1 = mock(Summary.class); + + Link link2 = mock(Link.class); + given(link2.getId()).willReturn(2L); - given(messageQueryService.findAllByChatAndCursor(chat, lastId, size)).willReturn(expectedDto); + Message msg1 = mock(Message.class); + given(msg1.getLinks()).willReturn(List.of(link1)); + + Message msg2 = mock(Message.class); + given(msg2.getLinks()).willReturn(List.of(link2)); + + List messages = List.of(msg1, msg2); + Slice messageSlice = new SliceImpl<>(messages); + + given(messageQueryService.findAllByChatAndCursor(chat, lastId, size)) + .willReturn(messageSlice); + + given(summaryQueryService.getSelectedSummariesByLinks(anyList())) + .willReturn(Map.of(1L, summary1)); // when MessagesDto result = messageService.getMessages(chat, lastId, size); // then - assertThat(result).isEqualTo(expectedDto); - verify(messageQueryService).findAllByChatAndCursor(chat, lastId, size); + assertThat(result).isNotNull(); + assertThat(result.messageDtos()).hasSize(2); + assertThat(result.hasNext()).isFalse(); + + var msgDto1 = result.messageDtos().get(0); + assertThat(msgDto1.linkDtos()).hasSize(1); + assertThat(msgDto1.linkDtos().get(0).summary()).isEqualTo(summary1); + + var msgDto2 = result.messageDtos().get(1); + assertThat(msgDto2.linkDtos()).hasSize(1); + assertThat(msgDto2.linkDtos().get(0).summary()).isNull(); // Map에 없으므로 null + + // Verify + verify(messageQueryService, times(1)).findAllByChatAndCursor(chat, lastId, size); + verify(summaryQueryService, times(1)).getSelectedSummariesByLinks(anyList()); + } + + @Test + @DisplayName("메시지가 없을 경우 빈 목록을 반환한다") + void shouldReturnEmptyWhenNoMessages() { + // given + Chat chat = mock(Chat.class); + Long lastId = null; + int size = 10; + + Slice emptySlice = new SliceImpl<>(Collections.emptyList()); + + given(messageQueryService.findAllByChatAndCursor(chat, lastId, size)) + .willReturn(emptySlice); + + // 빈 리스트가 넘어가면 SummaryService는 호출되지만 빈 맵을 반환하도록 설정 (혹은 실제 로직에 따라 호출됨) + given(summaryQueryService.getSelectedSummariesByLinks(anyList())) + .willReturn(Collections.emptyMap()); + + // when + MessagesDto result = messageService.getMessages(chat, lastId, size); + + // then + assertThat(result.messageDtos()).isEmpty(); + assertThat(result.hasNext()).isFalse(); + } + + @Test + @DisplayName("중복된 링크가 있어도 요약 조회 시에는 중복을 제거하여 요청한다") + void shouldRequestSummariesForDistinctLinks() { + // given + Chat chat = mock(Chat.class); + + Link link1 = mock(Link.class); // 동일한 객체 + + Message msg1 = mock(Message.class); + given(msg1.getLinks()).willReturn(List.of(link1)); + + Message msg2 = mock(Message.class); + given(msg2.getLinks()).willReturn(List.of(link1)); // msg1과 같은 링크 포함 + + Slice messageSlice = new SliceImpl<>(List.of(msg1, msg2)); + + given(messageQueryService.findAllByChatAndCursor(any(), any(), anyInt())) + .willReturn(messageSlice); + given(summaryQueryService.getSelectedSummariesByLinks(anyList())) + .willReturn(Collections.emptyMap()); + + // when + messageService.getMessages(chat, null, 10); + + // then + verify(summaryQueryService).getSelectedSummariesByLinks(argThat(list -> + list.size() == 1 // 두 메시지에 링크가 총 2개지만, 같은 객체이므로 1개로 줄어야 함 + )); } @Test diff --git a/src/test/java/com/sofa/linkiving/domain/link/repository/SummaryRepositoryTest.java b/src/test/java/com/sofa/linkiving/domain/link/repository/SummaryRepositoryTest.java new file mode 100644 index 00000000..9b8dd20a --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/link/repository/SummaryRepositoryTest.java @@ -0,0 +1,122 @@ +package com.sofa.linkiving.domain.link.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; + +import com.sofa.linkiving.domain.link.entity.Link; +import com.sofa.linkiving.domain.link.entity.Summary; +import com.sofa.linkiving.domain.member.entity.Member; + +@DataJpaTest +@ActiveProfiles("test") +@DisplayName("SummaryRepository 단위 테스트") +public class SummaryRepositoryTest { + + @Autowired + private SummaryRepository summaryRepository; + + @Autowired + private TestEntityManager em; + + @Test + @DisplayName("주어진 링크들에 속하고 selected가 true인 요약만 조회함") + void shouldFindAllByLinkInAndSelectedTrue() { + // given + Member member = Member.builder() + .email("test@test.com") + .password("pw") + .build(); + em.persist(member); + + Link link1 = Link.builder() + .member(member) + .url("url1") + .title("t1") + .build(); + Link link2 = Link.builder() + .member(member) + .url("url2") + .title("t2") + .build(); + Link link3 = Link.builder() + .member(member) + .url("url3") + .title("t3") + .build(); + + em.persist(link1); + em.persist(link2); + em.persist(link3); + + Summary summary1 = Summary.builder() + .link(link1) + .content("s1") + .select(true) + .build(); + Summary summary2 = Summary.builder() + .link(link2) + .content("s2") + .select(false) + .build(); + Summary summary3 = Summary + .builder() + .link(link3) + .content("s3") + .select(true) + .build(); + + em.persist(summary1); + em.persist(summary2); + em.persist(summary3); + + em.flush(); + em.clear(); + + // when + List result = summaryRepository.findAllByLinkInAndSelectedTrue(List.of(link1, link2)); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getContent()).isEqualTo("s1"); + assertThat(result.get(0).getLink().getId()).isEqualTo(link1.getId()); + } + + @Test + @DisplayName("링크 리스트가 비어있으면 빈 결과를 반환함") + void shouldReturnEmptyWhenLinkListIsEmpty() { + // given + Member member = Member.builder() + .email("test@test.com") + .password("pw") + .build(); + em.persist(member); + + Link link = Link.builder() + .member(member) + .url("url1") + .title("t1") + .build(); + em.persist(link); + + Summary summary = Summary.builder() + .link(link) + .content("s1") + .select(true) + .build(); + em.persist(summary); + + // when + List result = summaryRepository.findAllByLinkInAndSelectedTrue(List.of()); + + // then + assertThat(result).isEmpty(); + } +} diff --git a/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java b/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java index 202b04e7..de851428 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java @@ -17,8 +17,6 @@ import com.sofa.linkiving.domain.link.dto.internal.LinkDto; import com.sofa.linkiving.domain.link.dto.internal.LinksDto; -import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes; -import com.sofa.linkiving.domain.link.dto.response.LinkRes; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.entity.Summary; import com.sofa.linkiving.domain.link.error.LinkErrorCode; diff --git a/src/test/java/com/sofa/linkiving/domain/link/service/SummaryQueryServiceTest.java b/src/test/java/com/sofa/linkiving/domain/link/service/SummaryQueryServiceTest.java index 31697030..b65b1630 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/service/SummaryQueryServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/service/SummaryQueryServiceTest.java @@ -3,6 +3,9 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -12,12 +15,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.entity.Summary; import com.sofa.linkiving.domain.link.error.LinkErrorCode; import com.sofa.linkiving.domain.link.repository.SummaryRepository; import com.sofa.linkiving.global.error.exception.BusinessException; @ExtendWith(MockitoExtension.class) +@DisplayName("SummaryQueryService 단위 테스트") public class SummaryQueryServiceTest { @InjectMocks private SummaryQueryService summaryQueryService; @@ -56,4 +61,48 @@ void shouldThrowBusinessExceptionWhenSummaryNotFound() { verify(summaryRepository).findById(linkId); } + + @Test + @DisplayName("링크 리스트가 비어있으면 빈 Map을 반환하고 리포지토리를 호출하지 않음") + void shouldReturnEmptyMapWhenLinksListIsEmpty() { + // given + List emptyLinks = Collections.emptyList(); + + // when + Map result = summaryQueryService.getSelectedSummariesByLinks(emptyLinks); + + // then + assertThat(result).isEmpty(); + verify(summaryRepository, never()).findAllByLinkInAndSelectedTrue(any()); + } + + @Test + @DisplayName("링크 리스트로 선택된 요약들을 조회하여 Map으로 변환함") + void shouldGetSelectedSummariesByLinks() { + // given + Link link1 = mock(Link.class); + given(link1.getId()).willReturn(1L); + + Link link2 = mock(Link.class); + given(link2.getId()).willReturn(2L); + + Summary summary1 = mock(Summary.class); + given(summary1.getLink()).willReturn(link1); + + Summary summary2 = mock(Summary.class); + given(summary2.getLink()).willReturn(link2); + + List links = List.of(link1, link2); + List summaries = List.of(summary1, summary2); + + given(summaryRepository.findAllByLinkInAndSelectedTrue(links)).willReturn(summaries); + + // when + Map result = summaryQueryService.getSelectedSummariesByLinks(links); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(1L)).isEqualTo(summary1); + assertThat(result.get(2L)).isEqualTo(summary2); + } }