diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-curations/spec-based-curations.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-curations/spec-based-curations.adoc index 518c10d49..af005b680 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/api/admin-curations/spec-based-curations.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-curations/spec-based-curations.adoc @@ -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] ==== 응답 예시 ==== diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminSpecBasedCurationControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminSpecBasedCurationControllerDocsTest.kt index 489498cb8..8d06ab994 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminSpecBasedCurationControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminSpecBasedCurationControllerDocsTest.kt @@ -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 @@ -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() + ) ) ) } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt index a72f0c21f..47a612722 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt @@ -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() { @@ -220,15 +285,19 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() { .isEqualTo(404) } - private fun createRequest(payload: Any): Map = mapOf( + private fun createRequest( + payload: Any, + name: String = "통합 테스트 큐레이션", + isActive: Boolean = true + ): Map = 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) ) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationRepository.java index cb24aecef..687095e0d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationRepository.java @@ -18,7 +18,7 @@ public interface CurationRepository { Optional findVisibleById(Long id, LocalDate today); - Page searchForAdmin(String keyword, Boolean isActive, Pageable pageable); + Page searchForAdmin(String keyword, Long specId, Boolean isActive, Pageable pageable); Curation save(Curation curation); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationSearchRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationSearchRequest.java index 7f82f2322..4ad12d9f2 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationSearchRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/request/CurationSearchRequest.java @@ -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 { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationRepository.java index c6f6971ae..aaf3fd57d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationRepository.java @@ -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 searchForAdmin(String keyword, Boolean isActive, Pageable pageable); + Page searchForAdmin(String keyword, Long specId, Boolean isActive, Pageable pageable); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java index 085de5dda..80860f2d2 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java @@ -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 page = - curationRepository.searchForAdmin(request.keyword(), request.isActive(), pageable); + curationRepository.searchForAdmin(request.keyword(), specId, request.isActive(), pageable); Map specMap = curationSpecRepository .findAllByIdIn( @@ -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 page = - curationRepository.searchForAdmin(request.keyword(), request.isActive(), pageable); + curationRepository.searchForAdmin(request.keyword(), specId, request.isActive(), pageable); Map specMap = curationSpecRepository .findAllByIdIn( @@ -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(), diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/AdminSpecBasedCurationServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/AdminSpecBasedCurationServiceTest.java index 030c570f3..9fc4bd332 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/AdminSpecBasedCurationServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/AdminSpecBasedCurationServiceTest.java @@ -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_페이지_최대_크기_제한과_상태_필터링() { @@ -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); } diff --git a/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/InMemoryCurationRepository.java b/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/InMemoryCurationRepository.java index 5b7fbb6be..93147b16d 100644 --- a/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/InMemoryCurationRepository.java +++ b/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/InMemoryCurationRepository.java @@ -47,12 +47,13 @@ public Optional findVisibleById(Long id, LocalDate today) { } @Override - public Page searchForAdmin(String keyword, Boolean isActive, Pageable pageable) { + public Page searchForAdmin(String keyword, Long specId, Boolean isActive, Pageable pageable) { List 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();