diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java index a6fa42b01..eec5d703c 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatApi.java @@ -19,17 +19,22 @@ import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.global.auth.annotation.UserId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; @Tag(name = "(Normal) Chat: 채팅", description = "채팅 API") @RequestMapping("/chats") public interface ChatApi { + int MAX_SEARCH_LIMIT = 100; + @Operation(summary = "채팅방을 생성하거나 기존 채팅방을 반환한다.", description = """ ## 설명 - 특정 유저와의 1:1 채팅방을 생성하거나 기존 채팅방을 반환합니다. @@ -79,6 +84,30 @@ ResponseEntity getChatRooms( @UserId Integer userId ); + @Operation(summary = "채팅방 이름과 메시지 내용으로 채팅방을 검색한다.", description = """ + ## 설명 + - 현재 사용자가 접근 가능한 채팅방만 검색합니다. + - 채팅방 이름 매칭 결과와 메시지 내용 매칭 결과를 분리해서 반환합니다. + + ## 로직 + - 1:1 채팅은 상대방 이름과 사용자가 지정한 채팅방 이름으로 검색합니다. + - 그룹 채팅은 동아리 이름과 사용자가 지정한 채팅방 이름으로 검색합니다. + - 메시지 검색 결과는 채팅방별 최신 매칭 메시지 1개만 반환합니다. + - page, limit는 채팅방 이름 검색 결과와 메시지 검색 결과에 각각 동일하게 적용됩니다. + - limit는 최대 100까지 허용됩니다. + """) + @GetMapping("/rooms/search") + ResponseEntity searchChats( + @NotBlank(message = "검색어는 필수입니다.") + @RequestParam(name = "keyword") String keyword, + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @RequestParam(name = "page", defaultValue = "1") Integer page, + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @Max(value = MAX_SEARCH_LIMIT, message = "페이지 당 항목 수는 100 이하여야 합니다.") + @RequestParam(name = "limit", defaultValue = "20") Integer limit, + @UserId Integer userId + ); + @Operation(summary = "새 채팅방에 초대할 수 있는 사용자 목록을 조회한다.", description = """ ## 설명 - 현재 사용자가 속해 있는 채팅방들의 멤버를 기반으로 초대 가능 사용자 목록을 조회합니다. diff --git a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java index 861f8f40c..8df3f3a94 100644 --- a/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java +++ b/src/main/java/gg/agit/konect/domain/chat/controller/ChatController.java @@ -17,6 +17,7 @@ import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.domain.chat.service.ChatService; import gg.agit.konect.global.auth.annotation.UserId; @@ -56,6 +57,17 @@ public ResponseEntity getChatRooms( return ResponseEntity.ok(response); } + @Override + public ResponseEntity searchChats( + @RequestParam(name = "keyword") String keyword, + @RequestParam(name = "page", defaultValue = "1") Integer page, + @RequestParam(name = "limit", defaultValue = "20") Integer limit, + @UserId Integer userId + ) { + ChatSearchResponse response = chatService.searchChats(userId, keyword, page, limit); + return ResponseEntity.ok(response); + } + @Override public ResponseEntity getInvitableUsers( @RequestParam(name = "query", required = false) String query, diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java new file mode 100644 index 000000000..91a56d4af --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchResult.java @@ -0,0 +1,45 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import gg.agit.konect.domain.chat.enums.ChatType; +import gg.agit.konect.domain.chat.model.ChatMessage; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatMessageMatchResult( + @Schema(description = "채팅방 ID", example = "1", requiredMode = REQUIRED) + Integer roomId, + + @Schema(description = "채팅 타입", example = "DIRECT", requiredMode = REQUIRED) + ChatType chatType, + + @Schema(description = "채팅방 이름", example = "개발팀", requiredMode = REQUIRED) + String roomName, + + @Schema(description = "채팅방 이미지 URL", example = "https://example.com/image.png", requiredMode = NOT_REQUIRED) + String roomImageUrl, + + @Schema(description = "검색에 매칭된 메시지 내용", example = "안녕하세요", requiredMode = REQUIRED) + String matchedMessage, + + @Schema(description = "매칭된 메시지 전송 시간", example = "2025.12.19 23:21", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy.MM.dd HH:mm") + LocalDateTime matchedMessageSentAt +) { + + public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMessage message) { + return new ChatMessageMatchResult( + room.roomId(), + room.chatType(), + room.roomName(), + room.roomImageUrl(), + message.getContent(), + message.getCreatedAt() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java new file mode 100644 index 000000000..78b6360e1 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatMessageMatchesResponse.java @@ -0,0 +1,37 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatMessageMatchesResponse( + @Schema(description = "조건에 해당하는 메시지 매칭 총 개수", example = "10", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 메시지 매칭 개수", example = "5", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "메시지 내용으로 매칭된 채팅방 목록", requiredMode = REQUIRED) + List messages +) { + + public static ChatMessageMatchesResponse from(Page page) { + return new ChatMessageMatchesResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + page.getContent() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java new file mode 100644 index 000000000..3d50631c2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatRoomMatchesResponse.java @@ -0,0 +1,37 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatRoomMatchesResponse( + @Schema(description = "조건에 해당하는 채팅방 총 개수", example = "10", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 채팅방 개수", example = "5", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "채팅방 이름으로 매칭된 채팅방 목록", requiredMode = REQUIRED) + List rooms +) { + + public static ChatRoomMatchesResponse from(Page page) { + return new ChatRoomMatchesResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + page.getContent() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java b/src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java new file mode 100644 index 000000000..cff92558d --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/chat/dto/ChatSearchResponse.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.chat.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatSearchResponse( + @Schema(description = "채팅방 이름으로 매칭된 검색 결과", requiredMode = REQUIRED) + ChatRoomMatchesResponse roomMatches, + + @Schema(description = "메시지 내용으로 매칭된 검색 결과", requiredMode = REQUIRED) + ChatMessageMatchesResponse messageMatches +) { +} diff --git a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java index e796654b6..82551da83 100644 --- a/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java +++ b/src/main/java/gg/agit/konect/domain/chat/repository/ChatMessageRepository.java @@ -119,4 +119,31 @@ SELECT MAX(m2.id) """) List findLatestMessagesByRoomIds(@Param("roomIds") List roomIds); + @Query( + value = """ + SELECT cm + FROM ChatMessage cm + JOIN FETCH cm.chatRoom cr + WHERE cr.id IN :roomIds + AND LOCATE(LOWER(:keyword), LOWER(cm.content)) > 0 + AND cm.id = ( + SELECT MAX(innerCm.id) + FROM ChatMessage innerCm + WHERE innerCm.chatRoom.id = cr.id + AND LOCATE(LOWER(:keyword), LOWER(innerCm.content)) > 0 + ) + ORDER BY cm.createdAt DESC, cm.id DESC + """ + ) + List searchLatestMatchingMessagesByChatRoomIds( + @Param("roomIds") List roomIds, + @Param("keyword") String keyword + ); + + @Query(""" + SELECT COUNT(m) + FROM ChatMessage m + WHERE m.chatRoom.id = :chatRoomId + """) + long countByChatRoomId(@Param("chatRoomId") Integer chatRoomId); } diff --git a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java index d1f8b1eaa..00dfb8477 100644 --- a/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java +++ b/src/main/java/gg/agit/konect/domain/chat/service/ChatService.java @@ -8,6 +8,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -25,14 +26,18 @@ import gg.agit.konect.domain.chat.dto.AdminChatRoomProjection; import gg.agit.konect.domain.chat.dto.ChatInvitableUsersResponse; import gg.agit.konect.domain.chat.dto.ChatMessageDetailResponse; +import gg.agit.konect.domain.chat.dto.ChatMessageMatchResult; +import gg.agit.konect.domain.chat.dto.ChatMessageMatchesResponse; import gg.agit.konect.domain.chat.dto.ChatMessagePageResponse; import gg.agit.konect.domain.chat.dto.ChatMessageSendRequest; import gg.agit.konect.domain.chat.dto.ChatMuteResponse; import gg.agit.konect.domain.chat.dto.ChatRoomCreateRequest; +import gg.agit.konect.domain.chat.dto.ChatRoomMatchesResponse; import gg.agit.konect.domain.chat.dto.ChatRoomNameUpdateRequest; import gg.agit.konect.domain.chat.dto.ChatRoomResponse; import gg.agit.konect.domain.chat.dto.ChatRoomSummaryResponse; import gg.agit.konect.domain.chat.dto.ChatRoomsSummaryResponse; +import gg.agit.konect.domain.chat.dto.ChatSearchResponse; import gg.agit.konect.domain.chat.dto.UnreadMessageCount; import gg.agit.konect.domain.chat.enums.ChatInviteSortBy; import gg.agit.konect.domain.chat.enums.ChatType; @@ -155,47 +160,22 @@ public void leaveChatRoom(Integer userId, Integer roomId) { } public ChatRoomsSummaryResponse getChatRooms(Integer userId) { - chatRoomMembershipService.ensureClubRoomMemberships(userId); - - List directRooms = getDirectChatRooms(userId); - List clubRooms = getClubChatRooms(userId); - - List roomIds = new ArrayList<>(); - roomIds.addAll(directRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); - roomIds.addAll(clubRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); - - Map muteMap = getMuteMap(roomIds, userId); - Map customRoomNameMap = getCustomRoomNameMap(roomIds, userId); - List rooms = new ArrayList<>(); - - directRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( - room.roomId(), - room.chatType(), - resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), - room.roomImageUrl(), - room.lastMessage(), - room.lastSentAt(), - room.unreadCount(), - muteMap.getOrDefault(room.roomId(), false) - ))); - - clubRooms.forEach(room -> rooms.add(new ChatRoomSummaryResponse( - room.roomId(), - room.chatType(), - resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), - room.roomImageUrl(), - room.lastMessage(), - room.lastSentAt(), - room.unreadCount(), - muteMap.getOrDefault(room.roomId(), false) - ))); + return new ChatRoomsSummaryResponse(getAccessibleChatRooms(userId).rooms()); + } - rooms.sort( - Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, Comparator.nullsLast(Comparator.reverseOrder())) - .thenComparing(ChatRoomSummaryResponse::roomId) + public ChatSearchResponse searchChats(Integer userId, String keyword, Integer page, Integer limit) { + String normalizedKeyword = normalizeKeyword(keyword); + AccessibleChatRooms accessibleChatRooms = getAccessibleChatRooms(userId); + ChatRoomMatchesResponse roomMatches = searchRoomsByName(accessibleChatRooms, normalizedKeyword, page, limit); + ChatMessageMatchesResponse messageMatches = searchByMessageContent( + userId, + accessibleChatRooms.rooms(), + normalizedKeyword, + page, + limit ); - return new ChatRoomsSummaryResponse(rooms); + return new ChatSearchResponse(roomMatches, messageMatches); } public ChatInvitableUsersResponse getInvitableUsers( @@ -649,6 +629,123 @@ private ChatMessageDetailResponse sendClubMessageByRoomId(Integer roomId, Intege ); } + private AccessibleChatRooms getAccessibleChatRooms(Integer userId) { + chatRoomMembershipService.ensureClubRoomMemberships(userId); + + List directRooms = getDirectChatRooms(userId); + List clubRooms = getClubChatRooms(userId); + + List roomIds = new ArrayList<>(); + roomIds.addAll(directRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); + roomIds.addAll(clubRooms.stream().map(ChatRoomSummaryResponse::roomId).toList()); + + Map muteMap = getMuteMap(roomIds, userId); + Map customRoomNameMap = getCustomRoomNameMap(roomIds, userId); + Map defaultRoomNameMap = getDefaultRoomNameMap(directRooms, clubRooms); + List rooms = new ArrayList<>(); + directRooms.forEach(room -> rooms.add(applyRoomSettings(room, muteMap, customRoomNameMap))); + clubRooms.forEach(room -> rooms.add(applyRoomSettings(room, muteMap, customRoomNameMap))); + + rooms.sort( + Comparator.comparing(ChatRoomSummaryResponse::lastSentAt, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(ChatRoomSummaryResponse::roomId) + ); + return new AccessibleChatRooms(rooms, defaultRoomNameMap); + } + + private ChatRoomSummaryResponse applyRoomSettings( + ChatRoomSummaryResponse room, + Map muteMap, + Map customRoomNameMap + ) { + return new ChatRoomSummaryResponse( + room.roomId(), + room.chatType(), + resolveRoomName(room.roomId(), room.roomName(), customRoomNameMap), + room.roomImageUrl(), + room.lastMessage(), + room.lastSentAt(), + room.unreadCount(), + muteMap.getOrDefault(room.roomId(), false) + ); + } + + private ChatRoomMatchesResponse searchRoomsByName( + AccessibleChatRooms accessibleChatRooms, + String keyword, + Integer page, + Integer limit + ) { + List matchedRooms = accessibleChatRooms.rooms().stream() + .filter(room -> matchesRoomName(room, keyword, accessibleChatRooms.defaultRoomNameMap())) + .toList(); + + return ChatRoomMatchesResponse.from(toPage(matchedRooms, page, limit)); + } + + private ChatMessageMatchesResponse searchByMessageContent( + Integer userId, + List accessibleRooms, + String keyword, + Integer page, + Integer limit + ) { + if (accessibleRooms.isEmpty() || keyword.isBlank()) { + return ChatMessageMatchesResponse.from(emptyPage(page, limit)); + } + + Map roomMap = accessibleRooms.stream() + .collect(Collectors.toMap(ChatRoomSummaryResponse::roomId, room -> room)); + List roomIds = accessibleRooms.stream() + .map(ChatRoomSummaryResponse::roomId) + .toList(); + List directRoomIds = accessibleRooms.stream() + .filter(room -> room.chatType() == ChatType.DIRECT) + .map(ChatRoomSummaryResponse::roomId) + .toList(); + Map visibleMessageFromMap = getVisibleMessageFromMap(directRoomIds, userId); + + List matchedMessages = chatMessageRepository + .searchLatestMatchingMessagesByChatRoomIds(roomIds, keyword) + .stream() + .filter(message -> isVisibleMessageMatch(message, roomMap, visibleMessageFromMap)) + .map(message -> ChatMessageMatchResult.from(roomMap.get(message.getChatRoom().getId()), message)) + .toList(); + + return ChatMessageMatchesResponse.from(toPage(matchedMessages, page, limit)); + } + + private String normalizeKeyword(String keyword) { + if (keyword == null) { + return ""; + } + return keyword.trim(); + } + + private boolean containsKeyword(String text, String keyword) { + if (text == null || keyword.isBlank()) { + return false; + } + + return text.toLowerCase(Locale.ROOT).contains(keyword.toLowerCase(Locale.ROOT)); + } + + private Page toPage(List items, Integer page, Integer limit) { + PageRequest pageable = PageRequest.of(page - 1, limit); + long offset = (long)(page - 1) * limit; + if (offset >= items.size()) { + return new PageImpl<>(List.of(), pageable, items.size()); + } + + int fromIndex = (int)offset; + int toIndex = Math.min(fromIndex + limit, items.size()); + return new PageImpl<>(items.subList(fromIndex, toIndex), pageable, items.size()); + } + + private Page emptyPage(Integer page, Integer limit) { + return new PageImpl<>(List.of(), PageRequest.of(page - 1, limit), 0); + } + private Map getMuteMap(List roomIds, Integer userId) { if (roomIds.isEmpty()) { return Map.of(); @@ -668,6 +765,28 @@ private Map getMuteMap(List roomIds, Integer userId) return muteMap; } + private Map getVisibleMessageFromMap(List roomIds, Integer userId) { + if (roomIds.isEmpty()) { + return Map.of(); + } + + Map visibleMessageFromMap = new HashMap<>(); + for (ChatRoomMember roomMember : chatRoomMemberRepository.findByChatRoomIdsAndUserId(roomIds, userId)) { + visibleMessageFromMap.put(roomMember.getChatRoomId(), roomMember.getVisibleMessageFrom()); + } + return visibleMessageFromMap; + } + + private Map getDefaultRoomNameMap( + List directRooms, + List clubRooms + ) { + Map defaultRoomNameMap = new HashMap<>(); + directRooms.forEach(room -> defaultRoomNameMap.put(room.roomId(), room.roomName())); + clubRooms.forEach(room -> defaultRoomNameMap.put(room.roomId(), room.roomName())); + return defaultRoomNameMap; + } + private Map getCustomRoomNameMap(List roomIds, Integer userId) { if (roomIds.isEmpty()) { return Map.of(); @@ -682,6 +801,38 @@ private String resolveRoomName(Integer roomId, String defaultRoomName, Map defaultRoomNameMap + ) { + if (containsKeyword(room.roomName(), keyword)) { + return true; + } + + return containsKeyword(defaultRoomNameMap.get(room.roomId()), keyword); + } + + private boolean isVisibleMessageMatch( + ChatMessage message, + Map roomMap, + Map visibleMessageFromMap + ) { + ChatRoomSummaryResponse room = roomMap.get(message.getChatRoom().getId()); + if (room == null || room.chatType() != ChatType.DIRECT) { + return true; + } + + LocalDateTime visibleMessageFrom = visibleMessageFromMap.get(room.roomId()); + return visibleMessageFrom == null || message.getCreatedAt().isAfter(visibleMessageFrom); + } + + private record AccessibleChatRooms( + List rooms, + Map defaultRoomNameMap + ) { + } + private ChatRoom getDirectRoom(Integer roomId) { ChatRoom chatRoom = chatRoomRepository.findById(roomId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java index b81e50ef8..1e4ed4269 100644 --- a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -88,6 +88,10 @@ public Drive buildUserDriveService(String refreshToken) throws IOException, Gene private InputStream openCredentialsStream() throws IOException { String credentialsPath = googleSheetsProperties.credentialsPath(); + if (credentialsPath == null || credentialsPath.isBlank()) { + throw new IOException("Google Sheets credentials path is not configured."); + } + if (credentialsPath.startsWith("classpath:")) { Resource resource = resourceLoader.getResource(credentialsPath); if (!resource.exists()) { diff --git a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java index fb2caea21..e31174f52 100644 --- a/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/chat/ChatApiTest.java @@ -732,6 +732,195 @@ void getMessagesForbidden() throws Exception { } } + @Nested + @DisplayName("GET /chats/rooms/search - 채팅 검색") + class SearchChats { + + private User secondTargetUser; + private Club developmentClub; + + @BeforeEach + void setUpSearchFixture() { + targetUser = createUser("개발팀", "2021136002"); + secondTargetUser = createUser("개발자", "2021136003"); + outsiderUser = createUser("외부유저", "2021136004"); + developmentClub = persist(ClubFixture.create(university, "개발동아리")); + createClubMember(developmentClub, normalUser); + clearPersistenceContext(); + } + + @Test + @DisplayName("채팅방 이름과 상대방 이름으로 검색 결과를 분리해서 반환한다") + void searchChatsReturnsRoomMatchesForDirectAndGroupRooms() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + ChatRoom groupRoom = persist(ChatRoom.groupOf(developmentClub)); + addRoomMember(groupRoom, normalUser); + persistChatMessage(directRoom, normalUser, "안녕하세요"); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(2)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(2)) + .andExpect(jsonPath("$.roomMatches.totalPage").value(1)) + .andExpect(jsonPath("$.roomMatches.currentPage").value(1)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("개발팀")) + .andExpect(jsonPath("$.roomMatches.rooms[0].chatType").value("DIRECT")) + .andExpect(jsonPath("$.roomMatches.rooms[1].roomName").value("개발동아리")) + .andExpect(jsonPath("$.roomMatches.rooms[1].chatType").value("GROUP")) + .andExpect(jsonPath("$.messageMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(0)); + } + + @Test + @DisplayName("메시지 검색은 접근 가능한 방에서 방별 최신 매칭 메시지 1개만 반환한다") + void searchChatsReturnsLatestMatchingMessagePerAccessibleRoom() throws Exception { + // given + ChatRoom firstRoom = createDirectChatRoom(normalUser, targetUser); + ChatRoom secondRoom = createDirectChatRoom(normalUser, secondTargetUser); + ChatRoom outsiderRoom = createDirectChatRoom(outsiderUser, targetUser); + + persistChatMessage(firstRoom, normalUser, "첫 번째 키워드"); + persistChatMessage(secondRoom, secondTargetUser, "두 번째 키워드"); + persistChatMessage(outsiderRoom, outsiderUser, "외부 키워드"); + persistChatMessage(firstRoom, targetUser, "최신 키워드"); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=키워드&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(2)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(2)) + .andExpect(jsonPath("$.messageMatches.messages[0].roomName").value("개발팀")) + .andExpect(jsonPath("$.messageMatches.messages[0].matchedMessage").value("최신 키워드")) + .andExpect(jsonPath("$.messageMatches.messages[1].roomName").value("개발자")) + .andExpect(jsonPath("$.messageMatches.messages[1].matchedMessage").value("두 번째 키워드")); + } + + @Test + @DisplayName("나간 1:1 채팅방의 숨김 메시지는 검색 결과에 노출되지 않는다") + void searchChatsExcludesHiddenMessagesFromLeftDirectRoom() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + + mockLoginUser(normalUser.getId()); + performPost("/chats/rooms/" + directRoom.getId() + "/messages", new ChatMessageSendRequest("비밀 키워드")) + .andExpect(status().isOk()); + performDelete("/chats/rooms/" + directRoom.getId()) + .andExpect(status().isNoContent()); + + mockLoginUser(targetUser.getId()); + performPost("/chats/rooms/" + directRoom.getId() + "/messages", new ChatMessageSendRequest("다시 안녕")) + .andExpect(status().isOk()); + + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=비밀&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(0)); + } + + @Test + @DisplayName("메시지 검색은 LIKE 특수문자를 리터럴로 처리한다") + void searchChatsTreatsLikeSpecialCharactersAsLiteral() throws Exception { + // given + ChatRoom firstRoom = createDirectChatRoom(normalUser, targetUser); + ChatRoom secondRoom = createDirectChatRoom(normalUser, secondTargetUser); + + persistChatMessage(firstRoom, normalUser, "100% 완료"); + persistChatMessage(secondRoom, secondTargetUser, "1000 완료"); + mockLoginUser(normalUser.getId()); + LinkedMultiValueMap params = new LinkedMultiValueMap<>(Map.of( + "keyword", List.of("100%"), + "page", List.of("1"), + "limit", List.of("10") + )); + + // when & then + performGet("/chats/rooms/search", params) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(0)) + .andExpect(jsonPath("$.messageMatches.totalCount").value(1)) + .andExpect(jsonPath("$.messageMatches.currentCount").value(1)) + .andExpect(jsonPath("$.messageMatches.messages[0].roomName").value("개발팀")) + .andExpect(jsonPath("$.messageMatches.messages[0].matchedMessage").value("100% 완료")); + } + + @Test + @DisplayName("커스텀 채팅방 이름이 있어도 기본 상대방 이름으로 검색할 수 있다") + void searchChatsMatchesDefaultNameEvenWithCustomRoomName() throws Exception { + // given + ChatRoom directRoom = createDirectChatRoom(normalUser, targetUser); + mockLoginUser(normalUser.getId()); + performPatch("/chats/rooms/" + directRoom.getId() + "/name", new ChatRoomNameUpdateRequest("내 메모")) + .andExpect(status().isOk()); + + // when & then + performGet("/chats/rooms/search?keyword=개발팀&page=1&limit=10") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(1)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(1)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomId").value(directRoom.getId())) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("내 메모")); + } + + @Test + @DisplayName("채팅방 검색 결과에 페이지네이션을 적용한다") + void searchChatsAppliesPaginationToRoomMatches() throws Exception { + // given + createDirectChatRoom(normalUser, targetUser); + createDirectChatRoom(normalUser, secondTargetUser); + ChatRoom groupRoom = persist(ChatRoom.groupOf(developmentClub)); + addRoomMember(groupRoom, normalUser); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=2&limit=1") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(3)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(1)) + .andExpect(jsonPath("$.roomMatches.totalPage").value(3)) + .andExpect(jsonPath("$.roomMatches.currentPage").value(2)) + .andExpect(jsonPath("$.roomMatches.rooms[0].roomName").value("개발자")); + } + + @Test + @DisplayName("매우 큰 page 값이어도 빈 검색 결과를 안전하게 반환한다") + void searchChatsWithVeryLargePageReturnsEmptyResult() throws Exception { + // given + createDirectChatRoom(normalUser, targetUser); + ChatRoom groupRoom = persist(ChatRoom.groupOf(developmentClub)); + addRoomMember(groupRoom, normalUser); + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=2147483647&limit=100") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomMatches.totalCount").value(2)) + .andExpect(jsonPath("$.roomMatches.currentCount").value(0)) + .andExpect(jsonPath("$.roomMatches.currentPage").value(2147483647)); + } + + @Test + @DisplayName("limit가 최대값을 초과하면 400을 반환한다") + void searchChatsWithTooLargeLimitFails() throws Exception { + // given + mockLoginUser(normalUser.getId()); + + // when & then + performGet("/chats/rooms/search?keyword=개발&page=1&limit=101") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST_BODY")) + .andExpect(jsonPath("$.fieldErrors[0].field").value("searchChats.limit")); + } + } + @Nested @DisplayName("POST /chats/rooms/{chatRoomId}/mute - 채팅방 뮤트 토글") class ToggleMute { @@ -784,6 +973,29 @@ private User createUser(String name, String studentId) { return persist(UserFixture.createUser(university, name, studentId)); } + private ClubMember createClubMember(Club club, User user) { + Club managedClub = entityManager.getReference(Club.class, club.getId()); + User managedUser = entityManager.getReference(User.class, user.getId()); + ClubMember clubMember = persist(ClubMember.builder() + .club(managedClub) + .user(managedUser) + .clubPosition(ClubPosition.MEMBER) + .build()); + clearPersistenceContext(); + return clubMember; + } + + private ChatMessage persistChatMessage(ChatRoom chatRoom, User sender, String content) { + ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); + User managedSender = entityManager.getReference(User.class, sender.getId()); + + ChatMessage chatMessage = persist(ChatMessage.of(managedChatRoom, managedSender, content)); + managedChatRoom.updateLastMessage(chatMessage.getContent(), chatMessage.getCreatedAt()); + entityManager.flush(); + clearPersistenceContext(); + return chatMessage; + } + private void addRoomMember(ChatRoom chatRoom, User user) { ChatRoom managedChatRoom = entityManager.getReference(ChatRoom.class, chatRoom.getId()); User managedUser = entityManager.getReference(User.class, user.getId());