test #449

Merged
klaus merged 29 commits from test into main 2026-09-10 07:58:32 +00:00
3 changed files with 283 additions and 5 deletions
Showing only changes of commit 5a11c2e533 - Show all commits
@@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorCha
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
@@ -36,6 +37,7 @@ class CreatorChannelCommunityQueryService(
private val audioContentCloudFront: AudioContentCloudFront,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
@@ -69,6 +71,9 @@ class CreatorChannelCommunityQueryService(
offset = communityPage.offset,
limit = communityPage.fetchLimit
)
val communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage)
val displayContents = creatorCommunityTranslationService
.findDisplayContents(communityPosts.map { it.postId }, langContext.lang.code)
return CreatorChannelCommunityTab(
communityPostCount = queryPort.countCommunityPosts(
@@ -76,7 +81,9 @@ class CreatorChannelCommunityQueryService(
viewerId = viewerId,
canViewAdultContent = canViewAdultContent
),
communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage).map { it.toDomain(viewerId) },
communityPosts = communityPosts.map {
it.copy(content = displayContents[it.postId]?.content ?: it.content).toDomain(viewerId)
},
page = communityPage,
hasNext = queryPolicy.hasNext(fetchedPosts, communityPage)
)
@@ -89,7 +96,7 @@ class CreatorChannelCommunityQueryService(
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPost> {
return queryPortProvider.getObject()
val posts = queryPortProvider.getObject()
.findHomeCommunityPosts(
creatorId = creatorId,
viewerId = viewerId,
@@ -97,7 +104,12 @@ class CreatorChannelCommunityQueryService(
canViewAdultContent = canViewAdultContent,
limit = limit
)
.map { it.toDomain(viewerId) }
val displayContents = creatorCommunityTranslationService
.findDisplayContents(posts.map { it.postId }, langContext.lang.code)
return posts.map {
it.copy(content = displayContents[it.postId]?.content ?: it.content).toDomain(viewerId)
}
}
fun getCommunityPostDetail(
@@ -113,8 +125,15 @@ class CreatorChannelCommunityQueryService(
validateNotBlocked(queryPort, viewerId, postRecord.creatorId)
val locale = langContext.lang.code
val displayContent = creatorCommunityTranslationService
.findDisplayContents(listOf(postRecord.postId), locale)[postRecord.postId]
if (displayContent?.hasValidTranslation != true) {
creatorCommunityTranslationService.requestTranslations(postRecord.postId, locale)
}
return CreatorChannelCommunityPostDetail(
post = postRecord.toDomain(viewerId),
post = postRecord.copy(content = displayContent?.content ?: postRecord.content).toDomain(viewerId),
comments = getCommunityComments(postId, viewer, page = 0, size = 20, now = now)
)
}
@@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
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 org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
@@ -47,6 +49,9 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
@MockBean
private lateinit var audioContentCloudFront: AudioContentCloudFront
@MockBean
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
@Test
@DisplayName("커뮤니티 탭 API는 E2E로 정렬, fallback, 성인 필터, 유료 미디어 접근 정책을 반환한다")
fun shouldReturnCommunityTabThroughControllerServiceAndRepository() {
@@ -97,6 +102,41 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
Mockito.verifyNoMoreInteractions(audioContentCloudFront)
}
@Test
@DisplayName("커뮤니티 탭 API는 저장 번역을 페이지 단위로 선택하고 유료 미리보기를 유지한다")
fun shouldUseStoredTranslationsForCommunityTabWithoutScheduling() {
val fixture = createFixture()
val postIds = listOf(
fixture.pinnedPostId,
fixture.purchasedPaidPostId,
fixture.unpurchasedPaidPostId,
fixture.noImagePostId
)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(postIds, "ja"))
.thenReturn(
mapOf(
fixture.pinnedPostId to CreatorCommunityDisplayContent("translated pinned", true),
fixture.purchasedPaidPostId to CreatorCommunityDisplayContent("translated purchased", true),
fixture.unpurchasedPaidPostId to CreatorCommunityDisplayContent("가나다라마바사아자차카타파하🙂끝", true),
fixture.noImagePostId to CreatorCommunityDisplayContent("no image community", false)
)
)
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/community")
.header("Accept-Language", "ja")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.communityPosts[0].content").value("translated pinned"))
.andExpect(jsonPath("$.data.communityPosts[1].content").value("translated purchased"))
.andExpect(jsonPath("$.data.communityPosts[2].content").value("가나다라마바사아자차카타파하🙂..."))
.andExpect(jsonPath("$.data.communityPosts[3].content").value("no image community"))
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(postIds, "ja")
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
}
@Test
@DisplayName("커뮤니티 상세 API는 초기 댓글 20개와 최신 답글을 반환한다")
fun shouldReturnCommunityPostDetailWithInitialComments() {
@@ -115,6 +155,74 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.comments.comments[0].createdAtUtc").value(org.hamcrest.Matchers.endsWith("Z")))
}
@Test
@DisplayName("커뮤니티 상세 API는 저장 번역문에 유료 미리보기를 적용하고 요청 언어만 예약한다")
fun shouldSelectTranslatedContentBeforePaidMaskingAndRequestCurrentLanguage() {
val fixture = createTranslatedPaidDetailFixture(purchased = false)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja"))
.thenReturn(mapOf(fixture.postId to CreatorCommunityDisplayContent("translated paid content", true)))
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
.header("Accept-Language", "ja")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.content").value("translated paid..."))
Mockito.verify(creatorCommunityTranslationService, Mockito.never()).requestTranslations(fixture.postId, "ja")
}
@Test
@DisplayName("커뮤니티 상세 API는 구매자에게 선택한 유료 번역문 전체를 반환한다")
fun shouldReturnFullTranslatedContentToBuyer() {
val fixture = createTranslatedPaidDetailFixture(purchased = true)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja"))
.thenReturn(mapOf(fixture.postId to CreatorCommunityDisplayContent("translated paid content", true)))
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
.header("Accept-Language", "ja")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.content").value("translated paid content"))
}
@Test
@DisplayName("커뮤니티 상세 API는 소유자에게 선택한 유료 번역문 전체를 반환한다")
fun shouldReturnFullTranslatedContentToOwner() {
val fixture = createTranslatedPaidOwnerDetailFixture()
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja"))
.thenReturn(mapOf(fixture.postId to CreatorCommunityDisplayContent("translated paid content", true)))
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
.header("Accept-Language", "ja")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.content").value("translated paid content"))
}
@Test
@DisplayName("커뮤니티 상세 API는 반복 요청을 모두 성공시키고 같은 요청 언어만 전달한다")
fun shouldKeepRepeatedCommunityDetailRequestsSuccessful() {
val fixture = createTranslatedPaidDetailFixture(purchased = false)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja"))
.thenReturn(emptyMap())
repeat(2) {
mockMvc.perform(
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
.header("Accept-Language", "ja")
.with(user(MemberAdapter(fixture.viewer)))
).andExpect(status().isOk)
}
Mockito.verify(creatorCommunityTranslationService, Mockito.times(2)).requestTranslations(fixture.postId, "ja")
}
@Test
@DisplayName("커뮤니티 댓글 API는 댓글 페이지와 최신 답글을 반환한다")
fun shouldReturnPagedCommunityCommentsWithLatestReply() {
@@ -131,6 +239,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.comments.length()").value(20))
.andExpect(jsonPath("$.data.hasNext").value(true))
.andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(fixture.latestReplyId))
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
}
@Test
@@ -148,6 +258,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.replyCount").value(1))
.andExpect(jsonPath("$.data.replies[0].commentId").value(fixture.latestReplyId))
.andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist())
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
}
@Test
@@ -176,6 +288,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
}
@Test
@@ -189,6 +303,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
Mockito.verifyNoInteractions(creatorCommunityTranslationService)
}
@Test
@@ -435,6 +551,42 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
}!!
}
private fun createTranslatedPaidDetailFixture(purchased: Boolean): PostFixture {
return transactionTemplate.execute {
val viewer = saveMember("community-translated-viewer-$purchased", MemberRole.USER)
val creator = saveMember("community-translated-creator-$purchased", MemberRole.CREATOR)
savePreference(viewer, isAdultContentVisible = true)
val post = saveCommunity(
creator = creator,
isFixed = false,
price = 100,
content = "source paid content"
)
if (purchased) {
saveCommunityOrder(viewer, post)
}
entityManager.flush()
entityManager.clear()
PostFixture(viewer = viewer, postId = post.id!!)
}!!
}
private fun createTranslatedPaidOwnerDetailFixture(): PostFixture {
return transactionTemplate.execute {
val creator = saveMember("community-translated-owner", MemberRole.CREATOR)
savePreference(creator, isAdultContentVisible = true)
val post = saveCommunity(
creator = creator,
isFixed = false,
price = 100,
content = "source paid content"
)
entityManager.flush()
entityManager.clear()
PostFixture(viewer = creator, postId = post.id!!)
}!!
}
private fun createCommentUnavailableFixture(): PostFixture {
return transactionTemplate.execute {
val viewer = saveMember("community-unavailable-viewer", MemberRole.USER)
@@ -15,6 +15,8 @@ import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorCha
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
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 org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
@@ -175,6 +177,92 @@ class CreatorChannelCommunityQueryServiceTest {
assertEquals(true, posts.single().isLiked)
}
@Test
@DisplayName("커뮤니티 탭은 페이지 본문을 한 번에 번역 선택한 뒤 유료 미리보기를 적용한다")
fun shouldUseStoredTranslationsOnceForCommunityTabBeforePaidMasking() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
communityPosts = listOf(
communityPostRecord(11L, price = 0).copy(content = "source free"),
communityPostRecord(12L, price = 100).copy(content = "source paid"),
communityPostRecord(13L, price = 100, existOrdered = true).copy(content = "source buyer"),
communityPostRecord(14L, creatorId = 10L, price = 100).copy(content = "source owner"),
communityPostRecord(15L, price = 0).copy(content = "source stale")
)
}
val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
val service = createService(port, creatorCommunityTranslationService = translationService)
val postIds = port.communityPosts.map { it.postId }
Mockito.`when`(translationService.findDisplayContents(postIds, "en"))
.thenReturn(
mapOf(
11L to CreatorCommunityDisplayContent("translated free", true),
12L to CreatorCommunityDisplayContent("가나다라마바사아자차카타파하🙂끝", true),
13L to CreatorCommunityDisplayContent("translated buyer", true),
14L to CreatorCommunityDisplayContent("translated owner", true),
15L to CreatorCommunityDisplayContent("source stale", false)
)
)
val posts = service.getCommunityTab(1L, createMember(id = 10L), 0, 20).communityPosts
assertEquals(
listOf(
"translated free",
"가나다라마바사아자차카타파하🙂...",
"translated buyer",
"translated owner",
"source stale"
),
posts.map { it.content }
)
Mockito.verify(translationService).findDisplayContents(postIds, "en")
Mockito.verifyNoMoreInteractions(translationService)
}
@Test
@DisplayName("커뮤니티 탭 번역 조회는 fetchLimit 다음 게시글을 제외한 반환 페이지 ID만 사용한다")
fun shouldExcludeFetchLimitPlusOnePostFromCommunityTabTranslationLookup() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
communityPosts = (1L..51L).map { communityPostRecord(it, price = 0) }
}
val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
val service = createService(port, creatorCommunityTranslationService = translationService)
val returnedPostIds = (1L..50L).toList()
Mockito.`when`(translationService.findDisplayContents(returnedPostIds, "en")).thenReturn(emptyMap())
val tab = service.getCommunityTab(1L, createMember(id = 10L), page = 0, size = 50)
assertEquals(50, tab.communityPosts.size)
assertTrue(tab.hasNext)
Mockito.verify(translationService).findDisplayContents(returnedPostIds, "en")
Mockito.verifyNoMoreInteractions(translationService)
}
@Test
@DisplayName("채널 홈의 고정과 일반 본문은 각 그룹에서 번역 선택만 하고 작업을 만들지 않는다")
fun shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPostsWithoutScheduling() {
val port = FakeCreatorChannelCommunityQueryPort()
val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
val service = createService(port, creatorCommunityTranslationService = translationService)
port.homeCommunityPosts = listOf(communityPostRecord(21L, price = 0).copy(content = "source pinned"))
Mockito.`when`(translationService.findDisplayContents(listOf(21L), "en"))
.thenReturn(mapOf(21L to CreatorCommunityDisplayContent("translated pinned", true)))
val pinnedPosts = service.findHomeCommunityPosts(1L, 10L, true, false, 3)
port.homeCommunityPosts = listOf(communityPostRecord(22L, price = 0).copy(content = "source normal"))
Mockito.`when`(translationService.findDisplayContents(listOf(22L), "en"))
.thenReturn(mapOf(22L to CreatorCommunityDisplayContent("translated normal", true)))
val normalPosts = service.findHomeCommunityPosts(1L, 10L, false, false, 3)
assertEquals("translated pinned", pinnedPosts.single().content)
assertEquals("translated normal", normalPosts.single().content)
Mockito.verify(translationService).findDisplayContents(listOf(21L), "en")
Mockito.verify(translationService).findDisplayContents(listOf(22L), "en")
Mockito.verifyNoMoreInteractions(translationService)
}
@Test
@DisplayName("커뮤니티 상세는 게시글과 초기 댓글 및 최신 답글을 조립한다")
fun shouldAssembleCommunityPostDetailWithInitialComments() {
@@ -201,6 +289,20 @@ class CreatorChannelCommunityQueryServiceTest {
assertEquals("https://cdn.test/profile/default-profile.png", detail.comments.comments.first().writerProfileImageUrl)
}
@Test
@DisplayName("v2 상세는 원문과 같은 유효 번역문에도 누락 예약을 요청하지 않는다")
fun shouldNotRequestTranslationsWhenV2DetailHasValidTranslationMatchingSourceText() {
val port = FakeCreatorChannelCommunityQueryPort()
val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
val service = createService(port, creatorCommunityTranslationService = translationService)
Mockito.`when`(translationService.findDisplayContents(listOf(1L), "en"))
.thenReturn(mapOf(1L to CreatorCommunityDisplayContent("content-1", true)))
service.getCommunityPostDetail(1L, createMember(id = 10L), LocalDateTime.of(2026, 7, 6, 10, 0))
Mockito.verify(translationService, Mockito.never()).requestTranslations(1L, "en")
}
@Test
@DisplayName("댓글 불가 게시글은 댓글 port를 호출하지 않고 빈 댓글 응답을 반환한다")
fun shouldReturnEmptyCommentsWhenCommentUnavailable() {
@@ -298,7 +400,9 @@ class CreatorChannelCommunityQueryServiceTest {
private fun createService(
port: FakeCreatorChannelCommunityQueryPort,
audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
canViewAdultContent: Boolean = true
canViewAdultContent: Boolean = true,
creatorCommunityTranslationService: CreatorCommunityTranslationService =
Mockito.mock(CreatorCommunityTranslationService::class.java)
): CreatorChannelCommunityQueryService {
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
Mockito.`when`(
@@ -306,6 +410,8 @@ class CreatorChannelCommunityQueryServiceTest {
).thenReturn(canViewAdultContent)
val langContext = LangContext()
langContext.setLang(Lang.EN)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(Mockito.anyList(), Mockito.anyString()))
.thenReturn(emptyMap())
return CreatorChannelCommunityQueryService(
queryPortProvider = FixedCreatorChannelCommunityQueryPortProvider(port),
queryPolicy = CreatorChannelCommunityQueryPolicy(),
@@ -313,6 +419,7 @@ class CreatorChannelCommunityQueryServiceTest {
audioContentCloudFront = audioContentCloudFront,
messageSource = SodaMessageSource(),
langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService,
cloudFrontHost = "https://cdn.test"
)
}