Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/main/java/com/sofa/linkiving/domain/chat/ai/AiTitleClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.sofa.linkiving.domain.chat.ai;

public interface AiTitleClient {
/**
* AI 서버에 요약 요청을 보냅니다.
* @param firstChat 채팅 시작 대화
* @return 제목
*/
String generateSummary(String firstChat);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -11,4 +13,10 @@
public interface ChatApi {
@Operation(summary = "채팅방 목록 조회", description = "사용자의 채팅방 목록 정보(채팅방 Id, 제목)을 조회합니다.")
BaseResponse<ChatsRes> getChats(Member member);

@Operation(summary = "새로운 채팅 생성", description = "새로운 채팅을 생성합니다.")
BaseResponse<CreateChatRes> createChat(
CreateChatReq req,
Member member
);
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,4 +29,11 @@ public BaseResponse<ChatsRes> getChats(@AuthMember Member member) {
ChatsRes res = chatFacade.getChats(member);
return BaseResponse.success(res, "채팅방 목록 조회를 성공했습니다.");
}

@Override
@PostMapping
public BaseResponse<CreateChatRes> createChat(@RequestBody @Valid CreateChatReq req, @AuthMember Member member) {
CreateChatRes res = chatFacade.createChat(req.firstChat(), member);
return BaseResponse.success(res, "채팅방 생성 완료");
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Chat> chats = chatService.getChats(member);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@

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;

@Service
@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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@ public class ChatService {
public List<Chat> getChats(Member member) {
return chatQueryService.findAllOrderByLastMessageDesc(member);
}

public Chat createChat(String title, Member member) {
return chatCommandService.saveChat(title, member);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,8 +31,13 @@ public class ChatFacadeTest {

@Mock
private MessageService messageService;

@Mock
private FeedbackService feedbackService;

@Mock
private AiTitleClient aiTitleClient;

@Mock
private Member member;

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading