From 9144995c1404cda8890a78059ad6c5bf99027958 Mon Sep 17 00:00:00 2001 From: Jansoon Date: Mon, 15 Dec 2025 23:58:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Chat=20=EC=82=AD=EC=A0=9C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/controller/ChatApi.java | 3 + .../chat/controller/ChatController.java | 13 +- .../domain/chat/entity/Feedback.java | 3 +- .../domain/chat/facade/ChatFacade.java | 9 ++ .../chat/repository/FeedbackRepository.java | 7 + .../chat/repository/MessageRepository.java | 11 ++ .../chat/service/ChatCommandService.java | 4 + .../domain/chat/service/ChatService.java | 6 +- .../chat/service/FeedbackCommandService.java | 5 + .../domain/chat/service/FeedbackService.java | 6 + .../chat/service/MessageCommandService.java | 5 + .../chat/service/MessageQueryService.java | 8 + .../domain/chat/service/MessageService.java | 9 ++ .../domain/chat/facade/ChatFacadeTest.java | 21 +++ .../ChatControllerIntegrationTest.java | 109 ------------- .../chat/integration/ChatIntegrationTest.java | 143 ++++++++++++++--- .../repository/ChatDomainRepositoryTest.java | 149 ++++++++++++++++++ .../chat/service/ChatCommandServiceTest.java | 13 ++ .../chat/service/ChatQueryServiceTest.java | 15 ++ .../domain/chat/service/ChatServiceTest.java | 34 +++- .../service/FeedbackCommandServiceTest.java | 35 ++++ .../chat/service/FeedbackServiceTest.java | 38 +++++ .../service/MessageCommandServiceTest.java | 14 ++ .../chat/service/MessageQueryServiceTest.java | 44 ++++++ .../chat/service/MessageServiceTest.java | 41 ++++- 25 files changed, 609 insertions(+), 136 deletions(-) delete mode 100644 src/test/java/com/sofa/linkiving/domain/chat/integration/ChatControllerIntegrationTest.java create mode 100644 src/test/java/com/sofa/linkiving/domain/chat/repository/ChatDomainRepositoryTest.java create mode 100644 src/test/java/com/sofa/linkiving/domain/chat/service/FeedbackCommandServiceTest.java create mode 100644 src/test/java/com/sofa/linkiving/domain/chat/service/FeedbackServiceTest.java create mode 100644 src/test/java/com/sofa/linkiving/domain/chat/service/MessageQueryServiceTest.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 dbcc2217..3a249841 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 @@ -36,6 +36,9 @@ BaseResponse createChat( Member member ); + @Operation(summary = "링크 삭제", description = "해당 링크방과 채팅 기록을 전부 Hard Delete 진행합니다.") + BaseResponse deleteChat(Member member, Long chatId); + void sendMessage(@Parameter(description = "채팅방 Id", required = true) Long chatId, @Parameter(description = "사용자 질문 내용", required = true) String message, 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 7ac5040e..972da2dd 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 @@ -3,7 +3,9 @@ import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +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; @@ -40,14 +42,21 @@ public BaseResponse createChat(@RequestBody @Valid CreateChatReq return BaseResponse.success(res, "채팅방 생성 완료"); } - @MessageMapping("/send/{chatId}") @Override + @DeleteMapping("/{chatId}") + public BaseResponse deleteChat(@AuthMember Member member, @PathVariable Long chatId) { + chatFacade.deleteChat(member, chatId); + return BaseResponse.noContent("성공적으로 삭제했습니다."); + } + + @Override + @MessageMapping("/send/{chatId}") public void sendMessage(@DestinationVariable Long chatId, @Payload String message, @AuthMember Member member) { chatFacade.generateAnswer(chatId, member, message); } - @MessageMapping("/cancel/{chatId}") @Override + @MessageMapping("/cancel/{chatId}") public void cancelMessage(@DestinationVariable Long chatId, @AuthMember Member member) { chatFacade.cancelAnswer(chatId, member); } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/entity/Feedback.java b/src/main/java/com/sofa/linkiving/domain/chat/entity/Feedback.java index b58e2e0b..5d7e1946 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/entity/Feedback.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/entity/Feedback.java @@ -3,7 +3,6 @@ import com.sofa.linkiving.domain.chat.enums.Sentiment; import com.sofa.linkiving.global.common.BaseEntity; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -20,7 +19,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "feedbacks") public class Feedback extends BaseEntity { - @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "message_id") private Message message; 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 9973ebec..5def52e1 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 @@ -38,6 +38,15 @@ public ChatsRes getChats(Member member) { return ChatsRes.from(chats); } + @Transactional + public void deleteChat(Member member, Long chatId) { + Chat chat = chatService.getChat(chatId, member); + + feedbackService.deleteAll(chat); + messageService.deleteAll(chat); + chatService.delete(chat); + } + @Transactional public void generateAnswer(Long chatId, Member member, String message) { Chat chat = chatService.getChat(chatId, member); diff --git a/src/main/java/com/sofa/linkiving/domain/chat/repository/FeedbackRepository.java b/src/main/java/com/sofa/linkiving/domain/chat/repository/FeedbackRepository.java index 50bcb627..a0859a0f 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/repository/FeedbackRepository.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/repository/FeedbackRepository.java @@ -1,10 +1,17 @@ package com.sofa.linkiving.domain.chat.repository; 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; import com.sofa.linkiving.domain.chat.entity.Feedback; @Repository public interface FeedbackRepository extends JpaRepository { + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Feedback f WHERE f.message.id IN (SELECT m.id FROM Message m WHERE m.chat = :chat)") + void deleteAllByChat(@Param("chat") Chat chat); } 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 59159b0f..ddc702c3 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,10 +1,21 @@ package com.sofa.linkiving.domain.chat.repository; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; @Repository public interface MessageRepository extends JpaRepository { + @Modifying(clearAutomatically = true) + @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") + List findAllByChat(Chat chat); } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/service/ChatCommandService.java b/src/main/java/com/sofa/linkiving/domain/chat/service/ChatCommandService.java index 24901cbb..e7f7fc72 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/service/ChatCommandService.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/service/ChatCommandService.java @@ -21,4 +21,8 @@ public Chat saveChat(String title, Member member) { .build() ); } + + public void deleteChat(Chat chat) { + chatRepository.delete(chat); + } } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/service/ChatService.java b/src/main/java/com/sofa/linkiving/domain/chat/service/ChatService.java index 6b0f4fc3..7bb31b45 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/service/ChatService.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/service/ChatService.java @@ -8,9 +8,7 @@ import com.sofa.linkiving.domain.member.entity.Member; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -@Slf4j @Service @RequiredArgsConstructor public class ChatService { @@ -21,6 +19,10 @@ public Chat getChat(Long chatId, Member member) { return chatQueryService.findChat(chatId, member); } + public void delete(Chat chat) { + chatCommandService.deleteChat(chat); + } + public List getChats(Member member) { return chatQueryService.findAllOrderByLastMessageDesc(member); } 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 03fb8353..36cdba1c 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 @@ -2,6 +2,7 @@ import org.springframework.stereotype.Service; +import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.repository.FeedbackRepository; import lombok.RequiredArgsConstructor; @@ -10,4 +11,8 @@ @RequiredArgsConstructor public class FeedbackCommandService { private final FeedbackRepository feedbackRepository; + + 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 04ca2011..f97daa17 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 @@ -2,6 +2,8 @@ import org.springframework.stereotype.Service; +import com.sofa.linkiving.domain.chat.entity.Chat; + import lombok.RequiredArgsConstructor; @Service @@ -9,4 +11,8 @@ public class FeedbackService { private final FeedbackQueryService feedbackQueryService; private final FeedbackCommandService feedbackCommandService; + + public void deleteAll(Chat chat) { + feedbackCommandService.deleteFeedbacksByChat(chat); + } } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/service/MessageCommandService.java b/src/main/java/com/sofa/linkiving/domain/chat/service/MessageCommandService.java index adf0b760..5107e4a0 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/service/MessageCommandService.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/service/MessageCommandService.java @@ -2,6 +2,7 @@ import org.springframework.stereotype.Service; +import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.repository.MessageRepository; @@ -12,6 +13,10 @@ public class MessageCommandService { private final MessageRepository messageRepository; + public void deleteAllByChat(Chat chat) { + messageRepository.deleteAllByChat(chat); + } + public Message saveMessage(Message message) { return messageRepository.save(message); } 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 ace1434d..5ce23bf9 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 @@ -1,7 +1,11 @@ package com.sofa.linkiving.domain.chat.service; +import java.util.List; + import org.springframework.stereotype.Service; +import com.sofa.linkiving.domain.chat.entity.Chat; +import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.repository.MessageRepository; import lombok.RequiredArgsConstructor; @@ -10,4 +14,8 @@ @RequiredArgsConstructor public class MessageQueryService { private final MessageRepository messageRepository; + + public List findAllByChat(Chat chat) { + return messageRepository.findAllByChat(chat); + } } 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 b446f015..93767217 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,5 +1,6 @@ package com.sofa.linkiving.domain.chat.service; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -27,6 +28,14 @@ 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 List getMessagesByChat(Chat chat) { + return messageQueryService.findAllByChat(chat); + } + public void generateAnswer(Chat chat, String userMessage) { String roomId = chat.getId().toString(); 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 d18bbd1a..35923b42 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 @@ -88,4 +88,25 @@ void shouldReturnCreateChatResWhenCreateChat() { verify(chatService).createChat(generatedTitle, member); } + + @Test + @DisplayName("채팅방 삭제 요청 시 하위 데이터(피드백, 메시지) 일괄 삭제 및 채팅방 제거 위임") + void shouldDeleteAllRelatedDataWhenDeleteChat() { + // given + Long chatId = 1L; + Chat chat = mock(Chat.class); + + given(chatService.getChat(chatId, member)).willReturn(chat); + + // when + chatFacade.deleteChat(member, chatId); + + // then + // 1. 피드백 삭제 호출 확인 + verify(feedbackService).deleteAll(chat); + // 2. 메시지 삭제 호출 확인 + verify(messageService).deleteAll(chat); + // 3. 채팅방 삭제 호출 확인 + verify(chatService).delete(chat); + } } diff --git a/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatControllerIntegrationTest.java b/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatControllerIntegrationTest.java deleted file mode 100644 index c8d29104..00000000 --- a/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatControllerIntegrationTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.sofa.linkiving.domain.chat.integration; - -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.ai.AiTitleClient; -import com.sofa.linkiving.domain.chat.dto.request.CreateChatReq; -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; -import com.sofa.linkiving.infra.redis.RedisService; -import com.sofa.linkiving.security.userdetails.CustomMemberDetail; - -@SpringBootTest -@AutoConfigureMockMvc -@Transactional -@ActiveProfiles("test") -class ChatControllerIntegrationTest { - - private static final String BASE_URL = "/v1/chats"; - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @Autowired - private MemberRepository memberRepository; - - @Autowired - private AiTitleClient aiTitleClient; - - @MockitoBean - private RedisService redisService; - - @MockitoBean - private MessageService messageService; - - @MockitoBean - private FeedbackService feedbackService; - - private UserDetails testUserDetails; - - @BeforeEach - void setUp() { - Member testMember = memberRepository.save(Member.builder() - .email("chatuser@test.com") - .password("password") - .build()); - - testUserDetails = new CustomMemberDetail(testMember, Role.USER); - } - - @Test - @DisplayName("유효한 요청 시 채팅 생성 및 200 OK 반환") - void shouldCreateChatSuccessfullyWhenValidRequest() throws Exception { - // given - String firstChatContent = "AI 관련 최신 뉴스 알려줘"; - CreateChatReq req = new CreateChatReq(firstChatContent); - String title = aiTitleClient.generateSummary(firstChatContent); - - // when & then - mockMvc.perform(post(BASE_URL) - .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.title").value(title)) - .andExpect(jsonPath("$.data.firstChat").value(firstChatContent)); - } - - @Test - @DisplayName("첫 대화 내용 누락 시 400 Bad Request 반환") - void shouldReturnBadRequestWhenFirstChatIsBlank() throws Exception { - // given - CreateChatReq req = new CreateChatReq(""); - - // when & then - mockMvc.perform(post(BASE_URL) - .with(csrf()) - .with(user(testUserDetails)) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(req))) - .andDo(print()) - .andExpect(status().isBadRequest()); - } -} 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 4b0304df..cd04c6e4 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 @@ -1,28 +1,34 @@ package com.sofa.linkiving.domain.chat.integration; -import static org.mockito.BDDMockito.*; +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 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.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.sofa.linkiving.domain.chat.dto.response.ChatsRes; -import com.sofa.linkiving.domain.chat.dto.response.ChatsRes.ChatSummary; -import com.sofa.linkiving.domain.chat.facade.ChatFacade; +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.entity.Chat; +import com.sofa.linkiving.domain.chat.entity.Message; +import com.sofa.linkiving.domain.chat.enums.Type; +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; @@ -33,47 +39,148 @@ @AutoConfigureMockMvc @Transactional @ActiveProfiles("test") -public class ChatIntegrationTest { +class ChatIntegrationTest { + + private static final String BASE_URL = "/v1/chats"; + @Autowired private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + @Autowired private MemberRepository memberRepository; - @MockitoBean - private ChatFacade chatFacade; + @Autowired + private ChatRepository chatRepository; + + @Autowired + private MessageRepository messageRepository; + + @Autowired + private AiTitleClient aiTitleClient; + @MockitoBean private RedisService redisService; - private Member testMember; + @MockitoBean + private FeedbackService feedbackService; + + @MockitoBean + private MessageService messageService; + private UserDetails testUserDetails; + private Member testMember; @BeforeEach void setUp() { testMember = memberRepository.save(Member.builder() - .email("list@test.com") + .email("chatuser@test.com") .password("password") .build()); + testUserDetails = new CustomMemberDetail(testMember, Role.USER); } + @Test + @DisplayName("유효한 요청 시 채팅 생성 및 200 OK 반환") + void shouldCreateChatSuccessfullyWhenValidRequest() throws Exception { + // given + String firstChatContent = "AI 관련 최신 뉴스 알려줘"; + CreateChatReq req = new CreateChatReq(firstChatContent); + String title = aiTitleClient.generateSummary(firstChatContent); + + // when & then + mockMvc.perform(post(BASE_URL) + .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.title").value(title)) + .andExpect(jsonPath("$.data.firstChat").value(firstChatContent)); + } + + @Test + @DisplayName("첫 대화 내용 누락 시 400 Bad Request 반환") + void shouldReturnBadRequestWhenFirstChatIsBlank() throws Exception { + // given + CreateChatReq req = new CreateChatReq(""); + + // when & then + mockMvc.perform(post(BASE_URL) + .with(csrf()) + .with(user(testUserDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("채팅방 삭제 API 호출 시 성공 응답 반환") + void shouldReturnSuccessWhenDeleteChat() throws Exception { + // given + Chat chat = chatRepository.save(Chat.builder() + .member(testMember) + .title("삭제할 채팅방") + .build()); + + Long chatId = chat.getId(); + + // when & then + mockMvc.perform(delete(BASE_URL + "/{chatId}", chatId) + .with(csrf()) + .with(user(testUserDetails))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + assertThat(chatRepository.existsById(chatId)).isFalse(); + } + @Test @DisplayName("채팅방 목록 조회 API 호출 및 성공 응답 반환") void shouldReturnChatListWhenGetChats() throws Exception { // given - ChatsRes mockResponse = new ChatsRes(List.of( - new ChatSummary(1L, "Chat 1"), - new ChatSummary(2L, "Chat 2") - )); + Chat chat2 = chatRepository.save(Chat.builder() + .member(testMember) + .title("Chat 2") + .build()); + + // 생성 시간 차이를 두기 위해 잠시 대기 (정렬 테스트) + Thread.sleep(10); + + Chat chat1 = chatRepository.save(Chat.builder() + .member(testMember) + .title("Chat 1") + .build()); + + messageRepository.save(Message.builder() + .chat(chat1) + .content("1") + .type(Type.AI) + .build() + ); - given(chatFacade.getChats(any(Member.class))).willReturn(mockResponse); + messageRepository.save(Message.builder() + .chat(chat2) + .content("2") + .type(Type.USER) + .build() + ); // when & then - mockMvc.perform(get("/v1/chats") + mockMvc.perform(get(BASE_URL) .with(user(testUserDetails))) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.chats").isArray()) - .andExpect(jsonPath("$.data.chats[0].title").value("Chat 1")); + .andExpect(jsonPath("$.data.chats[0].title").value("Chat 2")) + .andExpect(jsonPath("$.data.chats[1].title").value("Chat 1")); } } diff --git a/src/test/java/com/sofa/linkiving/domain/chat/repository/ChatDomainRepositoryTest.java b/src/test/java/com/sofa/linkiving/domain/chat/repository/ChatDomainRepositoryTest.java new file mode 100644 index 00000000..84f8504e --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/repository/ChatDomainRepositoryTest.java @@ -0,0 +1,149 @@ +package com.sofa.linkiving.domain.chat.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; +import java.util.Optional; + +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.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.member.entity.Member; +import com.sofa.linkiving.domain.member.repository.MemberRepository; + +@DataJpaTest +@ActiveProfiles("test") +public class ChatDomainRepositoryTest { + @Autowired + private ChatRepository chatRepository; + + @Autowired + private MessageRepository messageRepository; + + @Autowired + private FeedbackRepository feedbackRepository; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("ChatRepository: 내 채팅방 조회") + void shouldFindChatByIdAndMember() { + // given + Member member = memberRepository.save(Member + .builder() + .email("test@test.com") + .password("test") + .build()); + + Chat chat = chatRepository.save(Chat.builder() + .member(member) + .title("Title") + .build()); + + // when + Optional result = chatRepository.findByIdAndMember(chat.getId(), member); + + // then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(chat); + } + + @Test + @DisplayName("MessageRepository: 채팅방의 모든 메시지 삭제") + void shouldDeleteAllMessagesByChat() { + // given + Member member = memberRepository.save(Member.builder() + .email("test@test.com") + .password("test") + .build()); + + Chat chat = chatRepository.save(Chat.builder() + .member(member) + .title("Chat") + .build()); + + messageRepository.save(Message.builder() + .chat(chat) + .content("Hello") + .type(Type.USER) + .build()); + + // when + messageRepository.deleteAllByChat(chat); + + // then + List remaining = messageRepository.findAllByChat(chat); + assertThat(remaining).isEmpty(); + } + + @Test + @DisplayName("FeedbackRepository: 메시지 목록에 포함된 피드백 일괄 삭제") + void shouldDeleteAllFeedbacksByMessageList() { + // given + Member member = memberRepository.save(Member.builder() + .email("feed@test.com") + .password("test") + .build()); + + Chat chat = chatRepository.save(Chat.builder() + .member(member) + .title("Chat") + .build()); + + // 메시지 생성 + Message msg1 = messageRepository.save(Message.builder() + .chat(chat) + .content("1") + .type(Type.AI) + .build()); + + Message msg2 = messageRepository.save(Message.builder() + .chat(chat) + .content("2") + .type(Type.AI) + .build()); + + // 피드백 생성 + feedbackRepository.save(new Feedback(msg1, "Good", Sentiment.LIKE)); + feedbackRepository.save(new Feedback(msg2, "Bad", Sentiment.DISLIKE)); + + // when + feedbackRepository.deleteAllByChat(chat); + + // then + assertThat(feedbackRepository.count()).isZero(); + } + + @Test + @DisplayName("통합 검증: 메시지가 없는 빈 채팅방 삭제 시 정상 작동") + void shouldDeleteEmptyChatWithoutError() { + // given + Member member = memberRepository.save(Member.builder() + .email("empty@test.com") + .password("test") + .build()); + + Chat emptyChat = chatRepository.save(Chat.builder() + .member(member) + .title("Empty Chat") + .build()); + + // when & then + assertThatCode(() -> { + feedbackRepository.deleteAllByChat(emptyChat); + messageRepository.deleteAllByChat(emptyChat); + chatRepository.delete(emptyChat); + }).doesNotThrowAnyException(); + + assertThat(chatRepository.existsById(emptyChat.getId())).isFalse(); + } +} diff --git a/src/test/java/com/sofa/linkiving/domain/chat/service/ChatCommandServiceTest.java b/src/test/java/com/sofa/linkiving/domain/chat/service/ChatCommandServiceTest.java index 30d14ca3..a0d40ad1 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/service/ChatCommandServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/ChatCommandServiceTest.java @@ -47,4 +47,17 @@ void shouldReturnSavedChatWhenSaveChat() { verify(chatRepository).save(any(Chat.class)); } + + @Test + @DisplayName("ChatRepository.delete 호출") + void shouldCallDeleteWhenDeleteChat() { + // given + Chat chat = mock(Chat.class); + + // when + chatCommandService.deleteChat(chat); + + // then + verify(chatRepository).delete(chat); + } } 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 45966612..4010da97 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 @@ -30,6 +30,21 @@ public class ChatQueryServiceTest { @Mock private Member member; + @Test + @DisplayName("ChatRepository.findByIdAndMember 호출 및 반환") + void shouldReturnChatWhenFindChat() { + // given + Long chatId = 1L; + Chat chat = mock(Chat.class); + given(chatRepository.findByIdAndMember(chatId, member)).willReturn(Optional.of(chat)); + + // when + Chat result = chatQueryService.findChat(chatId, member); + + // then + assertThat(result).isEqualTo(chat); + } + @Test @DisplayName("ChatRepository.findAllByMemberOrderByCreatedAtDesc 호출 및 반환") void shouldReturnChatListWhenFindAllOrderByLastMessageDesc() { 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 210b340f..778199c9 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 @@ -17,14 +17,15 @@ @ExtendWith(MockitoExtension.class) public class ChatServiceTest { + @InjectMocks private ChatService chatService; @Mock - private ChatQueryService chatQueryService; + private ChatCommandService chatCommandService; @Mock - private ChatCommandService chatCommandService; + private ChatQueryService chatQueryService; @Mock private Member member; @@ -60,4 +61,33 @@ void shouldCallSaveChatWhenCreateChat() { assertThat(result).isEqualTo(chat); verify(chatCommandService).saveChat(firstChat, member); } + + @Test + @DisplayName("ChatQueryService.findChat 호출 및 결과 반환") + void shouldReturnChatWhenGetChat() { + // given + Long chatId = 1L; + Chat chat = mock(Chat.class); + given(chatQueryService.findChat(chatId, member)).willReturn(chat); + + // when + Chat result = chatService.getChat(chatId, member); + + // then + 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/FeedbackCommandServiceTest.java b/src/test/java/com/sofa/linkiving/domain/chat/service/FeedbackCommandServiceTest.java new file mode 100644 index 00000000..59d6a1d7 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/FeedbackCommandServiceTest.java @@ -0,0 +1,35 @@ +package com.sofa.linkiving.domain.chat.service; + +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.entity.Chat; +import com.sofa.linkiving.domain.chat.repository.FeedbackRepository; + +@ExtendWith(MockitoExtension.class) +public class FeedbackCommandServiceTest { + @InjectMocks + private FeedbackCommandService feedbackCommandService; + + @Mock + private FeedbackRepository feedbackRepository; + + @Test + @DisplayName("FeedbackRepository.deleteAllByMessageInQuery 호출") + void shouldCallDeleteAllByMessageInQueryWhenDeleteAllByMessageIn() { + // given + Chat chat = mock(Chat.class); // List 대신 Chat 모킹 + + // when + feedbackCommandService.deleteFeedbacksByChat(chat); + + // then + verify(feedbackRepository).deleteAllByChat(chat); + } +} 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 new file mode 100644 index 00000000..504f8381 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/FeedbackServiceTest.java @@ -0,0 +1,38 @@ +package com.sofa.linkiving.domain.chat.service; + +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.entity.Chat; + +@ExtendWith(MockitoExtension.class) +public class FeedbackServiceTest { + @InjectMocks + private FeedbackService feedbackService; + + @Mock + private FeedbackCommandService feedbackCommandService; + + @Mock + private FeedbackQueryService feedbackQueryService; + + @Test + @DisplayName("FeedbackCommandService.deleteAllByMessageIn 호출 위임") + void shouldCallDeleteAllByMessageInWhenDeleteFeedbacks() { + // given + Chat chat = mock(Chat.class); + + // when + feedbackService.deleteAll(chat); + + // then + verify(feedbackCommandService).deleteFeedbacksByChat(chat); + } +} + diff --git a/src/test/java/com/sofa/linkiving/domain/chat/service/MessageCommandServiceTest.java b/src/test/java/com/sofa/linkiving/domain/chat/service/MessageCommandServiceTest.java index 5e9c3eac..ff3568c9 100644 --- a/src/test/java/com/sofa/linkiving/domain/chat/service/MessageCommandServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/MessageCommandServiceTest.java @@ -10,6 +10,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.entity.Message; import com.sofa.linkiving.domain.chat.repository.MessageRepository; @@ -36,4 +37,17 @@ void shouldReturnSavedMessageWhenSaveMessage() { assertThat(result).isEqualTo(message); verify(messageRepository).save(message); } + + @Test + @DisplayName("MessageRepository.deleteAllByChat 호출") + void shouldCallDeleteAllByChatWhenDeleteAllByChat() { + // given + Chat chat = mock(Chat.class); + + // when + messageCommandService.deleteAllByChat(chat); + + // then + verify(messageRepository).deleteAllByChat(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 new file mode 100644 index 00000000..a76ae158 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/MessageQueryServiceTest.java @@ -0,0 +1,44 @@ +package com.sofa.linkiving.domain.chat.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; + +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.entity.Chat; +import com.sofa.linkiving.domain.chat.entity.Message; +import com.sofa.linkiving.domain.chat.repository.MessageRepository; + +@ExtendWith(MockitoExtension.class) +public class MessageQueryServiceTest { + + @InjectMocks + private MessageQueryService messageQueryService; + + @Mock + private MessageRepository messageRepository; + + @Test + @DisplayName("MessageRepository.findAllByChat 호출 및 결과 반환") + void shouldReturnMessagesWhenFindAllByChat() { + // given + Chat chat = mock(Chat.class); + List expectedMessages = List.of(mock(Message.class)); + + given(messageRepository.findAllByChat(chat)).willReturn(expectedMessages); + + // when + List result = messageQueryService.findAllByChat(chat); + + // then + assertThat(result).isEqualTo(expectedMessages); + verify(messageRepository).findAllByChat(chat); + } +} 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 c2a1a236..121bd1ad 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 @@ -1,7 +1,9 @@ package com.sofa.linkiving.domain.chat.service; -import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Assertions; @@ -16,6 +18,7 @@ import org.springframework.test.util.ReflectionTestUtils; 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) @@ -24,6 +27,12 @@ public class MessageServiceTest { @InjectMocks private MessageService messageService; + @Mock + private MessageCommandService messageCommandService; + + @Mock + private MessageQueryService messageQueryService; + @Mock private SimpMessagingTemplate messagingTemplate; @@ -39,6 +48,36 @@ void setUp() { lenient().when(chat.getId()).thenReturn(1L); } + @Test + @DisplayName("MessageCommandService.deleteAllByChat 호출 위임") + void shouldCallDeleteAllByChatWhenDeleteAll() { + // given + Chat chat = mock(Chat.class); + + // when + messageService.deleteAll(chat); + + // then + verify(messageCommandService).deleteAllByChat(chat); + } + + @Test + @DisplayName("MessageQueryService.findAllByChat 호출 및 결과 반환") + void shouldReturnMessagesWhenGetMessagesByChat() { + // given + Chat chat = mock(Chat.class); + List messages = List.of(mock(Message.class)); + + given(messageQueryService.findAllByChat(chat)).willReturn(messages); + + // when + List result = messageService.getMessagesByChat(chat); + + // then + assertThat(result).isEqualTo(messages); + verify(messageQueryService).findAllByChat(chat); + } + @Test @DisplayName("답변 취소 요청 시 구독 취소 및 취소 메시지 전송") void shouldCancelSubscriptionAndSendMessageWhenCancelAnswer() {