package kr.co.vividnext.sodalive.content import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository import kr.co.vividnext.sodalive.content.order.LimitedEditionOrderRepository import kr.co.vividnext.sodalive.content.order.OrderRepository import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.pin.PinContent import kr.co.vividnext.sodalive.content.pin.PinContentRepository import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.support.originalLocalizedNameService import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mockito import org.springframework.boot.test.system.CapturedOutput import org.springframework.boot.test.system.OutputCaptureExtension import org.springframework.context.ApplicationEventPublisher import java.time.LocalDateTime import java.util.Optional @ExtendWith(OutputCaptureExtension::class) class AudioContentServiceTest { private lateinit var repository: AudioContentRepository private lateinit var explorerQueryRepository: ExplorerQueryRepository private lateinit var blockMemberRepository: BlockMemberRepository private lateinit var hashTagRepository: HashTagRepository private lateinit var orderRepository: OrderRepository private lateinit var limitedEditionOrderRepository: LimitedEditionOrderRepository private lateinit var themeQueryRepository: AudioContentThemeQueryRepository private lateinit var playbackTrackingRepository: PlaybackTrackingRepository private lateinit var creatorContentViewHistoryService: CreatorContentViewHistoryService private lateinit var commentRepository: AudioContentCommentRepository private lateinit var audioContentLikeRepository: AudioContentLikeRepository private lateinit var pinContentRepository: PinContentRepository private lateinit var memberRepository: MemberRepository private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler private lateinit var contentTranslationRepository: ContentTranslationRepository private lateinit var s3Uploader: S3Uploader private lateinit var audioContentCloudFront: AudioContentCloudFront private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService private lateinit var memberContentPreferenceService: MemberContentPreferenceService private lateinit var service: AudioContentService @BeforeEach fun setUp() { repository = Mockito.mock(AudioContentRepository::class.java) explorerQueryRepository = Mockito.mock(ExplorerQueryRepository::class.java) blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java) hashTagRepository = Mockito.mock(HashTagRepository::class.java) orderRepository = Mockito.mock(OrderRepository::class.java) limitedEditionOrderRepository = Mockito.mock(LimitedEditionOrderRepository::class.java) themeQueryRepository = Mockito.mock(AudioContentThemeQueryRepository::class.java) playbackTrackingRepository = Mockito.mock(PlaybackTrackingRepository::class.java) creatorContentViewHistoryService = Mockito.mock(CreatorContentViewHistoryService::class.java) commentRepository = Mockito.mock(AudioContentCommentRepository::class.java) audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java) pinContentRepository = Mockito.mock(PinContentRepository::class.java) memberRepository = Mockito.mock(MemberRepository::class.java) resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java) contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java) s3Uploader = Mockito.mock(S3Uploader::class.java) audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java) applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java) homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) service = AudioContentService( repository = repository, explorerQueryRepository = explorerQueryRepository, blockMemberRepository = blockMemberRepository, hashTagRepository = hashTagRepository, orderRepository = orderRepository, limitedEditionOrderRepository = limitedEditionOrderRepository, themeQueryRepository = themeQueryRepository, playbackTrackingRepository = playbackTrackingRepository, creatorContentViewHistoryService = creatorContentViewHistoryService, commentRepository = commentRepository, audioContentLikeRepository = audioContentLikeRepository, pinContentRepository = pinContentRepository, memberRepository = memberRepository, resourceTranslationJobScheduler = resourceTranslationJobScheduler, contentTranslationRepository = contentTranslationRepository, s3Uploader = s3Uploader, objectMapper = ObjectMapper(), audioContentCloudFront = audioContentCloudFront, applicationEventPublisher = applicationEventPublisher, messageSource = SodaMessageSource(), langContext = LangContext(), localizedNameService = originalLocalizedNameService(), contentThemeTranslationRepository = contentThemeTranslationRepository, homeFollowingNewsPublishService = homeFollowingNewsPublishService, memberContentPreferenceService = memberContentPreferenceService, audioContentBucket = "audio-bucket", coverImageBucket = "cover-bucket", coverImageHost = "https://cdn.test" ) } @Test @DisplayName("비성인 정책 사용자가 성인 콘텐츠 상세를 조회하면 인증 필요 예외를 반환한다") fun shouldThrowAdultVerificationRequiredWhenAdultContentRequestedByNonAdultPolicy() { val viewer = createMember(id = 1002L, nickname = "viewer") val creator = createMember(id = 2002L, nickname = "creator") val adultContent = createAudioContent(creator = creator, isAdult = true) Mockito.`when`(repository.findById(adultContent.id!!)).thenReturn(Optional.of(adultContent)) val exception = assertThrows(SodaException::class.java) { service.getDetail( id = adultContent.id!!, member = viewer, isAdultContentVisible = false, timezone = "Asia/Seoul" ) } assertEquals("common.error.adult_verification_required", exception.messageKey) Mockito.verifyNoInteractions(explorerQueryRepository) } @Test @DisplayName("차단 + 미구매 사용자 요청은 콘텐츠 상세에서 차단 예외를 반환한다") fun shouldThrowBlockedAccessWhenBlockedAndNotPurchased() { val viewer = createMember(id = 1000L, nickname = "viewer") val creator = createMember(id = 2000L, nickname = "creator") val audioContent = createAudioContent(creator) Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator) Mockito.`when`( orderRepository.isExistOrderedAndOrderType( memberId = viewer.id!!, contentId = audioContent.id!! ) ).thenReturn(Pair(false, null)) Mockito.`when`(blockMemberRepository.isBlocked(blockedMemberId = viewer.id!!, memberId = creator.id!!)) .thenReturn(true) val exception = assertThrows(SodaException::class.java) { service.getDetail( id = audioContent.id!!, member = viewer, isAdultContentVisible = false, timezone = "Asia/Seoul" ) } assertEquals("content.error.blocked_access", exception.messageKey) } @Test @DisplayName("차단 + 구매 사용자 요청은 상세 조회를 허용하고 댓글/이전다음 조회를 생략한다") fun shouldAllowDetailWhenBlockedAndPurchasedButSkipCommentAndNavigationQueries() { val viewer = createMember(id = 1001L, nickname = "viewer") val creator = createMember(id = 2001L, nickname = "creator") val audioContent = createAudioContent(creator) Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator) Mockito.`when`( orderRepository.isExistOrderedAndOrderType( memberId = viewer.id!!, contentId = audioContent.id!! ) ).thenReturn(Pair(true, OrderType.KEEP)) Mockito.`when`(blockMemberRepository.isBlocked(blockedMemberId = viewer.id!!, memberId = creator.id!!)) .thenReturn(true) Mockito.`when`(explorerQueryRepository.getCreatorFollowing(creator.id!!, viewer.id!!)).thenReturn(null) Mockito.`when`( limitedEditionOrderRepository.getOrderSequence( contentId = audioContent.id!!, memberId = viewer.id!! ) ).thenReturn(null) Mockito.`when`( audioContentCloudFront.generateSignedURL( resourcePath = audioContent.content!!, expirationTime = 7_200_000L ) ).thenReturn("https://signed.test/audio") Mockito.`when`( repository.getCreatorOtherContentList( cloudfrontHost = "https://cdn.test", contentId = audioContent.id!!, creatorId = creator.id!!, isAdult = false ) ).thenReturn(emptyList()) Mockito.`when`( repository.getSameThemeOtherContentList( cloudfrontHost = "https://cdn.test", contentId = audioContent.id!!, themeId = audioContent.theme!!.id!!, isAdult = false ) ).thenReturn(emptyList()) Mockito.`when`(audioContentLikeRepository.totalCountAudioContentLike(audioContent.id!!)).thenReturn(0) Mockito.`when`(audioContentLikeRepository.findByMemberIdAndContentId(viewer.id!!, audioContent.id!!)).thenReturn(null) Mockito.`when`( pinContentRepository.findByContentIdAndMemberId( contentId = audioContent.id!!, memberId = viewer.id!!, active = true ) ).thenReturn(null) Mockito.`when`(pinContentRepository.getPinContentList(memberId = viewer.id!!, active = true)).thenReturn(emptyList()) Mockito.`when`( contentThemeTranslationRepository.findByContentThemeIdAndLocale( contentThemeId = audioContent.theme!!.id!!, locale = "ko" ) ).thenReturn(null) val response = service.getDetail( id = audioContent.id!!, member = viewer, isAdultContentVisible = false, timezone = "Asia/Seoul" ) assertTrue(response.existOrdered) assertTrue(response.commentList.isEmpty()) assertEquals(0, response.commentCount) assertNull(response.previousContent) assertNull(response.nextContent) Mockito.verify(repository, Mockito.never()).findSeriesIdByContentId(audioContent.id!!, false) Mockito.verifyNoInteractions(commentRepository) val recordViewInvocation = Mockito.mockingDetails(creatorContentViewHistoryService).invocations .single { it.method.name == "recordView" } assertEquals(viewer.id!!, recordViewInvocation.arguments[0]) assertEquals(audioContent.id!!, recordViewInvocation.arguments[1]) } @Test @DisplayName("콘텐츠 조회 이력 기록 실패는 상세 응답을 실패시키지 않고 로그로 남긴다") fun shouldLogViewHistoryFailureWithoutFailingDetail(output: CapturedOutput) { val viewer = createMember(id = 1003L, nickname = "history-failure-viewer") val creator = createMember(id = 2003L, nickname = "history-failure-creator") val audioContent = createAudioContent(creator) stubSuccessfulDetailDependencies(viewer, creator, audioContent) Mockito.doThrow(IllegalStateException("history failed")) .`when`(creatorContentViewHistoryService) .recordView( memberId = Mockito.eq(viewer.id!!), contentId = Mockito.eq(audioContent.id!!), viewedAt = anyLocalDateTime() ) val response = service.getDetail( id = audioContent.id!!, member = viewer, isAdultContentVisible = false, timezone = "Asia/Seoul" ) assertEquals(audioContent.id!!, response.contentId) assertTrue(output.out.contains("event=creator_content_view_history_record_failure")) assertTrue(output.out.contains("memberId=${viewer.id}")) assertTrue(output.out.contains("contentId=${audioContent.id}")) } @Test @DisplayName("콘텐츠 상세는 가격과 저장값을 함께 반영해 포인트 사용 가능 여부를 반환한다") fun shouldReturnEffectivePointAvailabilityForAudioDetail() { val viewer = createMember(id = 1004L, nickname = "free-detail-viewer") val creator = createMember(id = 2004L, nickname = "free-detail-creator") val audioContent = createAudioContent(creator) audioContent.price = 0 audioContent.isPointAvailable = true stubSuccessfulDetailDependencies(viewer, creator, audioContent) val response = service.getDetail( id = audioContent.id!!, member = viewer, isAdultContentVisible = false, timezone = "Asia/Seoul" ) assertFalse(response.isAvailableUsePoint) audioContent.price = 100 audioContent.isPointAvailable = false val paidUnavailableResponse = service.getDetail( id = audioContent.id!!, member = viewer, isAdultContentVisible = false, timezone = "Asia/Seoul" ) assertFalse(paidUnavailableResponse.isAvailableUsePoint) audioContent.price = 100 audioContent.isPointAvailable = true val paidAvailableResponse = service.getDetail( id = audioContent.id!!, member = viewer, isAdultContentVisible = false, timezone = "Asia/Seoul" ) assertTrue(paidAvailableResponse.isAvailableUsePoint) } @Test @DisplayName("업로드 완료 시 즉시 공개 콘텐츠는 최근 소식을 발행한다") fun shouldPublishNewsWhenUploadCompleteMakesContentPublicImmediately() { val creator = createMember(id = 2100L, nickname = "audio-creator") creator.profileImage = "profile/audio-creator.png" val audioContent = createAudioContent(creator = creator, isAdult = true) audioContent.isActive = false audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0) audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0) Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) service.uploadComplete( contentId = audioContent.id!!, content = "output/${audioContent.id}/${audioContent.id}-content.mp3", duration = "00:03:00" ) Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded( contentId = audioContent.id!!, creatorId = creator.id!!, creatorNickname = creator.nickname!!, creatorProfileImagePath = creator.profileImage, title = audioContent.title, body = audioContent.detail, thumbnailImagePath = audioContent.coverImage, occurredAtUtc = audioContent.createdAt!!, visibleFromAtUtc = audioContent.createdAt!!, isAdult = true ) } @Test @DisplayName("유료 오디오가 상세 비공개이면 최근 소식은 전체 상세를 노출하지 않는다") fun shouldMaskPaidAudioDetailWhenPublishingNews() { val creator = createMember(id = 2120L, nickname = "paid-audio-creator") val audioContent = createAudioContent(creator = creator) audioContent.isActive = false audioContent.isFullDetailVisible = false audioContent.detail = "유료 오디오 상세 설명 전체 본문은 최근 소식에서 모두 보이면 안 됩니다" audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0) audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0) Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) service.uploadComplete( contentId = audioContent.id!!, content = "output/${audioContent.id}/${audioContent.id}-content.mp3", duration = "00:03:00" ) Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded( contentId = audioContent.id!!, creatorId = creator.id!!, creatorNickname = creator.nickname!!, creatorProfileImagePath = creator.profileImage, title = audioContent.title, body = "유료 오디오 상세 설명 전체 본문은 ...", thumbnailImagePath = audioContent.coverImage, occurredAtUtc = audioContent.createdAt!!, visibleFromAtUtc = audioContent.createdAt!!, isAdult = false ) } @Test @DisplayName("최근 소식 발행 실패는 업로드 완료 처리를 실패시키지 않는다") fun shouldNotFailUploadCompleteWhenNewsPublishFails() { val creator = createMember(id = 2130L, nickname = "publish-failure-creator") val audioContent = createAudioContent(creator = creator) audioContent.isActive = false audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0) audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0) Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) Mockito.doAnswer { throw IllegalStateException("publish failed") } .`when`(homeFollowingNewsPublishService) .publishContentUploaded( contentId = anyLongValue(), creatorId = anyLongValue(), creatorNickname = anyStringValue(), creatorProfileImagePath = Mockito.anyString(), title = anyStringValue(), body = anyStringValue(), thumbnailImagePath = Mockito.anyString(), occurredAtUtc = anyLocalDateTime(), visibleFromAtUtc = anyLocalDateTime(), isAdult = Mockito.anyBoolean() ) service.uploadComplete( contentId = audioContent.id!!, content = "output/${audioContent.id}/${audioContent.id}-content.mp3", duration = "00:03:00" ) assertTrue(audioContent.isActive) } @Test @DisplayName("업로드 완료 시 공개 시각이 생성 이후 업로드 전이면 최근 소식을 발행한다") fun shouldPublishNewsWhenReleaseDatePassedBeforeUploadComplete() { val creator = createMember(id = 2150L, nickname = "audio-late-upload-creator") val audioContent = createAudioContent(creator = creator) audioContent.isActive = false audioContent.createdAt = LocalDateTime.now().minusHours(2) audioContent.releaseDate = LocalDateTime.now().minusHours(1) Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) service.uploadComplete( contentId = audioContent.id!!, content = "output/${audioContent.id}/${audioContent.id}-content.mp3", duration = "00:03:00" ) Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded( contentId = audioContent.id!!, creatorId = creator.id!!, creatorNickname = creator.nickname!!, creatorProfileImagePath = creator.profileImage, title = audioContent.title, body = audioContent.detail, thumbnailImagePath = audioContent.coverImage, occurredAtUtc = audioContent.createdAt!!, visibleFromAtUtc = audioContent.releaseDate!!, isAdult = false ) } @Test @DisplayName("업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다") fun shouldNotPublishNewsWhenUploadCompleteKeepsScheduledContentInactive() { val now = LocalDateTime.now() val creator = createMember(id = 2200L, nickname = "scheduled-creator") val audioContent = createAudioContent(creator = creator) audioContent.isActive = false audioContent.createdAt = now audioContent.releaseDate = now.plusYears(1) Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) service.uploadComplete( contentId = audioContent.id!!, content = "output/${audioContent.id}/${audioContent.id}-content.mp3", duration = "00:03:00" ) Mockito.verifyNoInteractions(homeFollowingNewsPublishService) } @Test @DisplayName("예약 콘텐츠 공개 작업은 활성화 시점에 최근 소식을 발행한다") fun shouldPublishNewsWhenReleaseContentActivatesScheduledContent() { val creator = createMember(id = 2300L, nickname = "release-creator") creator.profileImage = "profile/release-creator.png" val audioContent = createAudioContent(creator = creator) audioContent.isActive = false audioContent.createdAt = LocalDateTime.of(2026, 6, 24, 9, 0) audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0) Mockito.`when`(repository.getNotReleaseContent()).thenReturn(listOf(audioContent)) service.releaseContent() Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded( contentId = audioContent.id!!, creatorId = creator.id!!, creatorNickname = creator.nickname!!, creatorProfileImagePath = creator.profileImage, title = audioContent.title, body = audioContent.detail, thumbnailImagePath = audioContent.coverImage, occurredAtUtc = audioContent.createdAt!!, visibleFromAtUtc = audioContent.releaseDate!!, isAdult = false ) } @Test @DisplayName("오디오 상세의 상단 고정 가능 여부는 활성 고정 9개 상한을 따른다") fun shouldExposeAvailablePinByNineActivePinLimit() { val creator = createMember(id = 2400L, nickname = "pin-limit-creator") val audioContent = createAudioContent(creator = creator) stubSuccessfulDetailDependencies(creator, creator, audioContent) Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true)) .thenReturn(createPinContents(8)) .thenReturn(createPinContents(9)) val availableResponse = service.getDetail( id = audioContent.id!!, member = creator, isAdultContentVisible = false, timezone = "Asia/Seoul" ) val unavailableResponse = service.getDetail( id = audioContent.id!!, member = creator, isAdultContentVisible = false, timezone = "Asia/Seoul" ) assertTrue(availableResponse.isAvailablePin) assertEquals(false, unavailableResponse.isAvailablePin) } @Test @DisplayName("10번째 상단 고정은 가장 오래된 활성 고정을 새 콘텐츠로 교체한다") fun shouldReuseOldestActivePinWhenPinningTenthContent() { val creator = createMember(id = 2410L, nickname = "pin-reuse-creator") val audioContent = createAudioContent(creator = creator) val oldestPinContent = createPinContents(1).single() Mockito.`when`(repository.findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!)) .thenReturn(audioContent) Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator) Mockito.`when`( pinContentRepository.findByContentIdAndMemberId( contentId = audioContent.id!!, memberId = creator.id!! ) ).thenReturn(null) Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true)) .thenReturn(listOf(oldestPinContent) + createPinContents(8)) service.pinToTheTop(contentId = audioContent.id!!, member = creator) Mockito.inOrder(memberRepository, repository, pinContentRepository).apply { verify(memberRepository).findByIdForUpdate(creator.id!!) verify(repository).findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!) verify(pinContentRepository).findByContentIdAndMemberId( contentId = audioContent.id!!, memberId = creator.id!! ) verify(pinContentRepository).getPinContentList(memberId = creator.id!!, active = true) } assertTrue(oldestPinContent.isActive) assertEquals(creator, oldestPinContent.member) assertEquals(audioContent, oldestPinContent.content) Mockito.verify(pinContentRepository).save(oldestPinContent) } @Test @DisplayName("상단 고정 해제는 고정 행 조회 전에 크리에이터를 잠근다") fun shouldLockCreatorBeforeFindingPinWhenUnpinningContent() { val creator = createMember(id = 2415L, nickname = "pin-unpin-lock-creator") val audioContent = createAudioContent(creator = creator) val pinContent = PinContent(isActive = true) pinContent.member = creator pinContent.content = audioContent Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator) Mockito.`when`( pinContentRepository.findByContentIdAndMemberId( contentId = audioContent.id!!, memberId = creator.id!! ) ).thenReturn(pinContent) service.unpinAtTheTop(contentId = audioContent.id!!, member = creator) Mockito.inOrder(memberRepository, pinContentRepository).apply { verify(memberRepository).findByIdForUpdate(creator.id!!) verify(pinContentRepository).findByContentIdAndMemberId( contentId = audioContent.id!!, memberId = creator.id!! ) } assertEquals(false, pinContent.isActive) } @Test @DisplayName("비활성 고정을 다시 활성화할 때도 활성 고정은 9개를 넘지 않는다") fun shouldKeepNineActivePinsWhenReactivatingInactivePin() { val creator = createMember(id = 2420L, nickname = "pin-reactivate-creator") val audioContent = createAudioContent(creator = creator) val inactivePinContent = PinContent(isActive = false) inactivePinContent.member = creator inactivePinContent.content = audioContent val oldestActivePinContent = createPinContents(1).single() Mockito.`when`(repository.findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!)) .thenReturn(audioContent) Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator) Mockito.`when`( pinContentRepository.findByContentIdAndMemberId( contentId = audioContent.id!!, memberId = creator.id!! ) ).thenReturn(inactivePinContent) Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true)) .thenReturn(listOf(oldestActivePinContent) + createPinContents(8)) service.pinToTheTop(contentId = audioContent.id!!, member = creator) assertEquals(false, oldestActivePinContent.isActive) assertTrue(inactivePinContent.isActive) } @Test @DisplayName("콘텐츠 삭제 시 해당 콘텐츠의 상단 고정도 함께 비활성화한다") fun shouldDeactivatePinContentWhenDeletingAudioContent() { val creator = createMember(id = 2430L, nickname = "pin-delete-creator") val audioContent = createAudioContent(creator = creator) val pinContent = PinContent(isActive = true) pinContent.member = creator pinContent.content = audioContent Mockito.`when`(repository.findByIdAndCreatorId(audioContent.id!!, creator.id!!)).thenReturn(audioContent) Mockito.`when`( pinContentRepository.findByContentIdAndMemberId( contentId = audioContent.id!!, memberId = creator.id!! ) ).thenReturn(pinContent) service.deleteAudioContent(audioContentId = audioContent.id!!, member = creator) assertEquals(false, audioContent.isActive) assertEquals(false, pinContent.isActive) } private fun createMember(id: Long, nickname: String): Member { val member = Member( email = "$nickname@test.com", password = "password", nickname = nickname ) member.id = id return member } private fun anyLongValue(): Long { return Mockito.anyLong() } private fun anyStringValue(): String { return Mockito.anyString() ?: "" } private fun createAudioContent(creator: Member, isAdult: Boolean = false): AudioContent { val theme = AudioContentTheme(theme = "수면", image = "sleep.png") theme.id = 300L val audioContent = AudioContent( title = "테스트 제목", detail = "테스트 상세 설명", languageCode = null, price = 100, purchaseOption = PurchaseOption.BOTH, isGeneratePreview = true, isOnlyRental = false, isAdult = isAdult, isPointAvailable = true, isCommentAvailable = true, isFullDetailVisible = true ) audioContent.id = 500L audioContent.member = creator audioContent.theme = theme audioContent.content = "output/500/content.mp3" audioContent.coverImage = "audio_content_cover/500/cover.jpg" audioContent.duration = "00:10:00" audioContent.isActive = true return audioContent } private fun createPinContents(count: Int): List { return (1..count).map { PinContent() } } private fun stubSuccessfulDetailDependencies(viewer: Member, creator: Member, audioContent: AudioContent) { Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator) Mockito.`when`( orderRepository.isExistOrderedAndOrderType( memberId = viewer.id!!, contentId = audioContent.id!! ) ).thenReturn(Pair(true, OrderType.KEEP)) Mockito.`when`(explorerQueryRepository.getCreatorFollowing(creator.id!!, viewer.id!!)).thenReturn(null) Mockito.`when`( limitedEditionOrderRepository.getOrderSequence( contentId = audioContent.id!!, memberId = viewer.id!! ) ).thenReturn(null) Mockito.`when`( audioContentCloudFront.generateSignedURL( resourcePath = audioContent.content!!, expirationTime = 7_200_000L ) ).thenReturn("https://signed.test/audio") Mockito.`when`( repository.getCreatorOtherContentList( cloudfrontHost = "https://cdn.test", contentId = audioContent.id!!, creatorId = creator.id!!, isAdult = false ) ).thenReturn(emptyList()) Mockito.`when`( repository.getSameThemeOtherContentList( cloudfrontHost = "https://cdn.test", contentId = audioContent.id!!, themeId = audioContent.theme!!.id!!, isAdult = false ) ).thenReturn(emptyList()) Mockito.`when`(audioContentLikeRepository.totalCountAudioContentLike(audioContent.id!!)).thenReturn(0) Mockito.`when`(audioContentLikeRepository.findByMemberIdAndContentId(viewer.id!!, audioContent.id!!)).thenReturn(null) Mockito.`when`( pinContentRepository.findByContentIdAndMemberId( contentId = audioContent.id!!, memberId = viewer.id!!, active = true ) ).thenReturn(null) Mockito.`when`(pinContentRepository.getPinContentList(memberId = viewer.id!!, active = true)).thenReturn(emptyList()) Mockito.`when`( contentThemeTranslationRepository.findByContentThemeIdAndLocale( contentThemeId = audioContent.theme!!.id!!, locale = "ko" ) ).thenReturn(null) } private fun anyLocalDateTime(): LocalDateTime { return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN } }