Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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)을 반환합니다. "
+ "각 영역에 동일한 페이지 번호와 크기를 적용합니다. "
+ "posts.totalElements는 페이지 번호·크기와 무관하게 검색어에 일치하는 "
+ "삭제되지 않은 모집 중 게시글의 전체 수입니다."
Expand All @@ -59,7 +61,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 @@ -10,6 +10,9 @@
import java.time.LocalDateTime;
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;
import org.sopt.buddys.domain.course.entity.CourseCity;
Expand Down Expand Up @@ -327,12 +330,69 @@ 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())
.andExpect(jsonPath("$.paths['/api/v1/search'].get.responses['500']").exists());
}

@ParameterizedTest
@CsvSource({"POST,posts,postId", "COURSE,courses,courseId", "USER,users,userId"})
void search_type_returnsOnlySelectedSection(String type, String section, String idField) throws Exception {
User viewer = saveUser("viewer@test.com", "viewer", "조회자", AccountStatus.ACTIVE);
Location location = saveLocation("France", "FR", "Paris", "파리");
long firstId = 0;
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 == 1) {
firstId = switch (type) {
case "POST" -> post.getId();
case "COURSE" -> course.getId();
default -> author.getId();
};
}
}

mockMvc.perform(get("/api/v1/search")
.header(HttpHeaders.AUTHORIZATION, bearerToken(viewer.getId()))
.param("keyword", " Paris ").param("type", type)
.param("page", "1").param("size", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", org.hamcrest.Matchers.aMapWithSize(1)))
.andExpect(jsonPath("$.data." + section + ".content[0]." + idField).value(firstId))
.andExpect(jsonPath("$.data." + section + ".page").value(1))
.andExpect(jsonPath("$.data." + section + ".size").value(1))
.andExpect(jsonPath("$.data." + section + ".totalElements").value(3))
.andExpect(jsonPath("$.data." + section + ".hasNext").value(true));

mockMvc.perform(get("/api/v1/search")
.header(HttpHeaders.AUTHORIZATION, bearerToken(viewer.getId()))
.param("keyword", "no-result").param("type", type))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", org.hamcrest.Matchers.aMapWithSize(1)))
.andExpect(jsonPath("$.data." + section + ".content").isEmpty())
.andExpect(jsonPath("$.data." + section + ".totalElements").value(0));
}

@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, long totalElements,
boolean hasNext, Long firstCourseId
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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.EnumSource;
import org.junit.jupiter.params.provider.NullSource;
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}: 선택 영역만 조회하고 생략하면 모두 조회한다")
@NullSource
@EnumSource(SearchType.class)
void search_queriesOnlySelectedSections(SearchType type) {
var pageable = PageRequest.of(2, 3);
var condition = new PostSearchCondition("Paris", null, null, null, null, null, null, null);
var courses = new CourseSearchResult(new CourseListResult(List.of(), 2, 3, false), 8);
var posts = new PostListResult(List.of(), 2, 3, false, 7);
if (type == null || type == SearchType.COURSE) {
given(courseService.searchCourses(1L, "Paris", 2, 3)).willReturn(courses);
}
if (type == null || type == SearchType.POST) {
given(postService.getPosts(1L, condition, 2, 3)).willReturn(posts);
}
if (type == null || type == SearchType.USER) {
given(userRepository.searchActiveUsersByNickname("Paris", 1L, AccountStatus.ACTIVE, pageable))
.willReturn(new SliceImpl<>(List.of(), pageable, false));
given(userRepository.countActiveUsersByNickname("Paris", 1L, AccountStatus.ACTIVE))
.willReturn(6L);
}

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

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