test #426

Merged
klaus merged 415 commits from test into main 2026-06-27 00:35:30 +00:00
2 changed files with 249 additions and 1 deletions
Showing only changes of commit 9fc6643c18 - Show all commits

View File

@@ -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,

View File

@@ -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