test #449

Merged
klaus merged 29 commits from test into main 2026-09-10 07:58:32 +00:00
3 changed files with 629 additions and 15 deletions
Showing only changes of commit 11e29460b7 - Show all commits
@@ -27,6 +27,7 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.utils.validateImage
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
@@ -39,6 +40,8 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.web.multipart.MultipartFile
import java.time.LocalDateTime
import java.time.ZoneId
import javax.persistence.EntityManager
import javax.persistence.LockModeType
@Service
@Transactional(readOnly = true)
@@ -58,6 +61,8 @@ class CreatorCommunityService(
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
private val entityManager: EntityManager,
@Value("\${cloud.aws.s3.bucket}")
private val imageBucket: String,
@@ -144,6 +149,7 @@ class CreatorCommunityService(
)
)
publishCommunityPostCreatedAfterCommit(post, member)
requestTranslationsAfterCommit(post.id!!)
}
private fun publishCommunityPostCreatedAfterCommit(post: CreatorCommunity, member: Member) {
@@ -166,6 +172,12 @@ class CreatorCommunityService(
}
}
private fun requestTranslationsAfterCommit(postId: Long) {
afterCommit {
creatorCommunityTranslationService.requestTranslations(postId, null)
}
}
private fun CreatorCommunity.newsContentPreview(): String {
if (price <= 0) {
return content
@@ -199,13 +211,17 @@ class CreatorCommunityService(
fun modifyCommunityPost(postImage: MultipartFile?, requestString: String, member: Member) {
val request = objectMapper.readValue(requestString, ModifyCommunityPostRequest::class.java)
val post = repository.findByIdAndMemberId(id = request.creatorCommunityId, memberId = member.id!!)
val post = repository.findByIdAndMemberIdForUpdate(postId = request.creatorCommunityId, memberId = member.id!!)
?: throw SodaException(messageKey = "common.error.invalid_request")
entityManager.refresh(post, LockModeType.PESSIMISTIC_WRITE)
postImage?.let { validateImage(it, post.price > 0) }
if (request.content != null) {
if (request.content != null && request.content != post.content) {
post.content = request.content
post.languageCode = null
post.contentRevision += 1
requestTranslationsAfterCommit(post.id!!)
}
if (request.isCommentAvailable != null) {
@@ -246,8 +262,9 @@ class CreatorCommunityService(
@Transactional
fun updateCommunityPostFixed(request: UpdateCommunityPostFixedRequest, member: Member) {
val post = repository.findByIdAndMemberId(id = request.postId, memberId = member.id!!)
val post = repository.findByIdAndMemberIdForUpdate(postId = request.postId, memberId = member.id!!)
?: throw SodaException(messageKey = "common.error.invalid_request")
entityManager.refresh(post, LockModeType.PESSIMISTIC_WRITE)
if (request.isFixed) {
if (!post.isFixed) {
@@ -285,6 +302,8 @@ class CreatorCommunityService(
limit = limit,
isAdult = isAdult
)
val displayContents = creatorCommunityTranslationService
.findDisplayContents(postList.map { it.id }, langContext.lang.code)
return postList
.map {
@@ -337,7 +356,7 @@ class CreatorCommunityService(
null
}
it.toCommunityPostListResponse(
it.copy(content = displayContents[it.id]?.content ?: it.content).toCommunityPostListResponse(
imageHost = imageHost,
audioUrl = audioUrl,
date = it.date.getTimeAgoString(),
@@ -376,6 +395,12 @@ class CreatorCommunityService(
throw SodaException(message = String.format(messageTemplate, post.creatorNickname))
}
val displayContent = creatorCommunityTranslationService
.findDisplayContents(listOf(post.id), langContext.lang.code)[post.id]
if (displayContent?.hasValidTranslation != true) {
creatorCommunityTranslationService.requestTranslations(post.id, langContext.lang.code)
}
val isLike = likeRepository.findByPostIdAndMemberId(postId = post.id, memberId = memberId)?.isActive ?: false
val likeCount = likeRepository.totalCountCommunityPostLikeByPostId(post.id)
val commentCount = if (post.isCommentAvailable) {
@@ -417,7 +442,7 @@ class CreatorCommunityService(
null
}
return post.toCommunityPostListResponse(
return post.copy(content = displayContent?.content ?: post.content).toCommunityPostListResponse(
imageHost = imageHost,
audioUrl = audioUrl,
date = post.date.getTimeAgoString(),
@@ -610,11 +635,13 @@ class CreatorCommunityService(
isAdult: Boolean
): List<GetCommunityPostListResponse> {
val postList = repository.getLatestPostListFromCreatorsYouFollow(memberId = memberId, isAdult = isAdult)
val visiblePostList = postList.filter {
!isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId)
}
val displayContents = creatorCommunityTranslationService
.findDisplayContents(visiblePostList.map { it.id }, langContext.lang.code)
return postList
.filter {
!isBlockedBetweenMembers(memberId = memberId, creatorId = it.creatorId)
}
return visiblePostList
.map {
val isLike =
likeRepository.findByPostIdAndMemberId(postId = it.id, memberId = memberId)?.isActive ?: false
@@ -649,7 +676,7 @@ class CreatorCommunityService(
val existOrdered = useCanRepository.isExistCommunityPostOrdered(postId = it.id, memberId = memberId)
it.toCommunityPostListResponse(
it.copy(content = displayContents[it.id]?.content ?: it.content).toCommunityPostListResponse(
imageHost = imageHost,
audioUrl = null,
date = it.date.getTimeAgoString(),
@@ -17,11 +17,14 @@ import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -36,10 +39,13 @@ import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher
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.io.InputStream
import java.time.LocalDateTime
import java.util.Optional
import javax.persistence.EntityManager
class CreatorCommunityServiceTest {
private lateinit var repository: CreatorCommunityRepository
@@ -50,6 +56,9 @@ class CreatorCommunityServiceTest {
private lateinit var s3Uploader: S3Uploader
private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
private lateinit var entityManager: EntityManager
private lateinit var langContext: LangContext
private lateinit var service: CreatorCommunityService
@BeforeEach
@@ -62,6 +71,9 @@ class CreatorCommunityServiceTest {
s3Uploader = Mockito.mock(S3Uploader::class.java)
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
entityManager = Mockito.mock(EntityManager::class.java)
langContext = LangContext()
service = CreatorCommunityService(
canPaymentService = Mockito.mock(CanPaymentService::class.java),
@@ -75,8 +87,10 @@ class CreatorCommunityServiceTest {
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
applicationEventPublisher = applicationEventPublisher,
messageSource = SodaMessageSource(),
langContext = LangContext(),
langContext = langContext,
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
creatorCommunityTranslationService = creatorCommunityTranslationService,
entityManager = entityManager,
imageBucket = "image-bucket",
contentBucket = "content-bucket",
imageHost = "https://cdn.test"
@@ -252,7 +266,7 @@ class CreatorCommunityServiceTest {
post.id = 501L
post.member = creator
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(3L)
val exception = assertThrows(SodaException::class.java) {
@@ -273,7 +287,7 @@ class CreatorCommunityServiceTest {
post.id = 601L
post.member = creator
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(2L)
service.updateCommunityPostFixed(
@@ -295,7 +309,7 @@ class CreatorCommunityServiceTest {
post.isFixed = true
post.fixedAt = LocalDateTime.now()
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
service.updateCommunityPostFixed(
request = UpdateCommunityPostFixedRequest(postId = post.id!!, isFixed = false),
@@ -306,6 +320,120 @@ class CreatorCommunityServiceTest {
assertNull(post.fixedAt)
}
@Test
@DisplayName("본문이 실제로 변경되면 언어를 초기화하고 본문 개정을 한 번 증가시킨다")
fun shouldResetLanguageAndIncrementRevisionWhenContentChanges() {
val creator = createMember(id = 304L, role = MemberRole.CREATOR, nickname = "creator")
val post = CreatorCommunity(
content = "before",
price = 0,
isCommentAvailable = true,
isAdult = false,
languageCode = "ko",
contentRevision = 7
).apply {
id = 304L
member = creator
}
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
service.modifyCommunityPost(
postImage = null,
requestString = """{"creatorCommunityId":304,"content":"after"}""",
member = creator
)
assertEquals("after", post.content)
assertNull(post.languageCode)
assertEquals(8, post.contentRevision)
Mockito.verify(creatorCommunityTranslationService).requestTranslations(post.id!!, null)
}
@Test
@DisplayName("동일 본문과 본문 외 수정은 언어와 개정 및 번역 요청을 유지한다")
fun shouldNotResetLanguageOrRequestTranslationsWhenContentIsUnchanged() {
val creator = createMember(id = 305L, role = MemberRole.CREATOR, nickname = "creator")
val post = CreatorCommunity(
content = "same",
price = 0,
isCommentAvailable = true,
isAdult = false,
languageCode = "ko",
contentRevision = 7
).apply {
id = 305L
member = creator
}
Mockito.`when`(repository.findByIdAndMemberIdForUpdate(post.id!!, creator.id!!)).thenReturn(post)
Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post)
service.modifyCommunityPost(
postImage = null,
requestString = """
{"creatorCommunityId":305,"content":"same","isCommentAvailable":false,"isAdult":true,"isActive":false}
""".trimIndent(),
member = creator
)
service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(post.id!!, isFixed = false), creator)
assertEquals("ko", post.languageCode)
assertEquals(7, post.contentRevision)
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
}
@Test
@DisplayName("게시글 생성은 커밋 후에만 번역을 요청한다")
fun shouldRequestTranslationsAfterCommunityPostCreationCommits() {
val creator = createMember(id = 306L, role = MemberRole.CREATOR, nickname = "creator")
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
invocation.getArgument<CreatorCommunity>(0).apply { id = 306L }
}
TransactionSynchronizationManager.initSynchronization()
try {
service.createCommunityPost(
audioFile = null,
postImage = null,
requestString = """{"content":"created","price":0,"isCommentAvailable":true,"isAdult":false}""",
member = creator
)
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() }
Mockito.verify(creatorCommunityTranslationService).requestTranslations(306L, null)
} finally {
TransactionSynchronizationManager.clearSynchronization()
}
}
@Test
@DisplayName("게시글 생성 롤백은 번역을 요청하지 않는다")
fun shouldNotRequestTranslationsWhenCommunityPostCreationRollsBack() {
val creator = createMember(id = 307L, role = MemberRole.CREATOR, nickname = "creator")
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
invocation.getArgument<CreatorCommunity>(0).apply { id = 307L }
}
TransactionSynchronizationManager.initSynchronization()
try {
service.createCommunityPost(
audioFile = null,
postImage = null,
requestString = """{"content":"rolled back","price":0,"isCommentAvailable":true,"isAdult":false}""",
member = creator
)
TransactionSynchronizationManager.getSynchronizations().forEach {
it.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)
}
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
} finally {
TransactionSynchronizationManager.clearSynchronization()
}
}
@Test
@DisplayName("커뮤니티 게시글 생성 성공 후 최근 소식을 게시글 정보로 발행한다")
fun shouldPublishNewsAfterCommunityPostCreated() {
@@ -329,7 +457,7 @@ class CreatorCommunityServiceTest {
Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated(
postId = 901L,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorNickname = creator.nickname,
creatorProfileImagePath = creator.profileImage,
title = "커뮤니티 새 게시글 본문입니다",
body = "커뮤니티 새 게시글 본문입니다",
@@ -369,6 +497,195 @@ class CreatorCommunityServiceTest {
Mockito.verifyNoInteractions(homeFollowingNewsPublishService)
}
@Test
@DisplayName("레거시 상세는 선택한 번역문에 유료 미리보기를 적용하고 요청 언어만 예약한다")
fun shouldSelectTranslatedContentBeforePaidMaskingAndRequestCurrentLanguageForLegacyDetail() {
langContext.setLang(Lang.JA)
val post = selectCommunityPost(postId = 100L, content = "source paid content", price = 100)
Mockito.`when`(repository.getCommunityPost(100L, true)).thenReturn(post)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(100L), "ja"))
.thenReturn(mapOf(100L to CreatorCommunityDisplayContent("translated paid content", true)))
val response = service.getCommunityPostDetail(100L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
assertEquals("translated paid...", response.content)
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(100L), "ja")
Mockito.verify(creatorCommunityTranslationService, Mockito.never()).requestTranslations(100L, "ja")
}
@Test
@DisplayName("레거시 상세는 원문과 같은 유효 번역문에도 누락 예약을 요청하지 않는다")
fun shouldNotRequestTranslationsWhenLegacyDetailHasValidTranslationMatchingSourceText() {
langContext.setLang(Lang.JA)
val post = selectCommunityPost(postId = 104L, content = "same content", price = 0)
Mockito.`when`(repository.getCommunityPost(104L, true)).thenReturn(post)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(104L), "ja"))
.thenReturn(mapOf(104L to CreatorCommunityDisplayContent("same content", true)))
service.getCommunityPostDetail(104L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
Mockito.verify(creatorCommunityTranslationService, Mockito.never()).requestTranslations(104L, "ja")
}
@Test
@DisplayName("레거시 상세는 번역이 없으면 현재 원문을 반환하고 요청 언어만 예약한다")
fun shouldReturnOriginalAndRequestCurrentLanguageWhenLegacyDetailTranslationIsMissing() {
langContext.setLang(Lang.JA)
val post = selectCommunityPost(postId = 101L, content = "current original", price = 0)
Mockito.`when`(repository.getCommunityPost(101L, true)).thenReturn(post)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(101L), "ja"))
.thenReturn(emptyMap())
val response = service.getCommunityPostDetail(101L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
assertEquals("current original", response.content)
Mockito.verify(creatorCommunityTranslationService).requestTranslations(101L, "ja")
}
@Test
@DisplayName("레거시 상세는 구매자에게 선택한 유료 번역문 전체를 반환한다")
fun shouldReturnFullTranslatedContentToLegacyDetailBuyer() {
langContext.setLang(Lang.JA)
val post = selectCommunityPost(postId = 102L, content = "source paid content", price = 100)
Mockito.`when`(repository.getCommunityPost(102L, true)).thenReturn(post)
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(102L, 200L)).thenReturn(true)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(102L), "ja"))
.thenReturn(mapOf(102L to CreatorCommunityDisplayContent("translated paid content", true)))
val response = service.getCommunityPostDetail(102L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
assertEquals("translated paid content", response.content)
}
@Test
@DisplayName("레거시 상세는 소유자에게 선택한 유료 번역문 전체를 반환한다")
fun shouldReturnFullTranslatedContentToLegacyDetailOwner() {
langContext.setLang(Lang.JA)
val post = selectCommunityPost(postId = 103L, creatorId = 200L, content = "source paid content", price = 100)
Mockito.`when`(repository.getCommunityPost(103L, true)).thenReturn(post)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(103L), "ja"))
.thenReturn(mapOf(103L to CreatorCommunityDisplayContent("translated paid content", true)))
val response = service.getCommunityPostDetail(103L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
assertEquals("translated paid content", response.content)
}
@Test
@DisplayName("레거시 목록은 페이지 본문을 한 번에 번역 선택한 뒤 기존 유료 미리보기를 적용한다")
fun shouldUseStoredTranslationsOnceForLegacyCommunityListBeforePaidMasking() {
langContext.setLang(Lang.JA)
val posts = listOf(
selectCommunityPost(postId = 201L, content = "source free", price = 0),
selectCommunityPost(postId = 202L, content = "source paid", price = 100),
selectCommunityPost(postId = 203L, content = "source buyer", price = 100),
selectCommunityPost(postId = 204L, creatorId = 200L, content = "source owner", price = 100),
selectCommunityPost(postId = 205L, content = "source stale", price = 0)
)
val postIds = posts.map { it.id }
Mockito.`when`(repository.getCommunityPostList(1L, 200L, 0L, 20L, true)).thenReturn(posts)
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(203L, 200L)).thenReturn(true)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(postIds, "ja"))
.thenReturn(
mapOf(
201L to CreatorCommunityDisplayContent("translated free", true),
202L to CreatorCommunityDisplayContent("가나다라마바사아자차카타파하🙂끝", true),
203L to CreatorCommunityDisplayContent("translated buyer", true),
204L to CreatorCommunityDisplayContent("translated owner", true),
205L to CreatorCommunityDisplayContent("source stale", false)
)
)
val responses = service.getCommunityPostList(
creatorId = 1L,
memberId = 200L,
timezone = "Asia/Seoul",
offset = 0,
limit = 20,
isAdult = true
)
assertEquals(
listOf(
"translated free",
"가나다라마바사아자차카타파하🙂...",
"translated buyer",
"translated owner",
"source stale"
),
responses.map { it.content }
)
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(postIds, "ja")
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
}
@Test
@DisplayName("레거시 최신 목록은 차단 필터 뒤 남은 본문만 한 번에 번역 선택하고 작업을 만들지 않는다")
fun shouldUseStoredTranslationsForVisibleLatestCommunityPostsWithoutScheduling() {
langContext.setLang(Lang.JA)
val visiblePost = selectCommunityPost(postId = 206L, content = "source visible", price = 0)
val blockedPost = selectCommunityPost(postId = 207L, creatorId = 2L, content = "source blocked", price = 0)
Mockito.`when`(repository.getLatestPostListFromCreatorsYouFollow(200L, true))
.thenReturn(listOf(visiblePost, blockedPost))
Mockito.`when`(blockMemberRepository.isBlocked(200L, 2L)).thenReturn(true)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(206L), "ja"))
.thenReturn(mapOf(206L to CreatorCommunityDisplayContent("translated visible", true)))
val responses = service.getLatestPostListFromCreatorsYouFollow(
timezone = "Asia/Seoul",
memberId = 200L,
isAdult = true
)
assertEquals(listOf("translated visible"), responses.map { it.content })
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(206L), "ja")
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
}
@Test
@DisplayName("차단된 레거시 상세은 번역 조회나 예약을 시작하지 않는다")
fun shouldNotScheduleTranslationsWhenLegacyDetailIsBlocked() {
val post = selectCommunityPost(postId = 104L, content = "blocked", price = 0)
Mockito.`when`(repository.getCommunityPost(104L, true)).thenReturn(post)
Mockito.`when`(blockMemberRepository.isBlocked(200L, 1L)).thenReturn(true)
assertThrows(SodaException::class.java) {
service.getCommunityPostDetail(104L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
}
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
}
@Test
@DisplayName("비활성 레거시 상세은 번역 조회나 예약을 시작하지 않는다")
fun shouldNotScheduleTranslationsWhenLegacyDetailIsInactive() {
Mockito.`when`(repository.getCommunityPost(105L, true)).thenReturn(null)
assertThrows(SodaException::class.java) {
service.getCommunityPostDetail(105L, memberId = 200L, timezone = "Asia/Seoul", isAdult = true)
}
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
}
@Test
@DisplayName("레거시 댓글과 구매 응답은 번역 예약 진입점이 아니다")
fun shouldNotScheduleTranslationsFromLegacyCommentOrPurchaseResponses() {
val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator")
val post = CreatorCommunity(content = "original", price = 100, isCommentAvailable = false, isAdult = false).apply {
id = 106L
createdAt = LocalDateTime.of(2026, 9, 10, 10, 0)
member = creator
}
Mockito.`when`(repository.findByIdAndActive(106L, true)).thenReturn(post)
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(106L, 200L)).thenReturn(true)
service.getCommunityPostCommentList(106L, 200L, "Asia/Seoul", 0, 20, true)
service.purchasePost(106L, 200L, "Asia/Seoul", true, "test")
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
}
@Test
@DisplayName("최근 소식 발행 실패는 커뮤니티 게시글 생성을 실패시키지 않는다")
fun shouldNotFailCommunityPostCreationWhenNewsPublishFails() {
@@ -457,4 +774,26 @@ class CreatorCommunityServiceTest {
member.id = id
return member
}
private fun selectCommunityPost(
postId: Long,
creatorId: Long = 1L,
content: String,
price: Int
): SelectCommunityPostResponse {
return SelectCommunityPostResponse(
id = postId,
creatorId = creatorId,
creatorNickname = "creator-$creatorId",
creatorProfileUrl = "profile/$creatorId.png",
imagePath = null,
audioPath = null,
content = content,
date = LocalDateTime.of(2026, 9, 10, 10, 0),
isCommentAvailable = false,
price = price,
isAdult = false,
isFixed = false
)
}
}
@@ -0,0 +1,248 @@
package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
import kr.co.vividnext.sodalive.i18n.translation.TranslationSource
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceField
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import org.hibernate.SessionFactory
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.ApplicationEventPublisher
import org.springframework.context.annotation.Import
import javax.persistence.EntityManager
import javax.persistence.EntityManagerFactory
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=" +
"jdbc:h2:mem:creator-community-detail-translation;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE",
"spring.jpa.properties.hibernate.generate_statistics=true"
]
)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(QueryDslConfig::class)
class CreatorCommunityTranslationEndToEndTest @Autowired constructor(
private val creatorCommunityRepository: CreatorCommunityRepository,
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
private val memberRepository: MemberRepository,
private val entityManager: EntityManager,
private val entityManagerFactory: EntityManagerFactory,
private val translationJobRepository: TranslationJobRepository
) {
@Test
@DisplayName("실제 번역 읽기는 작은 배치와 큰 배치에서 게시글과 번역을 각각 한 번씩 조회한다")
fun shouldReadTranslationsWithConstantSelectCountAcrossBatchSizes() {
val postIds = (1..4).map { index ->
persistPost(content = "batch source $index", languageCode = "ko", price = 0).id!!
}
val translationService = CreatorCommunityTranslationService(
creatorCommunityRepository = creatorCommunityRepository,
creatorCommunityTranslationRepository = creatorCommunityTranslationRepository,
sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java),
materializer = Mockito.mock(TranslationReadModelMaterializer::class.java),
resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java),
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java),
entityManager = entityManager
)
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
entityManager.flush()
entityManager.clear()
statistics.clear()
translationService.findDisplayContents(postIds.take(1), "ja")
val smallBatchStatementCount = statistics.prepareStatementCount
entityManager.clear()
statistics.clear()
translationService.findDisplayContents(postIds, "ja")
val largeBatchStatementCount = statistics.prepareStatementCount
assertEquals(2L, smallBatchStatementCount)
assertEquals(2L, largeBatchStatementCount)
}
@Test
@DisplayName("실제 레거시 목록 조회는 번역이 없어도 감지와 작업을 만들지 않는다")
fun shouldNotPublishOrScheduleWhenLegacyListReadsMissingTranslation() {
val post = persistPost(content = "missing translation", languageCode = null, price = 0)
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
val scheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
translationJobRepository.deleteAll()
entityManager.flush()
val initialJobCount = translationJobRepository.count()
val responses = legacyService(
sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java),
materializer = materializer,
publisher = publisher,
scheduler = scheduler
).getCommunityPostList(
creatorId = post.member!!.id!!,
memberId = 999L,
timezone = "Asia/Seoul",
offset = 0,
limit = 10,
isAdult = true
)
assertEquals(listOf("missing translation"), responses.map { it.content })
assertEquals(initialJobCount, translationJobRepository.count())
Mockito.verifyNoInteractions(publisher, materializer, scheduler)
}
@Test
@DisplayName("레거시 상세는 저장된 현재 번역문을 유료 마스킹 전에 사용한다")
fun shouldUseStoredTranslationBeforeLegacyPaidMasking() {
val post = persistPost(content = "source paid content", languageCode = "ko", price = 100)
creatorCommunityTranslationRepository.save(
CreatorCommunityTranslation(
creatorCommunityId = post.id!!,
locale = "ja",
content = "translated paid content",
sourceRevision = post.contentRevision,
sourceHash = SourceTextNormalizer.hash(post.content),
sourceLanguage = "ko"
)
)
entityManager.flush()
entityManager.clear()
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, post.id!!))
.thenReturn(translationSource(post.id!!, post.content, "ko", post.contentRevision))
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
Mockito.`when`(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, post.id!!, "ja"))
.thenReturn(true)
val response = legacyService(sourceExtractor, materializer, Mockito.mock(ApplicationEventPublisher::class.java))
.getCommunityPostDetail(post.id!!, memberId = 999L, timezone = "Asia/Seoul", isAdult = true)
assertEquals("translated paid...", response.content)
}
@Test
@DisplayName("언어 미확정 레거시 상세는 원문을 반환하고 요청 언어만 감지 이벤트에 전달한다")
fun shouldReturnOriginalAndRequestOnlyCurrentLanguageDetectionForLegacyDetail() {
val post = persistPost(content = "current original", languageCode = null, price = 0)
val sourceExtractor = Mockito.mock(TranslationSourceExtractor::class.java)
Mockito.`when`(sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, post.id!!)).thenReturn(null)
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
val response = legacyService(
sourceExtractor,
Mockito.mock(TranslationReadModelMaterializer::class.java),
publisher
).getCommunityPostDetail(post.id!!, memberId = 999L, timezone = "Asia/Seoul", isAdult = true)
assertEquals("current original", response.content)
val eventCaptor = ArgumentCaptor.forClass(LanguageDetectEvent::class.java)
Mockito.verify(publisher).publishEvent(eventCaptor.capture())
assertEquals("ja", eventCaptor.value.targetLanguage)
}
private fun legacyService(
sourceExtractor: TranslationSourceExtractor,
materializer: TranslationReadModelMaterializer,
publisher: ApplicationEventPublisher,
scheduler: ResourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
): CreatorCommunityService {
val langContext = LangContext().apply { setLang(Lang.JA) }
val translationService = CreatorCommunityTranslationService(
creatorCommunityRepository = creatorCommunityRepository,
creatorCommunityTranslationRepository = creatorCommunityTranslationRepository,
sourceExtractor = sourceExtractor,
materializer = materializer,
resourceTranslationJobScheduler = scheduler,
applicationEventPublisher = publisher,
entityManager = entityManager
)
return CreatorCommunityService(
canPaymentService = Mockito.mock(CanPaymentService::class.java),
repository = creatorCommunityRepository,
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java),
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java),
commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java),
useCanRepository = Mockito.mock(UseCanRepository::class.java),
s3Uploader = Mockito.mock(S3Uploader::class.java),
objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()),
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
applicationEventPublisher = publisher,
messageSource = SodaMessageSource(),
langContext = langContext,
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java),
creatorCommunityTranslationService = translationService,
entityManager = entityManager,
imageBucket = "test-image-bucket",
contentBucket = "test-content-bucket",
imageHost = "https://cdn.test"
)
}
private fun persistPost(content: String, languageCode: String?, price: Int): CreatorCommunity {
val creator = memberRepository.save(
Member(
email = "creator-${System.nanoTime()}@test.com",
password = "password",
nickname = "creator-${System.nanoTime()}",
role = MemberRole.CREATOR
)
)
return creatorCommunityRepository.save(
CreatorCommunity(
content = content,
price = price,
isCommentAvailable = false,
isAdult = false,
languageCode = languageCode
).apply { member = creator }
)
}
private fun translationSource(
postId: Long,
content: String,
sourceLanguage: String,
sourceRevision: Long
): TranslationSource {
return TranslationSource(
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
resourceId = postId,
sourceLanguage = sourceLanguage,
fields = listOf(TranslationSourceField("content", content)),
sourceRevision = sourceRevision
)
}
}