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.block.BlockMemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
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 kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
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.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
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 org.springframework.web.multipart.MultipartFile
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -82,6 +85,7 @@ class AudioContentService(
|
|||||||
private val langContext: LangContext,
|
private val langContext: LangContext,
|
||||||
|
|
||||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||||
|
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.content-bucket}")
|
@Value("\${cloud.aws.s3.content-bucket}")
|
||||||
private val audioContentBucket: String,
|
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
|
audioContent.isActive = true
|
||||||
|
|
||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
@@ -494,6 +499,10 @@ class AudioContentService(
|
|||||||
deepLinkId = contentId
|
deepLinkId = contentId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
publishContentUploadedAfterCommit(
|
||||||
|
audioContent = audioContent,
|
||||||
|
visibleFromAtUtc = audioContent.releaseDate ?: audioContent.createdAt ?: now
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,9 +529,64 @@ class AudioContentService(
|
|||||||
deepLinkId = audioContent.id!!
|
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
|
@Transactional
|
||||||
fun getDetail(
|
fun getDetail(
|
||||||
id: Long,
|
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.i18n.translation.ResourceTranslationJobScheduler
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
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 kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
@@ -57,6 +58,7 @@ class AudioContentServiceTest {
|
|||||||
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||||
private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository
|
private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository
|
||||||
|
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
|
||||||
|
|
||||||
private lateinit var service: AudioContentService
|
private lateinit var service: AudioContentService
|
||||||
|
|
||||||
@@ -80,6 +82,7 @@ class AudioContentServiceTest {
|
|||||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
||||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||||
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java)
|
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java)
|
||||||
|
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||||
|
|
||||||
service = AudioContentService(
|
service = AudioContentService(
|
||||||
repository = repository,
|
repository = repository,
|
||||||
@@ -103,6 +106,7 @@ class AudioContentServiceTest {
|
|||||||
messageSource = SodaMessageSource(),
|
messageSource = SodaMessageSource(),
|
||||||
langContext = LangContext(),
|
langContext = LangContext(),
|
||||||
contentThemeTranslationRepository = contentThemeTranslationRepository,
|
contentThemeTranslationRepository = contentThemeTranslationRepository,
|
||||||
|
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
|
||||||
audioContentBucket = "audio-bucket",
|
audioContentBucket = "audio-bucket",
|
||||||
coverImageBucket = "cover-bucket",
|
coverImageBucket = "cover-bucket",
|
||||||
coverImageHost = "https://cdn.test"
|
coverImageHost = "https://cdn.test"
|
||||||
@@ -273,6 +277,178 @@ class AudioContentServiceTest {
|
|||||||
assertTrue(output.out.contains("contentId=${audioContent.id}"))
|
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 {
|
private fun createMember(id: Long, nickname: String): Member {
|
||||||
val member = Member(
|
val member = Member(
|
||||||
email = "$nickname@test.com",
|
email = "$nickname@test.com",
|
||||||
@@ -283,6 +459,14 @@ class AudioContentServiceTest {
|
|||||||
return member
|
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 {
|
private fun createAudioContent(creator: Member, isAdult: Boolean = false): AudioContent {
|
||||||
val theme = AudioContentTheme(theme = "수면", image = "sleep.png")
|
val theme = AudioContentTheme(theme = "수면", image = "sleep.png")
|
||||||
theme.id = 300L
|
theme.id = 300L
|
||||||
|
|||||||
Reference in New Issue
Block a user