diff --git a/src/main/java/com/sofa/linkiving/domain/chat/ai/AiTitleClient.java b/src/main/java/com/sofa/linkiving/domain/chat/ai/AiTitleClient.java new file mode 100644 index 00000000..bd1a9799 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/ai/AiTitleClient.java @@ -0,0 +1,10 @@ +package com.sofa.linkiving.domain.chat.ai; + +public interface AiTitleClient { + /** + * AI 서버에 요약 요청을 보냅니다. + * @param firstChat 채팅 시작 대화 + * @return 제목 + */ + String generateSummary(String firstChat); +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/ai/MockAiTitleClient.java b/src/main/java/com/sofa/linkiving/domain/chat/ai/MockAiTitleClient.java new file mode 100644 index 00000000..ee8c1ba7 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/ai/MockAiTitleClient.java @@ -0,0 +1,13 @@ +package com.sofa.linkiving.domain.chat.ai; + +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +@Component +@Primary +public class MockAiTitleClient implements AiTitleClient { + @Override + public String generateSummary(String firstChat) { + return String.format("임시 제목[%s]", firstChat); + } +} 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 df9fe42e..e6db7769 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,6 +1,8 @@ package com.sofa.linkiving.domain.chat.controller; +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.member.entity.Member; import com.sofa.linkiving.global.common.BaseResponse; @@ -11,4 +13,10 @@ public interface ChatApi { @Operation(summary = "채팅방 목록 조회", description = "사용자의 채팅방 목록 정보(채팅방 Id, 제목)을 조회합니다.") BaseResponse getChats(Member member); + + @Operation(summary = "새로운 채팅 생성", description = "새로운 채팅을 생성합니다.") + BaseResponse createChat( + 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 2c1c60a8..1d54c9b3 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 @@ -1,15 +1,20 @@ package com.sofa.linkiving.domain.chat.controller; import org.springframework.web.bind.annotation.GetMapping; +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.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.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 @@ -24,4 +29,11 @@ public BaseResponse getChats(@AuthMember Member member) { ChatsRes res = chatFacade.getChats(member); return BaseResponse.success(res, "채팅방 목록 조회를 성공했습니다."); } + + @Override + @PostMapping + public BaseResponse createChat(@RequestBody @Valid CreateChatReq req, @AuthMember Member member) { + CreateChatRes res = chatFacade.createChat(req.firstChat(), member); + return BaseResponse.success(res, "채팅방 생성 완료"); + } } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/dto/request/CreateChatReq.java b/src/main/java/com/sofa/linkiving/domain/chat/dto/request/CreateChatReq.java new file mode 100644 index 00000000..5d236ce5 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/dto/request/CreateChatReq.java @@ -0,0 +1,14 @@ +package com.sofa.linkiving.domain.chat.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder +@Schema(description = "채팅방 생성 및 첫 대화 요청") +public record CreateChatReq( + @NotBlank(message = "첫 대화 내용은 필수입니다.") + @Schema(description = "채팅 시작에 사용되는 최초 대화", example = "AI 관련된 자료가 있어?") + String firstChat +) { +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/dto/response/CreateChatRes.java b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/CreateChatRes.java new file mode 100644 index 00000000..26f8474a --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/CreateChatRes.java @@ -0,0 +1,24 @@ +package com.sofa.linkiving.domain.chat.dto.response; + +import com.sofa.linkiving.domain.chat.entity.Chat; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +public record CreateChatRes( + @Schema(description = "채팅방 ID") + Long id, + @Schema(description = "채팅방 제목") + String title, + @Schema(description = "최초 대화") + String firstChat +) { + public static CreateChatRes from(Chat chat, String firstChat) { + return CreateChatRes.builder() + .id(chat.getId()) + .title(chat.getTitle()) + .firstChat(firstChat) + .build(); + } +} 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 82d869dc..ecffdd12 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 @@ -5,7 +5,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.sofa.linkiving.domain.chat.ai.AiTitleClient; import com.sofa.linkiving.domain.chat.dto.response.ChatsRes; +import com.sofa.linkiving.domain.chat.dto.response.CreateChatRes; import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.service.ChatService; import com.sofa.linkiving.domain.chat.service.FeedbackService; @@ -21,6 +23,15 @@ public class ChatFacade { private final ChatService chatService; private final MessageService messageService; private final FeedbackService feedbackService; + private final AiTitleClient aiTitleClient; + + @Transactional + public CreateChatRes createChat(String firstChat, Member member) { + String title = aiTitleClient.generateSummary(firstChat); + Chat chat = chatService.createChat(title, member); + + return CreateChatRes.from(chat, firstChat); + } public ChatsRes getChats(Member member) { List chats = chatService.getChats(member); 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 2add42cb..24901cbb 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 @@ -2,7 +2,9 @@ import org.springframework.stereotype.Service; +import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.repository.ChatRepository; +import com.sofa.linkiving.domain.member.entity.Member; import lombok.RequiredArgsConstructor; @@ -10,4 +12,13 @@ @RequiredArgsConstructor public class ChatCommandService { private final ChatRepository chatRepository; + + public Chat saveChat(String title, Member member) { + return chatRepository.save( + Chat.builder() + .member(member) + .title(title) + .build() + ); + } } 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 df440ced..f13794f5 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 @@ -18,4 +18,8 @@ public class ChatService { public List getChats(Member member) { return chatQueryService.findAllOrderByLastMessageDesc(member); } + + public Chat createChat(String title, Member member) { + return chatCommandService.saveChat(title, member); + } } 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 3e827c99..d18bbd1a 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 @@ -12,7 +12,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.sofa.linkiving.domain.chat.ai.AiTitleClient; import com.sofa.linkiving.domain.chat.dto.response.ChatsRes; +import com.sofa.linkiving.domain.chat.dto.response.CreateChatRes; import com.sofa.linkiving.domain.chat.entity.Chat; import com.sofa.linkiving.domain.chat.service.ChatService; import com.sofa.linkiving.domain.chat.service.FeedbackService; @@ -29,8 +31,13 @@ public class ChatFacadeTest { @Mock private MessageService messageService; + @Mock private FeedbackService feedbackService; + + @Mock + private AiTitleClient aiTitleClient; + @Mock private Member member; @@ -53,4 +60,32 @@ void shouldReturnChatsResWhenGetChats() { verify(chatService).getChats(member); } + + @Test + @DisplayName("createChat 호출 및 CreateChatRes 반환") + void shouldReturnCreateChatResWhenCreateChat() { + // given + String firstChat = "안녕하세요"; + String generatedTitle = "요약된 제목"; + Long chatId = 100L; + + Chat mockChat = mock(Chat.class); + + given(mockChat.getId()).willReturn(chatId); + given(mockChat.getTitle()).willReturn(generatedTitle); + + given(aiTitleClient.generateSummary(firstChat)).willReturn(generatedTitle); + given(chatService.createChat(generatedTitle, member)).willReturn(mockChat); + + // when + CreateChatRes result = chatFacade.createChat(firstChat, member); + + // then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(chatId); + assertThat(result.title()).isEqualTo(generatedTitle); + assertThat(result.firstChat()).isEqualTo(firstChat); + + verify(chatService).createChat(generatedTitle, member); + } } 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 new file mode 100644 index 00000000..c8d29104 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatControllerIntegrationTest.java @@ -0,0 +1,109 @@ +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/service/ChatCommandServiceTest.java b/src/test/java/com/sofa/linkiving/domain/chat/service/ChatCommandServiceTest.java new file mode 100644 index 00000000..30d14ca3 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/ChatCommandServiceTest.java @@ -0,0 +1,50 @@ +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; +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.ChatRepository; +import com.sofa.linkiving.domain.member.entity.Member; + +@ExtendWith(MockitoExtension.class) +public class ChatCommandServiceTest { + + @InjectMocks + private ChatCommandService chatCommandService; + + @Mock + private ChatRepository chatRepository; + + @Mock + private Member member; + + @Test + @DisplayName("ChatRepository.save 호출 및 저장된 Chat 반환") + void shouldReturnSavedChatWhenSaveChat() { + // given + String firstChat = "AI 관련 자료 찾아줘"; + Chat chat = Chat.builder() + .title(firstChat) + .member(member) + .build(); + + given(chatRepository.save(any(Chat.class))).willReturn(chat); + + // when + Chat result = chatCommandService.saveChat(firstChat, member); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTitle()).isEqualTo(firstChat); + + verify(chatRepository).save(any(Chat.class)); + } +} 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 d43c256b..210b340f 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 @@ -43,4 +43,21 @@ void shouldReturnChatsWhenGetChats() { assertThat(result).isEqualTo(expectedChats); verify(chatQueryService).findAllOrderByLastMessageDesc(member); } + + @Test + @DisplayName("createChat 요청 시 ChatCommandService.saveChat 위임") + void shouldCallSaveChatWhenCreateChat() { + // given + String firstChat = "첫 대화입니다"; + Chat chat = mock(Chat.class); + + given(chatCommandService.saveChat(firstChat, member)).willReturn(chat); + + // when + Chat result = chatService.createChat(firstChat, member); + + // then + assertThat(result).isEqualTo(chat); + verify(chatCommandService).saveChat(firstChat, member); + } } diff --git a/src/test/java/com/sofa/linkiving/infra/feign/OpenFeignIntegrationTest.java b/src/test/java/com/sofa/linkiving/infra/feign/OpenFeignIntegrationTest.java index 5ddd2425..01f2984c 100644 --- a/src/test/java/com/sofa/linkiving/infra/feign/OpenFeignIntegrationTest.java +++ b/src/test/java/com/sofa/linkiving/infra/feign/OpenFeignIntegrationTest.java @@ -28,16 +28,7 @@ @EnableFeignClients(clients = TestExternalClient.class) public class OpenFeignIntegrationTest { - @TestConfiguration - static class TestConfig { - @Bean - public CorsConfigurationSource corsConfigurationSource() { - return new UrlBasedCorsConfigurationSource(); - } - } - private static MockWebServer mockWebServer; - @Autowired TestExternalClient testExternalClient; @@ -94,4 +85,12 @@ void shouldThrowBusinessExceptionWhenBadGateway() { .isEqualTo(ExternalApiErrorCode.EXTERNAL_API_COMMUNICATION_ERROR); }); } + + @TestConfiguration + static class TestConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + return new UrlBasedCorsConfigurationSource(); + } + } }