diff --git a/src/main/java/com/lokoko/domain/campaignReview/application/service/CampaignReviewReadService.java b/src/main/java/com/lokoko/domain/campaignReview/application/service/CampaignReviewReadService.java new file mode 100644 index 00000000..0014b06c --- /dev/null +++ b/src/main/java/com/lokoko/domain/campaignReview/application/service/CampaignReviewReadService.java @@ -0,0 +1,271 @@ +package com.lokoko.domain.campaignReview.application.service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.lokoko.domain.campaign.api.dto.response.CampaignParticipatedResponse; +import com.lokoko.domain.campaign.application.mapper.CampaignMapper; +import com.lokoko.domain.campaign.application.service.CampaignGetService; +import com.lokoko.domain.campaign.domain.entity.Campaign; +import com.lokoko.domain.campaignReview.api.dto.response.CompletedReviewResponse; +import com.lokoko.domain.campaignReview.domain.entity.CampaignReview; +import com.lokoko.domain.campaignReview.domain.entity.enums.ReviewRound; +import com.lokoko.domain.creatorCampaign.application.service.CreatorCampaignGetService; +import com.lokoko.domain.creatorCampaign.domain.entity.CreatorCampaign; +import com.lokoko.domain.creatorCampaign.domain.enums.ParticipationStatus; +import com.lokoko.domain.creatorCampaign.exception.CampaignReviewAbleNotFoundException; +import com.lokoko.domain.media.socialclip.domain.entity.enums.ContentType; +import com.lokoko.global.config.BetaFeatureConfig; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CampaignReviewReadService { + + private final CampaignReviewGetService campaignReviewGetService; + private final CampaignGetService campaignGetService; + private final CreatorCampaignGetService creatorCampaignGetService; + private final CampaignMapper campaignMapper; + private final BetaFeatureConfig betaFeatureConfig; + + @Transactional + public CampaignParticipatedResponse getMyReviewableCampaign(Long creatorId, Long campaignId, ReviewRound round) { + CreatorCampaign creatorCampaign = findReviewableCampaign(creatorId, campaignId); + List firstReviews = + campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.FIRST); + List secondReviews = + campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.SECOND); + + markBrandNotesAsViewed(firstReviews); + + List reviewContents = round != null + ? createRoundSpecificReviewContentStatuses(creatorCampaign, round, firstReviews, secondReviews) + : createReviewContentStatuses(creatorCampaign, firstReviews, secondReviews); + + return campaignMapper.toCampaignParticipationResponse(creatorCampaign, reviewContents); + } + + @Transactional(readOnly = true) + public List getMyReviewables(Long creatorId, ReviewRound round) { + List eligibles = creatorCampaignGetService.findReviewable(creatorId); + + return eligibles.stream() + .filter(creatorCampaign -> creatorCampaign.getStatus() == ParticipationStatus.ACTIVE) + .map(creatorCampaign -> { + List reviewContents = round != null + ? createRoundSpecificReviewContentStatuses(creatorCampaign, round) + : createReviewContentStatuses(creatorCampaign); + + return campaignMapper.toCampaignParticipationResponse(creatorCampaign, reviewContents); + }) + .filter(response -> !response.reviewContents().isEmpty()) + .toList(); + } + + @Transactional(readOnly = true) + public CompletedReviewResponse getCompletedReviews(Long creatorId, Long campaignId) { + Campaign campaign = campaignGetService.findByCampaignId(campaignId); + CreatorCampaign creatorCampaign = creatorCampaignGetService.getByCampaignAndCreatorId(campaign, creatorId); + + if (creatorCampaign.getStatus() != ParticipationStatus.COMPLETED) { + throw new CampaignReviewAbleNotFoundException(); + } + + List reviewContents = + betaFeatureConfig.isSimplifiedReviewFlow() + ? createCompletedFirstReviewContents(creatorCampaign) + : createCompletedReviewContents(creatorCampaign); + + return CompletedReviewResponse.builder() + .campaignId(campaignId) + .campaignName(campaign.getTitle()) + .reviewContents(reviewContents) + .build(); + } + + private CreatorCampaign findReviewableCampaign(Long creatorId, Long campaignId) { + CreatorCampaign creatorCampaign; + if (betaFeatureConfig.isSimplifiedReviewFlow()) { + creatorCampaign = creatorCampaignGetService.findReviewableInMultipleStatusesForBeta(creatorId, campaignId); + } else { + creatorCampaign = creatorCampaignGetService.findReviewableInReviewByCampaign(creatorId, campaignId); + } + + if (creatorCampaign.getStatus() != ParticipationStatus.ACTIVE) { + throw new CampaignReviewAbleNotFoundException(); + } + return creatorCampaign; + } + + private List createReviewContentStatuses( + CreatorCampaign creatorCampaign + ) { + List firstReviews = + campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.FIRST); + List secondReviews = + campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.SECOND); + return createReviewContentStatuses(creatorCampaign, firstReviews, secondReviews); + } + + private List createReviewContentStatuses( + CreatorCampaign creatorCampaign, + List firstReviews, + List secondReviews + ) { + Campaign campaign = creatorCampaign.getCampaign(); + List campaignContentTypes = getCampaignContentTypes(campaign); + + Map firstReviewByContentType = toContentTypeMap(firstReviews); + Map secondReviewByContentType = toContentTypeMap(secondReviews); + + return campaignContentTypes.stream() + .map(contentType -> { + CampaignReview firstReview = firstReviewByContentType.get(contentType); + boolean hasFirstReview = firstReview != null; + ReviewRound currentRound = hasFirstReview ? ReviewRound.SECOND : ReviewRound.FIRST; + + String brandNote = null; + Instant revisionRequestedAt = null; + String captionWithHashtags = null; + List mediaUrls = null; + if (hasFirstReview) { + brandNote = firstReview.getBrandNote(); + revisionRequestedAt = firstReview.getRevisionRequestedAt(); + captionWithHashtags = firstReview.getCaptionWithHashtags(); + mediaUrls = campaignReviewGetService.getOrderedMediaUrls(firstReview); + } + + return campaignMapper.toReviewContentStatus( + contentType, + currentRound, + brandNote, + revisionRequestedAt, + captionWithHashtags, + mediaUrls + ); + }) + .filter(status -> !secondReviewByContentType.containsKey(status.contentType())) + .toList(); + } + + private List createRoundSpecificReviewContentStatuses( + CreatorCampaign creatorCampaign, + ReviewRound targetRound + ) { + List firstReviews = + campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.FIRST); + List secondReviews = + campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.SECOND); + return createRoundSpecificReviewContentStatuses(creatorCampaign, targetRound, firstReviews, secondReviews); + } + + private List createRoundSpecificReviewContentStatuses( + CreatorCampaign creatorCampaign, + ReviewRound targetRound, + List firstReviews, + List secondReviews + ) { + Campaign campaign = creatorCampaign.getCampaign(); + List campaignContentTypes = getCampaignContentTypes(campaign); + + Map firstReviewByContentType = toContentTypeMap(firstReviews); + Map secondReviewByContentType = toContentTypeMap(secondReviews); + + return campaignContentTypes.stream() + .filter(contentType -> { + boolean hasFirstReview = firstReviewByContentType.containsKey(contentType); + boolean hasSecondReview = secondReviewByContentType.containsKey(contentType); + return targetRound == ReviewRound.FIRST + ? !hasFirstReview + : hasFirstReview && !hasSecondReview; + }) + .map(contentType -> { + CampaignReview firstReview = firstReviewByContentType.get(contentType); + String brandNote = null; + Instant revisionRequestedAt = null; + String captionWithHashtags = null; + List mediaUrls = null; + if (targetRound == ReviewRound.SECOND) { + brandNote = firstReview.getBrandNote(); + revisionRequestedAt = firstReview.getRevisionRequestedAt(); + captionWithHashtags = firstReview.getCaptionWithHashtags(); + mediaUrls = campaignReviewGetService.getOrderedMediaUrls(firstReview); + } + + return campaignMapper.toReviewContentStatus( + contentType, + targetRound, + brandNote, + revisionRequestedAt, + captionWithHashtags, + mediaUrls + ); + }) + .toList(); + } + + private List createCompletedReviewContents( + CreatorCampaign creatorCampaign + ) { + List secondReviews = + campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.SECOND); + + return secondReviews.stream() + .map(review -> CompletedReviewResponse.CompletedReviewContent.builder() + .contentType(review.getContentType()) + .captionWithHashtags(review.getCaptionWithHashtags()) + .mediaUrls(campaignReviewGetService.getOrderedMediaUrls(review)) + .build()) + .toList(); + } + + private List createCompletedFirstReviewContents( + CreatorCampaign creatorCampaign + ) { + List firstReviews = + campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.FIRST); + + return firstReviews.stream() + .map(review -> CompletedReviewResponse.CompletedReviewContent.builder() + .contentType(review.getContentType()) + .captionWithHashtags(review.getCaptionWithHashtags()) + .mediaUrls(campaignReviewGetService.getOrderedMediaUrls(review)) + .build()) + .toList(); + } + + private void markBrandNotesAsViewed(List firstReviews) { + firstReviews.stream() + .filter(review -> review.getBrandNote() != null && !review.getBrandNote().isEmpty()) + .filter(review -> !review.isNoteViewed()) + .forEach(CampaignReview::markNoteAsViewed); + } + + private List getCampaignContentTypes(Campaign campaign) { + List contentTypes = new ArrayList<>(); + if (campaign.getFirstContentPlatform() != null) { + contentTypes.add(campaign.getFirstContentPlatform()); + } + if (campaign.getSecondContentPlatform() != null) { + contentTypes.add(campaign.getSecondContentPlatform()); + } + return contentTypes; + } + + private Map toContentTypeMap(List reviews) { + return reviews.stream() + .collect(Collectors.toMap( + CampaignReview::getContentType, + Function.identity(), + (existing, ignored) -> existing + )); + } +} diff --git a/src/main/java/com/lokoko/domain/campaignReview/application/usecase/CampaignReviewUsecase.java b/src/main/java/com/lokoko/domain/campaignReview/application/usecase/CampaignReviewUsecase.java index 49a39ed3..b0c1c6db 100644 --- a/src/main/java/com/lokoko/domain/campaignReview/application/usecase/CampaignReviewUsecase.java +++ b/src/main/java/com/lokoko/domain/campaignReview/application/usecase/CampaignReviewUsecase.java @@ -1,529 +1,228 @@ package com.lokoko.domain.campaignReview.application.usecase; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.lokoko.domain.campaign.api.dto.response.CampaignParticipatedResponse; -import com.lokoko.domain.campaign.application.mapper.CampaignMapper; import com.lokoko.domain.campaign.application.service.CampaignGetService; import com.lokoko.domain.campaign.domain.entity.Campaign; import com.lokoko.domain.campaignReview.api.dto.request.FirstReviewUploadRequest; import com.lokoko.domain.campaignReview.api.dto.request.SecondReviewUploadRequest; -import com.lokoko.domain.campaignReview.api.dto.response.ReviewUploadResponse; import com.lokoko.domain.campaignReview.api.dto.response.CompletedReviewResponse; +import com.lokoko.domain.campaignReview.api.dto.response.ReviewUploadResponse; import com.lokoko.domain.campaignReview.application.mapper.CampaignReviewMapper; -import com.lokoko.global.config.BetaFeatureConfig; import com.lokoko.domain.campaignReview.application.service.CampaignReviewGetService; +import com.lokoko.domain.campaignReview.application.service.CampaignReviewReadService; import com.lokoko.domain.campaignReview.application.service.CampaignReviewSaveService; -import com.lokoko.domain.campaignReview.application.service.CampaignReviewStatusManager; -import com.lokoko.domain.campaignReview.application.service.CampaignReviewValidationService; import com.lokoko.domain.campaignReview.application.service.CampaignReviewUpdateService; +import com.lokoko.domain.campaignReview.application.service.CampaignReviewValidationService; import com.lokoko.domain.campaignReview.application.service.CreatorCampaignUpdateService; import com.lokoko.domain.campaignReview.domain.entity.CampaignReview; import com.lokoko.domain.campaignReview.domain.entity.enums.ReviewRound; -import com.lokoko.domain.creatorCampaign.exception.CampaignReviewAbleNotFoundException; import com.lokoko.domain.creator.application.service.CreatorGetService; import com.lokoko.domain.creator.domain.entity.Creator; import com.lokoko.domain.creatorCampaign.application.service.CreatorCampaignGetService; import com.lokoko.domain.creatorCampaign.domain.entity.CreatorCampaign; -import com.lokoko.domain.creatorCampaign.domain.enums.ParticipationStatus; import com.lokoko.domain.media.api.dto.request.MediaPresignedUrlRequest; import com.lokoko.domain.media.api.dto.response.MediaPresignedUrlResponse; import com.lokoko.domain.media.application.utils.MediaValidationUtil; import com.lokoko.domain.media.socialclip.application.service.SocialClipSaveService; import com.lokoko.domain.media.socialclip.domain.entity.enums.ContentType; -import java.time.Instant; -import java.util.List; -import java.util.Objects; -import java.util.Optional; +import com.lokoko.global.config.BetaFeatureConfig; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class CampaignReviewUsecase { - private final CreatorGetService creatorGetService; - private final CampaignReviewGetService campaignReviewGetService; - private final CampaignGetService campaignGetService; - private final CreatorCampaignGetService creatorCampaignGetService; - - private final CampaignReviewSaveService campaignReviewSaveService; - private final CreatorCampaignUpdateService creatorCampaignUpdateService; - private final CampaignReviewUpdateService campaignReviewUpdateService; - private final CampaignReviewValidationService campaignReviewValidationService; - private final SocialClipSaveService socialClipSaveService; - - private final CampaignReviewStatusManager campaignReviewStatusManager; - private final BetaFeatureConfig betaFeatureConfig; - - private final CampaignReviewMapper campaignReviewMapper; - private final CampaignMapper campaignMapper; - - /** - * 1차 리뷰 업로드 - 타입은 Campaign.firstContentPlatform / secondContentPlatform 사용 - 두개 리뷰 컨텐츠를 입력 받아야하는 캠페인이면 2세트 모두 필수 - * 아니면 첫세트만 허용 - 동일 타입의 FIRST가 이미 존재하면 409 - */ - @Transactional - public ReviewUploadResponse uploadFirst(Long userId, Long campaignId, FirstReviewUploadRequest request) { - Creator creator = creatorGetService.findByUserId(userId); - Campaign campaign = campaignGetService.findByCampaignId(campaignId); - CreatorCampaign participation = - creatorCampaignGetService.getByCampaignAndCreatorId(campaign, creator.getId()); - - ContentType typeA = campaign.getFirstContentPlatform(); - ContentType typeB = campaign.getSecondContentPlatform(); - campaignReviewValidationService.validateTwoSetCombination(typeA, typeB); - - // A 세트(캠페인에 second가 없는 단일 타입 캠페인) - // 베타 버전에서는 firstMediaUrls, firstCaptionWithHashtags 에 대한 검증을 진행하지 않는다. - if (!betaFeatureConfig.isSimplifiedReviewFlow()) { - campaignReviewValidationService.requireFirstSetPresent(request.firstMediaUrls(), - request.firstCaptionWithHashtags()); - } - if (request.firstMediaUrls() != null && !request.firstMediaUrls().isEmpty()) { - MediaValidationUtil.validateTotalMediaCount(request.firstMediaUrls()); - } - - // B 세트(캠페인에 second가 있으면 필수, 없으면 금지) - if (typeB != null) { - // 베타 버전에서는 secondMediaUrls, secondCaptionWithHashtags 에 대한 검증을 진행하지 않는다. - if (!betaFeatureConfig.isSimplifiedReviewFlow()) { - campaignReviewValidationService.requireFirstSetPresent( - request.secondMediaUrls(), request.secondCaptionWithHashtags()); - } - if (request.secondMediaUrls() != null && !request.secondMediaUrls().isEmpty()) { - MediaValidationUtil.validateTotalMediaCount(request.secondMediaUrls()); - } - } else { - campaignReviewValidationService.ensureSecondSetAbsentForFirstRound( - request.secondMediaUrls(), request.secondCaptionWithHashtags()); - } - - // 미디어 합산 개수 제한 - campaignReviewValidationService.validateCombinedMediaLimit( - request.firstMediaUrls(), - (typeB != null) ? request.secondMediaUrls() : null - ); - - // 저장 A (베타 모드일 경우 postUrl 포함) - CampaignReview firstA; - if (betaFeatureConfig.isFirstReviewUrlEnabled() && request.firstPostUrl() != null) { - firstA = campaignReviewMapper.toFirstReview( - participation, typeA, request.firstCaptionWithHashtags(), request.firstPostUrl()); - } else { - firstA = campaignReviewMapper.toFirstReview( - participation, typeA, request.firstCaptionWithHashtags()); - } - CampaignReview savedA = campaignReviewSaveService.saveReview(firstA); - campaignReviewSaveService.saveMedia(savedA, request.firstMediaUrls()); - - // 저장 B(옵션) - if (typeB != null) { - CampaignReview firstB; - if (betaFeatureConfig.isFirstReviewUrlEnabled() && request.secondPostUrl() != null) { - firstB = campaignReviewMapper.toFirstReview( - participation, typeB, request.secondCaptionWithHashtags(), request.secondPostUrl()); - } else { - firstB = campaignReviewMapper.toFirstReview( - participation, typeB, request.secondCaptionWithHashtags()); - } - CampaignReview savedB = campaignReviewSaveService.saveReview(firstB); - campaignReviewSaveService.saveMedia(savedB, request.secondMediaUrls()); - } - - creatorCampaignUpdateService.refreshParticipationStatus(participation.getId()); - return campaignReviewMapper.toUploadResponse(savedA); - } - - - /** - * 2차 리뷰 업로드 - 타입은 Campaign.firstContentPlatform / secondContentPlatform 사용 - *

- 두 리뷰 컨텐츠를 모두 입력 받는 캠페인이면 2세트 모두 필수 (각각 postUrl 포함) - *

아니면 첫 세트만 허용(두 번째 세트 전달 시 400) - *

- 각 타입별로: 1차 존재 + 동일 타입이라면 이미 업로드한 2차 리뷰가 없다는 조건이 충족해야 함 - */ - @Transactional - public ReviewUploadResponse uploadSecond(Long userId, Long campaignId, SecondReviewUploadRequest request) { - Creator creator = creatorGetService.findByUserId(userId); - Campaign campaign = campaignGetService.findByCampaignId(campaignId); - CreatorCampaign participation = - creatorCampaignGetService.getByCampaignAndCreatorId(campaign, creator.getId()); - - ContentType typeA = campaign.getFirstContentPlatform(); - ContentType typeB = campaign.getSecondContentPlatform(); - campaignReviewValidationService.validateTwoSetCombination(typeA, typeB); - - // A 세트(필수: 미디어/캡션/postUrl) - campaignReviewValidationService.requireSecondSetPresent( - request.firstMediaUrls(), request.firstCaptionWithHashtags(), request.firstPostUrl()); - MediaValidationUtil.validateTotalMediaCount(request.firstMediaUrls()); - - // B 세트(캠페인에 second가 있으면 필수, 없으면 금지) - if (typeB != null) { - campaignReviewValidationService.requireSecondSetPresent( - request.secondMediaUrls(), request.secondCaptionWithHashtags(), request.secondPostUrl()); - MediaValidationUtil.validateTotalMediaCount(request.secondMediaUrls()); - } else { - campaignReviewValidationService.ensureSecondSetAbsentForSecondRound( - request.secondMediaUrls(), request.secondCaptionWithHashtags(), request.secondPostUrl()); - } - - // 미디어 합산 개수 제한 - campaignReviewValidationService.validateCombinedMediaLimit( - request.firstMediaUrls(), - (typeB != null) ? request.secondMediaUrls() : null - ); - - // 선행/중복 검증 & 저장 A - campaignReviewGetService.getFirstOrThrow(participation.getId(), typeA); - - CampaignReview secondA = campaignReviewMapper.toSecondReview( - participation, typeA, request.firstCaptionWithHashtags(), request.firstPostUrl()); - CampaignReview savedA = campaignReviewSaveService.saveReview(secondA); - campaignReviewSaveService.saveMedia(savedA, request.firstMediaUrls()); - - // 2차 리뷰 업로드 시 SocialClip 생성 (성과 지표 0으로 초기화) - socialClipSaveService.createForSecondReview(savedA); - - // B(옵션) - if (typeB != null) { - campaignReviewGetService.getFirstOrThrow(participation.getId(), typeB); - CampaignReview secondB = campaignReviewMapper.toSecondReview( - participation, typeB, request.secondCaptionWithHashtags(), request.secondPostUrl()); - CampaignReview savedB = campaignReviewSaveService.saveReview(secondB); - campaignReviewSaveService.saveMedia(savedB, request.secondMediaUrls()); - - // 2차 리뷰 업로드 시 SocialClip 생성 (성과 지표 0으로 초기화) - socialClipSaveService.createForSecondReview(savedB); - } - - creatorCampaignUpdateService.refreshParticipationStatus(participation.getId()); - return campaignReviewMapper.toUploadResponse(savedA); - } - - @Transactional - public CampaignParticipatedResponse getMyReviewableCampaign(Long userId, Long campaignId, ReviewRound round) { - Creator creator = creatorGetService.findByUserId(userId); - - // 베타버전에서는 여러 캠페인 상태에서 리뷰 업로드 가능 - CreatorCampaign creatorCampaign; - if (betaFeatureConfig.isSimplifiedReviewFlow()) { - // 베타버전: RECRUITING, RECRUITMENT_CLOSED, IN_REVIEW 상태 모두 허용 - creatorCampaign = creatorCampaignGetService.findReviewableInMultipleStatusesForBeta( - creator.getId(), campaignId); - } else { - // 정식버전: IN_REVIEW 상태만 허용 - creatorCampaign = creatorCampaignGetService.findReviewableInReviewByCampaign( - creator.getId(), campaignId); - } - - // ACTIVE 상태에서만 업로드 가능 - if (creatorCampaign.getStatus() != ParticipationStatus.ACTIVE) { - throw new CampaignReviewAbleNotFoundException(); - } - - // 조회 시점에 브랜드 노트가 있는 리뷰들의 noteViewed를 true로 업데이트 - markBrandNotesAsViewed(creatorCampaign); - - // round 파라미터가 있으면 해당 라운드만 필터링 - List reviewContents; - if (round != null) { - reviewContents = createRoundSpecificReviewContentStatuses(creatorCampaign, round); - } else { - reviewContents = createReviewContentStatuses(creatorCampaign); - } - - return campaignMapper.toCampaignParticipationResponse(creatorCampaign, reviewContents); - } - - @Transactional(readOnly = true) - public List getMyReviewables(Long userId, ReviewRound round) { - Creator creator = creatorGetService.findByUserId(userId); - List eligibles = creatorCampaignGetService.findReviewable(creator.getId()); - - return eligibles.stream() - .filter(cc -> cc.getStatus() == ParticipationStatus.ACTIVE) // ACTIVE만 필터링 - .map(creatorCampaign -> { - // round 파라미터가 있으면 해당 라운드만 필터링 - List reviewContents; - if (round != null) { - reviewContents = createRoundSpecificReviewContentStatuses(creatorCampaign, round); - } else { - reviewContents = createReviewContentStatuses(creatorCampaign); - } - - return campaignMapper.toCampaignParticipationResponse(creatorCampaign, reviewContents); - }) - .filter(response -> !response.reviewContents().isEmpty()) // 빈 컨텐츠는 제외 - .toList(); - } - - /** - * 실제 업로드 가능한 리뷰 라운드를 결정하는 메서드 - * ACTIVE 상태에서 기존 리뷰 존재 여부로 1차/2차 구분 - */ - private ReviewRound determineActualReviewRound(CreatorCampaign creatorCampaign) { - // 1차 리뷰가 존재하는지 확인 - boolean hasFirstReview = campaignReviewGetService.existsFirst(creatorCampaign.getId()); - - if (hasFirstReview) { - // 1차 리뷰가 있으면 2차 업로드 차례 - return ReviewRound.SECOND; - } else { - // 1차 리뷰가 없으면 1차 업로드 차례 - return ReviewRound.FIRST; - } - } - - /** - * 각 content type별 리뷰 상태를 생성하는 메서드 - */ - private List createReviewContentStatuses(CreatorCampaign creatorCampaign) { - Campaign campaign = creatorCampaign.getCampaign(); - List campaignContentTypes = List.of( - campaign.getFirstContentPlatform(), - campaign.getSecondContentPlatform() - ).stream().filter(ct -> ct != null).toList(); - - // 기존 리뷰 현황 조회 - List existingFirstTypes = campaignReviewGetService - .findContentTypesByRound(creatorCampaign.getId(), ReviewRound.FIRST); - List existingSecondTypes = campaignReviewGetService - .findContentTypesByRound(creatorCampaign.getId(), ReviewRound.SECOND); - - return campaignContentTypes.stream() - .map(contentType -> { - boolean hasFirstReview = existingFirstTypes.contains(contentType); - boolean hasSecondReview = existingSecondTypes.contains(contentType); - - ReviewRound nowRound = hasFirstReview ? ReviewRound.SECOND : ReviewRound.FIRST; - - // 2차 리뷰 업로드 시에만 해당 content type의 1차 리뷰에서 브랜드 노트 정보 가져오기 - String brandNote = null; - Instant revisionRequestedAt = null; - - if (nowRound == ReviewRound.SECOND) { - // 해당 content type의 1차 리뷰에서 브랜드 노트 조회 - Optional firstReviewForContentType = campaignReviewGetService - .findByContentType(creatorCampaign.getId(), ReviewRound.FIRST, contentType); - - if (firstReviewForContentType.isPresent()) { - CampaignReview review = firstReviewForContentType.get(); - brandNote = review.getBrandNote(); - revisionRequestedAt = review.getRevisionRequestedAt(); - } - } - - // 기존 리뷰의 캡션과 미디어 URL 정보 가져오기 - String captionWithHashtags = null; - List mediaUrls = null; - - // 현재 라운드에 해당하는 기존 리뷰가 있다면 정보 가져오기 - ReviewRound reviewRoundToCheck = hasFirstReview ? ReviewRound.FIRST : null; - if (reviewRoundToCheck != null) { - Optional existingReview = campaignReviewGetService - .findByContentType(creatorCampaign.getId(), reviewRoundToCheck, contentType); - - if (existingReview.isPresent()) { - CampaignReview review = existingReview.get(); - captionWithHashtags = review.getCaptionWithHashtags(); - mediaUrls = campaignReviewGetService.getOrderedMediaUrls(review); - } - } - - return campaignMapper.toReviewContentStatus(contentType, nowRound, brandNote, revisionRequestedAt, captionWithHashtags, mediaUrls); - }) - .filter(status -> { - // 이미 2차 리뷰까지 완료된 content type은 제외 - boolean hasSecondReview = existingSecondTypes.contains(status.contentType()); - return !hasSecondReview; - }) - .toList(); - } - - /** - * 특정 라운드에 해당하는 content type별 리뷰 상태를 생성하는 메서드 - */ - private List createRoundSpecificReviewContentStatuses( - CreatorCampaign creatorCampaign, ReviewRound targetRound) { - Campaign campaign = creatorCampaign.getCampaign(); - List campaignContentTypes = List.of( - campaign.getFirstContentPlatform(), - campaign.getSecondContentPlatform() - ).stream().filter(ct -> ct != null).toList(); - - // 기존 리뷰 현황 조회 - List existingFirstTypes = campaignReviewGetService - .findContentTypesByRound(creatorCampaign.getId(), ReviewRound.FIRST); - List existingSecondTypes = campaignReviewGetService - .findContentTypesByRound(creatorCampaign.getId(), ReviewRound.SECOND); - - return campaignContentTypes.stream() - .filter(contentType -> { - boolean hasFirstReview = existingFirstTypes.contains(contentType); - boolean hasSecondReview = existingSecondTypes.contains(contentType); - - if (targetRound == ReviewRound.FIRST) { - // 1차 라운드 요청: 1차 리뷰가 없는 것들만 - return !hasFirstReview; - } else { - // 2차 라운드 요청: 1차는 있고 2차는 없는 것들만 - return hasFirstReview && !hasSecondReview; - } - }) - .map(contentType -> { - // 2차 라운드 요청 시에만 브랜드 노트 정보 포함 - String brandNote = null; - Instant revisionRequestedAt = null; - - if (targetRound == ReviewRound.SECOND) { - Optional firstReviewForContentType = campaignReviewGetService - .findByContentType(creatorCampaign.getId(), ReviewRound.FIRST, contentType); - - if (firstReviewForContentType.isPresent()) { - CampaignReview review = firstReviewForContentType.get(); - brandNote = review.getBrandNote(); - revisionRequestedAt = review.getRevisionRequestedAt(); - } - } - - // 기존 리뷰의 캡션과 미디어 URL 정보 가져오기 - String captionWithHashtags = null; - List mediaUrls = null; - - if (targetRound == ReviewRound.SECOND) { - // 2차 리뷰 시에는 1차 리뷰 정보를 가져옴 - Optional firstReviewForContentType = campaignReviewGetService - .findByContentType(creatorCampaign.getId(), ReviewRound.FIRST, contentType); - - if (firstReviewForContentType.isPresent()) { - CampaignReview review = firstReviewForContentType.get(); - captionWithHashtags = review.getCaptionWithHashtags(); - mediaUrls = campaignReviewGetService.getOrderedMediaUrls(review); - } - } - - return campaignMapper.toReviewContentStatus(contentType, targetRound, brandNote, revisionRequestedAt, captionWithHashtags, mediaUrls); - }) - .toList(); - } - - /** - * 완료된 2차 리뷰의 컨텐츠를 생성하는 메서드 - */ - private List createCompletedReviewContents(CreatorCampaign creatorCampaign) { - // 실제 업로드된 2차 리뷰의 컨텐츠 타입들 - List existingSecondTypes = campaignReviewGetService - .findContentTypesByRound(creatorCampaign.getId(), ReviewRound.SECOND); - - return existingSecondTypes.stream() - .map(contentType -> { - // 2차 리뷰 정보 조회 - Optional secondReview = campaignReviewGetService - .findByContentType(creatorCampaign.getId(), ReviewRound.SECOND, contentType); - - if (secondReview.isPresent()) { - CampaignReview review = secondReview.get(); - String captionWithHashtags = review.getCaptionWithHashtags(); - List mediaUrls = campaignReviewGetService.getOrderedMediaUrls(review); - - return CompletedReviewResponse.CompletedReviewContent.builder() - .contentType(contentType) - .captionWithHashtags(captionWithHashtags) - .mediaUrls(mediaUrls) - .build(); - } - return null; - }) - .filter(content -> content != null) - .toList(); - } - - /** - * 완료된 1차 리뷰의 컨텐츠를 생성하는 메서드 (베타 기능) - */ - private List createCompletedFirstReviewContents(CreatorCampaign creatorCampaign) { - // 실제 업로드된 1차 리뷰의 컨텐츠 타입들 - List existingFirstTypes = campaignReviewGetService - .findContentTypesByRound(creatorCampaign.getId(), ReviewRound.FIRST); - - return existingFirstTypes.stream() - .map(contentType -> { - // 1차 리뷰 정보 조회 - Optional firstReview = campaignReviewGetService - .findByContentType(creatorCampaign.getId(), ReviewRound.FIRST, contentType); - - if (firstReview.isPresent()) { - CampaignReview review = firstReview.get(); - String captionWithHashtags = review.getCaptionWithHashtags(); - List mediaUrls = campaignReviewGetService.getOrderedMediaUrls(review); - - return CompletedReviewResponse.CompletedReviewContent.builder() - .contentType(contentType) - .captionWithHashtags(captionWithHashtags) - .mediaUrls(mediaUrls) - .build(); - } - return null; - }) - .filter(Objects::nonNull) - .toList(); - } - - /** - * 조회 시점에 브랜드 노트가 있는 모든 1차 리뷰의 noteViewed를 true로 업데이트 - */ - private void markBrandNotesAsViewed(CreatorCampaign creatorCampaign) { - // 해당 캠페인의 모든 1차 리뷰 조회 - List firstReviews = campaignReviewGetService - .getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.FIRST); - - // 브랜드 노트가 있는 리뷰들의 noteViewed를 true로 업데이트 - firstReviews.stream() - .filter(review -> review.getBrandNote() != null && !review.getBrandNote().isEmpty()) - .filter(review -> !review.isNoteViewed()) // 아직 확인하지 않은 것만 - .forEach(CampaignReview::markNoteAsViewed); - } - - - @Transactional(readOnly = true) - public MediaPresignedUrlResponse createMediaPresignedUrl(Long userId, MediaPresignedUrlRequest request) { - Creator creator = creatorGetService.findByUserId(userId); - List urls = campaignReviewUpdateService.createPresignedUrlForReview(creator.getId(), request); - - return campaignReviewMapper.toMediaPresignedUrlResponse(urls); - } - - /** - * 완료된 캠페인의 최종 리뷰 결과 조회 - * 베타 모드: 1차 리뷰 조회 - * 정식 모드: 2차 리뷰 조회 (2차가 없으면 1차 리뷰 조회) - */ - @Transactional(readOnly = true) - public CompletedReviewResponse getCompletedReviews(Long userId, Long campaignId) { - Creator creator = creatorGetService.findByUserId(userId); - Campaign campaign = campaignGetService.findByCampaignId(campaignId); - CreatorCampaign creatorCampaign = - creatorCampaignGetService.getByCampaignAndCreatorId(campaign, creator.getId()); - - // COMPLETED 상태만 허용 - if (creatorCampaign.getStatus() != ParticipationStatus.COMPLETED) { - throw new CampaignReviewAbleNotFoundException(); - } - - List reviewContents; - - if (betaFeatureConfig.isSimplifiedReviewFlow()) { - // 베타 모드: 1차 리뷰 조회 - reviewContents = createCompletedFirstReviewContents(creatorCampaign); - } else { - reviewContents = createCompletedReviewContents(creatorCampaign); - } - - return CompletedReviewResponse.builder() - .campaignId(campaignId) - .campaignName(campaign.getTitle()) - .reviewContents(reviewContents) - .build(); - } + private final CreatorGetService creatorGetService; + private final CampaignReviewGetService campaignReviewGetService; + private final CampaignGetService campaignGetService; + private final CreatorCampaignGetService creatorCampaignGetService; + + private final CampaignReviewSaveService campaignReviewSaveService; + private final CreatorCampaignUpdateService creatorCampaignUpdateService; + private final CampaignReviewUpdateService campaignReviewUpdateService; + private final CampaignReviewReadService campaignReviewReadService; + private final CampaignReviewValidationService campaignReviewValidationService; + private final SocialClipSaveService socialClipSaveService; + + private final BetaFeatureConfig betaFeatureConfig; + + private final CampaignReviewMapper campaignReviewMapper; + + /** + * 1차 리뷰 업로드 - 타입은 Campaign.firstContentPlatform / secondContentPlatform 사용 - 두개 리뷰 컨텐츠를 입력 받아야하는 캠페인이면 2세트 모두 필수 + * 아니면 첫세트만 허용 - 동일 타입의 FIRST가 이미 존재하면 409 + */ + @Transactional + public ReviewUploadResponse uploadFirst(Long userId, Long campaignId, FirstReviewUploadRequest request) { + Creator creator = creatorGetService.findByUserId(userId); + Campaign campaign = campaignGetService.findByCampaignId(campaignId); + CreatorCampaign participation = + creatorCampaignGetService.getByCampaignAndCreatorId(campaign, creator.getId()); + + ContentType typeA = campaign.getFirstContentPlatform(); + ContentType typeB = campaign.getSecondContentPlatform(); + campaignReviewValidationService.validateTwoSetCombination(typeA, typeB); + + // A 세트(캠페인에 second가 없는 단일 타입 캠페인) + // 베타 버전에서는 firstMediaUrls, firstCaptionWithHashtags 에 대한 검증을 진행하지 않는다. + if (!betaFeatureConfig.isSimplifiedReviewFlow()) { + campaignReviewValidationService.requireFirstSetPresent(request.firstMediaUrls(), + request.firstCaptionWithHashtags()); + } + if (request.firstMediaUrls() != null && !request.firstMediaUrls().isEmpty()) { + MediaValidationUtil.validateTotalMediaCount(request.firstMediaUrls()); + } + + // B 세트(캠페인에 second가 있으면 필수, 없으면 금지) + if (typeB != null) { + // 베타 버전에서는 secondMediaUrls, secondCaptionWithHashtags 에 대한 검증을 진행하지 않는다. + if (!betaFeatureConfig.isSimplifiedReviewFlow()) { + campaignReviewValidationService.requireFirstSetPresent( + request.secondMediaUrls(), request.secondCaptionWithHashtags()); + } + if (request.secondMediaUrls() != null && !request.secondMediaUrls().isEmpty()) { + MediaValidationUtil.validateTotalMediaCount(request.secondMediaUrls()); + } + } else { + campaignReviewValidationService.ensureSecondSetAbsentForFirstRound( + request.secondMediaUrls(), request.secondCaptionWithHashtags()); + } + + // 미디어 합산 개수 제한 + campaignReviewValidationService.validateCombinedMediaLimit( + request.firstMediaUrls(), + (typeB != null) ? request.secondMediaUrls() : null + ); + + // 저장 A (베타 모드일 경우 postUrl 포함) + CampaignReview firstA; + if (betaFeatureConfig.isFirstReviewUrlEnabled() && request.firstPostUrl() != null) { + firstA = campaignReviewMapper.toFirstReview( + participation, typeA, request.firstCaptionWithHashtags(), request.firstPostUrl()); + } else { + firstA = campaignReviewMapper.toFirstReview( + participation, typeA, request.firstCaptionWithHashtags()); + } + CampaignReview savedA = campaignReviewSaveService.saveReview(firstA); + campaignReviewSaveService.saveMedia(savedA, request.firstMediaUrls()); + + // 저장 B(옵션) + if (typeB != null) { + CampaignReview firstB; + if (betaFeatureConfig.isFirstReviewUrlEnabled() && request.secondPostUrl() != null) { + firstB = campaignReviewMapper.toFirstReview( + participation, typeB, request.secondCaptionWithHashtags(), request.secondPostUrl()); + } else { + firstB = campaignReviewMapper.toFirstReview( + participation, typeB, request.secondCaptionWithHashtags()); + } + CampaignReview savedB = campaignReviewSaveService.saveReview(firstB); + campaignReviewSaveService.saveMedia(savedB, request.secondMediaUrls()); + } + + creatorCampaignUpdateService.refreshParticipationStatus(participation.getId()); + return campaignReviewMapper.toUploadResponse(savedA); + } + + /** + * 2차 리뷰 업로드 - 타입은 Campaign.firstContentPlatform / secondContentPlatform 사용 + *

- 두 리뷰 컨텐츠를 모두 입력 받는 캠페인이면 2세트 모두 필수 (각각 postUrl 포함) + *

아니면 첫 세트만 허용(두 번째 세트 전달 시 400) + *

- 각 타입별로: 1차 존재 + 동일 타입이라면 이미 업로드한 2차 리뷰가 없다는 조건이 충족해야 함 + */ + @Transactional + public ReviewUploadResponse uploadSecond(Long userId, Long campaignId, SecondReviewUploadRequest request) { + Creator creator = creatorGetService.findByUserId(userId); + Campaign campaign = campaignGetService.findByCampaignId(campaignId); + CreatorCampaign participation = + creatorCampaignGetService.getByCampaignAndCreatorId(campaign, creator.getId()); + + ContentType typeA = campaign.getFirstContentPlatform(); + ContentType typeB = campaign.getSecondContentPlatform(); + campaignReviewValidationService.validateTwoSetCombination(typeA, typeB); + + // A 세트(필수: 미디어/캡션/postUrl) + campaignReviewValidationService.requireSecondSetPresent( + request.firstMediaUrls(), request.firstCaptionWithHashtags(), request.firstPostUrl()); + MediaValidationUtil.validateTotalMediaCount(request.firstMediaUrls()); + + // B 세트(캠페인에 second가 있으면 필수, 없으면 금지) + if (typeB != null) { + campaignReviewValidationService.requireSecondSetPresent( + request.secondMediaUrls(), request.secondCaptionWithHashtags(), request.secondPostUrl()); + MediaValidationUtil.validateTotalMediaCount(request.secondMediaUrls()); + } else { + campaignReviewValidationService.ensureSecondSetAbsentForSecondRound( + request.secondMediaUrls(), request.secondCaptionWithHashtags(), request.secondPostUrl()); + } + + // 미디어 합산 개수 제한 + campaignReviewValidationService.validateCombinedMediaLimit( + request.firstMediaUrls(), + (typeB != null) ? request.secondMediaUrls() : null + ); + + // 선행/중복 검증 & 저장 A + campaignReviewGetService.getFirstOrThrow(participation.getId(), typeA); + + CampaignReview secondA = campaignReviewMapper.toSecondReview( + participation, typeA, request.firstCaptionWithHashtags(), request.firstPostUrl()); + CampaignReview savedA = campaignReviewSaveService.saveReview(secondA); + campaignReviewSaveService.saveMedia(savedA, request.firstMediaUrls()); + + // 2차 리뷰 업로드 시 SocialClip 생성 (성과 지표 0으로 초기화) + socialClipSaveService.createForSecondReview(savedA); + + // B(옵션) + if (typeB != null) { + campaignReviewGetService.getFirstOrThrow(participation.getId(), typeB); + CampaignReview secondB = campaignReviewMapper.toSecondReview( + participation, typeB, request.secondCaptionWithHashtags(), request.secondPostUrl()); + CampaignReview savedB = campaignReviewSaveService.saveReview(secondB); + campaignReviewSaveService.saveMedia(savedB, request.secondMediaUrls()); + + // 2차 리뷰 업로드 시 SocialClip 생성 (성과 지표 0으로 초기화) + socialClipSaveService.createForSecondReview(savedB); + } + + creatorCampaignUpdateService.refreshParticipationStatus(participation.getId()); + return campaignReviewMapper.toUploadResponse(savedA); + } + + @Transactional + public CampaignParticipatedResponse getMyReviewableCampaign(Long userId, Long campaignId, ReviewRound round) { + Creator creator = creatorGetService.findByUserId(userId); + return campaignReviewReadService.getMyReviewableCampaign(creator.getId(), campaignId, round); + } + + @Transactional(readOnly = true) + public List getMyReviewables(Long userId, ReviewRound round) { + Creator creator = creatorGetService.findByUserId(userId); + return campaignReviewReadService.getMyReviewables(creator.getId(), round); + } + + @Transactional(readOnly = true) + public MediaPresignedUrlResponse createMediaPresignedUrl(Long userId, MediaPresignedUrlRequest request) { + Creator creator = creatorGetService.findByUserId(userId); + List urls = campaignReviewUpdateService.createPresignedUrlForReview(creator.getId(), request); + + return campaignReviewMapper.toMediaPresignedUrlResponse(urls); + } + + /** + * 완료된 캠페인의 최종 리뷰 결과 조회 + * 베타 모드: 1차 리뷰 조회 + * 정식 모드: 2차 리뷰 조회 (2차가 없으면 1차 리뷰 조회) + */ + @Transactional(readOnly = true) + public CompletedReviewResponse getCompletedReviews(Long userId, Long campaignId) { + Creator creator = creatorGetService.findByUserId(userId); + return campaignReviewReadService.getCompletedReviews(creator.getId(), campaignId); + } } diff --git a/src/test/java/com/lokoko/domain/campaignReview/application/service/CampaignReviewReadServiceTest.java b/src/test/java/com/lokoko/domain/campaignReview/application/service/CampaignReviewReadServiceTest.java new file mode 100644 index 00000000..b45224e0 --- /dev/null +++ b/src/test/java/com/lokoko/domain/campaignReview/application/service/CampaignReviewReadServiceTest.java @@ -0,0 +1,265 @@ +package com.lokoko.domain.campaignReview.application.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.lokoko.domain.campaign.api.dto.response.CampaignParticipatedResponse; +import com.lokoko.domain.campaign.application.mapper.CampaignMapper; +import com.lokoko.domain.campaign.application.service.CampaignGetService; +import com.lokoko.domain.campaign.domain.entity.Campaign; +import com.lokoko.domain.campaignReview.api.dto.response.CompletedReviewResponse; +import com.lokoko.domain.campaignReview.domain.entity.CampaignReview; +import com.lokoko.domain.campaignReview.domain.entity.enums.ReviewRound; +import com.lokoko.domain.creatorCampaign.application.service.CreatorCampaignGetService; +import com.lokoko.domain.creatorCampaign.domain.entity.CreatorCampaign; +import com.lokoko.domain.creatorCampaign.domain.enums.ParticipationStatus; +import com.lokoko.domain.creatorCampaign.exception.CampaignReviewAbleNotFoundException; +import com.lokoko.domain.media.socialclip.domain.entity.enums.ContentType; +import com.lokoko.global.config.BetaFeatureConfig; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("CampaignReviewReadService 테스트") +class CampaignReviewReadServiceTest { + + @Mock + private CampaignReviewGetService campaignReviewGetService; + + @Mock + private CampaignGetService campaignGetService; + + @Mock + private CreatorCampaignGetService creatorCampaignGetService; + + @Mock + private CampaignMapper campaignMapper; + + @Mock + private BetaFeatureConfig betaFeatureConfig; + + @InjectMocks + private CampaignReviewReadService campaignReviewReadService; + + @Test + @DisplayName("getMyReviewableCampaign() : 정상 플로우에서는 reviewable 조회 후 브랜드 노트를 읽음 처리한다") + void getMyReviewableCampaign_marksBrandNotesInNonBetaFlow() { + Long creatorId = 1L; + Long campaignId = 10L; + + CreatorCampaign creatorCampaign = mock(CreatorCampaign.class); + Campaign campaign = mock(Campaign.class); + CampaignReview noteReview = mock(CampaignReview.class); + CampaignParticipatedResponse expected = mock(CampaignParticipatedResponse.class); + CampaignParticipatedResponse.ReviewContentStatus reviewContentStatus = + mock(CampaignParticipatedResponse.ReviewContentStatus.class); + + given(betaFeatureConfig.isSimplifiedReviewFlow()).willReturn(false); + given(creatorCampaignGetService.findReviewableInReviewByCampaign(creatorId, campaignId)) + .willReturn(creatorCampaign); + given(creatorCampaign.getStatus()).willReturn(ParticipationStatus.ACTIVE); + given(creatorCampaign.getCampaign()).willReturn(campaign); + given(creatorCampaign.getId()).willReturn(101L); + given(campaign.getFirstContentPlatform()).willReturn(ContentType.INSTA_REELS); + given(campaign.getSecondContentPlatform()).willReturn(null); + + given(campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.FIRST)) + .willReturn(List.of(noteReview)); + given(campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.SECOND)) + .willReturn(List.of()); + given(noteReview.getBrandNote()).willReturn("수정해주세요"); + given(noteReview.isNoteViewed()).willReturn(false); + given(noteReview.getContentType()).willReturn(ContentType.INSTA_REELS); + given(noteReview.getCaptionWithHashtags()).willReturn("기존 캡션"); + given(campaignReviewGetService.getOrderedMediaUrls(noteReview)) + .willReturn(List.of("https://cdn.test/existing.jpg")); + given(campaignMapper.toReviewContentStatus(any(), any(), any(), any(), any(), any())) + .willReturn(reviewContentStatus); + given(reviewContentStatus.contentType()).willReturn(ContentType.INSTA_REELS); + given(campaignMapper.toCampaignParticipationResponse(creatorCampaign, List.of(reviewContentStatus))) + .willReturn(expected); + + CampaignParticipatedResponse result = + campaignReviewReadService.getMyReviewableCampaign(creatorId, campaignId, null); + + assertThat(result).isSameAs(expected); + then(noteReview).should().markNoteAsViewed(); + } + + @Test + @DisplayName("getMyReviewableCampaign() : SECOND 라운드 요청이면 1차 리뷰가 있고 2차 리뷰가 없는 컨텐츠만 반환한다") + void getMyReviewableCampaign_filtersSecondRoundContents() { + Long creatorId = 1L; + Long campaignId = 10L; + + CreatorCampaign creatorCampaign = mock(CreatorCampaign.class); + Campaign campaign = mock(Campaign.class); + CampaignReview firstReview = mock(CampaignReview.class); + CampaignParticipatedResponse expected = mock(CampaignParticipatedResponse.class); + CampaignParticipatedResponse.ReviewContentStatus reviewContentStatus = + mock(CampaignParticipatedResponse.ReviewContentStatus.class); + + given(betaFeatureConfig.isSimplifiedReviewFlow()).willReturn(false); + given(creatorCampaignGetService.findReviewableInReviewByCampaign(creatorId, campaignId)) + .willReturn(creatorCampaign); + given(creatorCampaign.getStatus()).willReturn(ParticipationStatus.ACTIVE); + given(creatorCampaign.getCampaign()).willReturn(campaign); + given(creatorCampaign.getId()).willReturn(101L); + given(campaign.getFirstContentPlatform()).willReturn(ContentType.INSTA_REELS); + given(campaign.getSecondContentPlatform()).willReturn(null); + + given(campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.FIRST)) + .willReturn(List.of(firstReview)); + given(campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.SECOND)) + .willReturn(List.of()); + given(firstReview.getContentType()).willReturn(ContentType.INSTA_REELS); + given(firstReview.getBrandNote()).willReturn("노트"); + given(firstReview.isNoteViewed()).willReturn(true); + given(firstReview.getCaptionWithHashtags()).willReturn("1차 캡션"); + given(campaignReviewGetService.getOrderedMediaUrls(firstReview)) + .willReturn(List.of("https://cdn.test/first.jpg")); + given(campaignMapper.toReviewContentStatus(any(), eq(ReviewRound.SECOND), any(), any(), any(), any())) + .willReturn(reviewContentStatus); + given(campaignMapper.toCampaignParticipationResponse(creatorCampaign, List.of(reviewContentStatus))) + .willReturn(expected); + + CampaignParticipatedResponse result = + campaignReviewReadService.getMyReviewableCampaign(creatorId, campaignId, ReviewRound.SECOND); + + assertThat(result).isSameAs(expected); + } + + @Test + @DisplayName("getMyReviewables() : ACTIVE 상태이면서 리뷰 컨텐츠가 있는 응답만 반환한다") + void getMyReviewables_returnsOnlyActiveResponsesWithContents() { + Long creatorId = 1L; + + CreatorCampaign activeCampaign = mock(CreatorCampaign.class); + CreatorCampaign inactiveCampaign = mock(CreatorCampaign.class); + Campaign campaign = mock(Campaign.class); + CampaignParticipatedResponse activeResponse = CampaignParticipatedResponse.builder() + .campaignId(1L) + .title("활성 캠페인") + .reviewContents(List.of( + CampaignParticipatedResponse.ReviewContentStatus.builder() + .contentType(ContentType.INSTA_REELS) + .nowReviewRound(ReviewRound.FIRST) + .build() + )) + .build(); + + given(creatorCampaignGetService.findReviewable(creatorId)).willReturn( + List.of(activeCampaign, inactiveCampaign)); + given(activeCampaign.getStatus()).willReturn(ParticipationStatus.ACTIVE); + given(inactiveCampaign.getStatus()).willReturn(ParticipationStatus.APPROVED); + given(activeCampaign.getCampaign()).willReturn(campaign); + given(activeCampaign.getId()).willReturn(201L); + given(campaign.getFirstContentPlatform()).willReturn(ContentType.INSTA_REELS); + given(campaign.getSecondContentPlatform()).willReturn(null); + given(campaignReviewGetService.getAllByCreatorCampaignAndRound(activeCampaign, ReviewRound.FIRST)) + .willReturn(List.of()); + given(campaignReviewGetService.getAllByCreatorCampaignAndRound(activeCampaign, ReviewRound.SECOND)) + .willReturn(List.of()); + given(campaignMapper.toReviewContentStatus(any(), any(), any(), any(), any(), any())) + .willReturn(activeResponse.reviewContents().get(0)); + given(campaignMapper.toCampaignParticipationResponse(activeCampaign, activeResponse.reviewContents())) + .willReturn(activeResponse); + + List result = + campaignReviewReadService.getMyReviewables(creatorId, null); + + assertThat(result).containsExactly(activeResponse); + } + + @Test + @DisplayName("getCompletedReviews() : 베타 플로우에서는 1차 리뷰를 완료 리뷰로 반환한다") + void getCompletedReviews_returnsFirstRoundContentsInBetaFlow() { + Long creatorId = 1L; + Long campaignId = 10L; + + Campaign campaign = mock(Campaign.class); + CreatorCampaign creatorCampaign = mock(CreatorCampaign.class); + CampaignReview firstReview = mock(CampaignReview.class); + + given(betaFeatureConfig.isSimplifiedReviewFlow()).willReturn(true); + given(campaignGetService.findByCampaignId(campaignId)).willReturn(campaign); + given(creatorCampaignGetService.getByCampaignAndCreatorId(campaign, creatorId)).willReturn(creatorCampaign); + given(creatorCampaign.getStatus()).willReturn(ParticipationStatus.COMPLETED); + given(campaign.getTitle()).willReturn("완료된 캠페인"); + given(campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.FIRST)) + .willReturn(List.of(firstReview)); + given(firstReview.getContentType()).willReturn(ContentType.INSTA_REELS); + given(firstReview.getCaptionWithHashtags()).willReturn("캡션"); + given(campaignReviewGetService.getOrderedMediaUrls(firstReview)) + .willReturn(List.of("https://cdn.test/image-1.jpg")); + + CompletedReviewResponse result = campaignReviewReadService.getCompletedReviews(creatorId, campaignId); + + assertThat(result.campaignId()).isEqualTo(campaignId); + assertThat(result.campaignName()).isEqualTo("완료된 캠페인"); + assertThat(result.reviewContents()).hasSize(1); + assertThat(result.reviewContents().get(0).contentType()).isEqualTo(ContentType.INSTA_REELS); + assertThat(result.reviewContents().get(0).captionWithHashtags()).isEqualTo("캡션"); + assertThat(result.reviewContents().get(0).mediaUrls()) + .containsExactly("https://cdn.test/image-1.jpg"); + } + + @Test + @DisplayName("getCompletedReviews() : 정식 플로우에서는 2차 리뷰를 완료 리뷰로 반환한다") + void getCompletedReviews_returnsSecondRoundContentsInNonBetaFlow() { + Long creatorId = 1L; + Long campaignId = 10L; + + Campaign campaign = mock(Campaign.class); + CreatorCampaign creatorCampaign = mock(CreatorCampaign.class); + CampaignReview secondReview = mock(CampaignReview.class); + + given(betaFeatureConfig.isSimplifiedReviewFlow()).willReturn(false); + given(campaignGetService.findByCampaignId(campaignId)).willReturn(campaign); + given(creatorCampaignGetService.getByCampaignAndCreatorId(campaign, creatorId)).willReturn(creatorCampaign); + given(creatorCampaign.getStatus()).willReturn(ParticipationStatus.COMPLETED); + given(campaign.getTitle()).willReturn("완료된 캠페인"); + given(campaignReviewGetService.getAllByCreatorCampaignAndRound(creatorCampaign, ReviewRound.SECOND)) + .willReturn(List.of(secondReview)); + given(secondReview.getContentType()).willReturn(ContentType.INSTA_REELS); + given(secondReview.getCaptionWithHashtags()).willReturn("2차 캡션"); + given(campaignReviewGetService.getOrderedMediaUrls(secondReview)) + .willReturn(List.of("https://cdn.test/second.jpg")); + + CompletedReviewResponse result = campaignReviewReadService.getCompletedReviews(creatorId, campaignId); + + assertThat(result.reviewContents()).hasSize(1); + assertThat(result.reviewContents().get(0).contentType()).isEqualTo(ContentType.INSTA_REELS); + assertThat(result.reviewContents().get(0).captionWithHashtags()).isEqualTo("2차 캡션"); + assertThat(result.reviewContents().get(0).mediaUrls()) + .containsExactly("https://cdn.test/second.jpg"); + } + + @Test + @DisplayName("getCompletedReviews() : COMPLETED 상태가 아니면 예외를 던진다") + void getCompletedReviews_throwsWhenParticipationIsNotCompleted() { + Long creatorId = 1L; + Long campaignId = 10L; + + Campaign campaign = mock(Campaign.class); + CreatorCampaign creatorCampaign = mock(CreatorCampaign.class); + + given(campaignGetService.findByCampaignId(campaignId)).willReturn(campaign); + given(creatorCampaignGetService.getByCampaignAndCreatorId(campaign, creatorId)).willReturn(creatorCampaign); + given(creatorCampaign.getStatus()).willReturn(ParticipationStatus.ACTIVE); + + assertThatThrownBy(() -> campaignReviewReadService.getCompletedReviews(creatorId, campaignId)) + .isInstanceOf(CampaignReviewAbleNotFoundException.class); + } +}