test #426
@@ -39,6 +39,7 @@ import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
@@ -47,6 +48,8 @@ import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.LocalDateTime
|
||||
@@ -82,6 +85,7 @@ class AudioContentService(
|
||||
private val langContext: LangContext,
|
||||
|
||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
|
||||
|
||||
@Value("\${cloud.aws.s3.content-bucket}")
|
||||
private val audioContentBucket: String,
|
||||
@@ -476,7 +480,8 @@ class AudioContentService(
|
||||
)
|
||||
)
|
||||
|
||||
if (audioContent.releaseDate == null || audioContent.releaseDate!! <= audioContent.createdAt) {
|
||||
val now = LocalDateTime.now()
|
||||
if (audioContent.releaseDate == null || audioContent.releaseDate!! <= now) {
|
||||
audioContent.isActive = true
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
@@ -494,6 +499,10 @@ class AudioContentService(
|
||||
deepLinkId = contentId
|
||||
)
|
||||
)
|
||||
publishContentUploadedAfterCommit(
|
||||
audioContent = audioContent,
|
||||
visibleFromAtUtc = audioContent.releaseDate ?: audioContent.createdAt ?: now
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,9 +529,64 @@ class AudioContentService(
|
||||
deepLinkId = audioContent.id!!
|
||||
)
|
||||
)
|
||||
publishContentUploadedAfterCommit(
|
||||
audioContent = audioContent,
|
||||
visibleFromAtUtc = audioContent.releaseDate ?: LocalDateTime.now()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishContentUploadedAfterCommit(audioContent: AudioContent, visibleFromAtUtc: LocalDateTime) {
|
||||
val creator = audioContent.member!!
|
||||
val occurredAtUtc = audioContent.createdAt ?: visibleFromAtUtc
|
||||
val newsBody = audioContent.newsDetailPreview()
|
||||
afterCommit {
|
||||
homeFollowingNewsPublishService.publishContentUploaded(
|
||||
contentId = audioContent.id!!,
|
||||
creatorId = creator.id!!,
|
||||
creatorNickname = creator.nickname,
|
||||
creatorProfileImagePath = creator.profileImage,
|
||||
title = audioContent.title,
|
||||
body = newsBody,
|
||||
thumbnailImagePath = audioContent.coverImage,
|
||||
occurredAtUtc = occurredAtUtc,
|
||||
visibleFromAtUtc = visibleFromAtUtc,
|
||||
isAdult = audioContent.isAdult
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioContent.newsDetailPreview(): String {
|
||||
if (price < 50 || isFullDetailVisible) {
|
||||
return detail
|
||||
}
|
||||
|
||||
val length = detail.length
|
||||
return if (length < 60) {
|
||||
"${detail.take(length / 2)}..."
|
||||
} else {
|
||||
"${detail.take(30)}..."
|
||||
}
|
||||
}
|
||||
|
||||
private fun afterCommit(action: () -> Unit) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
runCatching(action).onFailure { ex ->
|
||||
log.warn("event=home_following_news_publish_failure error={}", ex.message, ex)
|
||||
}
|
||||
return
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
object : TransactionSynchronization {
|
||||
override fun afterCommit() {
|
||||
runCatching(action).onFailure { ex ->
|
||||
log.warn("event=home_following_news_publish_failure error={}", ex.message, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun getDetail(
|
||||
id: Long,
|
||||
|
||||
@@ -21,6 +21,7 @@ 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.block.BlockMemberRepository
|
||||
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.assertNull
|
||||
@@ -57,6 +58,7 @@ class AudioContentServiceTest {
|
||||
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||
private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository
|
||||
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
|
||||
|
||||
private lateinit var service: AudioContentService
|
||||
|
||||
@@ -80,6 +82,7 @@ class AudioContentServiceTest {
|
||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java)
|
||||
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||
|
||||
service = AudioContentService(
|
||||
repository = repository,
|
||||
@@ -103,6 +106,7 @@ class AudioContentServiceTest {
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = LangContext(),
|
||||
contentThemeTranslationRepository = contentThemeTranslationRepository,
|
||||
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
|
||||
audioContentBucket = "audio-bucket",
|
||||
coverImageBucket = "cover-bucket",
|
||||
coverImageHost = "https://cdn.test"
|
||||
@@ -273,6 +277,178 @@ class AudioContentServiceTest {
|
||||
assertTrue(output.out.contains("contentId=${audioContent.id}"))
|
||||
}
|
||||
|
||||
@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 creator = createMember(id = 2200L, nickname = "scheduled-creator")
|
||||
val audioContent = createAudioContent(creator = creator)
|
||||
audioContent.isActive = false
|
||||
audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0)
|
||||
audioContent.releaseDate = LocalDateTime.of(2026, 6, 26, 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.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
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMember(id: Long, nickname: String): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
@@ -283,6 +459,14 @@ class AudioContentServiceTest {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user