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 @@ -59,6 +59,11 @@ GET /admin/api/v2/curations
include::{snippets}/admin/v2/curations/list/curl-request.adoc[]
include::{snippets}/admin/v2/curations/list/http-request.adoc[]

[discrete]
==== 요청 파라미터 ====

include::{snippets}/admin/v2/curations/list/query-parameters.adoc[]

[discrete]
==== 응답 예시 ====

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document
import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest
import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse
import org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint
import org.springframework.restdocs.request.RequestDocumentation.parameterWithName
import org.springframework.restdocs.request.RequestDocumentation.queryParameters
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.test.web.servlet.assertj.MockMvcTester
import java.time.LocalDate
Expand Down Expand Up @@ -98,13 +100,20 @@ class AdminSpecBasedCurationControllerDocsTest {
given(adminSpecBasedCurationService.search(any(CurationSearchRequest::class.java)))
.willReturn(GlobalResponse.fromPage(PageImpl(listOf(listResponse()))))

assertThat(mvc.get().uri("/v2/curations?keyword=&isActive=true&page=0&size=20"))
assertThat(mvc.get().uri("/v2/curations?keyword=&code=RECOMMENDED_WHISKY&isActive=true&page=0&size=20"))
.hasStatusOk()
.apply(
document(
"admin/v2/curations/list",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint())
preprocessResponse(prettyPrint()),
queryParameters(
parameterWithName("keyword").description("큐레이션명 검색어").optional(),
parameterWithName("code").description("큐레이션 스펙 코드 exact match 필터").optional(),
parameterWithName("isActive").description("활성 상태 필터").optional(),
parameterWithName("page").description("페이지 번호, 기본값 0").optional(),
parameterWithName("size").description("페이지 크기, 기본값 20").optional()
)
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,71 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() {
).hasStatus4xxClientError()
}

@Test
@DisplayName("Admin 큐레이션 목록은 code, keyword, isActive 조건을 조합해 조회할 수 있다")
fun listCurations_whenCodeKeywordAndActiveProvided_filtersResults() {
mockMvcTester
.post()
.uri("/v2/curations")
.header("Authorization", "Bearer $accessToken")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(createRequest(validPayload(), name = "통합 매치 큐레이션", isActive = true)))
.exchange()

val result = mockMvcTester
.get()
.uri("/v2/curations?keyword=매치&code=RECOMMENDED_WHISKY&isActive=true&page=0&size=20")
.header("Authorization", "Bearer $accessToken")
.exchange()

assertThat(result).hasStatusOk()
val data = dataNode(result)
assertThat(data.map { it.path("name").asText() }).contains("통합 매치 큐레이션")
assertThat(data.map { it.path("specCode").asText() }.distinct()).containsOnly("RECOMMENDED_WHISKY")
}

@Test
@DisplayName("Admin 큐레이션 목록은 알 수 없는 code를 빈 결과로 반환한다")
fun listCurations_whenUnknownCodeProvided_returnsEmptyResult() {
mockMvcTester
.post()
.uri("/v2/curations")
.header("Authorization", "Bearer $accessToken")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(createRequest(validPayload(), name = "통합 네거티브 큐레이션")))
.exchange()

val result = mockMvcTester
.get()
.uri("/v2/curations?code=UNKNOWN_CODE&page=0&size=20")
.header("Authorization", "Bearer $accessToken")
.exchange()

assertThat(result).hasStatusOk()
assertThat(dataNode(result)).isEmpty()
}

@Test
@DisplayName("Admin 큐레이션 목록은 blank code를 필터 미적용으로 처리한다")
fun listCurations_whenBlankCodeProvided_ignoresCodeFilter() {
mockMvcTester
.post()
.uri("/v2/curations")
.header("Authorization", "Bearer $accessToken")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(createRequest(validPayload(), name = "boundary-filter-curation")))
.exchange()

val result = mockMvcTester
.get()
.uri("/v2/curations?keyword=boundary&code=&page=0&size=20")
.header("Authorization", "Bearer $accessToken")
.exchange()

assertThat(result).hasStatusOk()
assertThat(dataNode(result).map { it.path("name").asText() }).contains("boundary-filter-curation")
}

@Test
@DisplayName("request spec의 required 필드가 없으면 400을 반환한다")
fun create_whenRequiredFieldMissing_returnsBadRequest() {
Expand Down Expand Up @@ -220,15 +285,19 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() {
.isEqualTo(404)
}

private fun createRequest(payload: Any): Map<String, Any?> = mapOf(
private fun createRequest(
payload: Any,
name: String = "통합 테스트 큐레이션",
isActive: Boolean = true
): Map<String, Any?> = mapOf(
"specId" to recommendedSpecId(),
"name" to "통합 테스트 큐레이션",
"name" to name,
"description" to "request spec 검증 테스트",
"imageUrls" to listOf("https://cdn.example.com/cover.jpg"),
"exposureStartDate" to "2026-06-01",
"exposureEndDate" to "2026-06-30",
"displayOrder" to 1,
"isActive" to true,
"isActive" to isActive,
"payload" to listOf(payload)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public interface CurationRepository {

Optional<Curation> findVisibleById(Long id, LocalDate today);

Page<Curation> searchForAdmin(String keyword, Boolean isActive, Pageable pageable);
Page<Curation> searchForAdmin(String keyword, Long specId, Boolean isActive, Pageable pageable);

Curation save(Curation curation);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

import lombok.Builder;

public record CurationSearchRequest(String keyword, Boolean isActive, Integer page, Integer size) {
public record CurationSearchRequest(
String keyword, String code, Boolean isActive, Integer page, Integer size) {

public CurationSearchRequest(String keyword, Boolean isActive, Integer page, Integer size) {
this(keyword, null, isActive, page, size);
}

@Builder
public CurationSearchRequest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ public interface JpaCurationRepository extends CurationRepository, JpaRepository
SELECT c
FROM curation c
WHERE (:keyword IS NULL OR :keyword = '' OR c.name LIKE CONCAT('%', :keyword, '%'))
AND (:specId IS NULL OR c.specId = :specId)
AND (:isActive IS NULL OR c.isActive = :isActive)
ORDER BY c.displayOrder ASC, c.id ASC
""")
Page<Curation> searchForAdmin(String keyword, Boolean isActive, Pageable pageable);
Page<Curation> searchForAdmin(String keyword, Long specId, Boolean isActive, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,12 @@ public CurationSpecResponse getSpecDetail(Long specId) {
@Transactional(readOnly = true)
public GlobalResponse search(CurationSearchRequest request) {
PageRequest pageable = PageRequest.of(request.page(), request.size());
Long specId = resolveSpecId(request.code());
if (isUnknownCode(request.code(), specId)) {
return GlobalResponse.fromPage(Page.empty(pageable));
}
Page<Curation> page =
curationRepository.searchForAdmin(request.keyword(), request.isActive(), pageable);
curationRepository.searchForAdmin(request.keyword(), specId, request.isActive(), pageable);
Map<Long, CurationSpec> specMap =
curationSpecRepository
.findAllByIdIn(
Expand All @@ -80,8 +84,12 @@ public GlobalResponse searchFeed(CurationSearchRequest request) {
int size = normalizeFeedSize(request.size());
int pageNumber = request.page() != null && request.page() > 0 ? request.page() : 0;
PageRequest pageable = PageRequest.of(pageNumber, size);
Long specId = resolveSpecId(request.code());
if (isUnknownCode(request.code(), specId)) {
return GlobalResponse.fromPage(Page.empty(pageable));
}
Page<Curation> page =
curationRepository.searchForAdmin(request.keyword(), request.isActive(), pageable);
curationRepository.searchForAdmin(request.keyword(), specId, request.isActive(), pageable);
Map<Long, CurationSpec> specMap =
curationSpecRepository
.findAllByIdIn(
Expand Down Expand Up @@ -178,6 +186,21 @@ private void validatePayload(CurationSpec spec, Object payload) {
}
}

private Long resolveSpecId(String code) {
if (isBlank(code)) {
return null;
}
return curationSpecRepository.findByCode(code.trim()).map(CurationSpec::getId).orElse(null);
}

private boolean isUnknownCode(String code, Long specId) {
return !isBlank(code) && specId == null;
}

private boolean isBlank(String value) {
return value == null || value.isBlank();
}

private AdminSpecBasedCurationListResponse toListResponse(Curation curation, CurationSpec spec) {
return new AdminSpecBasedCurationListResponse(
curation.getId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,101 @@ void setUp() {
assertThat(result.getData()).asList().hasSize(1);
}

@Test
@DisplayName("Admin 목록 검색은 code를 spec code exact match로 필터링한다")
void search_whenCodeProvided_filtersBySpecCode() {
CurationSpec recommendedSpec = createSpec();
CurationSpec pairingSpec = createPairingSpec();
adminSpecBasedCurationService.create(
createRequest(recommendedSpec.getId(), "추천 큐레이션", 1, true));
adminSpecBasedCurationService.create(createRequest(pairingSpec.getId(), "페어링 큐레이션", 2, true));

GlobalResponse result =
adminSpecBasedCurationService.search(
new CurationSearchRequest(null, "WHISKY_PAIRING", null, 0, 20));

assertThat(result.getData()).asList().hasSize(1);
assertThat(result.getData()).asList().extracting("specCode").containsExactly("WHISKY_PAIRING");
}

@Test
@DisplayName("Admin 목록 검색은 code와 keyword, isActive 조건을 함께 적용한다")
void search_whenCodeKeywordAndActiveProvided_combinesFilters() {
CurationSpec recommendedSpec = createSpec();
CurationSpec pairingSpec = createPairingSpec();
adminSpecBasedCurationService.create(createRequest(recommendedSpec.getId(), "추천 매치", 1, true));
adminSpecBasedCurationService.create(createRequest(pairingSpec.getId(), "페어링 매치", 2, true));
adminSpecBasedCurationService.create(createRequest(pairingSpec.getId(), "페어링 비활성", 3, false));

GlobalResponse result =
adminSpecBasedCurationService.search(
new CurationSearchRequest("매치", "WHISKY_PAIRING", true, 0, 20));

assertThat(result.getData()).asList().hasSize(1);
assertThat(result.getData()).asList().extracting("name").containsExactly("페어링 매치");
}

@Test
@DisplayName("Admin 목록 검색은 알 수 없는 code를 빈 결과로 반환한다")
void search_whenUnknownCodeProvided_returnsEmptyPage() {
CurationSpec spec = createSpec();
adminSpecBasedCurationService.create(createRequest(spec.getId()));

GlobalResponse result =
adminSpecBasedCurationService.search(
new CurationSearchRequest(null, "UNKNOWN_CODE", null, 0, 20));

assertThat(result.getData()).asList().isEmpty();
}

@Test
@DisplayName("Admin 목록 검색은 blank code를 필터 미적용으로 처리한다")
void search_whenBlankCodeProvided_ignoresCodeFilter() {
CurationSpec recommendedSpec = createSpec();
CurationSpec pairingSpec = createPairingSpec();
adminSpecBasedCurationService.create(
createRequest(recommendedSpec.getId(), "추천 큐레이션", 1, true));
adminSpecBasedCurationService.create(createRequest(pairingSpec.getId(), "페어링 큐레이션", 2, true));

GlobalResponse result =
adminSpecBasedCurationService.search(new CurationSearchRequest(null, " ", null, 0, 20));

assertThat(result.getData()).asList().hasSize(2);
}

@Test
@DisplayName("Admin feed 검색도 code 필터를 적용한다")
void searchFeed_whenCodeProvided_filtersBySpecCode() {
CurationSpec recommendedSpec = createSpec();
CurationSpec pairingSpec = createPairingSpec();
adminSpecBasedCurationService.create(
createRequest(recommendedSpec.getId(), "추천 큐레이션", 1, true));
adminSpecBasedCurationService.create(createRequest(pairingSpec.getId(), "페어링 큐레이션", 2, true));

GlobalResponse result =
adminSpecBasedCurationService.searchFeed(
new CurationSearchRequest(null, "WHISKY_PAIRING", null, 0, 10));

assertThat(result.getData()).asList().hasSize(1);
assertThat(result.getData()).asList().extracting("specCode").containsExactly("WHISKY_PAIRING");
}

@Test
@DisplayName("Admin feed 검색은 blank code를 필터 미적용으로 처리한다")
void searchFeed_whenBlankCodeProvided_ignoresCodeFilter() {
CurationSpec recommendedSpec = createSpec();
CurationSpec pairingSpec = createPairingSpec();
adminSpecBasedCurationService.create(
createRequest(recommendedSpec.getId(), "추천 큐레이션", 1, true));
adminSpecBasedCurationService.create(createRequest(pairingSpec.getId(), "페어링 큐레이션", 2, true));

GlobalResponse result =
adminSpecBasedCurationService.searchFeed(
new CurationSearchRequest(null, " ", null, 0, 10));

assertThat(result.getData()).asList().hasSize(2);
}

@Test
@DisplayName("Admin feed는 페이지 size를 최대 10개로 제한하고 비활성 포함 여부를 필터링한다")
void searchFeed_페이지_최대_크기_제한과_상태_필터링() {
Expand Down Expand Up @@ -227,6 +322,37 @@ private CurationSpec createSpec() {
1);
}

private CurationSpec createPairingSpec() {
return curationFixtureFactory.saveSpec(
"WHISKY_PAIRING",
"위스키 페어링",
"안주 조합 스펙 설명",
Map.of("type", "object", "required", List.of("source", "alcohol")),
Map.of(
"type",
"object",
"properties",
Map.of(
"source",
Map.of("type", "string"),
"alcohol",
Map.of(
"type",
"object",
"x-feed",
Map.of(
"enabled",
true,
"role",
"item-list",
"order",
10,
"description",
"피드 카드 페어링 정보를 구성하기 위해 포함한다.")))),
"alcohol",
1);
}

private CurationCreateRequest createRequest(Long specId) {
return createRequest(specId, "비 오는 날 위스키", 1, true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,13 @@ public Optional<Curation> findVisibleById(Long id, LocalDate today) {
}

@Override
public Page<Curation> searchForAdmin(String keyword, Boolean isActive, Pageable pageable) {
public Page<Curation> searchForAdmin(String keyword, Long specId, Boolean isActive, Pageable pageable) {
List<Curation> all =
database.values().stream()
.filter(
curation ->
keyword == null || keyword.isBlank() || curation.getName().contains(keyword))
.filter(curation -> specId == null || curation.getSpecId().equals(specId))
.filter(curation -> isActive == null || curation.getIsActive().equals(isActive))
.sorted(Comparator.comparing(Curation::getDisplayOrder).thenComparing(Curation::getId))
.toList();
Expand Down