diff --git a/src/main/java/com/sofa/linkiving/domain/chat/controller/FeedbackApi.java b/src/main/java/com/sofa/linkiving/domain/chat/controller/FeedbackApi.java new file mode 100644 index 00000000..cca4c55b --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/controller/FeedbackApi.java @@ -0,0 +1,15 @@ +package com.sofa.linkiving.domain.chat.controller; + +import com.sofa.linkiving.domain.chat.dto.request.AddFeedbackReq; +import com.sofa.linkiving.domain.chat.dto.response.AddFeedbackRes; +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.tags.Tag; + +@Tag(name = "Feedback", description = "피드백 관리 API") +public interface FeedbackApi { + @Operation(summary = "피드백 추가", description = "메세지에 피드백을 추가하고 생성된 피드백 ID를 반환합니다.") + BaseResponse createFeedback(Long messageId, AddFeedbackReq createFeedbackReq, Member member); +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/controller/FeedbackController.java b/src/main/java/com/sofa/linkiving/domain/chat/controller/FeedbackController.java new file mode 100644 index 00000000..1fc7d2eb --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/controller/FeedbackController.java @@ -0,0 +1,32 @@ +package com.sofa.linkiving.domain.chat.controller; + +import org.springframework.web.bind.annotation.PathVariable; +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.RestController; + +import com.sofa.linkiving.domain.chat.dto.request.AddFeedbackReq; +import com.sofa.linkiving.domain.chat.dto.response.AddFeedbackRes; +import com.sofa.linkiving.domain.chat.facade.FeedbackFacade; +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 +@RequestMapping("/v1/messages") +@RequiredArgsConstructor +public class FeedbackController implements FeedbackApi { + private final FeedbackFacade feedbackFacade; + + @Override + @PostMapping("/{messageId}/feedback") + public BaseResponse createFeedback(@PathVariable Long messageId, + @Valid @RequestBody AddFeedbackReq req, @AuthMember Member member) { + AddFeedbackRes res = feedbackFacade.createFeedback(member, messageId, req.sentiment(), req.text()); + return BaseResponse.success(res, "피드백이 등록되었습니다."); + } +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/dto/request/AddFeedbackReq.java b/src/main/java/com/sofa/linkiving/domain/chat/dto/request/AddFeedbackReq.java new file mode 100644 index 00000000..2b1b1ef5 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/dto/request/AddFeedbackReq.java @@ -0,0 +1,17 @@ +package com.sofa.linkiving.domain.chat.dto.request; + +import com.sofa.linkiving.domain.chat.enums.Sentiment; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AddFeedbackReq( + @NotNull(message = "피드백 상태는 필수입니다.") // 필수 값 체크 + @Schema(description = "피드백 상태 (LIKE, DISLIKE)", example = "LIKE") + Sentiment sentiment, + @Schema(description = "피드백 내용 (선택)", example = "도움이 되었습니다.") + @Size(max = 20, message = "피드백 내용은 20자를 넘을 수 없습니다.") + String text +) { +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/dto/response/AddFeedbackRes.java b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/AddFeedbackRes.java new file mode 100644 index 00000000..7a108c80 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/AddFeedbackRes.java @@ -0,0 +1,9 @@ +package com.sofa.linkiving.domain.chat.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AddFeedbackRes( + @Schema(description = "피드백 ID") + Long id +) { +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/error/MessageErrorCode.java b/src/main/java/com/sofa/linkiving/domain/chat/error/MessageErrorCode.java new file mode 100644 index 00000000..b7dded56 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/error/MessageErrorCode.java @@ -0,0 +1,18 @@ +package com.sofa.linkiving.domain.chat.error; + +import org.springframework.http.HttpStatus; + +import com.sofa.linkiving.global.error.code.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MessageErrorCode implements ErrorCode { + MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "MS-001", "메세지를 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/facade/FeedbackFacade.java b/src/main/java/com/sofa/linkiving/domain/chat/facade/FeedbackFacade.java new file mode 100644 index 00000000..2db3b22a --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/facade/FeedbackFacade.java @@ -0,0 +1,28 @@ +package com.sofa.linkiving.domain.chat.facade; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sofa.linkiving.domain.chat.dto.response.AddFeedbackRes; +import com.sofa.linkiving.domain.chat.entity.Message; +import com.sofa.linkiving.domain.chat.enums.Sentiment; +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 lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class FeedbackFacade { + private final FeedbackService feedbackService; + private final MessageService messageService; + + public AddFeedbackRes createFeedback(Member member, Long messageId, Sentiment sentiment, String text) { + + Message message = messageService.get(messageId, member); + Long id = feedbackService.create(message, sentiment, text); + return new AddFeedbackRes(id); + } +} 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 34c6472d..cb16ee22 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 @@ -1,6 +1,7 @@ package com.sofa.linkiving.domain.chat.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,9 +12,13 @@ import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; +import com.sofa.linkiving.domain.member.entity.Member; @Repository public interface MessageRepository extends JpaRepository { + @Query("SELECT m FROM Message m JOIN m.chat c WHERE m.id = :id AND c.member = :member") + Optional findByIdAndMember(@Param("id") Long id, @Param("member") Member member); + @Modifying(clearAutomatically = true) @Query("DELETE FROM Message m WHERE m.chat = :chat") void deleteAllByChat(Chat chat); diff --git a/src/main/java/com/sofa/linkiving/domain/chat/service/FeedbackCommandService.java b/src/main/java/com/sofa/linkiving/domain/chat/service/FeedbackCommandService.java index 36cdba1c..608c95dd 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/service/FeedbackCommandService.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/service/FeedbackCommandService.java @@ -3,6 +3,7 @@ import org.springframework.stereotype.Service; import com.sofa.linkiving.domain.chat.entity.Chat; +import com.sofa.linkiving.domain.chat.entity.Feedback; import com.sofa.linkiving.domain.chat.repository.FeedbackRepository; import lombok.RequiredArgsConstructor; @@ -12,6 +13,10 @@ public class FeedbackCommandService { private final FeedbackRepository feedbackRepository; + public Feedback save(Feedback feedback) { + return feedbackRepository.save(feedback); + } + public void deleteFeedbacksByChat(Chat chat) { feedbackRepository.deleteAllByChat(chat); } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/service/FeedbackService.java b/src/main/java/com/sofa/linkiving/domain/chat/service/FeedbackService.java index f97daa17..d88abb59 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/service/FeedbackService.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/service/FeedbackService.java @@ -3,6 +3,9 @@ import org.springframework.stereotype.Service; 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 lombok.RequiredArgsConstructor; @@ -12,6 +15,15 @@ public class FeedbackService { private final FeedbackQueryService feedbackQueryService; private final FeedbackCommandService feedbackCommandService; + public Long create(Message message, Sentiment sentiment, String text) { + Feedback feedback = Feedback.builder() + .message(message) + .sentiment(sentiment) + .text(text) + .build(); + return feedbackCommandService.save(feedback).getId(); + } + public void deleteAll(Chat chat) { feedbackCommandService.deleteFeedbacksByChat(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 1373eaa1..280146cd 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 @@ -9,7 +9,10 @@ import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; +import com.sofa.linkiving.domain.chat.error.MessageErrorCode; import com.sofa.linkiving.domain.chat.repository.MessageRepository; +import com.sofa.linkiving.domain.member.entity.Member; +import com.sofa.linkiving.global.error.exception.BusinessException; import lombok.RequiredArgsConstructor; @@ -18,6 +21,12 @@ public class MessageQueryService { private final MessageRepository messageRepository; + public Message findByIdAndMember(Long messageId, Member member) { + return messageRepository.findByIdAndMember(messageId, member).orElseThrow( + () -> new BusinessException(MessageErrorCode.MESSAGE_NOT_FOUND) + ); + } + public Slice findAllByChatAndCursor(Chat chat, Long lastId, int size) { PageRequest pageRequest = PageRequest.of(0, size + 1); List messages = messageRepository.findAllByChatAndCursor(chat, lastId, pageRequest); 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 8ef29b84..20c0768f 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 @@ -19,6 +19,7 @@ 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 com.sofa.linkiving.domain.member.entity.Member; import lombok.RequiredArgsConstructor; import reactor.core.Disposable; @@ -95,6 +96,10 @@ private void saveMessage(Chat chat, Type type, String content) { messageCommandService.saveMessage(message); } + public Message get(Long messageId, Member member) { + return messageQueryService.findByIdAndMember(messageId, member); + } + public void deleteAll(Chat chat) { messageCommandService.deleteAllByChat(chat); } diff --git a/src/test/java/com/sofa/linkiving/domain/chat/facade/FeedbackFacadeTest.java b/src/test/java/com/sofa/linkiving/domain/chat/facade/FeedbackFacadeTest.java new file mode 100644 index 00000000..3ad6c383 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/facade/FeedbackFacadeTest.java @@ -0,0 +1,56 @@ +package com.sofa.linkiving.domain.chat.facade; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.sofa.linkiving.domain.chat.dto.response.AddFeedbackRes; +import com.sofa.linkiving.domain.chat.entity.Message; +import com.sofa.linkiving.domain.chat.enums.Sentiment; +import com.sofa.linkiving.domain.chat.service.FeedbackService; +import com.sofa.linkiving.domain.chat.service.MessageService; +import com.sofa.linkiving.domain.member.entity.Member; + +@ExtendWith(MockitoExtension.class) +public class FeedbackFacadeTest { + @InjectMocks + private FeedbackFacade feedbackFacade; + + @Mock + private FeedbackService feedbackService; + + @Mock + private MessageService messageService; + + @Test + @DisplayName("피드백 생성 요청 시 Message 조회 후 Feedback을 생성하고 결과를 반환함") + void shouldCreateFeedbackAndReturnRes() { + // given + Long messageId = 1L; + Long feedbackId = 100L; + Sentiment sentiment = Sentiment.LIKE; + String text = "도움이 되었어요"; + + Message message = mock(Message.class); + Member member = mock(Member.class); + + given(messageService.get(messageId, member)).willReturn(message); + given(feedbackService.create(message, sentiment, text)).willReturn(feedbackId); + + // when + AddFeedbackRes result = feedbackFacade.createFeedback(member, messageId, sentiment, text); + + // then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(feedbackId); + + verify(messageService).get(messageId, member); + verify(feedbackService).create(message, sentiment, text); + } +} diff --git a/src/test/java/com/sofa/linkiving/domain/chat/integration/FeedbackIntegrationTest.java b/src/test/java/com/sofa/linkiving/domain/chat/integration/FeedbackIntegrationTest.java new file mode 100644 index 00000000..f5d0235d --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/integration/FeedbackIntegrationTest.java @@ -0,0 +1,145 @@ +package com.sofa.linkiving.domain.chat.integration; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +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.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sofa.linkiving.domain.chat.dto.request.AddFeedbackReq; +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.chat.repository.ChatRepository; +import com.sofa.linkiving.domain.chat.repository.FeedbackRepository; +import com.sofa.linkiving.domain.chat.repository.MessageRepository; +import com.sofa.linkiving.domain.member.entity.Member; +import com.sofa.linkiving.domain.member.enums.Role; +import com.sofa.linkiving.domain.member.repository.MemberRepository; +import com.sofa.linkiving.infra.redis.RedisService; +import com.sofa.linkiving.security.userdetails.CustomMemberDetail; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class FeedbackIntegrationTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ChatRepository chatRepository; + + @Autowired + private MessageRepository messageRepository; + + @Autowired + private FeedbackRepository feedbackRepository; + + @MockitoBean + private RedisService redisService; // 통합 테스트 환경 구성상 필요하다면 유지 + + private UserDetails testUserDetails; + private Message testMessage; + + @BeforeEach + void setUp() { + Member member = memberRepository.save(Member.builder() + .email("feedback_user@test.com") + .password("password") + .build()); + testUserDetails = new CustomMemberDetail(member, Role.USER); + + Chat chat = chatRepository.save(Chat.builder() + .member(member) + .title("Test Chat") + .build()); + + testMessage = messageRepository.save(Message.builder() + .chat(chat) + .type(Type.AI) + .content("AI Response") + .build()); + } + + @Test + @DisplayName("피드백 등록 요청 시 DB에 저장되고 200 OK를 반환함") + void shouldSaveFeedbackAndReturnOkWhenValidRequest() throws Exception { + // given + Long messageId = testMessage.getId(); + AddFeedbackReq req = new AddFeedbackReq(Sentiment.LIKE, "매우 유용한 답변입니다."); + + // when & then + mockMvc.perform(post("/v1/messages/{messageId}/feedback", messageId) + .with(csrf()) + .with(user(testUserDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").isNumber()); // 생성된 ID 반환 확인 + + Feedback savedFeedback = feedbackRepository.findAll().get(0); + assertThat(savedFeedback.getMessage().getId()).isEqualTo(messageId); + assertThat(savedFeedback.getSentiment()).isEqualTo(Sentiment.LIKE); + assertThat(savedFeedback.getText()).isEqualTo("매우 유용한 답변입니다."); + } + + @Test + @DisplayName("피드백 상태(Sentiment) 누락 시 400 Bad Request를 반환함") + void shouldReturnBadRequestWhenSentimentIsNull() throws Exception { + // given + Long messageId = testMessage.getId(); + AddFeedbackReq req = new AddFeedbackReq(null, "내용만 있음"); // @NotNull 위반 + + // when & then + mockMvc.perform(post("/v1/messages/{messageId}/feedback", messageId) + .with(csrf()) + .with(user(testUserDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("존재하지 않는 메시지에 피드백 등록 시 404 Not Found를 반환함") + void shouldReturnNotFoundWhenMessageDoesNotExist() throws Exception { + // given + Long invalidMessageId = 99999L; + AddFeedbackReq req = new AddFeedbackReq(Sentiment.DISLIKE, "별로예요"); + + // when & then + mockMvc.perform(post("/v1/messages/{messageId}/feedback", invalidMessageId) + .with(csrf()) + .with(user(testUserDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andDo(print()) + .andExpect(status().isNotFound()) // MessageQueryService에서 예외 발생 + .andExpect(jsonPath("$.success").value(false)); + } +} diff --git a/src/test/java/com/sofa/linkiving/domain/chat/service/FeedbackServiceTest.java b/src/test/java/com/sofa/linkiving/domain/chat/service/FeedbackServiceTest.java index 504f8381..7ac775dd 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/service/FeedbackServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/FeedbackServiceTest.java @@ -1,5 +1,6 @@ package com.sofa.linkiving.domain.chat.service; +import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; import org.junit.jupiter.api.DisplayName; @@ -10,9 +11,13 @@ import org.mockito.junit.jupiter.MockitoExtension; 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; @ExtendWith(MockitoExtension.class) public class FeedbackServiceTest { + @InjectMocks private FeedbackService feedbackService; @@ -22,6 +27,34 @@ public class FeedbackServiceTest { @Mock private FeedbackQueryService feedbackQueryService; + @Test + @DisplayName("피드백 생성 요청 시 엔티티를 생성하고 저장을 요청함") + void shouldCreateAndSaveFeedback() { + // given + Message message = mock(Message.class); + Sentiment sentiment = Sentiment.LIKE; + String text = "답변이 훌륭합니다."; + + Feedback savedFeedback = mock(Feedback.class); + given(savedFeedback.getId()).willReturn(10L); + + given(feedbackCommandService.save(any(Feedback.class))).willAnswer(invocation -> { + Feedback argument = invocation.getArgument(0); + assertThat(argument.getMessage()).isEqualTo(message); + assertThat(argument.getSentiment()).isEqualTo(sentiment); + assertThat(argument.getText()).isEqualTo(text); + + return savedFeedback; + }); + + // when + Long resultId = feedbackService.create(message, sentiment, text); + + // then + assertThat(resultId).isEqualTo(10L); + verify(feedbackCommandService).save(any(Feedback.class)); + } + @Test @DisplayName("FeedbackCommandService.deleteAllByMessageIn 호출 위임") void shouldCallDeleteAllByMessageInWhenDeleteFeedbacks() { @@ -35,4 +68,3 @@ void shouldCallDeleteAllByMessageInWhenDeleteFeedbacks() { verify(feedbackCommandService).deleteFeedbacksByChat(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 ac0de736..4c8717e8 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 @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,14 +18,18 @@ import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; +import com.sofa.linkiving.domain.chat.error.MessageErrorCode; import com.sofa.linkiving.domain.chat.repository.MessageRepository; +import com.sofa.linkiving.domain.member.entity.Member; +import com.sofa.linkiving.global.error.exception.BusinessException; @ExtendWith(MockitoExtension.class) public class MessageQueryServiceTest { + @Mock + Member member; @InjectMocks private MessageQueryService messageQueryService; - @Mock private MessageRepository messageRepository; @@ -76,4 +81,36 @@ void shouldReturnHasNextFalseWhenNoMoreData() { assertThat(result.hasNext()).isFalse(); assertThat(result.getContent()).hasSize(size); } + + @Test + @DisplayName("단일 메시지 조회 시 메시지가 존재하고 회원이 일치하면 메시지를 반환함") + void shouldReturnMessageWhenFound() { + // given + Long messageId = 1L; + Message message = mock(Message.class); + + given(messageRepository.findByIdAndMember(messageId, member)).willReturn(Optional.of(message)); + + // when + Message result = messageQueryService.findByIdAndMember(messageId, member); + + // then + assertThat(result).isEqualTo(message); + } + + @Test + @DisplayName("단일 메시지 조회 시 메시지가 없거나 회원이 불일치하면 예외를 던짐") + void shouldThrowExceptionWhenNotFound() { + // given + Long messageId = 999L; + + given(messageRepository.findByIdAndMember(messageId, member)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> messageQueryService.findByIdAndMember(messageId, member)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(MessageErrorCode.MESSAGE_NOT_FOUND); + } } + 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 8518b121..c33667c2 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 @@ -27,6 +27,7 @@ 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 com.sofa.linkiving.domain.member.entity.Member; @ExtendWith(MockitoExtension.class) public class MessageServiceTest { @@ -74,7 +75,6 @@ void shouldCallDeleteAllByChatWhenDeleteAll() { @DisplayName("메시지 목록과 포함된 링크의 요약을 정상적으로 조회하여 DTO로 반환한다") void shouldGetMessagesWithLinksAndSummaries() { // given - Chat chat = mock(Chat.class); Long lastId = 100L; int size = 10; @@ -125,7 +125,6 @@ void shouldGetMessagesWithLinksAndSummaries() { @DisplayName("메시지가 없을 경우 빈 목록을 반환한다") void shouldReturnEmptyWhenNoMessages() { // given - Chat chat = mock(Chat.class); Long lastId = null; int size = 10; @@ -134,7 +133,6 @@ void shouldReturnEmptyWhenNoMessages() { given(messageQueryService.findAllByChatAndCursor(chat, lastId, size)) .willReturn(emptySlice); - // 빈 리스트가 넘어가면 SummaryService는 호출되지만 빈 맵을 반환하도록 설정 (혹은 실제 로직에 따라 호출됨) given(summaryQueryService.getSelectedSummariesByLinks(anyList())) .willReturn(Collections.emptyMap()); @@ -150,15 +148,13 @@ void shouldReturnEmptyWhenNoMessages() { @DisplayName("중복된 링크가 있어도 요약 조회 시에는 중복을 제거하여 요청한다") void shouldRequestSummariesForDistinctLinks() { // given - Chat chat = mock(Chat.class); - - Link link1 = mock(Link.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과 같은 링크 포함 + given(msg2.getLinks()).willReturn(List.of(link1)); Slice messageSlice = new SliceImpl<>(List.of(msg1, msg2)); @@ -171,9 +167,7 @@ void shouldRequestSummariesForDistinctLinks() { messageService.getMessages(chat, null, 10); // then - verify(summaryQueryService).getSelectedSummariesByLinks(argThat(list -> - list.size() == 1 // 두 메시지에 링크가 총 2개지만, 같은 객체이므로 1개로 줄어야 함 - )); + verify(summaryQueryService).getSelectedSummariesByLinks(argThat(list -> list.size() == 1)); } @Test @@ -194,7 +188,6 @@ void shouldCancelSubscriptionAndSendMessageWhenCancelAnswer() { @DisplayName("이미 답변 생성 중일 경우 중복 요청 무시") void shouldIgnoreRequestWhenAlreadyGenerating() { // given - // messageBuffers 필드에 강제로 현재 채팅방 ID를 넣어 생성 중인 상태로 만듦 @SuppressWarnings("unchecked") Map buffers = (Map)ReflectionTestUtils.getField(messageService, "messageBuffers"); @@ -205,7 +198,24 @@ void shouldIgnoreRequestWhenAlreadyGenerating() { messageService.generateAnswer(chat, "질문"); // then - // WebClient 호출 로직으로 넘어가지 않아야 하므로 SubscriptionManager 호출이 없어야 함 verify(subscriptionManager, never()).add(anyString(), any()); } + + @Test + @DisplayName("단일 메시지 조회 요청 시 QueryService를 호출하여 결과를 반환함") + void shouldCallFindByIdWhenGet() { + // given + Long messageId = 1L; + Message message = mock(Message.class); + Member member = mock(Member.class); + + given(messageQueryService.findByIdAndMember(messageId, member)).willReturn(message); + + // when + Message result = messageService.get(messageId, member); + + // then + assertThat(result).isEqualTo(message); + verify(messageQueryService).findByIdAndMember(messageId, member); + } }