Skip to content

Commit

Permalink
feat: ✨ Get Chat Member Informations By Batch (#189)
Browse files Browse the repository at this point in the history
* feat: add read method in to the chat_member_search_service

* feat: add api_error_code & exception

* feat: chat_member response mapper

* feat: get chat_member list usecase

* docs: get chat member swagger

* feat: impl controller

* fix: query param @not_null -> @not_empty

* test: controller unit test

* test: integration test

* fix: 혼동을 주는 member_id -> user_id로 수정 및 멤버 정보 조회 시 chat_member_id를 사용하도록 쿼리 수정

* docs: 채팅방 멤버 조회 시, 경고 사항 스웨거에 추가

* test: 테스트 시, 사용자 아이디 -> 채팅방 멤버 아이디로 수정
  • Loading branch information
psychology50 authored Nov 2, 2024
1 parent 4fb271b commit e0f21fa
Show file tree
Hide file tree
Showing 13 changed files with 380 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
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.ArraySchema;
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 jakarta.validation.constraints.NotEmpty;
import kr.co.pennyway.api.apis.chat.dto.ChatMemberReq;
import kr.co.pennyway.api.apis.chat.dto.ChatMemberRes;
import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes;
import kr.co.pennyway.api.common.annotation.ApiExceptionExplanation;
import kr.co.pennyway.api.common.annotation.ApiResponseExplanations;
import kr.co.pennyway.api.common.exception.ApiErrorCode;
import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails;
import kr.co.pennyway.domain.domains.chatroom.exception.ChatRoomErrorCode;
import kr.co.pennyway.domain.domains.member.exception.ChatMemberErrorCode;
Expand All @@ -21,6 +25,9 @@
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.Set;

@Tag(name = "[채팅방 멤버 API]")
public interface ChatMemberApi {
Expand All @@ -42,4 +49,17 @@ ResponseEntity<?> joinChatRoom(
@Validated @RequestBody ChatMemberReq.Join payload,
@AuthenticationPrincipal SecurityUserDetails user
);

@Operation(summary = "채팅방 멤버 조회", method = "GET", description = "채팅방 멤버 목록을 조회한다. 오로지 요청자의 채팅방 접근 권한만을 검사하며, 요청 아이디의 채팅방 포함 여부에 대한 검사 및 응답은 포함하지 않는다.")
@Parameters({
@Parameter(name = "chatRoomId", description = "채팅방 ID", required = true, in = ParameterIn.PATH),
@Parameter(name = "ids", description = """
멤버 ID 목록. 중복을 허용하며, 순서가 일관되지 않아도 된다. 단, 최대 50개까지 조회 가능하며, null을 허용하지 않는다. 값은 `[채팅방 API] 채팅방 조회`의 응답으로 얻은 `otherParticipantIds`의 값을 사용하면 된다. (주의, userId가 아님!)"""
, required = true, in = ParameterIn.QUERY, array = @ArraySchema(schema = @Schema(type = "integer")))
})
@ApiResponseExplanations(errors = {
@ApiExceptionExplanation(value = ApiErrorCode.class, constant = "OVERFLOW_QUERY_PARAMETER", summary = "쿼리 파라미터 오버플로우", description = "쿼리 파라미터가 최대 개수를 초과하여 채팅방 멤버 조회에 실패했습니다.")
})
@ApiResponse(responseCode = "200", description = "채팅방 멤버 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatMembers", array = @ArraySchema(schema = @Schema(implementation = ChatMemberRes.Detail.class)))))
ResponseEntity<?> readChatMembers(@PathVariable("chatRoomId") Long chatRoomId, @Validated @NotEmpty @RequestParam("ids") Set<Long> ids);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package kr.co.pennyway.api.apis.chat.controller;

import jakarta.validation.constraints.NotEmpty;
import kr.co.pennyway.api.apis.chat.api.ChatMemberApi;
import kr.co.pennyway.api.apis.chat.dto.ChatMemberReq;
import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes;
import kr.co.pennyway.api.apis.chat.usecase.ChatMemberUseCase;
import kr.co.pennyway.api.common.exception.ApiErrorCode;
import kr.co.pennyway.api.common.exception.ApiErrorException;
import kr.co.pennyway.api.common.response.SuccessResponse;
import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails;
import lombok.RequiredArgsConstructor;
Expand All @@ -14,12 +17,16 @@
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.Set;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/v2/chat-rooms/{chatRoomId}/chat-members")
public class ChatMemberController implements ChatMemberApi {
private static final String CHAT_ROOM = "chatRoom";
private static final String CHAT_MEMBERS = "chatMembers";

private final ChatMemberUseCase chatMemberUseCase;

@Override
Expand All @@ -34,4 +41,15 @@ public ResponseEntity<?> joinChatRoom(

return ResponseEntity.ok(SuccessResponse.from(CHAT_ROOM, detail));
}

@Override
@GetMapping("")
@PreAuthorize("isAuthenticated() and @chatRoomManager.hasPermission(principal.userId, #chatRoomId)")
public ResponseEntity<?> readChatMembers(@PathVariable("chatRoomId") Long chatRoomId, @Validated @NotEmpty @RequestParam("ids") Set<Long> chatMemberIds) {
if (chatMemberIds.size() > 50) {
throw new ApiErrorException(ApiErrorCode.OVERFLOW_QUERY_PARAMETER);
}

return ResponseEntity.ok(SuccessResponse.from(CHAT_MEMBERS, chatMemberUseCase.readChatMembers(chatRoomId, chatMemberIds)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package kr.co.pennyway.api.apis.chat.mapper;

import kr.co.pennyway.api.apis.chat.dto.ChatMemberRes;
import kr.co.pennyway.common.annotation.Mapper;
import kr.co.pennyway.domain.domains.member.domain.ChatMember;

import java.util.List;

@Mapper
public final class ChatMemberMapper {
public static List<ChatMemberRes.Detail> toChatMemberResDetail(List<ChatMember> chatMembers) {
return chatMembers.stream()
.map(chatMember -> ChatMemberRes.Detail.from(chatMember, false))
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package kr.co.pennyway.api.apis.chat.service;

import kr.co.pennyway.domain.domains.member.domain.ChatMember;
import kr.co.pennyway.domain.domains.member.service.ChatMemberService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Set;

@Slf4j
Expand All @@ -16,4 +18,8 @@ public class ChatMemberSearchService {
public Set<Long> readJoinedChatRoomIds(Long userId) {
return chatMemberService.readChatRoomIdsByUserId(userId);
}

public List<ChatMember> readChatMembers(Long chatRoomId, Set<Long> chatMemberIds) {
return chatMemberService.readChatMembersByIdIn(chatRoomId, chatMemberIds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ public ChatRoomRes.RoomWithParticipants execute(Long userId, Long chatRoomId) {
.filter(sender -> !sender.equals(userId))
.collect(Collectors.toSet());

List<ChatMember> recentParticipants = chatMemberService.readChatMembersByMemberIdIn(chatRoomId, recentParticipantIds);
List<ChatMember> recentParticipants = chatMemberService.readChatMembersByUserIdIn(chatRoomId, recentParticipantIds);

recentParticipantIds.add(userId);
List<Long> otherMemberIds = chatMemberService.readChatMemberIdsByMemberIdNotIn(chatRoomId, recentParticipantIds);
List<Long> otherMemberIds = chatMemberService.readChatMemberIdsByUserIdNotIn(chatRoomId, recentParticipantIds);

return ChatRoomMapper.toChatRoomResRoomWithParticipants(myInfo, recentParticipants, otherMemberIds, chatMessages);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
package kr.co.pennyway.api.apis.chat.usecase;

import kr.co.pennyway.api.apis.chat.dto.ChatMemberRes;
import kr.co.pennyway.api.apis.chat.dto.ChatRoomRes;
import kr.co.pennyway.api.apis.chat.mapper.ChatMemberMapper;
import kr.co.pennyway.api.apis.chat.mapper.ChatRoomMapper;
import kr.co.pennyway.api.apis.chat.service.ChatMemberJoinService;
import kr.co.pennyway.api.apis.chat.service.ChatMemberSearchService;
import kr.co.pennyway.common.annotation.UseCase;
import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom;
import kr.co.pennyway.domain.domains.member.domain.ChatMember;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.tuple.Pair;

import java.util.List;
import java.util.Set;

@Slf4j
@UseCase
@RequiredArgsConstructor
public class ChatMemberUseCase {
private final ChatMemberJoinService chatMemberJoinService;
private final ChatMemberSearchService chatMemberSearchService;

public ChatRoomRes.Detail joinChatRoom(Long userId, Long chatRoomId, Integer password) {
Pair<ChatRoom, Integer> chatRoom = chatMemberJoinService.execute(userId, chatRoomId, password);

return ChatRoomMapper.toChatRoomResDetail(chatRoom.getLeft(), false, chatRoom.getRight());
}

public List<ChatMemberRes.Detail> readChatMembers(Long chatRoomId, Set<Long> chatMemberIds) {
List<ChatMember> chatMembers = chatMemberSearchService.readChatMembers(chatRoomId, chatMemberIds);

return ChatMemberMapper.toChatMemberResDetail(chatMembers);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package kr.co.pennyway.api.common.exception;

import kr.co.pennyway.common.exception.BaseErrorCode;
import kr.co.pennyway.common.exception.CausedBy;
import kr.co.pennyway.common.exception.ReasonCode;
import kr.co.pennyway.common.exception.StatusCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum ApiErrorCode implements BaseErrorCode {
// 400 Bad Request
OVERFLOW_QUERY_PARAMETER(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "쿼리 파라미터가 너무 많습니다."),
;

private final StatusCode statusCode;
private final ReasonCode reasonCode;
private final String message;

@Override
public CausedBy causedBy() {
return CausedBy.of(statusCode, reasonCode);
}

@Override
public String getExplainError() throws NoSuchFieldError {
return message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.co.pennyway.api.common.exception;

import kr.co.pennyway.common.exception.CausedBy;
import kr.co.pennyway.common.exception.GlobalErrorException;

public class ApiErrorException extends GlobalErrorException {
private final ApiErrorCode errorCode;

public ApiErrorException(ApiErrorCode errorCode) {
super(errorCode);
this.errorCode = errorCode;
}

@Override
public CausedBy causedBy() {
return errorCode.causedBy();
}

public String getExplainError() {
return errorCode.getExplainError();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package kr.co.pennyway.api.apis.chat.controller;

import kr.co.pennyway.api.apis.chat.dto.ChatMemberRes;
import kr.co.pennyway.api.apis.chat.usecase.ChatMemberUseCase;
import kr.co.pennyway.api.config.supporter.WithSecurityMockUser;
import kr.co.pennyway.domain.domains.member.type.ChatMemberRole;
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.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.LongStream;

import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(controllers = ChatMemberController.class)
@ActiveProfiles("test")
public class ChatMemberBathGetControllerTest {
@Autowired
private MockMvc mockMvc;

@MockBean
private ChatMemberUseCase chatMemberUseCase;

@BeforeEach
void setUp(WebApplicationContext webApplicationContext) {
this.mockMvc = MockMvcBuilders
.webAppContextSetup(webApplicationContext)
.defaultRequest(MockMvcRequestBuilders.get("/**").with(csrf()))
.build();
}

@Test
@DisplayName("채팅방 멤버 조회에 성공한다")
@WithSecurityMockUser
void successReadChatMembers() throws Exception {
// given
Long chatRoomId = 1L;
Set<Long> memberIds = Set.of(1L, 2L, 3L);
List<ChatMemberRes.Detail> expectedResponse = createMockMemberDetails();

given(chatMemberUseCase.readChatMembers(chatRoomId, memberIds)).willReturn(expectedResponse);

// when & then
mockMvc.perform(MockMvcRequestBuilders.get("/v2/chat-rooms/{chatRoomId}/chat-members", chatRoomId)
.param("ids", "1,2,3")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.chatMembers").isArray())
.andExpect(jsonPath("$.data.chatMembers.length()").value(3))
.andDo(print());
}

@Test
@DisplayName("50개를 초과하는 멤버 ID 요청 시 실패한다")
@WithSecurityMockUser
void failReadChatMembersWhenExceedLimit() throws Exception {
// given
Long chatRoomId = 1L;
Set<Long> memberIds = LongStream.rangeClosed(1, 51)
.boxed()
.collect(Collectors.toSet());

// when & then
mockMvc.perform(MockMvcRequestBuilders.get("/v2/chat-rooms/{chatRoomId}/chat-members", chatRoomId)
.param("ids", memberIds.stream()
.map(String::valueOf)
.collect(Collectors.joining(",")))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andDo(print());
}

@Test
@DisplayName("ids가 null인 경우 실패한다 <400 Bad Request>")
@WithSecurityMockUser
void failReadChatMembersWhenIdsIsNull() throws Exception {
// given
Long chatRoomId = 1L;

// when & then
mockMvc.perform(MockMvcRequestBuilders.get("/v2/chat-rooms/{chatRoomId}/chat-members", chatRoomId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andDo(print());
}

@Test
@DisplayName("ids가 빈 값일 경우 실패한다")
@WithSecurityMockUser
void failReadChatMembersWhenIdsIsEmpty() throws Exception {
// given
Long chatRoomId = 1L;

// when & then
mockMvc.perform(MockMvcRequestBuilders.get("/v2/chat-rooms/{chatRoomId}/chat-members", chatRoomId)
.param("ids", "")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest())
.andDo(print());
}

private List<ChatMemberRes.Detail> createMockMemberDetails() {
return List.of(
new ChatMemberRes.Detail(1L, "User1", ChatMemberRole.MEMBER, null, LocalDateTime.now()),
new ChatMemberRes.Detail(2L, "User2", ChatMemberRole.MEMBER, null, LocalDateTime.now()),
new ChatMemberRes.Detail(3L, "User3", ChatMemberRole.MEMBER, null, LocalDateTime.now())
);
}
}
Loading

0 comments on commit e0f21fa

Please sign in to comment.