Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public class SearchController {
@Operation(
summary = "통합 검색",
description = "검색어로 코스, 사용자, 모집 중 동행 게시글을 동시에 검색합니다. "
+ "type을 생략하면 세 영역을 모두 조회하고 반환하며, POST/COURSE/USER를 지정하면 해당 영역만 조회하고 반환합니다. "
+ "선택하지 않은 영역은 응답 필드에서 제외됩니다. 잘못된 type은 400(GLB-E001)을 반환합니다. "
+ "각 영역에 동일한 페이지 번호와 크기를 적용합니다. "
+ "courses.totalElements와 users.totalElements는 page=0에서만 전체 건수를 제공하며 "
+ "page>0에서는 건수를 조회하지 않고 명시적으로 null을 반환합니다. "
Expand All @@ -61,7 +63,8 @@ public BaseResponse<SearchResponse> search(
userId,
request.normalizedKeyword(),
request.pageOrDefault(),
request.sizeOrDefault()
request.sizeOrDefault(),
request.searchType()
))
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import org.sopt.buddys.domain.search.service.command.SearchType;

public record SearchRequest(
@Schema(description = "검색어", example = "파리", requiredMode = Schema.RequiredMode.REQUIRED)
Expand All @@ -17,9 +19,18 @@ public record SearchRequest(
@Schema(description = "페이지 크기. 1 이상 100 이하입니다.", example = "5", defaultValue = "5")
@Min(1)
@Max(100)
Integer size
Integer size,

@Schema(description = "검색 영역. 생략하면 모든 영역을 조회하고 반환합니다. 지정하면 해당 영역만 조회하고 반환합니다. 잘못된 값은 400(GLB-E001)을 반환합니다.",
allowableValues = {"POST", "COURSE", "USER"})
@Pattern(regexp = "POST|COURSE|USER", message = "type은 POST, COURSE, USER 중 하나여야 합니다.")
String type
) {

public SearchType searchType() {
return type == null ? null : SearchType.valueOf(type);
}

public String normalizedKeyword() {
return keyword.trim();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
package org.sopt.buddys.domain.search.dto.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import org.sopt.buddys.domain.post.dto.response.PostListResponse;
import org.sopt.buddys.domain.search.service.result.SearchResult;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record SearchResponse(
@Schema(description = "코스 검색 결과", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "코스 검색 결과. type 생략 또는 COURSE 지정 시에만 포함", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
CourseSearchResponse courses,

@Schema(description = "사용자 검색 결과", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "사용자 검색 결과. type 생략 또는 USER 지정 시에만 포함", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
UserSearchResponse users,

@Schema(description = "동행 게시글 검색 결과", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "동행 게시글 검색 결과. type 생략 또는 POST 지정 시에만 포함", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
PostListResponse posts
) {

public static SearchResponse from(SearchResult result) {
return new SearchResponse(
CourseSearchResponse.from(result.courses()),
UserSearchResponse.from(result.users()),
PostListResponse.from(result.posts())
result.courses() == null ? null : CourseSearchResponse.from(result.courses()),
result.users() == null ? null : UserSearchResponse.from(result.users()),
result.posts() == null ? null : PostListResponse.from(result.posts())
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.sopt.buddys.domain.course.service.CourseService;
import org.sopt.buddys.domain.post.service.PostService;
import org.sopt.buddys.domain.post.service.command.PostSearchCondition;
import org.sopt.buddys.domain.search.service.command.SearchType;
import org.sopt.buddys.domain.search.service.result.SearchResult;
import org.sopt.buddys.domain.search.service.result.UserSearchResult;
import org.sopt.buddys.domain.search.service.result.UserSearchResult.UserSummaryResult;
Expand All @@ -25,16 +26,18 @@ public class SearchService {
private final UserRepository userRepository;
private final PostService postService;

public SearchResult search(Long userId, String keyword, int page, int size) {
public SearchResult search(Long userId, String keyword, int page, int size, SearchType type) {
return new SearchResult(
courseService.searchCourses(userId, keyword, page, size),
searchUsers(userId, keyword, page, size),
postService.getPosts(
type == null || type == SearchType.COURSE
? courseService.searchCourses(userId, keyword, page, size) : null,
type == null || type == SearchType.USER
? searchUsers(userId, keyword, page, size) : null,
type == null || type == SearchType.POST ? postService.getPosts(
userId,
new PostSearchCondition(keyword, null, null, null, null, null, null, null),
page,
size
)
) : null
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.sopt.buddys.domain.search.service.command;

public enum SearchType {
POST,
COURSE,
USER
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
Expand All @@ -20,6 +21,7 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.sopt.buddys.domain.course.entity.Course;
import org.sopt.buddys.domain.course.entity.CourseBookmark;
Expand Down Expand Up @@ -95,7 +97,7 @@ class SearchControllerTest extends IntegrationTestSupport {
@Autowired
private PlaceRepository placeRepository;

@Autowired
@MockitoSpyBean
private PostRepository postRepository;

@Autowired
Expand Down Expand Up @@ -374,6 +376,11 @@ void search_openApiContract_matchesRequestAndResponse() throws Exception {
.value(0))
.andExpect(jsonPath("$.paths['/api/v1/search'].get.parameters[?(@.name == 'size')].schema.default")
.value(5))
.andExpect(jsonPath("$.paths['/api/v1/search'].get.parameters[?(@.name == 'type')].required")
.value(false))
.andExpect(jsonPath("$.paths['/api/v1/search'].get.parameters[?(@.name == 'type')].schema.enum")
.value(org.hamcrest.Matchers.contains(java.util.List.of("POST", "COURSE", "USER"))))
.andExpect(jsonPath("$.components.schemas.SearchResponse.required").doesNotExist())
.andExpect(jsonPath("$.paths['/api/v1/search'].get.responses['200']").exists())
.andExpect(jsonPath("$.paths['/api/v1/search'].get.responses['400']").exists())
.andExpect(jsonPath("$.paths['/api/v1/search'].get.responses['401']").exists())
Expand All @@ -393,6 +400,91 @@ void search_openApiContract_matchesRequestAndResponse() throws Exception {
}
}

@ParameterizedTest(name = "type={0}, page={1}: 응답 영역과 검색·count 범위를 유지한다")
@CsvSource({",0", ",1", "POST,0", "POST,1", "COURSE,0", "COURSE,1", "USER,0", "USER,1"})
void search_typeAndPage_returnsSelectedSectionsAndCountsOnlyWhenNeeded(String type, int page)
throws Exception {
User viewer = saveUser("viewer@test.com", "viewer", "조회자", AccountStatus.ACTIVE);
Location location = saveLocation("France", "FR", "Paris", "파리");
Map<String, Long> expectedIds = new java.util.HashMap<>();
for (int index = 0; index < 3; index++) {
User author = saveUser("author" + index + "@test.com", "author" + index,
"ParisUser" + index, AccountStatus.ACTIVE);
Course course = saveCourse(author, location, "Paris course", "content");
Post post = savePost(author, location, "Paris post", "content");
if (index == 2 - page) {
expectedIds.put("courses", course.getId());
expectedIds.put("users", author.getId());
expectedIds.put("posts", post.getId());
}
}

for (String keyword : List.of(" Paris ", "no-result")) {
clearInvocations(courseRepository, userRepository, postRepository);
var request = get("/api/v1/search")
.header(HttpHeaders.AUTHORIZATION, bearerToken(viewer.getId()))
.param("keyword", keyword).param("page", String.valueOf(page)).param("size", "1");
if (type != null) {
request.param("type", type);
}
var result = mockMvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", org.hamcrest.Matchers.aMapWithSize(type == null ? 3 : 1)));
boolean hasResults = !keyword.equals("no-result");
for (var entry : Map.of("COURSE", "courses", "USER", "users", "POST", "posts").entrySet()) {
String section = entry.getValue();
String path = "$.data." + section;
if (type != null && !type.equals(entry.getKey())) {
result.andExpect(jsonPath(path).doesNotHaveJsonPath());
continue;
}
result.andExpect(jsonPath(path + ".page").value(page))
.andExpect(jsonPath(path + ".size").value(1))
.andExpect(jsonPath(path + ".content.length()").value(hasResults ? 1 : 0))
.andExpect(jsonPath(path + ".hasNext").value(hasResults))
.andExpect(jsonPath(path + ".totalElements").hasJsonPath());
if (page > 0 && !section.equals("posts")) {
result.andExpect(jsonPath(path + ".totalElements").value(nullValue()));
} else {
result.andExpect(jsonPath(path + ".totalElements").value(hasResults ? 3 : 0));
}
if (hasResults) {
String idField = switch (section) {
case "courses" -> "courseId";
case "users" -> "userId";
default -> "postId";
};
result.andExpect(jsonPath(path + ".content[0]." + idField).value(expectedIds.get(section)));
}
}
assertSearchQueryScope(type, page);
}
}

private void assertSearchQueryScope(String type, int page) {
int courses = type == null || type.equals("COURSE") ? 1 : 0;
int users = type == null || type.equals("USER") ? 1 : 0;
int posts = type == null || type.equals("POST") ? 1 : 0;
verify(courseRepository, times(courses)).searchCoursesByKeyword(anyString(), any());
verify(courseRepository, times(page == 0 ? courses : 0)).countCoursesByKeyword(anyString());
verify(userRepository, times(users)).searchActiveUsersByNickname(anyString(), any(), any(), any());
verify(userRepository, times(page == 0 ? users : 0))
.countActiveUsersByNickname(anyString(), any(), any());
verify(postRepository, times(posts)).searchPosts(any(), any(), any());
verify(postRepository, times(posts)).countPosts(any());
}

@ParameterizedTest
@ValueSource(strings = {"INVALID", "post", "", " ", " POST ", "POST,USER"})
void search_invalidType_returnsBadRequest(String type) throws Exception {
User viewer = saveUser("viewer@test.com", "viewer", "조회자", AccountStatus.ACTIVE);
mockMvc.perform(get("/api/v1/search")
.header(HttpHeaders.AUTHORIZATION, bearerToken(viewer.getId()))
.param("keyword", "Paris").param("type", type))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("GLB-E001"));
}

private void assertCoursePage(
User viewer, int page, int size, int contentSize,
boolean hasNext, Long firstCourseId
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package org.sopt.buddys.domain.search.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;

import java.util.List;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.sopt.buddys.domain.course.service.CourseService;
import org.sopt.buddys.domain.course.service.result.CourseListResult;
import org.sopt.buddys.domain.course.service.result.CourseSearchResult;
import org.sopt.buddys.domain.post.service.PostService;
import org.sopt.buddys.domain.post.service.command.PostSearchCondition;
import org.sopt.buddys.domain.post.service.result.PostListResult;
import org.sopt.buddys.domain.search.service.command.SearchType;
import org.sopt.buddys.domain.user.entity.AccountStatus;
import org.sopt.buddys.domain.user.repository.UserRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.SliceImpl;

@ExtendWith(MockitoExtension.class)
class SearchServiceTest {

@InjectMocks private SearchService searchService;
@Mock private CourseService courseService;
@Mock private UserRepository userRepository;
@Mock private PostService postService;

@ParameterizedTest(name = "type={0}, page={1}: 선택 영역만 조회하고 첫 페이지에서만 사용자 count한다")
@CsvSource({",0", ",2", "POST,0", "POST,2", "COURSE,0", "COURSE,2", "USER,0", "USER,2"})
void search_queriesOnlySelectedSections(SearchType type, int page) {
var pageable = PageRequest.of(page, 3);
var condition = new PostSearchCondition("Paris", null, null, null, null, null, null, null);
var courses = new CourseSearchResult(new CourseListResult(List.of(), page, 3, false), page == 0 ? 8L : null);
var posts = new PostListResult(List.of(), page, 3, false, 7);
if (type == null || type == SearchType.COURSE) {
given(courseService.searchCourses(1L, "Paris", page, 3)).willReturn(courses);
}
if (type == null || type == SearchType.POST) {
given(postService.getPosts(1L, condition, page, 3)).willReturn(posts);
}
if (type == null || type == SearchType.USER) {
given(userRepository.searchActiveUsersByNickname("Paris", 1L, AccountStatus.ACTIVE, pageable))
.willReturn(new SliceImpl<>(List.of(), pageable, false));
if (page == 0) {
given(userRepository.countActiveUsersByNickname("Paris", 1L, AccountStatus.ACTIVE))
.willReturn(6L);
}
}

var result = searchService.search(1L, "Paris", page, 3, type);

if (type == null || type == SearchType.COURSE) {
assertThat(result.courses()).isSameAs(courses);
verify(courseService).searchCourses(1L, "Paris", page, 3);
} else {
assertThat(result.courses()).isNull();
verifyNoInteractions(courseService);
}
if (type == null || type == SearchType.POST) {
assertThat(result.posts()).isSameAs(posts);
verify(postService).getPosts(1L, condition, page, 3);
} else {
assertThat(result.posts()).isNull();
verifyNoInteractions(postService);
}
if (type == null || type == SearchType.USER) {
assertThat(result.users().totalElements()).isEqualTo(page == 0 ? 6L : null);
assertThat(result.users().page()).isEqualTo(page);
assertThat(result.users().size()).isEqualTo(3);
verify(userRepository).searchActiveUsersByNickname("Paris", 1L, AccountStatus.ACTIVE, pageable);
if (page == 0) {
verify(userRepository).countActiveUsersByNickname("Paris", 1L, AccountStatus.ACTIVE);
}
} else {
assertThat(result.users()).isNull();
verifyNoInteractions(userRepository);
}
verifyNoMoreInteractions(courseService, postService, userRepository);
}
}
Loading