diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f901252a..e0ffa1f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,60 +1,60 @@ -#name: Clokey CI -# -#on: -# pull_request: -# branches: [ "main", "develop" ] -# paths: -# - "clokey-api/**" -# - "clokey-domain/**" -# - "clokey-common/**" -# - "clokey-common-web/**" -# - ".github/workflows/ci.yml" -# -#permissions: -# contents: read -# -#jobs: -# build: -# runs-on: ubuntu-latest -# -# steps: -# - uses: actions/checkout@v4 -# with: -# clean: true -# -# - name: Setup Java 21 -# uses: actions/setup-java@v4 -# with: -# distribution: 'corretto' -# java-version: '21' -# -# - name: Start Redis container for test -# run: docker compose -f ./docker-compose-test.yml up -d -# -# - name: Gradle Caching -# uses: actions/cache@v4 -# with: -# path: | -# ~/.gradle/caches -# ~/.gradle/wrapper -# key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} -# restore-keys: ${{ runner.os }}-gradle- -# -# - name: Grant execute permission for gradlew -# run: chmod +x ./gradlew -# -# - name: Spotless Check -# run: ./gradlew spotlessCheck -# -# - name: SonarCloud Caching -# uses: actions/cache@v4 -# with: -# path: ~/.sonar/cache -# key: ${{ runner.os }}-sonar -# restore-keys: ${{ runner.os }}-sonar -# -# - name: Test and Analyze -# env: -# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -# run: ./gradlew test sonar --info --stacktrace +name: Clokey CI + +on: + pull_request: + branches: [ "main", "develop" ] + paths: + - "clokey-api/**" + - "clokey-domain/**" + - "clokey-common/**" + - "clokey-common-web/**" + - ".github/workflows/ci.yml" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + clean: true + + - name: Setup Java 21 + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '21' + + - name: Start Redis container for test + run: docker compose -f ./docker-compose-test.yml up -d + + - name: Gradle Caching + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Spotless Check + run: ./gradlew spotlessCheck + + - name: SonarCloud Caching + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Test and Analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: ./gradlew test sonar --info --stacktrace diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 09916f8b..757bc108 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -1,7 +1,7 @@ name: Clokey-Dev CD on: - pull_request: + push: branches: [ develop ] jobs: diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/repository/ClothRepository.java b/clokey-api/src/main/java/org/clokey/domain/cloth/repository/ClothRepository.java index f215d617..3ed0386c 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/repository/ClothRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/repository/ClothRepository.java @@ -3,4 +3,6 @@ import org.clokey.cloth.entity.Cloth; import org.springframework.data.jpa.repository.JpaRepository; -public interface ClothRepository extends JpaRepository, ClothRepositoryCustom {} +public interface ClothRepository extends JpaRepository, ClothRepositoryCustom { + boolean existsByMemberId(Long memberId); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/coordinate/repository/CoordinateRepository.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/repository/CoordinateRepository.java index bb9ca49c..b0f66e22 100644 --- a/clokey-api/src/main/java/org/clokey/domain/coordinate/repository/CoordinateRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/coordinate/repository/CoordinateRepository.java @@ -30,4 +30,6 @@ default boolean existsDailyCoordinateByDateAndMemberId(LocalDate date, Long memb @Query("select c from Coordinate c where c.member.id = :memberId and c.liked = true") List findLikedCoordinatesByMemberId(Long memberId); + + boolean existsByMemberId(Long memberId); } diff --git a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryRepository.java b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryRepository.java index 923cc66d..69909dda 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryRepository.java @@ -3,4 +3,6 @@ import org.clokey.history.entity.History; import org.springframework.data.jpa.repository.JpaRepository; -public interface HistoryRepository extends JpaRepository {} +public interface HistoryRepository extends JpaRepository { + boolean existsByMemberId(Long memberId); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/statistics/controller/StatisticsController.java b/clokey-api/src/main/java/org/clokey/domain/statistics/controller/StatisticsController.java new file mode 100644 index 00000000..ea7181d5 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/statistics/controller/StatisticsController.java @@ -0,0 +1,64 @@ +package org.clokey.domain.statistics.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.clokey.cloth.enums.Season; +import org.clokey.code.GlobalBaseSuccessCode; +import org.clokey.domain.statistics.dto.response.ClosetUtilizationResponse; +import org.clokey.domain.statistics.dto.response.FavoriteCategoryItemsResponse; +import org.clokey.domain.statistics.dto.response.FavoriteItemsResponse; +import org.clokey.domain.statistics.dto.response.StatisticsCheckConditionResponse; +import org.clokey.domain.statistics.service.StatisticsService; +import org.clokey.response.BaseResponse; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * TODO: 앞으로, 운영이 활성화 되고 트래픽이 많아지게 되면 해당 API의 성능에 대한 점검이 필요합니다. 통계 테이블을 따로 분리하거나, Redis를 통한 캐싱을 고려해 + * 보세요. + */ +@RestController +@RequestMapping("/statistics") +@RequiredArgsConstructor +@Tag(name = "16. 통계 API", description = "통계 관련 API입니다.") +@Validated +public class StatisticsController { + + private final StatisticsService statisticsService; + + @GetMapping("/check-conditions") + @Operation(summary = "통계 최소 조건 확인", description = "통계 집계가 가능한 최소 조건을 확인하는 API입니다.") + public BaseResponse checkStatisticsCondition() { + StatisticsCheckConditionResponse response = statisticsService.checkStatisticsCondition(); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } + + @GetMapping("/favorite-category-items") + @Operation(summary = "카테고리별 최애 아이템 조회", description = "카테고리별 아이템의 개수와 점유율을 조회하는 API입니다..") + public BaseResponse getFavoriteCategoryItems( + @Parameter(description = "카테고리 ID") @RequestParam Long categoryId) { + FavoriteCategoryItemsResponse response = + statisticsService.getFavoriteCategoryItems(categoryId); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } + + @GetMapping("/favorite-items") + @Operation(summary = "옷장 아이템 통계 조회", description = "옷장 아이템 통계를 조회합니다.") + public BaseResponse getFavoriteItems() { + FavoriteItemsResponse response = statisticsService.getFavoriteItems(); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } + + @GetMapping("/closet-utilization") + @Operation( + summary = "옷장 활용도 조회", + description = + "시즌별 옷장 활용도를 조회합니다. HistoryClothTag에 태그되었거나 Daily Coordinate에 포함된 옷을 활용된 것으로 간주합니다.") + public BaseResponse getClosetUtilization( + @Parameter(description = "시즌", example = "SPRING") @RequestParam Season season) { + ClosetUtilizationResponse response = statisticsService.getClosetUtilization(season); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/statistics/dto/CategoryCountDto.java b/clokey-api/src/main/java/org/clokey/domain/statistics/dto/CategoryCountDto.java new file mode 100644 index 00000000..02e3ee7f --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/statistics/dto/CategoryCountDto.java @@ -0,0 +1,4 @@ +package org.clokey.domain.statistics.dto; + +/** 1차 카테고리에 속한 옷들을 집계하기 위한 통계용 DTO입니다. */ +public record CategoryCountDto(Long categoryId, String categoryName, Long count) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/statistics/dto/response/ClosetUtilizationResponse.java b/clokey-api/src/main/java/org/clokey/domain/statistics/dto/response/ClosetUtilizationResponse.java new file mode 100644 index 00000000..72a0c61a --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/statistics/dto/response/ClosetUtilizationResponse.java @@ -0,0 +1,27 @@ +package org.clokey.domain.statistics.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record ClosetUtilizationResponse( + @Schema(description = "활용된 옷의 개수", example = "15") Long utilizedCount, + @Schema(description = "활용되지 않은 옷의 개수", example = "10") Long unutilizedCount, + @Schema(description = "활용된 옷 목록") List utilizedClothes, + @Schema(description = "활용되지 않은 옷 목록") + List unutilizedClothes) { + public static ClosetUtilizationResponse of( + Long utilizedCount, + Long unutilizedCount, + List utilizedClothes, + List unutilizedClothes) { + return new ClosetUtilizationResponse( + utilizedCount, unutilizedCount, utilizedClothes, unutilizedClothes); + } + + @Schema(name = "ClosetUtilizationResponsePayload", description = "옷 정보") + public record Payload( + @Schema(description = "옷 이미지 URL", example = "https://example.com/image.jpg") + String imageUrl, + @Schema(description = "옷 이름", example = "맨투맨") String name, + @Schema(description = "브랜드", example = "나이키") String brand) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/statistics/dto/response/FavoriteCategoryItemsResponse.java b/clokey-api/src/main/java/org/clokey/domain/statistics/dto/response/FavoriteCategoryItemsResponse.java new file mode 100644 index 00000000..86fc64b9 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/statistics/dto/response/FavoriteCategoryItemsResponse.java @@ -0,0 +1,19 @@ +package org.clokey.domain.statistics.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record FavoriteCategoryItemsResponse( + @Schema(description = "카테고리별 최애 아이템 목록") + List payloads) { + public static FavoriteCategoryItemsResponse of(List payloads) { + return new FavoriteCategoryItemsResponse(payloads); + } + + @Schema(name = "FavoriteCategoryItemsResponsePayload", description = "카테고리별 최애 아이템 정보") + public record Payload( + @Schema(description = "2차 카테고리 ID (기타의 경우 null)", example = "10") Long categoryId, + @Schema(description = "2차 카테고리 이름", example = "맨투맨") String categoryName, + @Schema(description = "점유율", example = "0.35") Double occupancyRate, + @Schema(description = "옷의 개수", example = "15") Long clothCount) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/statistics/dto/response/FavoriteItemsResponse.java b/clokey-api/src/main/java/org/clokey/domain/statistics/dto/response/FavoriteItemsResponse.java new file mode 100644 index 00000000..e10034e0 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/statistics/dto/response/FavoriteItemsResponse.java @@ -0,0 +1,17 @@ +package org.clokey.domain.statistics.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record FavoriteItemsResponse( + @Schema(description = "카테고리별 최애 아이템 목록") List payloads) { + public static FavoriteItemsResponse of(List payloads) { + return new FavoriteItemsResponse(payloads); + } + + @Schema(name = "FavoriteItemsResponsePayload", description = "카테고리별 최애 아이템 정보") + public record Payload( + @Schema(description = "2차 카테고리 ID", example = "10") Long categoryId, + @Schema(description = "2차 카테고리 이름", example = "맨투맨") String categoryName, + @Schema(description = "옷의 개수", example = "15") Long clothCount) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/statistics/dto/response/StatisticsCheckConditionResponse.java b/clokey-api/src/main/java/org/clokey/domain/statistics/dto/response/StatisticsCheckConditionResponse.java new file mode 100644 index 00000000..614c46ac --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/statistics/dto/response/StatisticsCheckConditionResponse.java @@ -0,0 +1,10 @@ +package org.clokey.domain.statistics.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record StatisticsCheckConditionResponse( + @Schema(description = "통계 집계 가능 여부", example = "true") boolean canAggregate) { + public static StatisticsCheckConditionResponse of(boolean canAggregate) { + return new StatisticsCheckConditionResponse(canAggregate); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/statistics/exception/StatisticsErrorCode.java b/clokey-api/src/main/java/org/clokey/domain/statistics/exception/StatisticsErrorCode.java new file mode 100644 index 00000000..1c2f6367 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/statistics/exception/StatisticsErrorCode.java @@ -0,0 +1,21 @@ +package org.clokey.domain.statistics.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.clokey.dto.ErrorReasonDto; +import org.clokey.exception.BaseErrorCode; + +@Getter +@AllArgsConstructor +public enum StatisticsErrorCode implements BaseErrorCode { + NOT_PARENT_CATEGORY(400, "STATISTICS_4001", "1차 카테고리가 아닙니다."); + + private final int status; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getErrorReason() { + return ErrorReasonDto.of(status, code, message); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/statistics/repository/StatisticsRepositoryCustom.java b/clokey-api/src/main/java/org/clokey/domain/statistics/repository/StatisticsRepositoryCustom.java new file mode 100644 index 00000000..f798f21f --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/statistics/repository/StatisticsRepositoryCustom.java @@ -0,0 +1,18 @@ +package org.clokey.domain.statistics.repository; + +import java.util.List; +import java.util.Set; +import org.clokey.cloth.enums.Season; +import org.clokey.domain.statistics.dto.CategoryCountDto; + +public interface StatisticsRepositoryCustom { + List countClothesByChildCategories(Long memberId, Long parentCategoryId); + + List countClothesByCategoriesTopN(Long memberId, int limit); + + long countClothesBySeason(Long memberId, Season season); + + Set findUtilizedClothIds(Long memberId, Season season); + + List findAllClothesBySeason(Long memberId, Season season); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/statistics/repository/StatisticsRepositoryImpl.java b/clokey-api/src/main/java/org/clokey/domain/statistics/repository/StatisticsRepositoryImpl.java new file mode 100644 index 00000000..3bcdaeba --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/statistics/repository/StatisticsRepositoryImpl.java @@ -0,0 +1,110 @@ +package org.clokey.domain.statistics.repository; + +import static org.clokey.cloth.entity.QCloth.cloth; +import static org.clokey.coordinate.entity.QCoordinate.coordinate; +import static org.clokey.coordinate.entity.QCoordinateCloth.coordinateCloth; +import static org.clokey.history.entity.QHistoryClothTag.historyClothTag; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.clokey.cloth.enums.Season; +import org.clokey.coordinate.enums.CoordinateType; +import org.clokey.domain.statistics.dto.CategoryCountDto; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class StatisticsRepositoryImpl implements StatisticsRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List countClothesByChildCategories( + Long memberId, Long parentCategoryId) { + return queryFactory + .select( + Projections.constructor( + CategoryCountDto.class, + cloth.category.id, + cloth.category.name, + cloth.id.count())) + .from(cloth) + .where(cloth.member.id.eq(memberId), cloth.category.parent.id.eq(parentCategoryId)) + .groupBy(cloth.category) + .orderBy(cloth.id.count().desc()) + .fetch(); + } + + @Override + public List countClothesByCategoriesTopN(Long memberId, int limit) { + return queryFactory + .select( + Projections.constructor( + CategoryCountDto.class, + cloth.category.id, + cloth.category.name, + cloth.id.count())) + .from(cloth) + .where(cloth.member.id.eq(memberId)) + .groupBy(cloth.category) + .orderBy(cloth.id.count().desc()) + .limit(limit) + .fetch(); + } + + @Override + public long countClothesBySeason(Long memberId, Season season) { + return queryFactory + .select(cloth.id.count()) + .from(cloth) + .where(cloth.member.id.eq(memberId), cloth.season.eq(season)) + .fetchOne(); + } + + @Override + public Set findUtilizedClothIds(Long memberId, Season season) { + Set utilizedClothIds = new HashSet<>(); + + // HistoryClothTag에 태그된 옷 ID들 + List historyClothIds = + queryFactory + .select(historyClothTag.cloth.id) + .from(historyClothTag) + .where( + historyClothTag.cloth.member.id.eq(memberId), + historyClothTag.cloth.season.eq(season)) + .distinct() + .fetch(); + utilizedClothIds.addAll(historyClothIds); + + // Coordinate (DAILY) -> CoordinateCloth에 존재하는 옷 ID들 + List coordinateClothIds = + queryFactory + .select(coordinateCloth.cloth.id) + .from(coordinateCloth) + .join(coordinateCloth.coordinate, coordinate) + .where( + coordinate.member.id.eq(memberId), + coordinate.coordinateType.eq(CoordinateType.DAILY), + coordinateCloth.cloth.member.id.eq(memberId), + coordinateCloth.cloth.season.eq(season)) + .distinct() + .fetch(); + utilizedClothIds.addAll(coordinateClothIds); + + return utilizedClothIds; + } + + @Override + public List findAllClothesBySeason( + Long memberId, Season season) { + return queryFactory + .selectFrom(cloth) + .where(cloth.member.id.eq(memberId), cloth.season.eq(season)) + .fetch(); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/statistics/service/StatisticsService.java b/clokey-api/src/main/java/org/clokey/domain/statistics/service/StatisticsService.java new file mode 100644 index 00000000..5b7b406d --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/statistics/service/StatisticsService.java @@ -0,0 +1,17 @@ +package org.clokey.domain.statistics.service; + +import org.clokey.cloth.enums.Season; +import org.clokey.domain.statistics.dto.response.ClosetUtilizationResponse; +import org.clokey.domain.statistics.dto.response.FavoriteCategoryItemsResponse; +import org.clokey.domain.statistics.dto.response.FavoriteItemsResponse; +import org.clokey.domain.statistics.dto.response.StatisticsCheckConditionResponse; + +public interface StatisticsService { + StatisticsCheckConditionResponse checkStatisticsCondition(); + + FavoriteCategoryItemsResponse getFavoriteCategoryItems(Long categoryId); + + FavoriteItemsResponse getFavoriteItems(); + + ClosetUtilizationResponse getClosetUtilization(Season season); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/statistics/service/StatisticsServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/statistics/service/StatisticsServiceImpl.java new file mode 100644 index 00000000..381e4392 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/statistics/service/StatisticsServiceImpl.java @@ -0,0 +1,186 @@ +package org.clokey.domain.statistics.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.clokey.category.entity.Category; +import org.clokey.cloth.entity.Cloth; +import org.clokey.cloth.enums.Season; +import org.clokey.domain.category.exception.CategoryErrorCode; +import org.clokey.domain.category.repository.CategoryRepository; +import org.clokey.domain.cloth.repository.ClothRepository; +import org.clokey.domain.coordinate.repository.CoordinateRepository; +import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.statistics.dto.CategoryCountDto; +import org.clokey.domain.statistics.dto.response.ClosetUtilizationResponse; +import org.clokey.domain.statistics.dto.response.FavoriteCategoryItemsResponse; +import org.clokey.domain.statistics.dto.response.FavoriteItemsResponse; +import org.clokey.domain.statistics.dto.response.StatisticsCheckConditionResponse; +import org.clokey.domain.statistics.exception.StatisticsErrorCode; +import org.clokey.domain.statistics.repository.StatisticsRepositoryCustom; +import org.clokey.exception.BaseCustomException; +import org.clokey.global.util.MemberUtil; +import org.clokey.member.entity.Member; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StatisticsServiceImpl implements StatisticsService { + + private final MemberUtil memberUtil; + private final CategoryRepository categoryRepository; + private final StatisticsRepositoryCustom statisticsRepositoryCustom; + private final HistoryRepository historyRepository; + private final ClothRepository clothRepository; + private final CoordinateRepository coordinateRepository; + + @Override + public StatisticsCheckConditionResponse checkStatisticsCondition() { + final Member currentMember = memberUtil.getCurrentMember(); + + boolean hasHistory = historyRepository.existsByMemberId(currentMember.getId()); + boolean hasCloth = clothRepository.existsByMemberId(currentMember.getId()); + boolean hasCoordinate = coordinateRepository.existsByMemberId(currentMember.getId()); + + boolean canAggregate = hasHistory && hasCloth && hasCoordinate; + + return StatisticsCheckConditionResponse.of(canAggregate); + } + + @Override + public FavoriteCategoryItemsResponse getFavoriteCategoryItems(Long categoryId) { + final Member currentMember = memberUtil.getCurrentMember(); + final Category parentCategory = getCategoryById(categoryId); + + validateParentCategory(parentCategory); + + List categoryCounts = + statisticsRepositoryCustom.countClothesByChildCategories( + currentMember.getId(), parentCategory.getId()); + + List payloads = + buildFavoriteCategoryItemsPayloads(categoryCounts); + + return FavoriteCategoryItemsResponse.of(payloads); + } + + @Override + public FavoriteItemsResponse getFavoriteItems() { + final Member currentMember = memberUtil.getCurrentMember(); + + List categoryCounts = + statisticsRepositoryCustom.countClothesByCategoriesTopN(currentMember.getId(), 5); + + List payloads = + categoryCounts.stream() + .map( + dto -> + new FavoriteItemsResponse.Payload( + dto.categoryId(), dto.categoryName(), dto.count())) + .toList(); + + return FavoriteItemsResponse.of(payloads); + } + + @Override + public ClosetUtilizationResponse getClosetUtilization(Season season) { + final Member currentMember = memberUtil.getCurrentMember(); + + List allClothes = + statisticsRepositoryCustom.findAllClothesBySeason(currentMember.getId(), season); + + Set utilizedClothIds = + statisticsRepositoryCustom.findUtilizedClothIds(currentMember.getId(), season); + + List utilizedClothes = + allClothes.stream() + .filter(cloth -> utilizedClothIds.contains(cloth.getId())) + .map( + cloth -> + new ClosetUtilizationResponse.Payload( + cloth.getClothImageUrl(), + cloth.getName(), + cloth.getBrand())) + .toList(); + + List unutilizedClothes = + allClothes.stream() + .filter(cloth -> !utilizedClothIds.contains(cloth.getId())) + .map( + cloth -> + new ClosetUtilizationResponse.Payload( + cloth.getClothImageUrl(), + cloth.getName(), + cloth.getBrand())) + .toList(); + + long utilizedCount = utilizedClothes.size(); + long unutilizedCount = unutilizedClothes.size(); + + return ClosetUtilizationResponse.of( + utilizedCount, unutilizedCount, utilizedClothes, unutilizedClothes); + } + + /** 4개 이하의 2차 카테고리들이 집계되었으면 모두 보여주고 5개 이상 부터는 탑3를 제외한 나머지는 기타로 묶이게 됩니다. */ + private List buildFavoriteCategoryItemsPayloads( + List categoryCounts) { + if (categoryCounts.isEmpty()) { + return List.of(); + } + + long totalCount = categoryCounts.stream().mapToLong(CategoryCountDto::count).sum(); + + if (categoryCounts.size() <= 4) { + return categoryCounts.stream() + .map( + dto -> + new FavoriteCategoryItemsResponse.Payload( + dto.categoryId(), + dto.categoryName(), + calculateOccupancyRate(dto.count(), totalCount), + dto.count())) + .toList(); + } + + List payloads = new ArrayList<>(); + + for (int i = 0; i < 3; i++) { + CategoryCountDto dto = categoryCounts.get(i); + payloads.add( + new FavoriteCategoryItemsResponse.Payload( + dto.categoryId(), + dto.categoryName(), + calculateOccupancyRate(dto.count(), totalCount), + dto.count())); + } + + long otherCount = categoryCounts.stream().skip(3).mapToLong(CategoryCountDto::count).sum(); + payloads.add( + new FavoriteCategoryItemsResponse.Payload( + null, "기타", calculateOccupancyRate(otherCount, totalCount), otherCount)); + + return payloads; + } + + private Double calculateOccupancyRate(long count, long totalCount) { + if (totalCount == 0) { + return 0.0; + } + return (double) count / totalCount; + } + + private Category getCategoryById(Long categoryId) { + return categoryRepository + .findById(categoryId) + .orElseThrow(() -> new BaseCustomException(CategoryErrorCode.CATEGORY_NOT_FOUND)); + } + + private void validateParentCategory(Category category) { + if (category.getParent() != null) { + throw new BaseCustomException(StatisticsErrorCode.NOT_PARENT_CATEGORY); + } + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/statistics/controller/StatisticsControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/statistics/controller/StatisticsControllerTest.java new file mode 100644 index 00000000..156618c9 --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/statistics/controller/StatisticsControllerTest.java @@ -0,0 +1,188 @@ +package org.clokey.domain.statistics.controller; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.clokey.cloth.enums.Season; +import org.clokey.domain.statistics.dto.response.ClosetUtilizationResponse; +import org.clokey.domain.statistics.dto.response.FavoriteCategoryItemsResponse; +import org.clokey.domain.statistics.dto.response.FavoriteItemsResponse; +import org.clokey.domain.statistics.dto.response.StatisticsCheckConditionResponse; +import org.clokey.domain.statistics.service.StatisticsService; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@WebMvcTest(StatisticsController.class) +@AutoConfigureMockMvc(addFilters = false) +class StatisticsControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockitoBean private StatisticsService statisticsService; + + @Nested + class 통계_최소_조건_확인_요청_시 { + + @Test + void 유효한_요청이면_통계_집계_가능_여부를_반환한다() throws Exception { + // given + StatisticsCheckConditionResponse response = StatisticsCheckConditionResponse.of(true); + + given(statisticsService.checkStatisticsCondition()).willReturn(response); + + // when + ResultActions perform = + mockMvc.perform( + get("/statistics/check-conditions") + .contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.canAggregate").value(true)); + } + } + + @Nested + class 카테고리별_최애_아이템_조회_요청_시 { + + @Test + void 유효한_요청이면_카테고리별_최애_아이템을_반환한다() throws Exception { + // given + FavoriteCategoryItemsResponse response = + FavoriteCategoryItemsResponse.of( + List.of( + new FavoriteCategoryItemsResponse.Payload(1L, "맨투맨", 0.5, 10L), + new FavoriteCategoryItemsResponse.Payload(2L, "후드티", 0.3, 6L), + new FavoriteCategoryItemsResponse.Payload(3L, "반바지", 0.2, 4L))); + + given(statisticsService.getFavoriteCategoryItems(1L)).willReturn(response); + + // when + ResultActions perform = + mockMvc.perform( + get("/statistics/favorite-category-items") + .param("categoryId", "1") + .contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.payloads", hasSize(3))) + .andExpect(jsonPath("$.result.payloads[*].categoryId").value(contains(1, 2, 3))) + .andExpect( + jsonPath("$.result.payloads[*].categoryName") + .value(contains("맨투맨", "후드티", "반바지"))) + .andExpect( + jsonPath("$.result.payloads[*].occupancyRate") + .value(contains(0.5, 0.3, 0.2))) + .andExpect( + jsonPath("$.result.payloads[*].clothCount").value(contains(10, 6, 4))); + } + } + + @Nested + class 옷장_아이템_통계_조회_요청_시 { + + @Test + void 유효한_요청이면_옷장_아이템_통계를_반환한다() throws Exception { + // given + FavoriteItemsResponse response = + FavoriteItemsResponse.of( + List.of( + new FavoriteItemsResponse.Payload(1L, "맨투맨", 10L), + new FavoriteItemsResponse.Payload(2L, "후드티", 8L), + new FavoriteItemsResponse.Payload(3L, "니트", 5L))); + + given(statisticsService.getFavoriteItems()).willReturn(response); + + // when + ResultActions perform = + mockMvc.perform( + get("/statistics/favorite-items") + .contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.payloads", hasSize(3))) + .andExpect(jsonPath("$.result.payloads[*].categoryId").value(contains(1, 2, 3))) + .andExpect( + jsonPath("$.result.payloads[*].categoryName") + .value(contains("맨투맨", "후드티", "니트"))) + .andExpect( + jsonPath("$.result.payloads[*].clothCount").value(contains(10, 8, 5))); + } + } + + @Nested + class 옷장_활용도_조회_요청_시 { + + @Test + void 유효한_요청이면_옷장_활용도를_반환한다() throws Exception { + // given + ClosetUtilizationResponse response = + ClosetUtilizationResponse.of( + 15L, + 10L, + List.of( + new ClosetUtilizationResponse.Payload( + "https://example.com/image1.jpg", "맨투맨", "나이키")), + List.of( + new ClosetUtilizationResponse.Payload( + "https://example.com/image2.jpg", "후드티", "아디다스"))); + + given(statisticsService.getClosetUtilization(Season.SPRING)).willReturn(response); + + // when + ResultActions perform = + mockMvc.perform( + get("/statistics/closet-utilization") + .param("season", "SPRING") + .contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.utilizedCount").value(15)) + .andExpect(jsonPath("$.result.unutilizedCount").value(10)) + .andExpect(jsonPath("$.result.utilizedClothes", hasSize(1))) + .andExpect( + jsonPath("$.result.utilizedClothes[*].imageUrl") + .value(contains("https://example.com/image1.jpg"))) + .andExpect(jsonPath("$.result.utilizedClothes[*].name").value(contains("맨투맨"))) + .andExpect(jsonPath("$.result.utilizedClothes[*].brand").value(contains("나이키"))) + .andExpect(jsonPath("$.result.unutilizedClothes", hasSize(1))) + .andExpect( + jsonPath("$.result.unutilizedClothes[*].imageUrl") + .value(contains("https://example.com/image2.jpg"))) + .andExpect( + jsonPath("$.result.unutilizedClothes[*].name").value(contains("후드티"))) + .andExpect( + jsonPath("$.result.unutilizedClothes[*].brand") + .value(contains("아디다스"))); + } + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/statistics/service/StatisticsServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/statistics/service/StatisticsServiceTest.java new file mode 100644 index 00000000..b0cda646 --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/statistics/service/StatisticsServiceTest.java @@ -0,0 +1,1495 @@ +package org.clokey.domain.statistics.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDate; +import java.util.List; +import org.clokey.IntegrationTest; +import org.clokey.category.entity.Category; +import org.clokey.cloth.entity.Cloth; +import org.clokey.cloth.enums.Season; +import org.clokey.coordinate.entity.Coordinate; +import org.clokey.coordinate.entity.CoordinateCloth; +import org.clokey.domain.category.exception.CategoryErrorCode; +import org.clokey.domain.category.repository.CategoryRepository; +import org.clokey.domain.cloth.repository.ClothRepository; +import org.clokey.domain.coordinate.repository.CoordinateClothRepository; +import org.clokey.domain.coordinate.repository.CoordinateRepository; +import org.clokey.domain.history.repository.HistoryClothTagRepository; +import org.clokey.domain.history.repository.HistoryImageRepository; +import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.history.repository.SituationRepository; +import org.clokey.domain.lookbook.repository.LookBookRepository; +import org.clokey.domain.member.repository.MemberRepository; +import org.clokey.domain.statistics.dto.response.ClosetUtilizationResponse; +import org.clokey.domain.statistics.dto.response.FavoriteCategoryItemsResponse; +import org.clokey.domain.statistics.dto.response.FavoriteItemsResponse; +import org.clokey.domain.statistics.dto.response.StatisticsCheckConditionResponse; +import org.clokey.domain.statistics.exception.StatisticsErrorCode; +import org.clokey.domain.statistics.repository.StatisticsRepositoryCustom; +import org.clokey.exception.BaseCustomException; +import org.clokey.global.util.MemberUtil; +import org.clokey.history.entity.History; +import org.clokey.history.entity.HistoryClothTag; +import org.clokey.history.entity.HistoryImage; +import org.clokey.history.entity.Situation; +import org.clokey.lookbook.entity.LookBook; +import org.clokey.member.entity.Member; +import org.clokey.member.entity.OauthInfo; +import org.clokey.member.enums.OauthProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +class StatisticsServiceTest extends IntegrationTest { + + @Autowired private StatisticsService statisticsService; + @Autowired private CategoryRepository categoryRepository; + @Autowired private ClothRepository clothRepository; + @Autowired private HistoryRepository historyRepository; + @Autowired private HistoryImageRepository historyImageRepository; + @Autowired private HistoryClothTagRepository historyClothTagRepository; + @Autowired private CoordinateRepository coordinateRepository; + @Autowired private CoordinateClothRepository coordinateClothRepository; + @Autowired private LookBookRepository lookBookRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private SituationRepository situationRepository; + + @MockitoBean private MemberUtil memberUtil; + @Autowired private StatisticsRepositoryCustom statisticsRepositoryCustom; + + @Nested + class 통계_최소_조건을_확인할_때 { + + @BeforeEach + void setUp() { + Member member = + Member.createMember( + "testEmail", + "testClokeyId", + "testNickName", + OauthInfo.createOauthInfo("testOauthId", OauthProvider.KAKAO)); + memberRepository.save(member); + + Category category = Category.createCategory("testCategoryName", null); + categoryRepository.save(category); + + Situation situation = Situation.createSituation("testSituationName"); + situationRepository.save(situation); + + given(memberUtil.getCurrentMember()).willReturn(member); + } + + @Test + void 기록_옷_코디가_모두_있으면_통계_집계가_가능하다() { + // given + Member member = memberUtil.getCurrentMember(); + Category category = categoryRepository.findById(1L).orElseThrow(); + Situation situation = situationRepository.findById(1L).orElseThrow(); + + Cloth cloth = + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + category, + member); + clothRepository.save(cloth); + + History history = + History.createHistory(LocalDate.now(), "testMemo1", member, situation); + historyRepository.save(history); + + Coordinate coordinate = Coordinate.createDailyCoordinate("testImageUrl1", member); + coordinateRepository.save(coordinate); + + // when + StatisticsCheckConditionResponse response = + statisticsService.checkStatisticsCondition(); + + // then + assertThat(response.canAggregate()).isTrue(); + } + + @Test + void 기록이_없으면_통계_집계가_불가능하다() { + // given + Member member = memberUtil.getCurrentMember(); + Category category = categoryRepository.findById(1L).orElseThrow(); + + Cloth cloth = + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + category, + member); + clothRepository.save(cloth); + + Coordinate coordinate = Coordinate.createDailyCoordinate("testImageUrl1", member); + coordinateRepository.save(coordinate); + + // when + StatisticsCheckConditionResponse response = + statisticsService.checkStatisticsCondition(); + + // then + assertThat(response.canAggregate()).isFalse(); + } + + @Test + void 옷이_없으면_통계_집계가_불가능하다() { + // given + Member member = memberUtil.getCurrentMember(); + Situation situation = situationRepository.findById(1L).orElseThrow(); + + History history = + History.createHistory(LocalDate.now(), "testMemo1", member, situation); + historyRepository.save(history); + + Coordinate coordinate = Coordinate.createDailyCoordinate("testImageUrl1", member); + coordinateRepository.save(coordinate); + + // when + StatisticsCheckConditionResponse response = + statisticsService.checkStatisticsCondition(); + + // then + assertThat(response.canAggregate()).isFalse(); + } + + @Test + void 코디가_없으면_통계_집계가_불가능하다() { + // given + Member member = memberUtil.getCurrentMember(); + Category category = categoryRepository.findById(1L).orElseThrow(); + Situation situation = situationRepository.findById(1L).orElseThrow(); + + Cloth cloth = + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + category, + member); + clothRepository.save(cloth); + + History history = + History.createHistory(LocalDate.now(), "testMemo1", member, situation); + historyRepository.save(history); + + // when + StatisticsCheckConditionResponse response = + statisticsService.checkStatisticsCondition(); + + // then + assertThat(response.canAggregate()).isFalse(); + } + + @Test + void 아무것도_없으면_통계_집계가_불가능하다() { + // when + StatisticsCheckConditionResponse response = + statisticsService.checkStatisticsCondition(); + + // then + assertThat(response.canAggregate()).isFalse(); + } + } + + @Nested + class 카테고리별_최애_아이템을_조회할_때 { + + @BeforeEach + void setUp() { + Member member = + Member.createMember( + "testEmail", + "testClokeyId", + "testNickName", + OauthInfo.createOauthInfo("testOauthId", OauthProvider.KAKAO)); + memberRepository.save(member); + + Category parentCategory = Category.createCategory("testParentCategoryName", null); + Category childCategory1 = + Category.createCategory("testChildCategoryName1", parentCategory); + Category childCategory2 = + Category.createCategory("testChildCategoryName2", parentCategory); + Category childCategory3 = + Category.createCategory("testChildCategoryName3", parentCategory); + Category childCategory4 = + Category.createCategory("testChildCategoryName4", parentCategory); + Category childCategory5 = + Category.createCategory("testChildCategoryName5", parentCategory); + + categoryRepository.saveAll( + List.of( + parentCategory, + childCategory1, + childCategory2, + childCategory3, + childCategory4, + childCategory5)); + + given(memberUtil.getCurrentMember()).willReturn(member); + } + + @Test + void 유효한_요청이면_카테고리별_최애_아이템을_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + Category parentCategory = categoryRepository.findById(1L).orElseThrow(); + Category childCategory1 = categoryRepository.findById(2L).orElseThrow(); + Category childCategory2 = categoryRepository.findById(3L).orElseThrow(); + Category childCategory3 = categoryRepository.findById(4L).orElseThrow(); + + List clothes = + List.of( + // 카테고리 1 5개 + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + childCategory1, + member), + Cloth.createCloth( + "testImageUrl2", + null, + "testName2", + "testBrand2", + Season.SPRING, + childCategory1, + member), + Cloth.createCloth( + "testImageUrl3", + null, + "testName3", + "testBrand3", + Season.SPRING, + childCategory1, + member), + Cloth.createCloth( + "testImageUrl4", + null, + "testName4", + "testBrand4", + Season.SPRING, + childCategory1, + member), + Cloth.createCloth( + "testImageUrl5", + null, + "testName5", + "testBrand5", + Season.SPRING, + childCategory1, + member), + // 카테고리 2 3개 + Cloth.createCloth( + "testImageUrl6", + null, + "testName6", + "testBrand6", + Season.SPRING, + childCategory2, + member), + Cloth.createCloth( + "testImageUrl7", + null, + "testName7", + "testBrand7", + Season.SPRING, + childCategory2, + member), + Cloth.createCloth( + "testImageUrl8", + null, + "testName8", + "testBrand8", + Season.SPRING, + childCategory2, + member), + // 카테고리 3 2개 + Cloth.createCloth( + "testImageUrl9", + null, + "testName9", + "testBrand9", + Season.SPRING, + childCategory3, + member), + Cloth.createCloth( + "testImageUrl10", + null, + "testName10", + "testBrand10", + Season.SPRING, + childCategory3, + member)); + clothRepository.saveAll(clothes); + + // when + FavoriteCategoryItemsResponse response = + statisticsService.getFavoriteCategoryItems(parentCategory.getId()); + + // then + assertThat(response.payloads()).hasSize(3); + assertThat(response.payloads()) + .extracting("categoryId", "occupancyRate", "clothCount") + .containsExactly( + tuple(childCategory1.getId(), 0.5, 5L), + tuple(childCategory2.getId(), 0.3, 3L), + tuple(childCategory3.getId(), 0.2, 2L)); + } + + @Test + void 카테고리별_최애_아이템이_4개일_때_모두_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + Category parentCategory = categoryRepository.findById(1L).orElseThrow(); + Category childCategory1 = categoryRepository.findById(2L).orElseThrow(); + Category childCategory2 = categoryRepository.findById(3L).orElseThrow(); + Category childCategory3 = categoryRepository.findById(4L).orElseThrow(); + Category childCategory4 = categoryRepository.findById(5L).orElseThrow(); + + List clothes = + List.of( + // 카테고리 1 5개 + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + childCategory1, + member), + Cloth.createCloth( + "testImageUrl2", + null, + "testName2", + "testBrand2", + Season.SPRING, + childCategory1, + member), + Cloth.createCloth( + "testImageUrl3", + null, + "testName3", + "testBrand3", + Season.SPRING, + childCategory1, + member), + Cloth.createCloth( + "testImageUrl4", + null, + "testName4", + "testBrand4", + Season.SPRING, + childCategory1, + member), + Cloth.createCloth( + "testImageUrl5", + null, + "testName5", + "testBrand5", + Season.SPRING, + childCategory1, + member), + // 카테고리 2 3개 + Cloth.createCloth( + "testImageUrl6", + null, + "testName6", + "testBrand6", + Season.SPRING, + childCategory2, + member), + Cloth.createCloth( + "testImageUrl7", + null, + "testName7", + "testBrand7", + Season.SPRING, + childCategory2, + member), + Cloth.createCloth( + "testImageUrl8", + null, + "testName8", + "testBrand8", + Season.SPRING, + childCategory2, + member), + // 카테고리 3 2개 + Cloth.createCloth( + "testImageUrl9", + null, + "testName9", + "testBrand9", + Season.SPRING, + childCategory3, + member), + Cloth.createCloth( + "testImageUrl10", + null, + "testName10", + "testBrand10", + Season.SPRING, + childCategory3, + member), + // 카테고리 4 1개 + Cloth.createCloth( + "testImageUrl11", + null, + "testName11", + "testBrand11", + Season.SPRING, + childCategory4, + member)); + clothRepository.saveAll(clothes); + + // when + FavoriteCategoryItemsResponse response = + statisticsService.getFavoriteCategoryItems(parentCategory.getId()); + + // then + assertThat(response.payloads()).hasSize(4); + assertThat(response.payloads()) + .extracting("categoryId") + .containsExactly( + childCategory1.getId(), + childCategory2.getId(), + childCategory3.getId(), + childCategory4.getId()); + } + + @Test + void 카테고리별_최애_아이템이_5개_이상일_때_탑3와_기타를_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + Category parentCategory = categoryRepository.findById(1L).orElseThrow(); + Category childCategory1 = categoryRepository.findById(2L).orElseThrow(); + Category childCategory2 = categoryRepository.findById(3L).orElseThrow(); + Category childCategory3 = categoryRepository.findById(4L).orElseThrow(); + Category childCategory4 = categoryRepository.findById(5L).orElseThrow(); + Category childCategory5 = categoryRepository.findById(6L).orElseThrow(); + + List clothes = + List.of( + // 카테고리 1 5개 + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + childCategory1, + member), + Cloth.createCloth( + "testImageUrl2", + null, + "testName2", + "testBrand2", + Season.SPRING, + childCategory1, + member), + Cloth.createCloth( + "testImageUrl3", + null, + "testName3", + "testBrand3", + Season.SPRING, + childCategory1, + member), + Cloth.createCloth( + "testImageUrl4", + null, + "testName4", + "testBrand4", + Season.SPRING, + childCategory1, + member), + Cloth.createCloth( + "testImageUrl5", + null, + "testName5", + "testBrand5", + Season.SPRING, + childCategory1, + member), + // 카테고리 2 3개 + Cloth.createCloth( + "testImageUrl6", + null, + "testName6", + "testBrand6", + Season.SPRING, + childCategory2, + member), + Cloth.createCloth( + "testImageUrl7", + null, + "testName7", + "testBrand7", + Season.SPRING, + childCategory2, + member), + Cloth.createCloth( + "testImageUrl8", + null, + "testName8", + "testBrand8", + Season.SPRING, + childCategory2, + member), + // 카테고리 3 2개 + Cloth.createCloth( + "testImageUrl9", + null, + "testName9", + "testBrand9", + Season.SPRING, + childCategory3, + member), + Cloth.createCloth( + "testImageUrl10", + null, + "testName10", + "testBrand10", + Season.SPRING, + childCategory3, + member), + // 카테고리 4 1개 + Cloth.createCloth( + "testImageUrl11", + null, + "testName11", + "testBrand11", + Season.SPRING, + childCategory4, + member), + // 카테고리 5 1개 + Cloth.createCloth( + "testImageUrl12", + null, + "testName12", + "testBrand12", + Season.SPRING, + childCategory5, + member)); + clothRepository.saveAll(clothes); + + // when + FavoriteCategoryItemsResponse response = + statisticsService.getFavoriteCategoryItems(parentCategory.getId()); + + // then + assertThat(response.payloads()).hasSize(4); + assertThat(response.payloads()) + .extracting("categoryId", "clothCount") + .containsExactly( + tuple(childCategory1.getId(), 5L), + tuple(childCategory2.getId(), 3L), + tuple(childCategory3.getId(), 2L), + tuple(null, 2L)); // 1 + 1 + } + + @Test + void 카테고리별_최애_아이템이_없을_때_빈_리스트를_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + Category parentCategory = categoryRepository.findById(1L).orElseThrow(); + + // when + FavoriteCategoryItemsResponse response = + statisticsService.getFavoriteCategoryItems(parentCategory.getId()); + + // then + assertThat(response.payloads()).isEmpty(); + } + + @Test + void 카테고리가_존재하지_않으면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> statisticsService.getFavoriteCategoryItems(999L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(CategoryErrorCode.CATEGORY_NOT_FOUND.getMessage()); + } + + @Test + void 카테고리가_1차_카테고리가_아니면_예외가_발생한다() { + // given + Category childCategory = categoryRepository.findById(2L).orElseThrow(); + + // when & then + assertThatThrownBy( + () -> statisticsService.getFavoriteCategoryItems(childCategory.getId())) + .isInstanceOf(BaseCustomException.class) + .hasMessage(StatisticsErrorCode.NOT_PARENT_CATEGORY.getMessage()); + } + } + + @Nested + class 옷장_아이템_통계를_조회할_때 { + + @BeforeEach + void setUp() { + Member member = + Member.createMember( + "testEmail", + "testClokeyId", + "testNickName", + OauthInfo.createOauthInfo("testOauthId", OauthProvider.KAKAO)); + memberRepository.save(member); + + Category category1 = Category.createCategory("testCategoryName1", null); + Category category2 = Category.createCategory("testCategoryName2", null); + Category category3 = Category.createCategory("testCategoryName3", null); + categoryRepository.saveAll(List.of(category1, category2, category3)); + + given(memberUtil.getCurrentMember()).willReturn(member); + } + + @Test + void 유효한_요청이면_옷장_아이템_통계를_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + Category category1 = categoryRepository.findById(1L).orElseThrow(); + Category category2 = categoryRepository.findById(2L).orElseThrow(); + Category category3 = categoryRepository.findById(3L).orElseThrow(); + + List clothes = + List.of( + // 카테고리 1 5개 + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl2", + null, + "testName2", + "testBrand2", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl3", + null, + "testName3", + "testBrand3", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl4", + null, + "testName4", + "testBrand4", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl5", + null, + "testName5", + "testBrand5", + Season.SPRING, + category1, + member), + // 카테고리 2 3개 + Cloth.createCloth( + "testImageUrl6", + null, + "testName6", + "testBrand6", + Season.SPRING, + category2, + member), + Cloth.createCloth( + "testImageUrl7", + null, + "testName7", + "testBrand7", + Season.SPRING, + category2, + member), + Cloth.createCloth( + "testImageUrl8", + null, + "testName8", + "testBrand8", + Season.SPRING, + category2, + member), + // 카테고리 3 2개 + Cloth.createCloth( + "testImageUrl9", + null, + "testName9", + "testBrand9", + Season.SPRING, + category3, + member), + Cloth.createCloth( + "testImageUrl10", + null, + "testName10", + "testBrand10", + Season.SPRING, + category3, + member)); + clothRepository.saveAll(clothes); + + // when + FavoriteItemsResponse response = statisticsService.getFavoriteItems(); + + // then + assertThat(response.payloads()).hasSize(3); + assertThat(response.payloads()) + .extracting("categoryId", "clothCount") + .containsExactly( + tuple(category1.getId(), 5L), + tuple(category2.getId(), 3L), + tuple(category3.getId(), 2L)); + } + + @Test + void 옷장_아이템의_카테고리가_5개일_때_모두_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + Category category1 = categoryRepository.findById(1L).orElseThrow(); + Category category2 = categoryRepository.findById(2L).orElseThrow(); + Category category3 = categoryRepository.findById(3L).orElseThrow(); + + Category category4 = Category.createCategory("testCategoryName4", null); + Category category5 = Category.createCategory("testCategoryName5", null); + categoryRepository.saveAll(List.of(category4, category5)); + + List clothes = + List.of( + // 카테고리 1 5개 + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl2", + null, + "testName2", + "testBrand2", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl3", + null, + "testName3", + "testBrand3", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl4", + null, + "testName4", + "testBrand4", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl5", + null, + "testName5", + "testBrand5", + Season.SPRING, + category1, + member), + // 카테고리 2 4개 + Cloth.createCloth( + "testImageUrl6", + null, + "testName6", + "testBrand6", + Season.SPRING, + category2, + member), + Cloth.createCloth( + "testImageUrl7", + null, + "testName7", + "testBrand7", + Season.SPRING, + category2, + member), + Cloth.createCloth( + "testImageUrl8", + null, + "testName8", + "testBrand8", + Season.SPRING, + category2, + member), + Cloth.createCloth( + "testImageUrl9", + null, + "testName9", + "testBrand9", + Season.SPRING, + category2, + member), + // 카테고리 3 3개 + Cloth.createCloth( + "testImageUrl10", + null, + "testName10", + "testBrand10", + Season.SPRING, + category3, + member), + Cloth.createCloth( + "testImageUrl11", + null, + "testName11", + "testBrand11", + Season.SPRING, + category3, + member), + Cloth.createCloth( + "testImageUrl12", + null, + "testName12", + "testBrand12", + Season.SPRING, + category3, + member), + // 카테고리 4 2개 + Cloth.createCloth( + "testImageUrl13", + null, + "testName13", + "testBrand13", + Season.SPRING, + category4, + member), + Cloth.createCloth( + "testImageUrl14", + null, + "testName14", + "testBrand14", + Season.SPRING, + category4, + member), + // 카테고리 5 1개 + Cloth.createCloth( + "testImageUrl15", + null, + "testName15", + "testBrand15", + Season.SPRING, + category5, + member)); + clothRepository.saveAll(clothes); + + // when + FavoriteItemsResponse response = statisticsService.getFavoriteItems(); + + // then + assertThat(response.payloads()).hasSize(5); + assertThat(response.payloads()) + .extracting("categoryId", "clothCount") + .containsExactly( + tuple(category1.getId(), 5L), + tuple(category2.getId(), 4L), + tuple(category3.getId(), 3L), + tuple(category4.getId(), 2L), + tuple(category5.getId(), 1L)); + } + + @Test + void 옷장_아이템이_5개_초과일_때_탑5만_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + Category category1 = categoryRepository.findById(1L).orElseThrow(); + Category category2 = categoryRepository.findById(2L).orElseThrow(); + Category category3 = categoryRepository.findById(3L).orElseThrow(); + + Category category4 = Category.createCategory("testCategoryName4", null); + Category category5 = Category.createCategory("testCategoryName5", null); + Category category6 = Category.createCategory("testCategoryName6", null); + categoryRepository.saveAll(List.of(category4, category5, category6)); + + List clothes = + List.of( + // 카테고리 1 6개 + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl2", + null, + "testName2", + "testBrand2", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl3", + null, + "testName3", + "testBrand3", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl4", + null, + "testName4", + "testBrand4", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl5", + null, + "testName5", + "testBrand5", + Season.SPRING, + category1, + member), + Cloth.createCloth( + "testImageUrl6", + null, + "testName6", + "testBrand6", + Season.SPRING, + category1, + member), + // 카테고리 2 5개 + Cloth.createCloth( + "testImageUrl7", + null, + "testName7", + "testBrand7", + Season.SPRING, + category2, + member), + Cloth.createCloth( + "testImageUrl8", + null, + "testName8", + "testBrand8", + Season.SPRING, + category2, + member), + Cloth.createCloth( + "testImageUrl9", + null, + "testName9", + "testBrand9", + Season.SPRING, + category2, + member), + Cloth.createCloth( + "testImageUrl10", + null, + "testName10", + "testBrand10", + Season.SPRING, + category2, + member), + Cloth.createCloth( + "testImageUrl11", + null, + "testName11", + "testBrand11", + Season.SPRING, + category2, + member), + // 카테고리 3 4개 + Cloth.createCloth( + "testImageUrl12", + null, + "testName12", + "testBrand12", + Season.SPRING, + category3, + member), + Cloth.createCloth( + "testImageUrl13", + null, + "testName13", + "testBrand13", + Season.SPRING, + category3, + member), + Cloth.createCloth( + "testImageUrl14", + null, + "testName14", + "testBrand14", + Season.SPRING, + category3, + member), + Cloth.createCloth( + "testImageUrl15", + null, + "testName15", + "testBrand15", + Season.SPRING, + category3, + member), + // 카테고리 4 3개 + Cloth.createCloth( + "testImageUrl16", + null, + "testName16", + "testBrand16", + Season.SPRING, + category4, + member), + Cloth.createCloth( + "testImageUrl17", + null, + "testName17", + "testBrand17", + Season.SPRING, + category4, + member), + Cloth.createCloth( + "testImageUrl18", + null, + "testName18", + "testBrand18", + Season.SPRING, + category4, + member), + // 카테고리 5 2개 + Cloth.createCloth( + "testImageUrl19", + null, + "testName19", + "testBrand19", + Season.SPRING, + category5, + member), + Cloth.createCloth( + "testImageUrl20", + null, + "testName20", + "testBrand20", + Season.SPRING, + category5, + member), + // 카테고리 6 1개 + Cloth.createCloth( + "testImageUrl21", + null, + "testName21", + "testBrand21", + Season.SPRING, + category6, + member)); + clothRepository.saveAll(clothes); + + // when + FavoriteItemsResponse response = statisticsService.getFavoriteItems(); + + // then + assertThat(response.payloads()).hasSize(5); + assertThat(response.payloads()) + .extracting("categoryId", "clothCount") + .containsExactly( + tuple(category1.getId(), 6L), + tuple(category2.getId(), 5L), + tuple(category3.getId(), 4L), + tuple(category4.getId(), 3L), + tuple(category5.getId(), 2L)); + } + + @Test + void 옷장_아이템이_없을_때_빈_리스트를_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + + // when + FavoriteItemsResponse response = statisticsService.getFavoriteItems(); + + // then + assertThat(response.payloads()).isEmpty(); + } + } + + @Nested + class 옷장_활용도를_조회할_때 { + + @BeforeEach + void setUp() { + Member member = + Member.createMember( + "testEmail", + "testClokeyId", + "testNickName", + OauthInfo.createOauthInfo("testOauthId", OauthProvider.KAKAO)); + memberRepository.save(member); + + Category category = Category.createCategory("testCategoryName", null); + categoryRepository.save(category); + + Situation situation = Situation.createSituation("testSituationName"); + situationRepository.save(situation); + + given(memberUtil.getCurrentMember()).willReturn(member); + } + + @Test + void 유효한_요청이면_옷장_활용도를_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + Category category = categoryRepository.findById(1L).orElseThrow(); + Situation situation = situationRepository.findById(1L).orElseThrow(); + + Cloth utilizedCloth1 = + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + category, + member); + Cloth utilizedCloth2 = + Cloth.createCloth( + "testImageUrl2", + null, + "testName2", + "testBrand2", + Season.SPRING, + category, + member); + Cloth unutilizedCloth = + Cloth.createCloth( + "testImageUrl3", + null, + "testName3", + "testBrand3", + Season.SPRING, + category, + member); + + clothRepository.saveAll(List.of(utilizedCloth1, utilizedCloth2, unutilizedCloth)); + + History history = History.createHistory(LocalDate.now(), "testMemo", member, situation); + historyRepository.save(history); + + HistoryImage historyImage = HistoryImage.createHistoryImage("testImageUrl", history); + historyImageRepository.save(historyImage); + + HistoryClothTag historyClothTag = + HistoryClothTag.createHistoryClothTag(historyImage, utilizedCloth1, 0.5, 0.5); + historyClothTagRepository.save(historyClothTag); + + Coordinate coordinate = + Coordinate.createDailyCoordinate("testCoordinateImageUrl", member); + coordinateRepository.save(coordinate); + + CoordinateCloth coordinateCloth = + CoordinateCloth.createCoordinateCloth( + 1.0, 1.0, 1.0, 30.0, 1, coordinate, utilizedCloth2); + coordinateClothRepository.save(coordinateCloth); + + // when + ClosetUtilizationResponse response = + statisticsService.getClosetUtilization(Season.SPRING); + + // then + assertThat(response.utilizedCount()).isEqualTo(2); + assertThat(response.unutilizedCount()).isEqualTo(1); + assertThat(response.utilizedClothes()).hasSize(2); + assertThat(response.unutilizedClothes()).hasSize(1); + assertThat(response.utilizedClothes()) + .extracting("imageUrl", "name", "brand") + .containsExactlyInAnyOrder( + tuple("testImageUrl1", "testName1", "testBrand1"), + tuple("testImageUrl2", "testName2", "testBrand2")); + assertThat(response.unutilizedClothes()) + .extracting("imageUrl", "name", "brand") + .containsExactly(tuple("testImageUrl3", "testName3", "testBrand3")); + } + + @Test + void 활용된_옷이_없을_때_모두_활용되지_않은_옷으로_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + Category category = categoryRepository.findById(1L).orElseThrow(); + + Cloth unutilizedCloth1 = + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + category, + member); + Cloth unutilizedCloth2 = + Cloth.createCloth( + "testImageUrl2", + null, + "testName2", + "testBrand2", + Season.SPRING, + category, + member); + + clothRepository.saveAll(List.of(unutilizedCloth1, unutilizedCloth2)); + + // when + ClosetUtilizationResponse response = + statisticsService.getClosetUtilization(Season.SPRING); + + // then + assertThat(response.utilizedCount()).isEqualTo(0); + assertThat(response.unutilizedCount()).isEqualTo(2); + assertThat(response.utilizedClothes()).isEmpty(); + assertThat(response.unutilizedClothes()).hasSize(2); + } + + @Test + void 모든_옷이_활용되었을_때_활용되지_않은_옷은_없다() { + // given + Member member = memberUtil.getCurrentMember(); + Category category = categoryRepository.findById(1L).orElseThrow(); + Situation situation = situationRepository.findById(1L).orElseThrow(); + + Cloth utilizedCloth1 = + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + category, + member); + Cloth utilizedCloth2 = + Cloth.createCloth( + "testImageUrl2", + null, + "testName2", + "testBrand2", + Season.SPRING, + category, + member); + + clothRepository.saveAll(List.of(utilizedCloth1, utilizedCloth2)); + + History history = History.createHistory(LocalDate.now(), "testMemo", member, situation); + historyRepository.save(history); + + HistoryImage historyImage = HistoryImage.createHistoryImage("testImageUrl", history); + historyImageRepository.save(historyImage); + + HistoryClothTag historyClothTag1 = + HistoryClothTag.createHistoryClothTag(historyImage, utilizedCloth1, 0.5, 0.5); + HistoryClothTag historyClothTag2 = + HistoryClothTag.createHistoryClothTag(historyImage, utilizedCloth2, 0.6, 0.6); + historyClothTagRepository.saveAll(List.of(historyClothTag1, historyClothTag2)); + + // when + ClosetUtilizationResponse response = + statisticsService.getClosetUtilization(Season.SPRING); + + // then + assertThat(response.utilizedCount()).isEqualTo(2); + assertThat(response.unutilizedCount()).isEqualTo(0); + assertThat(response.utilizedClothes()).hasSize(2); + assertThat(response.unutilizedClothes()).isEmpty(); + } + + @Test + void 옷이_없을_때_모두_0으로_반환한다() { + // given + Member member = memberUtil.getCurrentMember(); + + // when + ClosetUtilizationResponse response = + statisticsService.getClosetUtilization(Season.SPRING); + + // then + assertThat(response.utilizedCount()).isEqualTo(0); + assertThat(response.unutilizedCount()).isEqualTo(0); + assertThat(response.utilizedClothes()).isEmpty(); + assertThat(response.unutilizedClothes()).isEmpty(); + } + + @Test + void DAILY_타입_Coordinate만_활용된_옷으로_카운트한다() { + // given + Member member = memberUtil.getCurrentMember(); + Category category = categoryRepository.findById(1L).orElseThrow(); + + Cloth dailyUtilizedCloth = + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + category, + member); + Cloth defaultUnutilizedCloth = + Cloth.createCloth( + "testImageUrl2", + null, + "testName2", + "testBrand2", + Season.SPRING, + category, + member); + Cloth unutilizedCloth = + Cloth.createCloth( + "testImageUrl3", + null, + "testName3", + "testBrand3", + Season.SPRING, + category, + member); + + clothRepository.saveAll( + List.of(dailyUtilizedCloth, defaultUnutilizedCloth, unutilizedCloth)); + + Coordinate dailyCoordinate = + Coordinate.createDailyCoordinate("testDailyImageUrl", member); + coordinateRepository.save(dailyCoordinate); + + CoordinateCloth dailyCoordinateCloth = + CoordinateCloth.createCoordinateCloth( + 1.0, 1.0, 1.0, 30.0, 1, dailyCoordinate, dailyUtilizedCloth); + coordinateClothRepository.save(dailyCoordinateCloth); + + LookBook lookBook = LookBook.createLookBook("testLookBookName", member); + lookBookRepository.save(lookBook); + + Coordinate defaultCoordinate = + Coordinate.createCoordinateManual( + "testName", "testMemo", "testImageUrl", member, lookBook); + coordinateRepository.save(defaultCoordinate); + + CoordinateCloth defaultCoordinateCloth = + CoordinateCloth.createCoordinateCloth( + 1.0, 1.0, 1.0, 30.0, 1, defaultCoordinate, defaultUnutilizedCloth); + coordinateClothRepository.save(defaultCoordinateCloth); + + // when + ClosetUtilizationResponse response = + statisticsService.getClosetUtilization(Season.SPRING); + + // then + assertThat(response.utilizedCount()).isEqualTo(1); + assertThat(response.unutilizedCount()).isEqualTo(2); + assertThat(response.utilizedClothes()).hasSize(1); + assertThat(response.utilizedClothes()) + .extracting("imageUrl", "name", "brand") + .containsExactly(tuple("testImageUrl1", "testName1", "testBrand1")); + assertThat(response.unutilizedClothes()) + .extracting("imageUrl", "name", "brand") + .containsExactlyInAnyOrder( + tuple("testImageUrl2", "testName2", "testBrand2"), + tuple("testImageUrl3", "testName3", "testBrand3")); + } + + @Test + void HistoryClothTag에_태그된_옷만_활용된_옷으로_카운트한다() { + // given + Member member = memberUtil.getCurrentMember(); + Category category = categoryRepository.findById(1L).orElseThrow(); + Situation situation = situationRepository.findById(1L).orElseThrow(); + + Cloth utilizedCloth1 = + Cloth.createCloth( + "testImageUrl1", + null, + "testName1", + "testBrand1", + Season.SPRING, + category, + member); + Cloth utilizedCloth2 = + Cloth.createCloth( + "testImageUrl2", + null, + "testName2", + "testBrand2", + Season.SPRING, + category, + member); + Cloth unutilizedCloth = + Cloth.createCloth( + "testImageUrl3", + null, + "testName3", + "testBrand3", + Season.SPRING, + category, + member); + + clothRepository.saveAll(List.of(utilizedCloth1, utilizedCloth2, unutilizedCloth)); + + History history = History.createHistory(LocalDate.now(), "testMemo", member, situation); + historyRepository.save(history); + + HistoryImage historyImage = HistoryImage.createHistoryImage("testImageUrl", history); + historyImageRepository.save(historyImage); + + HistoryClothTag historyClothTag1 = + HistoryClothTag.createHistoryClothTag(historyImage, utilizedCloth1, 0.5, 0.5); + HistoryClothTag historyClothTag2 = + HistoryClothTag.createHistoryClothTag(historyImage, utilizedCloth2, 0.6, 0.6); + historyClothTagRepository.saveAll(List.of(historyClothTag1, historyClothTag2)); + + // when + ClosetUtilizationResponse response = + statisticsService.getClosetUtilization(Season.SPRING); + + // then + assertThat(response.utilizedCount()).isEqualTo(2); + assertThat(response.unutilizedCount()).isEqualTo(1); + assertThat(response.utilizedClothes()).hasSize(2); + assertThat(response.utilizedClothes()) + .extracting("imageUrl", "name", "brand") + .containsExactlyInAnyOrder( + tuple("testImageUrl1", "testName1", "testBrand1"), + tuple("testImageUrl2", "testName2", "testBrand2")); + assertThat(response.unutilizedClothes()) + .extracting("imageUrl", "name", "brand") + .containsExactly(tuple("testImageUrl3", "testName3", "testBrand3")); + } + } +}