test #449
+37
-10
@@ -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(),
|
||||
|
||||
+344
-5
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+248
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user