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 @@ -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<ChannelListItemResponse> getChannels(String name, Pageable pageable);
Page<ChannelListItemResponse> getChannels(String name, List<Category> primaryCategories,
Pageable pageable);

ChannelDetailResponse getChannel(UUID id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,8 +39,10 @@ public class ChannelServiceImpl implements ChannelService {

@Override
@Transactional(readOnly = true)
public Page<ChannelListItemResponse> getChannels(String name, Pageable pageable) {
return channelRepository.searchActiveChannels(name, pageable).map(ChannelListItemResponse::from);
public Page<ChannelListItemResponse> getChannels(String name, List<Category> primaryCategories,
Pageable pageable) {
return channelRepository.searchActiveChannels(name, primaryCategories, pageable)
.map(ChannelListItemResponse::from);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Channel> searchActiveChannels(String name, Pageable pageable);
Page<Channel> searchActiveChannels(String name, List<Category> primaryCategories,
Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,14 +29,16 @@ public class ChannelRepositoryImpl implements ChannelRepositoryCustom {
private final JPAQueryFactory queryFactory;

@Override
public Page<Channel> searchActiveChannels(String name, Pageable pageable) {
public Page<Channel> searchActiveChannels(String name, List<Category> primaryCategories,
Pageable pageable) {
QChannel channel = QChannel.channel;
BooleanExpression activeOnly = channel.active.isTrue();
BooleanExpression nameMatch = nameContainsIgnoreCase(channel, name);
BooleanExpression categoryMatch = primaryCategoryIn(channel, primaryCategories);

JPAQuery<Channel> 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());
Expand All @@ -45,7 +48,7 @@ public Page<Channel> searchActiveChannels(String name, Pageable pageable) {
JPAQuery<Long> countQuery = queryFactory
.select(channel.count())
.from(channel)
.where(activeOnly, nameMatch);
.where(activeOnly, nameMatch, categoryMatch);

return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
Expand All @@ -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<Category> primaryCategories) {
return primaryCategories == null || primaryCategories.isEmpty()
? null
: channel.primaryCategory.in(primaryCategories);
}

@SuppressWarnings({"rawtypes", "unchecked"})
private OrderSpecifier<?>[] toOrderSpecifiers(Sort sort) {
PathBuilder<Channel> entityPath = new PathBuilder<>(Channel.class, "channel");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) 여러 업종을 고를 수 있고, 그중 하나에 해당하면 남는다. \
Comment thread
1117mg marked this conversation as resolved.
정렬은 name, createdAt 만 지원한다(형식: sort=name,desc / 기본값 name,asc)""")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공",
useReturnTypeSchema = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ public class ChannelController implements ChannelApiDocs {
public ApiResponse<PageResponse<ChannelListItemResponse>> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,6 +14,9 @@ public record ChannelSearchRequest(
@Schema(description = "채널명 검색어", example = "11번가")
String name,

@Schema(description = "대표 업종 코드값", example = "SHOPPING_COMMERCE")
List<Category> primaryCategory,

@Schema(description = "페이지 번호(0-base)", example = "0")
Integer page,

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -39,28 +42,114 @@ void searchActiveChannels_unpagedReturnsAll() {
.toList();

Page<Channel> 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);
assertThat(page.getTotalElements()).isEqualTo(activeNames.size());
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());
}

@Test
@DisplayName("page 지정 시에는 요청한 크기만큼만 반환한다")
void searchActiveChannels_pagedLimitsContent() {
Page<Channel> 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<String> expected = namesOf(category);

Page<Channel> 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<Category> categories = activeChannels().stream()
.map(Channel::getPrimaryCategory)
.distinct()
.limit(2)
.toList();
assumeTrue(categories.size() == 2, "업종이 둘 이상인 카탈로그가 필요하다");
Comment thread
1117mg marked this conversation as resolved.
List<String> expected = categories.stream().flatMap(category -> namesOf(category).stream())
.toList();

Page<Channel> 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<Channel> matched = channelRepository.searchActiveChannels(
target.getName(), List.of(target.getPrimaryCategory()),
Pageable.unpaged(Sort.by("name")));
Page<Channel> 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<String> namesOf(Category category) {
return activeChannels().stream()
.filter(channel -> category.equals(channel.getPrimaryCategory()))
.map(Channel::getName)
.toList();
}

private List<Channel> 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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -65,7 +66,7 @@ void getChannels_noParams_returnsAll() throws Exception {
ArgumentCaptor<Pageable> pageableCaptor = ArgumentCaptor.forClass(Pageable.class);
List<ChannelListItemResponse> 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"))
Expand All @@ -78,25 +79,90 @@ 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<Pageable> 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"))
.andExpect(status().isOk());
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")),
Expand All @@ -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"))
Expand Down
Loading