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 @@ -54,21 +54,33 @@ public List<ProductSpecBasedCurationListResponse> listActiveCurations() {
@Transactional(readOnly = true)
public CursorResponse<ProductSpecBasedCurationFeedItemResponse> searchFeed(
Long cursor, Integer size) {
return searchFeed(null, null, cursor, size);
}

@Transactional(readOnly = true)
public CursorResponse<ProductSpecBasedCurationFeedItemResponse> searchFeed(
String keyword, String code, Long cursor, Integer size) {
int pageSize = normalizeFeedSize(size);
long currentCursor = cursor != null && cursor > 0 ? cursor : 0L;
List<Curation> visibleCurations = curationRepository.findAllVisibleOn(LocalDate.now());
int fromIndex = Math.min(Math.toIntExact(currentCursor), visibleCurations.size());
int toIndex = Math.min(fromIndex + pageSize + 1, visibleCurations.size());
List<Curation> pageContent = new ArrayList<>(visibleCurations.subList(fromIndex, toIndex));
Map<Long, CurationSpec> specMap =
Map<Long, CurationSpec> allSpecMap =
curationSpecRepository
.findAllByIdIn(
pageContent.stream().map(Curation::getSpecId).collect(Collectors.toSet()))
visibleCurations.stream().map(Curation::getSpecId).collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(CurationSpec::getId, Function.identity()));
List<Curation> filteredCurations =
visibleCurations.stream()
.filter(curation -> matchesCode(allSpecMap.get(curation.getSpecId()), code))
.filter(
curation -> matchesKeyword(curation, allSpecMap.get(curation.getSpecId()), keyword))
.toList();
int fromIndex = Math.min(Math.toIntExact(currentCursor), filteredCurations.size());
int toIndex = Math.min(fromIndex + pageSize + 1, filteredCurations.size());
List<Curation> pageContent = new ArrayList<>(filteredCurations.subList(fromIndex, toIndex));
List<ProductSpecBasedCurationFeedItemResponse> items =
pageContent.stream()
.map(curation -> toFeedResponse(curation, specMap.get(curation.getSpecId())))
.map(curation -> toFeedResponse(curation, allSpecMap.get(curation.getSpecId())))
.toList();
return CursorResponse.of(items, currentCursor, pageSize);
}
Expand Down Expand Up @@ -157,6 +169,33 @@ private List<String> imageUrls(Curation curation) {
.toList();
}

private boolean matchesCode(CurationSpec spec, String code) {
if (isBlank(code)) {
return true;
}
return spec != null && spec.getCode().equals(code.trim());
}

private boolean matchesKeyword(Curation curation, CurationSpec spec, String keyword) {
if (isBlank(keyword)) {
return true;
}
String normalizedKeyword = keyword.trim();
return contains(curation.getName(), normalizedKeyword)
|| contains(curation.getDescription(), normalizedKeyword)
|| (spec != null
&& (contains(spec.getName(), normalizedKeyword)
|| contains(spec.getDescription(), normalizedKeyword)));
}

private boolean contains(String value, String keyword) {
return value != null && value.contains(keyword);
}

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

private int normalizeFeedSize(Integer size) {
if (size == null || size < 1) {
return MAX_FEED_SIZE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ void searchFeed_whenSizeExceedsLimit_capsToTenAndKeepsVisibility() throws IOExce
createCuration(
spec.getId(), "미노출", 1, true, LocalDate.now().plusDays(1), LocalDate.now().plusDays(5));

var result = productService.searchFeed(0L, 30);
var result = productService.searchFeed(null, null, 0L, 30);

assertThat(result.items()).hasSize(10);
assertThat(result.pageable().getPageSize()).isEqualTo(10L);
Expand All @@ -185,6 +185,44 @@ void searchFeed_whenSizeExceedsLimit_capsToTenAndKeepsVisibility() throws IOExce
assertThat(result.pageable().getHasNext()).isTrue();
}

@Test
@DisplayName(
"Product feed 검색은 keyword를 큐레이션 제목/설명과 스펙 제목/설명에 LIKE 적용하고 code는 별도 exact match로 필터링한다")
void searchFeed_whenKeywordAndCodeProvided_filtersBeforeCursorPagination() throws IOException {
CurationSpec recommendedSpec = createSpec();
CurationSpec pairingSpec =
curationFixtureFactory.saveSpec(
"WHISKY_PAIRING",
"위스키 페어링",
"안주 조합 스펙 설명",
schema("whisky_pairing.json", "Request"),
schema("whisky_pairing.json", "Response"),
"alcohol",
1);
createCuration(recommendedSpec.getId(), "큐레이션 제목 매치", "일반 설명", 1, true);
createCuration(recommendedSpec.getId(), "일반 제목", "큐레이션 설명 매치", 2, true);
createCuration(pairingSpec.getId(), "일반 제목", "일반 설명", 3, true);
createCuration(pairingSpec.getId(), "큐레이션 제목 매치 페어링", "일반 설명", 4, true);

var keywordResult = productService.searchFeed("큐레이션", null, 0L, 10);
var specKeywordResult = productService.searchFeed("안주", null, 0L, 10);
var codeResult = productService.searchFeed(null, "WHISKY_PAIRING", 0L, 10);
var combinedResult = productService.searchFeed("큐레이션", "WHISKY_PAIRING", 0L, 10);
var negativeResult = productService.searchFeed("큐레이션", "UNKNOWN_CODE", 0L, 10);
var boundaryResult = productService.searchFeed(" ", " ", 0L, 10);

assertThat(keywordResult.items())
.extracting("name")
.containsExactly("큐레이션 제목 매치", "일반 제목", "큐레이션 제목 매치 페어링");
assertThat(specKeywordResult.items())
.extracting("name")
.containsExactly("일반 제목", "큐레이션 제목 매치 페어링");
assertThat(codeResult.items()).extracting("name").containsExactly("일반 제목", "큐레이션 제목 매치 페어링");
assertThat(combinedResult.items()).extracting("name").containsExactly("큐레이션 제목 매치 페어링");
assertThat(negativeResult.items()).isEmpty();
assertThat(boundaryResult.items()).hasSize(4);
}

@Test
@DisplayName("노출 기간 밖 큐레이션 상세 조회는 Product v2에서 찾을 수 없다")
void getDetail_whenCurationOutsideExposureWindow_throwsNotFound() throws IOException {
Expand Down Expand Up @@ -252,12 +290,36 @@ private Long createCuration(
boolean active,
LocalDate exposureStartDate,
LocalDate exposureEndDate) {
return createCuration(
specId, name, "설명", displayOrder, active, exposureStartDate, exposureEndDate);
}

private Long createCuration(
Long specId, String name, String description, int displayOrder, boolean active) {
return createCuration(
specId,
name,
description,
displayOrder,
active,
LocalDate.now().minusDays(1),
LocalDate.now().plusDays(1));
}

private Long createCuration(
Long specId,
String name,
String description,
int displayOrder,
boolean active,
LocalDate exposureStartDate,
LocalDate exposureEndDate) {
return curationFixtureFactory
.saveCuration(
new CurationCreateRequest(
specId,
name,
"설명",
description,
List.of("https://cdn.example.com/cover.jpg"),
exposureStartDate,
exposureEndDate,
Expand Down
8 changes: 8 additions & 0 deletions bottlenote-product-api/src/docs/asciidoc/api/curation/v2.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ include::{snippets}/curation/v2/list/response-body.adoc[]
=== Spec 기반 큐레이션 v2 피드 조회 ===

- Product 피드 화면에서 사용할 spec 기반 큐레이션 목록을 cursor 방식으로 조회합니다.
- `keyword`는 단일 문자열이며 큐레이션 제목/설명, 스펙 제목/설명에 LIKE 조건으로 적용합니다.
- `code`는 `keyword`와 별개의 큐레이션 스펙 코드 exact match 필터입니다.
- `code` 값은 큐레이션 목록 조회 API(`GET /api/v2/curations`)의 `specCode` 또는 상세 조회 API(`GET /api/v2/curations/{curationId}`)의 `spec.code`에서 확인할 수 있습니다.
- 각 item은 상세 응답 형태에서 `spec`을 제외한 구조입니다.
- `payload`는 상세 payload와 동일한 중첩/배열 구조를 유지하되, responseSpec의 `x-feed.enabled = true` 필드만 포함합니다.

Expand All @@ -105,6 +108,11 @@ GET /api/v2/curations/feed
include::{snippets}/curation/v2/feed/curl-request.adoc[]
include::{snippets}/curation/v2/feed/http-request.adoc[]

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

include::{snippets}/curation/v2/feed/query-parameters.adoc[]

[discrete]
==== 응답 파라미터 ====

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ public ResponseEntity<?> getCurations() {

@GetMapping("/feed")
public ResponseEntity<?> getCurationFeed(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String code,
@RequestParam(required = false, defaultValue = "0") Long cursor,
@RequestParam(required = false, defaultValue = "10") Integer size) {
return GlobalResponse.ok(productSpecBasedCurationService.searchFeed(cursor, size));
return GlobalResponse.ok(
productSpecBasedCurationService.searchFeed(keyword, code, cursor, size));
}

@GetMapping("/{curationId}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,50 @@ void searchFeed_whenTastingEventAlcoholsHasNoXFeed_excludesAlcoholsPayload() thr
assertThat(payload.path("isRecruiting").asBoolean()).isTrue();
assertThat(payload.path("applicationLink").asText()).isEqualTo("https://example.com/apply");
}

@Test
@DisplayName("Product feed는 keyword와 code 조건을 분리해 필터링하고 빈 조건은 전체 조건으로 처리한다")
void searchFeed_whenKeywordAndCodeProvided_filtersBySearchContract() throws Exception {
// given
createCuration("제목 매치 큐레이션", "일반 설명", 1, true, List.of(manualItem("제목")));
createCuration("일반 큐레이션", "설명 매치 큐레이션", 2, true, List.of(manualItem("설명")));
Long tastingId = createTastingEventCuration();

// when
MvcTestResult keywordResult =
mockMvcTester
.get()
.uri("/api/v2/curations/feed?keyword=큐레이션&size=10")
.contentType(APPLICATION_JSON)
.exchange();
MvcTestResult codeResult =
mockMvcTester
.get()
.uri("/api/v2/curations/feed?code=WHISKY_TASTING_EVENT&size=10")
.contentType(APPLICATION_JSON)
.exchange();
MvcTestResult negativeResult =
mockMvcTester
.get()
.uri("/api/v2/curations/feed?keyword=큐레이션&code=UNKNOWN_CODE&size=10")
.contentType(APPLICATION_JSON)
.exchange();
MvcTestResult boundaryResult =
mockMvcTester
.get()
.uri("/api/v2/curations/feed?keyword=%20%20%20&code=%20%20%20&size=10")
.contentType(APPLICATION_JSON)
.exchange();

// then
assertThat(dataNode(keywordResult).path("items")).hasSize(3);
assertThat(dataNode(codeResult).path("items")).hasSize(1);
assertThat(dataNode(codeResult).path("items").get(0).path("id").asLong())
.isEqualTo(tastingId);
assertThat(dataNode(negativeResult).path("items")).isEmpty();
assertThat(dataNode(boundaryResult).path("items").isArray()).isTrue();
assertThat(dataNode(boundaryResult).path("pageable").path("pageSize").asInt()).isEqualTo(10);
}
}

@Nested
Expand Down Expand Up @@ -340,8 +384,18 @@ void getDetail_whenMissingCuration_returnsNotFound() {

private Long createCuration(
String name, int displayOrder, boolean active, List<Map<String, Object>> payload) {
return createCuration(name, "통합 테스트 큐레이션", displayOrder, active, payload);
}

private Long createCuration(
String name,
String description,
int displayOrder,
boolean active,
List<Map<String, Object>> payload) {
return createCuration(
name,
description,
displayOrder,
active,
LocalDate.now().minusDays(1),
Expand All @@ -356,12 +410,24 @@ private Long createCuration(
LocalDate exposureStartDate,
LocalDate exposureEndDate,
List<Map<String, Object>> payload) {
return createCuration(
name, "통합 테스트 큐레이션", displayOrder, active, exposureStartDate, exposureEndDate, payload);
}

private Long createCuration(
String name,
String description,
int displayOrder,
boolean active,
LocalDate exposureStartDate,
LocalDate exposureEndDate,
List<Map<String, Object>> payload) {
return adminSpecBasedCurationService
.create(
new CurationCreateRequest(
recommendedSpec.getId(),
name,
"통합 테스트 큐레이션",
description,
List.of("https://cdn.example.com/cover.jpg"),
exposureStartDate,
exposureEndDate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import app.bottlenote.curation.controller.ProductSpecBasedCurationController;
Expand Down Expand Up @@ -102,7 +103,7 @@ void getCurations() throws Exception {
@Test
@DisplayName("spec 기반 큐레이션 v2 피드를 상세 응답 기반 payload로 조회할 수 있다")
void getCurationFeed() throws Exception {
when(productSpecBasedCurationService.searchFeed(0L, 10))
when(productSpecBasedCurationService.searchFeed("페어링", "WHISKY_PAIRING", 0L, 10))
.thenReturn(
CursorResponse.of(
List.of(
Expand Down Expand Up @@ -132,11 +133,21 @@ void getCurationFeed() throws Exception {
10));

mockMvc
.perform(get("/api/v2/curations/feed"))
.perform(get("/api/v2/curations/feed?keyword=페어링&code=WHISKY_PAIRING&cursor=0&size=10"))
.andExpect(status().isOk())
.andDo(
document(
"curation/v2/feed",
queryParameters(
parameterWithName("keyword")
.description("검색어. 큐레이션 제목/설명, 스펙 제목/설명에 LIKE 조건으로 적용됩니다.")
.optional(),
parameterWithName("code")
.description(
"큐레이션 스펙 코드 exact match 필터. 사용 가능한 값은 큐레이션 목록 조회의 specCode 또는 상세 조회의 spec.code에서 확인합니다.")
.optional(),
parameterWithName("cursor").description("조회 시작 커서. 기본값 0").optional(),
parameterWithName("size").description("페이지 크기. 최대 10, 기본값 10").optional()),
responseFields(
fieldWithPath("success").type(BOOLEAN).description("응답 성공 여부"),
fieldWithPath("code").type(NUMBER).description("응답 코드"),
Expand Down