From d94039212aadfee189dc124a6f7db8e382840d21 Mon Sep 17 00:00:00 2001 From: Yelim Lee Date: Fri, 7 Aug 2026 20:33:37 +0900 Subject: [PATCH] feat(channel): filter the channel list by primary category --- .../channel/application/ChannelService.java | 5 +- .../application/ChannelServiceImpl.java | 7 +- .../repository/ChannelRepositoryCustom.java | 5 +- .../repository/ChannelRepositoryImpl.java | 15 ++- .../channel/presentation/ChannelApiDocs.java | 2 + .../presentation/ChannelController.java | 4 +- .../dto/ChannelSearchRequest.java | 5 + .../channel/domain/ChannelRepositoryTest.java | 95 ++++++++++++++++++- .../presentation/ChannelControllerTest.java | 76 ++++++++++++++- 9 files changed, 197 insertions(+), 17 deletions(-) diff --git a/src/main/java/chaeso/zip/server/channel/application/ChannelService.java b/src/main/java/chaeso/zip/server/channel/application/ChannelService.java index d1eacb0..13d535c 100644 --- a/src/main/java/chaeso/zip/server/channel/application/ChannelService.java +++ b/src/main/java/chaeso/zip/server/channel/application/ChannelService.java @@ -2,13 +2,16 @@ import chaeso.zip.server.channel.application.dto.ChannelDetailResponse; import chaeso.zip.server.channel.application.dto.ChannelListItemResponse; +import chaeso.zip.server.channel.domain.vo.Category; +import java.util.List; import java.util.UUID; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface ChannelService { - Page getChannels(String name, Pageable pageable); + Page getChannels(String name, List primaryCategories, + Pageable pageable); ChannelDetailResponse getChannel(UUID id); } diff --git a/src/main/java/chaeso/zip/server/channel/application/ChannelServiceImpl.java b/src/main/java/chaeso/zip/server/channel/application/ChannelServiceImpl.java index 22222a5..3e39f10 100644 --- a/src/main/java/chaeso/zip/server/channel/application/ChannelServiceImpl.java +++ b/src/main/java/chaeso/zip/server/channel/application/ChannelServiceImpl.java @@ -15,6 +15,7 @@ import chaeso.zip.server.channel.domain.repository.ChannelProductRepository; import chaeso.zip.server.channel.domain.repository.ChannelReferenceRepository; import chaeso.zip.server.channel.domain.repository.ChannelRepository; +import chaeso.zip.server.channel.domain.vo.Category; import java.util.List; import java.util.Map; import java.util.Objects; @@ -38,8 +39,10 @@ public class ChannelServiceImpl implements ChannelService { @Override @Transactional(readOnly = true) - public Page getChannels(String name, Pageable pageable) { - return channelRepository.searchActiveChannels(name, pageable).map(ChannelListItemResponse::from); + public Page getChannels(String name, List primaryCategories, + Pageable pageable) { + return channelRepository.searchActiveChannels(name, primaryCategories, pageable) + .map(ChannelListItemResponse::from); } @Override diff --git a/src/main/java/chaeso/zip/server/channel/domain/repository/ChannelRepositoryCustom.java b/src/main/java/chaeso/zip/server/channel/domain/repository/ChannelRepositoryCustom.java index 5baae76..02a5376 100644 --- a/src/main/java/chaeso/zip/server/channel/domain/repository/ChannelRepositoryCustom.java +++ b/src/main/java/chaeso/zip/server/channel/domain/repository/ChannelRepositoryCustom.java @@ -1,10 +1,13 @@ package chaeso.zip.server.channel.domain.repository; import chaeso.zip.server.channel.domain.entity.Channel; +import chaeso.zip.server.channel.domain.vo.Category; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface ChannelRepositoryCustom { - Page searchActiveChannels(String name, Pageable pageable); + Page searchActiveChannels(String name, List primaryCategories, + Pageable pageable); } diff --git a/src/main/java/chaeso/zip/server/channel/domain/repository/ChannelRepositoryImpl.java b/src/main/java/chaeso/zip/server/channel/domain/repository/ChannelRepositoryImpl.java index c4c1c68..5ead0f7 100644 --- a/src/main/java/chaeso/zip/server/channel/domain/repository/ChannelRepositoryImpl.java +++ b/src/main/java/chaeso/zip/server/channel/domain/repository/ChannelRepositoryImpl.java @@ -2,6 +2,7 @@ import chaeso.zip.server.channel.domain.entity.Channel; import chaeso.zip.server.channel.domain.entity.QChannel; +import chaeso.zip.server.channel.domain.vo.Category; import chaeso.zip.server.common.exception.BusinessException; import chaeso.zip.server.common.exception.CommonErrorCode; import com.querydsl.core.types.Order; @@ -28,14 +29,16 @@ public class ChannelRepositoryImpl implements ChannelRepositoryCustom { private final JPAQueryFactory queryFactory; @Override - public Page searchActiveChannels(String name, Pageable pageable) { + public Page searchActiveChannels(String name, List primaryCategories, + Pageable pageable) { QChannel channel = QChannel.channel; BooleanExpression activeOnly = channel.active.isTrue(); BooleanExpression nameMatch = nameContainsIgnoreCase(channel, name); + BooleanExpression categoryMatch = primaryCategoryIn(channel, primaryCategories); JPAQuery contentQuery = queryFactory .selectFrom(channel) - .where(activeOnly, nameMatch) + .where(activeOnly, nameMatch, categoryMatch) .orderBy(toOrderSpecifiers(pageable.getSort())); if (pageable.isPaged()) { // unpaged 는 offset/limit 없이 전체를 조회한다 contentQuery.offset(pageable.getOffset()).limit(pageable.getPageSize()); @@ -45,7 +48,7 @@ public Page searchActiveChannels(String name, Pageable pageable) { JPAQuery countQuery = queryFactory .select(channel.count()) .from(channel) - .where(activeOnly, nameMatch); + .where(activeOnly, nameMatch, categoryMatch); return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); } @@ -54,6 +57,12 @@ private BooleanExpression nameContainsIgnoreCase(QChannel channel, String name) return StringUtils.hasText(name) ? channel.name.containsIgnoreCase(name.trim()) : null; } + private BooleanExpression primaryCategoryIn(QChannel channel, List primaryCategories) { + return primaryCategories == null || primaryCategories.isEmpty() + ? null + : channel.primaryCategory.in(primaryCategories); + } + @SuppressWarnings({"rawtypes", "unchecked"}) private OrderSpecifier[] toOrderSpecifiers(Sort sort) { PathBuilder entityPath = new PathBuilder<>(Channel.class, "channel"); diff --git a/src/main/java/chaeso/zip/server/channel/presentation/ChannelApiDocs.java b/src/main/java/chaeso/zip/server/channel/presentation/ChannelApiDocs.java index f581c01..95ec949 100644 --- a/src/main/java/chaeso/zip/server/channel/presentation/ChannelApiDocs.java +++ b/src/main/java/chaeso/zip/server/channel/presentation/ChannelApiDocs.java @@ -123,6 +123,8 @@ public interface ChannelApiDocs { page/size 를 모두 생략하면 페이지네이션 없이 전체 채널을 반환한다. \ page 또는 size 중 하나라도 지정하면 페이지 조회로 동작한다(생략된 값은 page=0, size=12). size 는 최대 100. \ name 지정 시 채널명으로 필터링. \ + primaryCategory 지정 시 그 대표 업종의 채널만 반환한다. 여러 번 넘기거나(primaryCategory=A&primaryCategory=B) \ + 쉼표로 이어(primaryCategory=A,B) 여러 업종을 고를 수 있고, 그중 하나에 해당하면 남는다. \ 정렬은 name, createdAt 만 지원한다(형식: sort=name,desc / 기본값 name,asc)""") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true, diff --git a/src/main/java/chaeso/zip/server/channel/presentation/ChannelController.java b/src/main/java/chaeso/zip/server/channel/presentation/ChannelController.java index d418a76..966ed33 100644 --- a/src/main/java/chaeso/zip/server/channel/presentation/ChannelController.java +++ b/src/main/java/chaeso/zip/server/channel/presentation/ChannelController.java @@ -28,8 +28,8 @@ public class ChannelController implements ChannelApiDocs { public ApiResponse> getChannels( @ParameterObject ChannelSearchRequest request, @SortDefault(sort = "name") @ParameterObject Sort sort) { - return ApiResponse.success(PageResponse.from( - channelService.getChannels(request.name(), request.toPageable(sort)))); + return ApiResponse.success(PageResponse.from(channelService.getChannels( + request.name(), request.primaryCategory(), request.toPageable(sort)))); } @Override diff --git a/src/main/java/chaeso/zip/server/channel/presentation/dto/ChannelSearchRequest.java b/src/main/java/chaeso/zip/server/channel/presentation/dto/ChannelSearchRequest.java index 5d9eff2..bf7660c 100644 --- a/src/main/java/chaeso/zip/server/channel/presentation/dto/ChannelSearchRequest.java +++ b/src/main/java/chaeso/zip/server/channel/presentation/dto/ChannelSearchRequest.java @@ -1,8 +1,10 @@ package chaeso.zip.server.channel.presentation.dto; +import chaeso.zip.server.channel.domain.vo.Category; import chaeso.zip.server.common.exception.BusinessException; import chaeso.zip.server.common.exception.CommonErrorCode; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -12,6 +14,9 @@ public record ChannelSearchRequest( @Schema(description = "채널명 검색어", example = "11번가") String name, + @Schema(description = "대표 업종 코드값", example = "SHOPPING_COMMERCE") + List primaryCategory, + @Schema(description = "페이지 번호(0-base)", example = "0") Integer page, diff --git a/src/test/java/chaeso/zip/server/channel/domain/ChannelRepositoryTest.java b/src/test/java/chaeso/zip/server/channel/domain/ChannelRepositoryTest.java index 14a606c..8eeac69 100644 --- a/src/test/java/chaeso/zip/server/channel/domain/ChannelRepositoryTest.java +++ b/src/test/java/chaeso/zip/server/channel/domain/ChannelRepositoryTest.java @@ -1,13 +1,16 @@ package chaeso.zip.server.channel.domain; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import chaeso.zip.server.channel.domain.entity.Channel; import chaeso.zip.server.channel.domain.entity.ChannelProduct; import chaeso.zip.server.channel.domain.repository.ChannelPricingRepository; import chaeso.zip.server.channel.domain.repository.ChannelProductRepository; import chaeso.zip.server.channel.domain.repository.ChannelRepository; +import chaeso.zip.server.channel.domain.vo.Category; import chaeso.zip.server.support.PostgresDataJpaTest; +import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -39,7 +42,7 @@ void searchActiveChannels_unpagedReturnsAll() { .toList(); Page page = - channelRepository.searchActiveChannels(null, Pageable.unpaged(Sort.by("name"))); + channelRepository.searchActiveChannels(null, null, Pageable.unpaged(Sort.by("name"))); assertThat(page.getContent()).extracting(Channel::getName) .containsExactlyInAnyOrderElementsOf(activeNames); @@ -47,7 +50,7 @@ void searchActiveChannels_unpagedReturnsAll() { assertThat(page.getTotalPages()).isEqualTo(1); // 이름순 정렬은 전체 조회에도 적용된다 (정렬 기준은 DB collation) assertThat(page.getContent().getFirst().getName()) - .isEqualTo(channelRepository.searchActiveChannels(null, PageRequest.of(0, 1, Sort.by("name"))) + .isEqualTo(channelRepository.searchActiveChannels(null, null, PageRequest.of(0, 1, Sort.by("name"))) .getContent().getFirst().getName()); } @@ -55,12 +58,98 @@ void searchActiveChannels_unpagedReturnsAll() { @DisplayName("page 지정 시에는 요청한 크기만큼만 반환한다") void searchActiveChannels_pagedLimitsContent() { Page page = - channelRepository.searchActiveChannels(null, PageRequest.of(0, 2, Sort.by("name"))); + channelRepository.searchActiveChannels(null, null, PageRequest.of(0, 2, Sort.by("name"))); assertThat(page.getContent()).hasSizeLessThanOrEqualTo(2); assertThat(page.getSize()).isEqualTo(2); } + @Test + @DisplayName("primaryCategory 를 주면 그 업종의 활성 채널만 반환한다") + void searchActiveChannels_filtersByPrimaryCategory() { + Category category = activeChannels().getFirst().getPrimaryCategory(); + List expected = namesOf(category); + + Page page = channelRepository.searchActiveChannels( + null, List.of(category), Pageable.unpaged(Sort.by("name"))); + + assertThat(page.getContent()).extracting(Channel::getName) + .containsExactlyInAnyOrderElementsOf(expected); + assertThat(page.getContent()).allSatisfy( + channel -> assertThat(channel.getPrimaryCategory()).isEqualTo(category)); + // 업종으로 좁혔으니 전체 조회보다 많을 수 없고, 카운트 쿼리에도 같은 조건이 걸린다 + assertThat(page.getTotalElements()).isEqualTo(expected.size()); + } + + @Test + @DisplayName("업종을 여러 개 주면 그중 하나에 해당하는 채널을 모두 반환한다") + void searchActiveChannels_filtersByAnyOfPrimaryCategories() { + List categories = activeChannels().stream() + .map(Channel::getPrimaryCategory) + .distinct() + .limit(2) + .toList(); + assumeTrue(categories.size() == 2, "업종이 둘 이상인 카탈로그가 필요하다"); + List expected = categories.stream().flatMap(category -> namesOf(category).stream()) + .toList(); + + Page page = channelRepository.searchActiveChannels( + null, categories, Pageable.unpaged(Sort.by("name"))); + + assertThat(page.getContent()).extracting(Channel::getName) + .containsExactlyInAnyOrderElementsOf(expected); + assertThat(page.getTotalElements()).isEqualTo(expected.size()); + assertThat(page.getTotalElements()).isGreaterThan(channelRepository + .searchActiveChannels(null, List.of(categories.getFirst()), Pageable.unpaged()) + .getTotalElements()); + } + + @Test + @DisplayName("빈 업종 목록은 조건으로 걸지 않아 전체 조회와 같다") + void searchActiveChannels_emptyCategoriesDoNotFilter() { + long all = channelRepository.searchActiveChannels(null, null, Pageable.unpaged()) + .getTotalElements(); + + assertThat(channelRepository.searchActiveChannels(null, List.of(), Pageable.unpaged()) + .getTotalElements()).isEqualTo(all); + } + + @Test + @DisplayName("채널명과 primaryCategory 를 함께 주면 두 조건을 모두 만족하는 채널만 반환한다") + void searchActiveChannels_combinesNameAndPrimaryCategory() { + Channel target = activeChannels().getFirst(); + + Page matched = channelRepository.searchActiveChannels( + target.getName(), List.of(target.getPrimaryCategory()), + Pageable.unpaged(Sort.by("name"))); + Page mismatched = channelRepository.searchActiveChannels( + target.getName(), List.of(otherCategoryThan(target.getPrimaryCategory())), + Pageable.unpaged(Sort.by("name"))); + + assertThat(matched.getContent()).extracting(Channel::getName).contains(target.getName()); + assertThat(mismatched.getContent()).extracting(Channel::getName) + .doesNotContain(target.getName()); + } + + private List namesOf(Category category) { + return activeChannels().stream() + .filter(channel -> category.equals(channel.getPrimaryCategory())) + .map(Channel::getName) + .toList(); + } + + private List activeChannels() { + return channelRepository.findAll().stream().filter(Channel::isActive).toList(); + } + + /** 대상 채널의 업종이 아닌 아무 업종. 이름이 맞아도 업종이 다르면 걸러지는지 보는 데 쓴다. */ + private Category otherCategoryThan(Category category) { + return Arrays.stream(Category.values()) + .filter(candidate -> candidate != category) + .findFirst() + .orElseThrow(); + } + @Test @DisplayName("전체 채널·상품·단가의 배열/단일 enum 이 예외 없이 매핑된다 (빈 배열·null 포함)") void arraysAndEnumsMapForAllRows() { diff --git a/src/test/java/chaeso/zip/server/channel/presentation/ChannelControllerTest.java b/src/test/java/chaeso/zip/server/channel/presentation/ChannelControllerTest.java index fb2c803..158bc61 100644 --- a/src/test/java/chaeso/zip/server/channel/presentation/ChannelControllerTest.java +++ b/src/test/java/chaeso/zip/server/channel/presentation/ChannelControllerTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; @@ -65,7 +66,7 @@ void getChannels_noParams_returnsAll() throws Exception { ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); List channels = List.of( channelListItem("11번가 광고"), channelListItem("네이버 GFA"), channelListItem("카카오모먼트")); - given(channelService.getChannels(isNull(), any(Pageable.class))) + given(channelService.getChannels(isNull(), isNull(), any(Pageable.class))) .willReturn(new PageImpl<>(channels, Pageable.unpaged(), channels.size())); mockMvc.perform(get("/api/v1/channels")) @@ -78,17 +79,82 @@ void getChannels_noParams_returnsAll() throws Exception { .andExpect(jsonPath("$.data.first").value(true)) .andExpect(jsonPath("$.data.last").value(true)); - verify(channelService).getChannels(isNull(), pageableCaptor.capture()); + verify(channelService).getChannels(isNull(), isNull(), pageableCaptor.capture()); Pageable pageable = pageableCaptor.getValue(); assertThat(pageable.isUnpaged()).isTrue(); assertThat(pageable.getSort()).isEqualTo(Sort.by("name")); } + @Test + @DisplayName("primaryCategory 를 지정하면 업종을 그대로 조회에 넘긴다") + void getChannels_withPrimaryCategory() throws Exception { + given(channelService.getChannels(any(), any(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(), Pageable.unpaged(), 0)); + + mockMvc.perform(get("/api/v1/channels").param("primaryCategory", "SHOPPING_COMMERCE")) + .andExpect(status().isOk()); + + verify(channelService) + .getChannels(isNull(), eq(List.of(Category.SHOPPING_COMMERCE)), any(Pageable.class)); + } + + @Test + @DisplayName("primaryCategory 를 여러 번 넘기면 고른 업종을 순서대로 모두 넘긴다") + void getChannels_withRepeatedPrimaryCategory() throws Exception { + given(channelService.getChannels(any(), any(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(), Pageable.unpaged(), 0)); + + mockMvc.perform(get("/api/v1/channels") + .param("primaryCategory", "SHOPPING_COMMERCE") + .param("primaryCategory", "GAME")) + .andExpect(status().isOk()); + + verify(channelService).getChannels(isNull(), + eq(List.of(Category.SHOPPING_COMMERCE, Category.GAME)), any(Pageable.class)); + } + + @Test + @DisplayName("primaryCategory 를 쉼표로 이어 넘겨도 여러 업종으로 인식한다") + void getChannels_withCommaSeparatedPrimaryCategory() throws Exception { + given(channelService.getChannels(any(), any(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(), Pageable.unpaged(), 0)); + + mockMvc.perform(get("/api/v1/channels").param("primaryCategory", "SHOPPING_COMMERCE,GAME")) + .andExpect(status().isOk()); + + verify(channelService).getChannels(isNull(), + eq(List.of(Category.SHOPPING_COMMERCE, Category.GAME)), any(Pageable.class)); + } + + @Test + @DisplayName("채널명과 업종을 함께 주면 둘 다 조회에 넘긴다") + void getChannels_withNameAndPrimaryCategory() throws Exception { + given(channelService.getChannels(any(), any(), any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(), Pageable.unpaged(), 0)); + + mockMvc.perform(get("/api/v1/channels") + .param("name", "11번가") + .param("primaryCategory", "SHOPPING_COMMERCE")) + .andExpect(status().isOk()); + + verify(channelService).getChannels( + eq("11번가"), eq(List.of(Category.SHOPPING_COMMERCE)), any(Pageable.class)); + } + + @Test + @DisplayName("없는 업종 코드값을 주면 400 C-001 을 반환한다") + void getChannels_invalidPrimaryCategory() throws Exception { + mockMvc.perform(get("/api/v1/channels").param("primaryCategory", "NOT_A_CATEGORY")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value(CommonErrorCode.INVALID_INPUT_VALUE.getCode())); + } + @Test @DisplayName("page 또는 size 를 지정하면 해당 값으로 페이지 조회한다 (생략된 값은 page=0, size=12)") void getChannels_withPagingParams() throws Exception { ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); - given(channelService.getChannels(any(), any(Pageable.class))) + given(channelService.getChannels(any(), any(), any(Pageable.class))) .willReturn(new PageImpl<>(List.of(), PageRequest.of(0, 12, Sort.by("name")), 0)); mockMvc.perform(get("/api/v1/channels").param("size", "5")) @@ -96,7 +162,7 @@ void getChannels_withPagingParams() throws Exception { mockMvc.perform(get("/api/v1/channels").param("page", "2")) .andExpect(status().isOk()); - verify(channelService, times(2)).getChannels(isNull(), pageableCaptor.capture()); + verify(channelService, times(2)).getChannels(isNull(), isNull(), pageableCaptor.capture()); assertThat(pageableCaptor.getAllValues()) .containsExactly( PageRequest.of(0, 5, Sort.by("name")), @@ -117,7 +183,7 @@ void getChannels_invalidSize(String size) throws Exception { @Test @DisplayName("size 가 상한과 같으면 통과한다") void getChannels_maxSize() throws Exception { - given(channelService.getChannels(any(), any(Pageable.class))) + given(channelService.getChannels(any(), any(), any(Pageable.class))) .willReturn(Page.empty()); mockMvc.perform(get("/api/v1/channels").param("size", "100"))