diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt index 8e8dc442..d896247b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt @@ -1,9 +1,12 @@ package kr.co.vividnext.sodalive.v2.home.following.application +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService +import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort import org.springframework.stereotype.Service @@ -16,6 +19,8 @@ import java.time.LocalDateTime class HomeFollowingQueryService( private val queryPort: HomeFollowingQueryPort, private val memberContentPreferenceService: MemberContentPreferenceService, + private val langContext: LangContext, + private val creatorCommunityTranslationService: CreatorCommunityTranslationService, private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(Clock.systemUTC()) } ) { fun findHomeFollowing(member: Member): HomeFollowing { @@ -24,6 +29,15 @@ class HomeFollowingQueryService( val effectiveViewerGender = member.effectiveGender() val isViewerCreator = member.role == MemberRole.CREATOR val now = nowProvider() + val recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT) + val communityPostIds = recentNews.mapNotNull { news -> + if (news.type == FollowingNewsType.COMMUNITY_POST) news.communityPost?.postId else null + } + val displayContents = if (communityPostIds.isEmpty()) { + emptyMap() + } else { + creatorCommunityTranslationService.findDisplayContents(communityPostIds, langContext.lang.code) + } return HomeFollowing( followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT), @@ -43,7 +57,14 @@ class HomeFollowingQueryService( now, MONTHLY_SCHEDULES_LIMIT ), - recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT) + recentNews = recentNews.map { news -> + if (news.type != FollowingNewsType.COMMUNITY_POST) return@map news + + val communityPost = news.communityPost ?: return@map news + displayContents[communityPost.postId] + ?.let { displayContent -> news.copy(communityPost = communityPost.copy(content = displayContent.content)) } + ?: news + } ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt index 75099cf5..c0c0e176 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt @@ -18,6 +18,8 @@ import kr.co.vividnext.sodalive.member.MemberKind import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.following.CreatorFollowing 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 kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot @@ -63,6 +65,9 @@ class HomeFollowingEndToEndTest @Autowired constructor( @MockBean private lateinit var countryContext: CountryContext + @MockBean + private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService + @Test @DisplayName("팔로잉 탭 API는 비회원에게 200 OK와 로그인 필요 빈 섹션 응답을 반환한다") fun shouldReturnLoginRequiredForAnonymous() { @@ -137,6 +142,56 @@ class HomeFollowingEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.recentNews[?(@.communityPost.postId == ${fixture.paidPostId})]").isEmpty) } + @Test + @DisplayName("인증 회원 팔로잉 탭은 노출된 커뮤니티 소식만 영어 표시 본문으로 반환하고 다른 소식은 유지한다") + fun shouldReturnEnglishCommunityTranslationForAuthenticatedFollowingTab() { + Mockito.doReturn("US").`when`(countryContext).countryCode + val fixture = createFixture() + val fallbackPostId = transactionTemplate.execute { + val creator = entityManager.find(Member::class.java, fixture.creatorId) + val fallbackPost = saveCommunityPost( + creator, + "historical following fallback body", + price = 0, + imagePath = "community/fallback.png" + ) + saveNews( + memberId = fixture.viewer.id!!, + creatorId = fixture.creatorId, + sourceKey = "fallback-community-news", + visibleFromAtUtc = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10), + newsType = FollowingNewsType.COMMUNITY_POST, + targetId = fallbackPost.id!! + ) + entityManager.flush() + fallbackPost.id!! + }!! + Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fallbackPostId, fixture.freePostId), "en")) + .thenReturn( + mapOf( + fallbackPostId to CreatorCommunityDisplayContent("current following fallback body en", false), + fixture.freePostId to CreatorCommunityDisplayContent("translated following body en", true) + ) + ) + + mockMvc.perform( + get("/api/v2/home/following") + .header("Accept-Language", "en") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.recentNews[0].type").value("COMMUNITY_POST")) + .andExpect(jsonPath("$.data.recentNews[0].communityPost.content").value("current following fallback body en")) + .andExpect(jsonPath("$.data.recentNews[1].type").value("CREATOR_RANKING")) + .andExpect(jsonPath("$.data.recentNews[2].type").value("COMMUNITY_POST")) + .andExpect(jsonPath("$.data.recentNews[2].communityPost.content").value("translated following body en")) + .andExpect(jsonPath("$.data.recentNews[3].type").value("AUDIO_CONTENT")) + .andExpect(jsonPath("$.data.recentNews[3].audioContent.title").value("home-following-audio")) + + Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(fallbackPostId, fixture.freePostId), "en") + Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService) + } + private fun createFixture(): Fixture { return transactionTemplate.execute { val now = LocalDateTime.now(ZoneOffset.UTC) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt index db834f1d..cff15ee5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt @@ -1,10 +1,16 @@ package kr.co.vividnext.sodalive.v2.home.following.application +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.auth.Auth import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +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.domain.FollowingNewsType +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews @@ -23,10 +29,14 @@ import java.util.TimeZone class HomeFollowingQueryServiceTest { private val queryPort = RecordingHomeFollowingQueryPort() private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + private val langContext = LangContext() + private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java) private val now = LocalDateTime.of(2026, 6, 25, 12, 0) private val service = HomeFollowingQueryService( queryPort, - memberContentPreferenceService + memberContentPreferenceService, + langContext, + creatorCommunityTranslationService ) { now } @Test @@ -86,7 +96,12 @@ class HomeFollowingQueryServiceTest { try { TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) val defaultQueryPort = RecordingHomeFollowingQueryPort() - val defaultService = HomeFollowingQueryService(defaultQueryPort, memberContentPreferenceService) + val defaultService = HomeFollowingQueryService( + defaultQueryPort, + memberContentPreferenceService, + langContext, + creatorCommunityTranslationService + ) val member = member(12L) Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true) val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1) @@ -103,10 +118,57 @@ class HomeFollowingQueryServiceTest { } } + @Test + @DisplayName("팔로잉 최근 소식은 커뮤니티 게시물만 영어 표시 본문으로 일괄 치환하고 다른 소식과 원문 폴백을 유지한다") + fun shouldTranslateOnlyCommunityPostNewsInOneEnglishBatch() { + langContext.setLang(Lang.EN) + val translatedNews = communityNews("community-1", 101L, "original-1") + val audioNews = HomeFollowingNews( + newsId = "audio-1", + type = FollowingNewsType.AUDIO_CONTENT, + visibleFromAtUtc = "2026-06-25T11:59:00Z" + ) + val fallbackNews = communityNews("community-2", 102L, "historical-original-2") + queryPort.recentNews = listOf(translatedNews, audioNews, fallbackNews) + Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(101L, 102L), "en")) + .thenReturn( + mapOf( + 101L to CreatorCommunityDisplayContent("translated-1-en", true), + 102L to CreatorCommunityDisplayContent("current-original-2", false) + ) + ) + + val home = service.findHomeFollowing(member(14L)) + + assertEquals("translated-1-en", home.recentNews[0].communityPost?.content) + assertEquals(audioNews, home.recentNews[1]) + assertEquals("current-original-2", home.recentNews[2].communityPost?.content) + Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(101L, 102L), "en") + Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService) + } + private fun member(id: Long): Member { return Member(email = "member-$id@test.com", password = "password", nickname = "member-$id").apply { this.id = id } } + private fun communityNews(newsId: String, postId: Long, content: String): HomeFollowingNews { + return HomeFollowingNews( + newsId = newsId, + type = FollowingNewsType.COMMUNITY_POST, + visibleFromAtUtc = "2026-06-25T12:00:00Z", + communityPost = HomeFollowingCommunityPostNews( + postId = postId, + creatorProfileImage = "profile.png", + creatorNickname = "creator", + imageUrl = "image.png", + content = content, + createdAt = "2026-06-25T12:00:00Z", + likeCount = 1, + commentCount = 2 + ) + ) + } + private class RecordingHomeFollowingQueryPort : HomeFollowingQueryPort { var memberId: Long? = null var followingCreatorsLimit: Int? = null @@ -122,6 +184,7 @@ class HomeFollowingQueryServiceTest { var recentNewsLimit: Int? = null var recentNewsNow: LocalDateTime? = null var recentNewsCanViewAdultContent: Boolean? = null + var recentNews: List = emptyList() override fun findFollowingCreators(memberId: Long, limit: Int): List { this.memberId = memberId @@ -168,7 +231,7 @@ class HomeFollowingQueryServiceTest { recentNewsCanViewAdultContent = canViewAdultContent recentNewsNow = nowUtc recentNewsLimit = limit - return emptyList() + return recentNews } } }