-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: ✨ Chat History Search API (redis score system changed) (#195)
* 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
1 parent
14ca265
commit bf2a278
Showing
13 changed files
with
726 additions
and
51 deletions.
There are no files selected for viewing
31 changes: 31 additions & 0 deletions
31
pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatApi.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
); | ||
} |
31 changes: 31 additions & 0 deletions
31
...pp-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatController.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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))); | ||
} | ||
} |
20 changes: 20 additions & 0 deletions
20
pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMapper.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
} | ||
} |
19 changes: 19 additions & 0 deletions
19
...pp-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatSearchService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
24 changes: 24 additions & 0 deletions
24
...yway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatUseCase.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
257 changes: 257 additions & 0 deletions
257
.../test/java/kr/co/pennyway/api/apis/chat/integration/ChatPaginationGetIntegrationTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
Oops, something went wrong.