758 lines
35 KiB
Kotlin
758 lines
35 KiB
Kotlin
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<PinContent> {
|
|
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
|
|
}
|
|
}
|