Skip to content

Commit

Permalink
feat: ✨ Chat History Search API (redis score system changed) (#195)
Browse files Browse the repository at this point in the history
* test: happy path integration test

* rename: fix concrete dependency in chat_message_service

* feat: chat search service

* feat: impl chat mapper

* feat: impl chat use case

* feat: impl chat_controller

* fix: fix redis zrevrangebyscore command

* test: fix perform_request isn't using last_message_id and size parameter

* test: additional integration test

* fix: using scores, max value is last_message_id - 100

* refactor: convert score soring to lecxicographical sorting

* test: chat_message repository test apply tsid

* test: fix intergration test

* docs: apply swagger

* test: chatroom detail integration test fix

* fix: add validation within chat_message_repository
  • Loading branch information
psychology50 authored Nov 10, 2024
1 parent 14ca265 commit bf2a278
Show file tree
Hide file tree
Showing 13 changed files with 726 additions and 51 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package kr.co.pennyway.api.apis.chat.api;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.SchemaProperty;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.co.pennyway.api.common.response.SliceResponseTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;

@Tag(name = "[채팅 API]")
public interface ChatApi {
@Operation(summary = "채팅 조회", description = "채팅방의 채팅 이력을 무한스크롤 조회합니다. 결과는 최신순으로 정렬되며, `lastMessageId`를 포함하지 않은 이전 메시지들을 조회합니다.<br/> content는 채팅방 조회의 `recentMessages` 필드와 동일합니다.")
@Parameters({
@Parameter(name = "chatRoomId", description = "채팅방 ID", required = true, in = ParameterIn.PATH),
@Parameter(name = "lastMessageId", description = "마지막으로 읽은 메시지 ID", required = true, in = ParameterIn.QUERY),
@Parameter(name = "size", description = "조회할 채팅 수(default: 30)", example = "30", required = false, in = ParameterIn.QUERY)
})
@ApiResponse(responseCode = "200", description = "채팅 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chats", schema = @Schema(implementation = SliceResponseTemplate.class))))
ResponseEntity<?> readChats(
@PathVariable("chatRoomId") Long chatRoomId,
@RequestParam(value = "lastMessageId") Long lastMessageId,
@RequestParam(value = "size", defaultValue = "30") int size
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package kr.co.pennyway.api.apis.chat.controller;

import kr.co.pennyway.api.apis.chat.api.ChatApi;
import kr.co.pennyway.api.apis.chat.usecase.ChatUseCase;
import kr.co.pennyway.api.common.response.SuccessResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/v2/chat-rooms/{chatRoomId}/chats")
public class ChatController implements ChatApi {
private static final String CHATS = "chats";

private final ChatUseCase chatUseCase;

@Override
@GetMapping("")
@PreAuthorize("isAuthenticated() and @chatRoomManager.hasPermission(principal.userId, #chatRoomId)")
public ResponseEntity<?> readChats(
@PathVariable("chatRoomId") Long chatRoomId,
@RequestParam(value = "lastMessageId") Long lastMessageId,
@RequestParam(value = "size", defaultValue = "30") int size
) {
return ResponseEntity.ok(SuccessResponse.from(CHATS, chatUseCase.readChats(chatRoomId, lastMessageId, size)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kr.co.pennyway.api.apis.chat.mapper;

import kr.co.pennyway.api.apis.chat.dto.ChatRes;
import kr.co.pennyway.api.common.response.SliceResponseTemplate;
import kr.co.pennyway.common.annotation.Mapper;
import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage;
import org.springframework.data.domain.Slice;

import java.util.List;

@Mapper
public class ChatMapper {
public static SliceResponseTemplate<ChatRes.ChatDetail> toChatDetails(Slice<ChatMessage> chatMessages) {
List<ChatRes.ChatDetail> details = chatMessages.getContent().stream()
.map(ChatRes.ChatDetail::from)
.toList();

return SliceResponseTemplate.of(details, chatMessages.getPageable(), chatMessages.getNumberOfElements(), chatMessages.hasNext());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package kr.co.pennyway.api.apis.chat.service;

import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage;
import kr.co.pennyway.domain.common.redis.message.service.ChatMessageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatSearchService {
private final ChatMessageService chatMessageService;

public Slice<ChatMessage> readChats(Long chatRoomId, Long lastMessageId, int size) {
return chatMessageService.readMessagesBefore(chatRoomId, lastMessageId, size);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.co.pennyway.api.apis.chat.usecase;

import kr.co.pennyway.api.apis.chat.dto.ChatRes;
import kr.co.pennyway.api.apis.chat.mapper.ChatMapper;
import kr.co.pennyway.api.apis.chat.service.ChatSearchService;
import kr.co.pennyway.api.common.response.SliceResponseTemplate;
import kr.co.pennyway.common.annotation.UseCase;
import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Slice;

@Slf4j
@UseCase
@RequiredArgsConstructor
public class ChatUseCase {
private final ChatSearchService chatSearchService;

public SliceResponseTemplate<ChatRes.ChatDetail> readChats(Long chatRoomId, Long lastMessageId, int size) {
Slice<ChatMessage> chatMessages = chatSearchService.readChats(chatRoomId, lastMessageId, size);

return ChatMapper.toChatDetails(chatMessages);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
package kr.co.pennyway.api.apis.chat.integration;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import kr.co.pennyway.api.apis.chat.dto.ChatRes;
import kr.co.pennyway.api.common.response.SliceResponseTemplate;
import kr.co.pennyway.api.common.response.SuccessResponse;
import kr.co.pennyway.api.common.util.ApiTestHelper;
import kr.co.pennyway.api.common.util.RequestParameters;
import kr.co.pennyway.api.config.ExternalApiDBTestConfig;
import kr.co.pennyway.api.config.ExternalApiIntegrationTest;
import kr.co.pennyway.api.config.fixture.ChatMemberFixture;
import kr.co.pennyway.api.config.fixture.ChatRoomFixture;
import kr.co.pennyway.api.config.fixture.UserFixture;
import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage;
import kr.co.pennyway.domain.common.redis.message.domain.ChatMessageBuilder;
import kr.co.pennyway.domain.common.redis.message.repository.ChatMessageRepository;
import kr.co.pennyway.domain.common.redis.message.type.MessageCategoryType;
import kr.co.pennyway.domain.common.redis.message.type.MessageContentType;
import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom;
import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository;
import kr.co.pennyway.domain.domains.member.domain.ChatMember;
import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository;
import kr.co.pennyway.domain.domains.member.type.ChatMemberRole;
import kr.co.pennyway.domain.domains.user.domain.User;
import kr.co.pennyway.domain.domains.user.repository.UserRepository;
import kr.co.pennyway.infra.client.guid.IdGenerator;
import kr.co.pennyway.infra.common.jwt.JwtProvider;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
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.web.client.TestRestTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;

@Slf4j
@ExternalApiIntegrationTest
public class ChatPaginationGetIntegrationTest extends ExternalApiDBTestConfig {
private static final String BASE_URL = "/v2/chat-rooms/{chatRoomId}/chats";

@Autowired
private TestRestTemplate restTemplate;

@Autowired
private UserRepository userRepository;

@Autowired
private ChatRoomRepository chatRoomRepository;

@Autowired
private ChatMemberRepository chatMemberRepository;

@Autowired
private ChatMessageRepository chatMessageRepository;

@Autowired
private JwtProvider accessTokenProvider;

@Autowired
private IdGenerator<Long> idGenerator;

@Autowired
private ObjectMapper objectMapper;

@Autowired
private RedisTemplate<String, String> redisTemplate;

private ApiTestHelper apiTestHelper;

@BeforeEach
void setUp() {
apiTestHelper = new ApiTestHelper(restTemplate, objectMapper, accessTokenProvider);
}

@AfterEach
void tearDown() {
Set<String> keys = redisTemplate.keys("chatroom:*:message");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}

@Test
@DisplayName("채팅방의 이전 메시지들을 정상적으로 페이징하여 조회할 수 있다")
void successReadChats() {
// given
User user = createUser();
ChatRoom chatRoom = createChatRoom();
createChatMember(user, chatRoom, ChatMemberRole.ADMIN);
List<ChatMessage> messages = setupTestMessages(chatRoom.getId(), user.getId(), 50);

// when
ResponseEntity<?> response = performRequest(user, chatRoom.getId(), messages.get(49).getChatId(), 30);

// then
assertAll(
() -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
() -> {
SliceResponseTemplate<ChatRes.ChatDetail> slice = extractChatDetail(response);

assertEquals(messages.get(48).getChatId(), slice.contents().get(0).chatId(), "lastMessageId에 해당하는 메시지는 포함되지 않아야 합니다");
assertThat(slice.contents()).hasSize(30);
assertThat(slice.hasNext()).isTrue();
}
);
}

@Test
@DisplayName("채팅방 멤버가 아닌 사용자는 메시지를 조회할 수 없다")
void readChatsWithoutPermissionTest() {
// given
User nonMember = createUser();
ChatRoom chatRoom = createChatRoom();

// when
ResponseEntity<?> response = performRequest(nonMember, chatRoom.getId(), 0L, 30);

// then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}

@Test
@DisplayName("존재하지 않는 채팅방의 메시지는 조회할 수 없다")
void readChatsFromNonExistentRoomTest() {
// given
User user = createUser();
Long nonExistentRoomId = 9999L;

// when
ResponseEntity<?> response = performRequest(user, nonExistentRoomId, 0L, 30);

// then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}

@Test
@DisplayName("마지막 페이지를 조회할 때 hasNext는 false여야 한다")
void readLastPageTest() {
// given
User user = createUser();
ChatRoom chatRoom = createChatRoom();
createChatMember(user, chatRoom, ChatMemberRole.ADMIN);
List<ChatMessage> messages = setupTestMessages(chatRoom.getId(), user.getId(), 10);

// when
ResponseEntity<?> response = performRequest(user, chatRoom.getId(), messages.get(0).getChatId(), 10);

// then
assertAll(
() -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
() -> {
SliceResponseTemplate<ChatRes.ChatDetail> slice = extractChatDetail(response);

assertThat(slice.contents()).hasSize(0);
assertThat(slice.hasNext()).isFalse();
}
);
}

@Test
@DisplayName("메시지가 없는 채팅방을 조회하면 빈 리스트를 반환해야 한다")
void readEmptyChatsTest() {
// given
User user = createUser();
ChatRoom chatRoom = createChatRoom();
createChatMember(user, chatRoom, ChatMemberRole.ADMIN);

// when
ResponseEntity<?> response = performRequest(user, chatRoom.getId(), Long.MAX_VALUE, 30);

// then
assertAll(
() -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
() -> {
SliceResponseTemplate<ChatRes.ChatDetail> slice = extractChatDetail(response);

assertThat(slice.contents()).isEmpty();
assertThat(slice.hasNext()).isFalse();
}
);
}

private ResponseEntity<?> performRequest(User user, Long chatRoomId, Long lastMessageId, int size) {
RequestParameters parameters = RequestParameters.defaultGet(BASE_URL)
.user(user)
.queryParams(RequestParameters.createQueryParams("lastMessageId", lastMessageId, "size", size))
.uriVariables(new Object[]{chatRoomId})
.build();

return apiTestHelper.callApi(
parameters,
new TypeReference<SuccessResponse<Map<String, SliceResponseTemplate<ChatRes.ChatDetail>>>>() {
}
);
}

private User createUser() {
return userRepository.save(UserFixture.GENERAL_USER.toUser());
}

private ChatRoom createChatRoom() {
return chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate()));
}

private ChatMember createChatMember(User user, ChatRoom chatRoom, ChatMemberRole role) {
return switch (role) {
case ADMIN -> chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(user, chatRoom));
case MEMBER -> chatMemberRepository.save(ChatMemberFixture.MEMBER.toEntity(user, chatRoom));
};
}

private List<ChatMessage> setupTestMessages(Long chatRoomId, Long senderId, int count) {
List<ChatMessage> messages = new ArrayList<>();

for (int i = 0; i < count; i++) {
ChatMessage message = ChatMessageBuilder.builder()
.chatRoomId(chatRoomId)
.chatId(idGenerator.generate())
.content("Test message " + i)
.contentType(MessageContentType.TEXT)
.categoryType(MessageCategoryType.NORMAL)
.sender(senderId)
.build();

messages.add(chatMessageRepository.save(message));
}

return messages;
}

private SliceResponseTemplate<ChatRes.ChatDetail> extractChatDetail(ResponseEntity<?> response) {
SliceResponseTemplate<ChatRes.ChatDetail> slice = null;

try {
SuccessResponse<Map<String, SliceResponseTemplate<ChatRes.ChatDetail>>> successResponse = (SuccessResponse<Map<String, SliceResponseTemplate<ChatRes.ChatDetail>>>) response.getBody();
slice = successResponse.getData().get("chats");
} catch (Exception e) {
fail("응답 데이터 추출에 실패했습니다.", e);
}

return slice;
}
}
Loading

0 comments on commit bf2a278

Please sign in to comment.