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 f2a98db2..df9fe42e 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,7 +1,14 @@ package com.sofa.linkiving.domain.chat.controller; +import com.sofa.linkiving.domain.chat.dto.response.ChatsRes; +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 = "Chat", description = "링크 관리 API") +@Tag(name = "Chat", description = "채팅 관리 API") public interface ChatApi { + @Operation(summary = "채팅방 목록 조회", description = "사용자의 채팅방 목록 정보(채팅방 Id, 제목)을 조회합니다.") + BaseResponse getChats(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 63e7bd5b..2c1c60a8 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,12 +1,27 @@ package com.sofa.linkiving.domain.chat.controller; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.sofa.linkiving.domain.chat.dto.response.ChatsRes; +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 lombok.RequiredArgsConstructor; @RestController -@RequestMapping("/v1/chat") +@RequestMapping("/v1/chats") @RequiredArgsConstructor public class ChatController implements ChatApi { + private final ChatFacade chatFacade; + + @Override + @GetMapping + public BaseResponse getChats(@AuthMember Member member) { + ChatsRes res = chatFacade.getChats(member); + return BaseResponse.success(res, "채팅방 목록 조회를 성공했습니다."); + } } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/dto/response/ChatsRes.java b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/ChatsRes.java new file mode 100644 index 00000000..225cf388 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/dto/response/ChatsRes.java @@ -0,0 +1,34 @@ +package com.sofa.linkiving.domain.chat.dto.response; + +import java.util.List; + +import com.sofa.linkiving.domain.chat.entity.Chat; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatsRes( + @Schema(description = "채팅방 목록") + List chats +) { + + public static ChatsRes from(List chatEntities) { + List summaries = chatEntities.stream() + .map(ChatSummary::from) + .toList(); + return new ChatsRes(summaries); + } + + public record ChatSummary( + @Schema(description = "채팅방 Id") + Long id, + @Schema(description = "채팅방 제목") + String title + ) { + public static ChatSummary from(Chat chat) { + return new ChatSummary( + chat.getId(), + chat.getTitle() + ); + } + } +} 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 new file mode 100644 index 00000000..82d869dc --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/chat/facade/ChatFacade.java @@ -0,0 +1,29 @@ +package com.sofa.linkiving.domain.chat.facade; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sofa.linkiving.domain.chat.dto.response.ChatsRes; +import com.sofa.linkiving.domain.chat.entity.Chat; +import com.sofa.linkiving.domain.chat.service.ChatService; +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(readOnly = true) +public class ChatFacade { + private final ChatService chatService; + private final MessageService messageService; + private final FeedbackService feedbackService; + + public ChatsRes getChats(Member member) { + List chats = chatService.getChats(member); + return ChatsRes.from(chats); + } +} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/repository/ChatRepository.java b/src/main/java/com/sofa/linkiving/domain/chat/repository/ChatRepository.java index aefdbc3a..506b675c 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/repository/ChatRepository.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/repository/ChatRepository.java @@ -1,10 +1,24 @@ package com.sofa.linkiving.domain.chat.repository; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import com.sofa.linkiving.domain.chat.entity.Chat; +import com.sofa.linkiving.domain.member.entity.Member; @Repository public interface ChatRepository extends JpaRepository { + @Query(""" + SELECT c + FROM Chat c + JOIN Message m ON m.chat = c + WHERE c.member = :member + GROUP BY c + ORDER BY MAX(m.createdAt) DESC + """) + List findAllByMemberOrderByLastMessageDesc(@Param("member") Member member); } diff --git a/src/main/java/com/sofa/linkiving/domain/chat/service/ChatFacade.java b/src/main/java/com/sofa/linkiving/domain/chat/service/ChatFacade.java deleted file mode 100644 index bde2875c..00000000 --- a/src/main/java/com/sofa/linkiving/domain/chat/service/ChatFacade.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.sofa.linkiving.domain.chat.service; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class ChatFacade { - private final ChatService chatService; - private final MessageService messageService; - private final FeedbackService feedbackService; -} diff --git a/src/main/java/com/sofa/linkiving/domain/chat/service/ChatQueryService.java b/src/main/java/com/sofa/linkiving/domain/chat/service/ChatQueryService.java index b79cf4f5..f2b43a06 100644 --- a/src/main/java/com/sofa/linkiving/domain/chat/service/ChatQueryService.java +++ b/src/main/java/com/sofa/linkiving/domain/chat/service/ChatQueryService.java @@ -1,8 +1,12 @@ 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.repository.ChatRepository; +import com.sofa.linkiving.domain.member.entity.Member; import lombok.RequiredArgsConstructor; @@ -10,4 +14,8 @@ @RequiredArgsConstructor public class ChatQueryService { private final ChatRepository chatRepository; + + public List findAllOrderByLastMessageDesc(Member member) { + return chatRepository.findAllByMemberOrderByLastMessageDesc(member); + } } 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 8566c080..df440ced 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 @@ -1,7 +1,12 @@ 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.member.entity.Member; + import lombok.RequiredArgsConstructor; @Service @@ -9,4 +14,8 @@ public class ChatService { private final ChatCommandService chatCommandService; private final ChatQueryService chatQueryService; + + public List getChats(Member member) { + return chatQueryService.findAllOrderByLastMessageDesc(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 new file mode 100644 index 00000000..3e827c99 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/facade/ChatFacadeTest.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 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.dto.response.ChatsRes; +import com.sofa.linkiving.domain.chat.entity.Chat; +import com.sofa.linkiving.domain.chat.service.ChatService; +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 ChatFacadeTest { + @InjectMocks + private ChatFacade chatFacade; + + @Mock + private ChatService chatService; + + @Mock + private MessageService messageService; + @Mock + private FeedbackService feedbackService; + @Mock + private Member member; + + @Test + @DisplayName("ChatService.getChats 호출 및 ChatsRes 변환 반환") + void shouldReturnChatsResWhenGetChats() { + // given + Chat chat = mock(Chat.class); + given(chat.getId()).willReturn(1L); + given(chat.getTitle()).willReturn("Title"); + + given(chatService.getChats(member)).willReturn(List.of(chat)); + + // when + ChatsRes result = chatFacade.getChats(member); + + // then + assertThat(result.chats()).hasSize(1); + assertThat(result.chats().get(0).title()).isEqualTo("Title"); + + verify(chatService).getChats(member); + } +} 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 new file mode 100644 index 00000000..4b0304df --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/integration/ChatIntegrationTest.java @@ -0,0 +1,79 @@ +package com.sofa.linkiving.domain.chat.integration; + +import static org.mockito.BDDMockito.*; +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.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.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") +public class ChatIntegrationTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private MemberRepository memberRepository; + + @MockitoBean + private ChatFacade chatFacade; + @MockitoBean + private RedisService redisService; + + private Member testMember; + private UserDetails testUserDetails; + + @BeforeEach + void setUp() { + testMember = memberRepository.save(Member.builder() + .email("list@test.com") + .password("password") + .build()); + testUserDetails = new CustomMemberDetail(testMember, Role.USER); + } + + @Test + @DisplayName("채팅방 목록 조회 API 호출 및 성공 응답 반환") + void shouldReturnChatListWhenGetChats() throws Exception { + // given + ChatsRes mockResponse = new ChatsRes(List.of( + new ChatSummary(1L, "Chat 1"), + new ChatSummary(2L, "Chat 2") + )); + + given(chatFacade.getChats(any(Member.class))).willReturn(mockResponse); + + // when & then + mockMvc.perform(get("/v1/chats") + .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")); + } +} diff --git a/src/test/java/com/sofa/linkiving/domain/chat/repository/ChatRepositoryTest.java b/src/test/java/com/sofa/linkiving/domain/chat/repository/ChatRepositoryTest.java new file mode 100644 index 00000000..4f41718c --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/repository/ChatRepositoryTest.java @@ -0,0 +1,94 @@ +package com.sofa.linkiving.domain.chat.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +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.member.entity.Member; +import com.sofa.linkiving.domain.member.repository.MemberRepository; + +import jakarta.persistence.EntityManager; + +@DataJpaTest +@ActiveProfiles("test") +public class ChatRepositoryTest { + @Autowired + private ChatRepository chatRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private MessageRepository messageRepository; + @Autowired + private EntityManager em; + + private Member member; + + @BeforeEach + void setUp() { + member = memberRepository.save(Member.builder() + .email("test@example.com") + .password("test") + .build()); + + em.flush(); + em.clear(); + } + + @Test + @DisplayName("메시지가 있는 채팅방만 조회되며, 최신 메시지 순으로 정렬됨") + void shouldReturnOnlyChatsWithMessagesOrderByLastMessageTime() throws InterruptedException { + // given + + // 메시지 없는 채팅방 -> 조회되지 않아야 함 + chatRepository.save(Chat + .builder() + .member(member) + .title("No Msg Chat") + .build()); + + Thread.sleep(100); + + // 오래된 메시지가 있는 채팅방 + Chat chatOldMsg = chatRepository.save(Chat.builder() + .member(member) + .title("Old Msg Chat") + .build()); + messageRepository.save(Message.builder() + .chat(chatOldMsg) + .content("Old") + .type(Type.USER) + .build()); + + Thread.sleep(100); + + // 최신 메시지가 있는 채팅방 + Chat chatNewMsg = chatRepository.save(Chat.builder() + .member(member) + .title("New Msg Chat") + .build()); + messageRepository.save(Message + .builder() + .chat(chatNewMsg) + .content("New") + .type(Type.USER) + .build()); + + // when + List result = chatRepository.findAllByMemberOrderByLastMessageDesc(member); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).getTitle()).isEqualTo("New Msg Chat"); + assertThat(result.get(1).getTitle()).isEqualTo("Old Msg 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 new file mode 100644 index 00000000..8a7ef177 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/ChatQueryServiceTest.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.repository.ChatRepository; +import com.sofa.linkiving.domain.member.entity.Member; + +@ExtendWith(MockitoExtension.class) +public class ChatQueryServiceTest { + @InjectMocks + private ChatQueryService chatQueryService; + + @Mock + private ChatRepository chatRepository; + + @Mock + private Member member; + + @Test + @DisplayName("ChatRepository.findAllByMemberOrderByCreatedAtDesc 호출 및 반환") + void shouldReturnChatListWhenFindAllOrderByLastMessageDesc() { + // given + List chats = List.of(mock(Chat.class)); + given(chatRepository.findAllByMemberOrderByLastMessageDesc(member)).willReturn(chats); + + // when + List result = chatQueryService.findAllOrderByLastMessageDesc(member); + + // then + assertThat(result).isEqualTo(chats); + verify(chatRepository).findAllByMemberOrderByLastMessageDesc(member); + } +} 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 new file mode 100644 index 00000000..d43c256b --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/chat/service/ChatServiceTest.java @@ -0,0 +1,46 @@ +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.member.entity.Member; + +@ExtendWith(MockitoExtension.class) +public class ChatServiceTest { + @InjectMocks + private ChatService chatService; + + @Mock + private ChatQueryService chatQueryService; + + @Mock + private ChatCommandService chatCommandService; + + @Mock + private Member member; + + @Test + @DisplayName("채팅방 목록 조회 시 ChatQueryService.findAll을 호출하고 결과 반환") + void shouldReturnChatsWhenGetChats() { + // given + List expectedChats = List.of(mock(Chat.class)); + given(chatQueryService.findAllOrderByLastMessageDesc(member)).willReturn(expectedChats); + + // when + List result = chatService.getChats(member); + + // then + assertThat(result).isEqualTo(expectedChats); + verify(chatQueryService).findAllOrderByLastMessageDesc(member); + } +}