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 033a332c..88d166f0 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: @@ -63,7 +63,7 @@ jobs: username: ubuntu host: ${{ secrets.DEV_EC2_HOST }} key: ${{ secrets.DEV_EC2_SSH_KEY }} - envs: DOCKERHUB_USERNAME,DEV_MYSQL_HOST,MYSQL_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,DEV_KAKAO_CLIENT_ID,DEV_KAKAO_CLIENT_SECRET,DEV_APPLE_CLIENT_ID,DEV_APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,DEV_AWS_ACCESS_KEY_ID,DEV_AWS_SECRET_ACCESS_KEY,AWS_REGION,DEV_S3_BUCKET,DEV_S3_ENDPOINT,SWAGGER_USERNAME,SWAGGER_PASSWORD + envs: DOCKERHUB_USERNAME,DEV_MYSQL_HOST,MYSQL_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,DEV_KAKAO_CLIENT_ID,DEV_KAKAO_CLIENT_SECRET,DEV_APPLE_CLIENT_ID,DEV_APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,DEV_AWS_ACCESS_KEY_ID,DEV_AWS_SECRET_ACCESS_KEY,AWS_REGION,DEV_S3_BUCKET,DEV_S3_ENDPOINT,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64 script: | export DOCKERHUB_NAME=${{ secrets.DOCKERHUB_USERNAME }} export DOCKER_TAG=dev-app @@ -98,7 +98,13 @@ jobs: export SWAGGER_USERNAME=${{ secrets.SWAGGER_USERNAME }} export SWAGGER_PASSWORD=${{ secrets.SWAGGER_PASSWORD }} - + + sudo mkdir -p /home/ubuntu/secrets + echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | sudo tee /home/ubuntu/secrets/firebase-sa.json > /dev/null + sudo chmod 600 /home/ubuntu/secrets/firebase-sa.json + + export FIREBASE_CREDENTIALS_PATH=/home/ubuntu/secrets/firebase-sa.json + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu @@ -106,4 +112,4 @@ jobs: echo "Cleaning up dangling Docker images..." docker image prune -f - + diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index bdb09a8f..76d4cb70 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -73,12 +73,12 @@ jobs: username: ubuntu host: ${{ secrets.PROD_EC2_HOST }} key: ${{ secrets.PROD_EC2_SSH_KEY }} - envs: DOCKERHUB_USERNAME,SPRING_PROFILES_ACTIVE,PROD_MYSQL_HOST,MYSQL_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,PROD_KAKAO_CLIENT_ID,PROD_KAKAO_CLIENT_SECRET,PROD_APPLE_CLIENT_ID,PROD_APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,PROD_AWS_ACCESS_KEY_ID,PROD_AWS_SECRET_ACCESS_KEY,AWS_REGION,PROD_S3_BUCKET,PROD_S3_ENDPOINT,SWAGGER_USERNAME,SWAGGER_PASSWORD + envs: DOCKERHUB_USERNAME,SPRING_PROFILES_ACTIVE,PROD_MYSQL_HOST,MYSQL_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,PROD_KAKAO_CLIENT_ID,PROD_KAKAO_CLIENT_SECRET,PROD_APPLE_CLIENT_ID,PROD_APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,PROD_AWS_ACCESS_KEY_ID,PROD_AWS_SECRET_ACCESS_KEY,AWS_REGION,PROD_S3_BUCKET,PROD_S3_ENDPOINT,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64 script: | export DOCKERHUB_NAME=${{ secrets.DOCKERHUB_USERNAME }} export DOCKER_TAG=prod-app - export DEV_MYSQL_HOST=${{ secrets.PROD_MYSQL_HOST }} + export PROD_MYSQL_HOST=${{ secrets.PROD_MYSQL_HOST }} export MYSQL_PORT=${{ secrets.MYSQL_PORT }} export DB_NAME=${{ secrets.DB_NAME }} export DB_USERNAME=${{ secrets.DB_USERNAME }} @@ -100,15 +100,21 @@ jobs: export JWT_REFRESH_TOKEN_EXPIRATION_TIME=${{ secrets.JWT_REFRESH_TOKEN_EXPIRATION_TIME }} export JWT_ISSUER=${{ secrets.JWT_ISSUER }} - export DEV_AWS_ACCESS_KEY_ID=${{ secrets.PROD_AWS_ACCESS_KEY_ID }} - export DEV_AWS_SECRET_ACCESS_KEY=${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }} + export PROD_AWS_ACCESS_KEY_ID=${{ secrets.PROD_AWS_ACCESS_KEY_ID }} + export PROD_AWS_SECRET_ACCESS_KEY=${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }} export AWS_REGION=${{ secrets.AWS_REGION }} - export DEV_S3_BUCKET=${{ secrets.PROD_S3_BUCKET }} - export DEV_S3_ENDPOINT=${{ secrets.PROD_S3_ENDPOINT }} + export PROD_S3_BUCKET=${{ secrets.PROD_S3_BUCKET }} + export PROD_S3_ENDPOINT=${{ secrets.PROD_S3_ENDPOINT }} export SWAGGER_USERNAME=${{ secrets.SWAGGER_USERNAME }} export SWAGGER_PASSWORD=${{ secrets.SWAGGER_PASSWORD }} + sudo mkdir -p /home/ubuntu/secrets + echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | sudo tee /home/ubuntu/secrets/firebase-sa.json > /dev/null + sudo chmod 600 /home/ubuntu/secrets/firebase-sa.json + + export FIREBASE_CREDENTIALS_PATH=/home/ubuntu/secrets/firebase-sa.json + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu diff --git a/clokey-api/dev-compose.yml b/clokey-api/dev-compose.yml index a02df69c..d210dd10 100644 --- a/clokey-api/dev-compose.yml +++ b/clokey-api/dev-compose.yml @@ -29,6 +29,9 @@ services: APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} APPLE_CLIENT_SECRET: ${APPLE_CLIENT_SECRET} + # Firebase + FIREBASE_CREDENTIALS_PATH: /run/secrets/firebase-sa.json + # JWT JWT_ACCESS_TOKEN_SECRET: ${JWT_ACCESS_TOKEN_SECRET} JWT_REFRESH_TOKEN_SECRET: ${JWT_REFRESH_TOKEN_SECRET} @@ -46,6 +49,10 @@ services: # Swagger SWAGGER_USERNAME: ${SWAGGER_USERNAME} SWAGGER_PASSWORD: ${SWAGGER_PASSWORD} + + volumes: + - /home/ubuntu/secrets/firebase-sa.json:/run/secrets/firebase-sa.json:ro + networks: - app_network diff --git a/clokey-api/prod-compose.yml b/clokey-api/prod-compose.yml index c75a69cc..55e4b97c 100644 --- a/clokey-api/prod-compose.yml +++ b/clokey-api/prod-compose.yml @@ -29,6 +29,9 @@ services: APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} APPLE_CLIENT_SECRET: ${APPLE_CLIENT_SECRET} + # Firebase + FIREBASE_CREDENTIALS_PATH: /run/secrets/firebase-sa.json + # JWT JWT_ACCESS_TOKEN_SECRET: ${JWT_ACCESS_TOKEN_SECRET} JWT_REFRESH_TOKEN_SECRET: ${JWT_REFRESH_TOKEN_SECRET} @@ -46,6 +49,10 @@ services: # Swagger SWAGGER_USERNAME: ${SWAGGER_USERNAME} SWAGGER_PASSWORD: ${SWAGGER_PASSWORD} + + volumes: + - /home/ubuntu/secrets/firebase-sa.json:/run/secrets/firebase-sa.json:ro + networks: - app_network 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/notification/controller/CodiveNotificationController.java b/clokey-api/src/main/java/org/clokey/domain/notification/controller/CodiveNotificationController.java index b2955546..dc37ddee 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/controller/CodiveNotificationController.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/controller/CodiveNotificationController.java @@ -3,8 +3,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.clokey.code.GlobalBaseSuccessCode; +import org.clokey.domain.notification.dto.request.TemperatureNotificationRequest; import org.clokey.domain.notification.dto.response.NotificationListResponse; import org.clokey.domain.notification.dto.response.UnreadNotificationResponse; import org.clokey.domain.notification.service.CodiveNotificationService; @@ -55,4 +57,12 @@ public BaseResponse updateAllReadStatus() { codiveNotificationService.updateAllReadStatus(); return BaseResponse.onSuccess(GlobalBaseSuccessCode.NO_CONTENT, null); } + + @PostMapping("/today-temperature") + @Operation(summary = "오늘의 온도 API", description = "오늘의 온도를 알리는 알림을 발송합니다.") + public BaseResponse sendTemperatureNotification( + @Valid @RequestBody TemperatureNotificationRequest request) { + codiveNotificationService.sendNewTemperatureNotification(request); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.NO_CONTENT, null); + } } diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/dto/request/TemperatureNotificationRequest.java b/clokey-api/src/main/java/org/clokey/domain/notification/dto/request/TemperatureNotificationRequest.java new file mode 100644 index 00000000..98b54f48 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/notification/dto/request/TemperatureNotificationRequest.java @@ -0,0 +1,8 @@ +package org.clokey.domain.notification.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record TemperatureNotificationRequest( + @NotNull(message = "온도는 비워둘 수 없습니다.") @Schema(description = "현재 기온", example = "-10.5") + Double temperature) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationService.java b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationService.java index 240dffdc..a9bbb400 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationService.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationService.java @@ -2,6 +2,7 @@ import org.clokey.domain.comment.event.NewCommentEvent; import org.clokey.domain.comment.event.NewReplyEvent; +import org.clokey.domain.notification.dto.request.TemperatureNotificationRequest; import org.clokey.domain.notification.dto.response.NotificationListResponse; import org.clokey.domain.notification.dto.response.UnreadNotificationResponse; import org.clokey.response.SliceResponse; @@ -16,6 +17,8 @@ public interface CodiveNotificationService { void sendNewReplyNotification(NewReplyEvent event); + void sendNewTemperatureNotification(TemperatureNotificationRequest request); + SliceResponse getNotificationList( Long lastNotificationId, Integer size); diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java index 01a2ea55..bb32362a 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java @@ -14,6 +14,7 @@ import org.clokey.domain.history.repository.HistoryRepository; import org.clokey.domain.member.exception.MemberErrorCode; import org.clokey.domain.member.repository.MemberRepository; +import org.clokey.domain.notification.dto.request.TemperatureNotificationRequest; import org.clokey.domain.notification.dto.response.NotificationListResponse; import org.clokey.domain.notification.dto.response.UnreadNotificationResponse; import org.clokey.domain.notification.exception.NotificationErrorCode; @@ -52,6 +53,9 @@ public class CodiveNotificationServiceImpl implements CodiveNotificationService private static final String NEW_COMMENT_NOTIFICATION = "%s님이 회원님의 기록에 댓글을 남겼습니다. : %s"; private static final String NEW_REPLY_NOTIFICATION = "%s님이 회원님의 댓글에 답장을 남겼습니다. : %s"; + private static final String TODAY_TEMPERATURE_NOTIFICATION = + "오늘의 기온은 %d도 입니다!\n날씨에 맞는 오늘의 옷차림이 기다리고 있어요👀"; + @Override public void sendNewFollowerNotification(Long followFromId, Long followToId) { Member followFromMember = getMemberById(followFromId); @@ -214,6 +218,28 @@ public void sendNewReplyNotification(NewReplyEvent event) { } } + @Override + public void sendNewTemperatureNotification(TemperatureNotificationRequest request) { + Member receiver = memberUtil.getCurrentMember(); + String content = ""; + + if (isAbleToSendNotification(receiver)) { + + Notification notification = Notification.builder().setBody(content).build(); + Message message = + Message.builder() + .setToken(receiver.getDeviceToken()) + .setNotification(notification) + .build(); + + try { + firebaseMessaging.send(message); + } catch (FirebaseMessagingException e) { + throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); + } + } + } + @Override public SliceResponse getNotificationList( Long lastNotificationId, Integer size) { 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/main/java/org/clokey/global/config/security/SecurityConfig.java b/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java index a6ca4037..1d6e5367 100644 --- a/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java +++ b/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java @@ -95,7 +95,8 @@ public SecurityFilterChain apiFilterChain( http.authorizeHttpRequests( auth -> - auth.requestMatchers("/public/**") + auth.requestMatchers( + "/public/**", "/swagger-ui/**", "/v3/api-docs/**") .permitAll() .anyRequest() .authenticated()) @@ -119,8 +120,8 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.setAllowedOriginPatterns( List.of( "http://localhost:3000", - "https://dev-clokey.shop", - "https://prod-clokey.shop")); + "https://dev.clokey.store", + "https://prod.clokey.store")); configuration.setAllowedMethods( List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); diff --git a/clokey-api/src/main/resources/application-dev.yml b/clokey-api/src/main/resources/application-dev.yml index 014fb409..a1526773 100644 --- a/clokey-api/src/main/resources/application-dev.yml +++ b/clokey-api/src/main/resources/application-dev.yml @@ -76,7 +76,6 @@ swagger: username: ${SWAGGER_USERNAME} password: ${SWAGGER_PASSWORD} - spring-doc: default-consumes-media-type: application/json default-produces-media-type: application/json @@ -85,3 +84,6 @@ spring-doc: operations-sorter : method path: /swagger-ui doc-expansion : none + +firebase: + credentials-path: ${FIREBASE_CREDENTIALS_PATH} diff --git a/clokey-api/src/main/resources/application-local.yml b/clokey-api/src/main/resources/application-local.yml index 632243f1..65dd285e 100644 --- a/clokey-api/src/main/resources/application-local.yml +++ b/clokey-api/src/main/resources/application-local.yml @@ -85,3 +85,6 @@ logging: level: org.hibernate.SQL: DEBUG org.hibernate.orm.jdbc.bind: TRACE + +firebase: + credentials-path: ${FIREBASE_CREDENTIALS_PATH} diff --git a/clokey-api/src/main/resources/application-prod.yml b/clokey-api/src/main/resources/application-prod.yml index e9db8efe..8ed0a788 100644 --- a/clokey-api/src/main/resources/application-prod.yml +++ b/clokey-api/src/main/resources/application-prod.yml @@ -71,3 +71,6 @@ aws: s3: bucket: ${PROD_S3_BUCKET} endpoint: ${PROD_S3_ENDPOINT:https://s3.ap-northeast-2.amazonaws.com} + +firebase: + credentials-path: ${FIREBASE_CREDENTIALS_PATH} diff --git a/clokey-api/src/test/java/org/clokey/domain/notification/controller/NotificationControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/notification/controller/NotificationControllerTest.java index 2a8b65f3..e55323e8 100644 --- a/clokey-api/src/test/java/org/clokey/domain/notification/controller/NotificationControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/notification/controller/NotificationControllerTest.java @@ -2,13 +2,14 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; 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.time.LocalDateTime; import java.util.List; +import org.clokey.domain.notification.dto.request.TemperatureNotificationRequest; import org.clokey.domain.notification.dto.response.NotificationListResponse; import org.clokey.domain.notification.dto.response.UnreadNotificationResponse; import org.clokey.domain.notification.service.CodiveNotificationService; @@ -18,19 +19,24 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; 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; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; @WebMvcTest(CodiveNotificationController.class) @AutoConfigureMockMvc(addFilters = false) public class NotificationControllerTest { @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @MockitoBean private CodiveNotificationService codiveNotificationService; @Nested @@ -127,6 +133,7 @@ class 알림_읽음_처리_요청_시 { @Nested class 알림_전체_읽음_처리_요청_시 { + @Test void 유효한_요청이면_전체_알림_상태를_읽음으로_변경한다() throws Exception { // given @@ -142,4 +149,53 @@ class 알림_전체_읽음_처리_요청_시 { .andExpect(jsonPath("$.message").value("요청 성공 및 반환값 없음")); } } + + @Nested + class 오늘의_온도_알림_발송_요청_시 { + + @Test + void 유효한_요청이면_오늘의_온도_알림을_발송한다() throws Exception { + // given + TemperatureNotificationRequest request = new TemperatureNotificationRequest(-5.5); + willDoNothing() + .given(codiveNotificationService) + .sendNewTemperatureNotification(request); + + // when + ResultActions perform = + mockMvc.perform( + post("/notifications/today-temperature") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON204")) + .andExpect(jsonPath("$.message").value("요청 성공 및 반환값 없음")); + } + + @ParameterizedTest + @NullSource + void 오늘의_온도를_비워두면_예외가_발생한다(Double temp) throws Exception { + // given + TemperatureNotificationRequest request = new TemperatureNotificationRequest(temp); + + // when + ResultActions perform = + mockMvc.perform( + post("/notifications/today-temperature") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + perform.andExpect(status().isBadRequest()) + .andExpect(MockMvcResultMatchers.jsonPath("$.isSuccess").value(false)) + .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("COMMON400")) + .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("잘못된 요청입니다.")) + .andExpect( + MockMvcResultMatchers.jsonPath("$.result.temperature") + .value("온도는 비워둘 수 없습니다.")); + } + } } diff --git a/clokey-api/src/test/java/org/clokey/domain/notification/service/NotificationServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/notification/service/NotificationServiceTest.java index d04e750b..05d17b49 100644 --- a/clokey-api/src/test/java/org/clokey/domain/notification/service/NotificationServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/notification/service/NotificationServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.BDDMockito.given; import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -23,6 +24,7 @@ import org.clokey.domain.member.event.NewPendingFollowerEvent; import org.clokey.domain.member.repository.MemberRepository; import org.clokey.domain.member.service.MemberService; +import org.clokey.domain.notification.dto.request.TemperatureNotificationRequest; import org.clokey.domain.notification.dto.response.NotificationListResponse; import org.clokey.domain.notification.dto.response.UnreadNotificationResponse; import org.clokey.domain.notification.exception.NotificationErrorCode; @@ -621,4 +623,65 @@ void setUp() { .isEqualTo(ReadStatus.READ)); } } + + @Nested + class 오늘의_온도_알림을_보낼_때 { + + @BeforeEach + void setUp() { + Member member1 = + Member.createMember( + "testEmail1", + "testCodiveId1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + member1.updateDeviceToken("test-device-token-for-temp"); + memberRepository.save(member1); + given(memberUtil.getCurrentMember()).willReturn(member1); + + MemberTerm mockAgreement = Mockito.mock(MemberTerm.class); + given(mockAgreement.isAgreed()).willReturn(true); + + given( + memberTermRepository.findByMemberIdAndTermId( + eq(member1.getId()), + eq(TermInfo.PUSH_NOTIFICATION_RECEIVE.getId()))) + .willReturn(Optional.of(mockAgreement)); + } + + @Test + void 유효한_요청이면_오늘의_온도_알림을_전송한다() throws Exception { + // given + TemperatureNotificationRequest request = new TemperatureNotificationRequest(-16.5); + + // when + notificationService.sendNewTemperatureNotification(request); + + // then + // firebaseMessaging.send() 메서드가 1번 호출되었는지 검증 + Mockito.verify(firebaseMessaging, Mockito.times(1)).send(Mockito.any(Message.class)); + } + + @Test + void 알림_수신_미동의_시_전송하지_않는다() throws Exception { + // given + MemberTerm mockAgreement = Mockito.mock(MemberTerm.class); + given(mockAgreement.isAgreed()).willReturn(false); + + Member member = memberUtil.getCurrentMember(); + given( + memberTermRepository.findByMemberIdAndTermId( + eq(member.getId()), + eq(TermInfo.PUSH_NOTIFICATION_RECEIVE.getId()))) + .willReturn(Optional.of(mockAgreement)); + + TemperatureNotificationRequest request = new TemperatureNotificationRequest(-16.5); + + // when + notificationService.sendNewTemperatureNotification(request); + + // then + Mockito.verify(firebaseMessaging, Mockito.times(0)).send(Mockito.any(Message.class)); + } + } } 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")); + } + } +} diff --git a/clokey-infrastructure/src/main/java/org/clokey/config/FirebaseConfig.java b/clokey-infrastructure/src/main/java/org/clokey/config/FirebaseConfig.java index bdc769d6..bb6a9bec 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/config/FirebaseConfig.java +++ b/clokey-infrastructure/src/main/java/org/clokey/config/FirebaseConfig.java @@ -4,7 +4,7 @@ import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import com.google.firebase.messaging.FirebaseMessaging; -import java.io.ByteArrayInputStream; +import java.io.FileInputStream; import java.io.IOException; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; @@ -32,29 +32,16 @@ private FirebaseApp getFirebaseApp() throws IOException { } } + String path = firebaseProperties.getCredentialsPath(); + if (path == null || path.isBlank()) { + throw new IllegalStateException("FIREBASE_CREDENTIALS_PATH is empty"); + } + FirebaseOptions options = FirebaseOptions.builder() - .setCredentials( - GoogleCredentials.fromStream( - new ByteArrayInputStream( - getFirebaseConfigJson().getBytes()))) + .setCredentials(GoogleCredentials.fromStream(new FileInputStream(path))) .build(); return FirebaseApp.initializeApp(options); } - - private String getFirebaseConfigJson() { - return String.format( - "{ \"type\": \"%s\", \"project_id\": \"%s\", \"private_key_id\": \"%s\", \"private_key\": \"%s\", \"client_email\": \"%s\", \"client_id\": \"%s\", \"auth_uri\": \"%s\", \"token_uri\": \"%s\", \"auth_provider_x509_cert_url\": \"%s\", \"client_x509_cert_url\": \"%s\" }", - firebaseProperties.getType(), - firebaseProperties.getProjectId(), - firebaseProperties.getPrivateKeyId(), - firebaseProperties.getPrivateKey().replace("\\n", "\n"), // 줄바꿈 처리 - firebaseProperties.getClientEmail(), - firebaseProperties.getClientId(), - firebaseProperties.getAuthUri(), - firebaseProperties.getTokenUri(), - firebaseProperties.getAuthProviderX509CertUrl(), - firebaseProperties.getClientX509CertUrl()); - } } diff --git a/clokey-infrastructure/src/main/java/org/clokey/config/FirebaseProperties.java b/clokey-infrastructure/src/main/java/org/clokey/config/FirebaseProperties.java index 7f095d50..2823fad3 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/config/FirebaseProperties.java +++ b/clokey-infrastructure/src/main/java/org/clokey/config/FirebaseProperties.java @@ -10,14 +10,5 @@ @Configuration @ConfigurationProperties(prefix = "firebase") public class FirebaseProperties { - private String type; - private String projectId; - private String privateKeyId; - private String privateKey; - private String clientEmail; - private String clientId; - private String authUri; - private String tokenUri; - private String authProviderX509CertUrl; - private String clientX509CertUrl; + private String credentialsPath; }