From 11e29460b75422f3afed992ca3556cf3872fa709 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:13:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-community):=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=EA=B3=BC=20=EC=83=81=EC=84=B8=EC=97=90=20=EB=B3=B8=EB=AC=B8=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorCommunityService.kt | 47 ++- .../CreatorCommunityServiceTest.kt | 349 +++++++++++++++++- ...CreatorCommunityTranslationEndToEndTest.kt | 248 +++++++++++++ 3 files changed, 629 insertions(+), 15 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index ffd5e9e0..9eb1805b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -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 { 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(), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt index ad19b50e..93283d6b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt @@ -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(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(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 + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt new file mode 100644 index 00000000..7e56d58b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt @@ -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 + ) + } +}