test #449

Merged
klaus merged 29 commits from test into main 2026-09-10 07:58:32 +00:00
4 changed files with 240 additions and 18 deletions
Showing only changes of commit da59373184 - Show all commits
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
@@ -31,7 +32,8 @@ class HomeRecommendationQueryService(
private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null, private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null,
private val popularCommunitySnapshotFallbackService: PopularCommunitySnapshotFallbackPort? = null, private val popularCommunitySnapshotFallbackService: PopularCommunitySnapshotFallbackPort? = null,
private val clock: Clock = Clock.systemUTC(), private val clock: Clock = Clock.systemUTC(),
private val langContext: LangContext private val langContext: LangContext,
private val creatorCommunityTranslationService: CreatorCommunityTranslationService
) { ) {
private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy() private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy()
@@ -154,9 +156,20 @@ class HomeRecommendationQueryService(
.associateBy { it.communityId } .associateBy { it.communityId }
val selectedCreatorIds = mutableSetOf<Long>() val selectedCreatorIds = mutableSetOf<Long>()
return snapshots.mapNotNull { snapshot -> val recommendations = snapshots.mapNotNull { snapshot ->
detailsById[snapshot.targetId]?.takeIf { selectedCreatorIds.add(it.creatorId) } detailsById[snapshot.targetId]?.takeIf { selectedCreatorIds.add(it.creatorId) }
}.take(limit) }.take(limit)
if (recommendations.isEmpty()) return emptyList()
val displayContents = creatorCommunityTranslationService.findDisplayContents(
recommendations.map { it.communityId },
langContext.lang.code
)
return recommendations.map { recommendation ->
displayContents[recommendation.communityId]
?.let { displayContent -> recommendation.copy(content = displayContent.content) }
?: recommendation
}
} }
private fun findPopularCommunitySnapshotsWithFallback(): List<RecommendationSnapshotRecord> { private fun findPopularCommunitySnapshotsWithFallback(): List<RecommendationSnapshotRecord> {
@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.api.home package kr.co.vividnext.sodalive.v2.api.home
import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberAdapter
@@ -12,6 +13,8 @@ import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade
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.recommendation.adapter.out.persistence.RecommendationSnapshot import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
@@ -27,6 +30,7 @@ import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.system.CapturedOutput import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension import org.springframework.boot.test.system.OutputCaptureExtension
import org.springframework.http.MediaType import org.springframework.http.MediaType
@@ -53,6 +57,9 @@ class HomeRecommendationControllerTest @Autowired constructor(
private val creatorFollowingRepository: CreatorFollowingRepository, private val creatorFollowingRepository: CreatorFollowingRepository,
private val entityManager: EntityManager private val entityManager: EntityManager
) { ) {
@MockBean
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
@Test @Test
@DisplayName("추천 크리에이터 동시 팔로우 비로그인 요청은 Spring Security에서 거부한다") @DisplayName("추천 크리에이터 동시 팔로우 비로그인 요청은 Spring Security에서 거부한다")
fun shouldRejectAnonymousFollowRequest() { fun shouldRejectAnonymousFollowRequest() {
@@ -230,6 +237,60 @@ class HomeRecommendationControllerTest @Autowired constructor(
assertTrue(output.out.contains("emptySections=")) assertTrue(output.out.contains("emptySections="))
} }
@Test
@DisplayName("비회원 홈 추천은 반환된 인기 커뮤니티 게시물만 일본어 표시 본문으로 반환한다")
fun shouldReturnJapanesePopularCommunityTranslationForAnonymous() {
val translatedCreator = saveMember("popular-community-translated-creator", MemberRole.CREATOR)
val fallbackCreator = saveMember("popular-community-fallback-creator", MemberRole.CREATOR)
val translatedPost = saveCommunityPost(translatedCreator, "historical translated popular body")
val fallbackPost = saveCommunityPost(fallbackCreator, "historical fallback popular body")
val snapshotAt = RecommendationSnapshotWindowPolicy()
.previousKstDayUtcWindow(LocalDateTime.now(ZoneOffset.UTC))
.snapshotAt
entityManager.persist(
RecommendationSnapshot(
sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
targetId = translatedPost.id!!,
score = 100.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.1
)
)
entityManager.persist(
RecommendationSnapshot(
sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
targetId = fallbackPost.id!!,
score = 90.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.2
)
)
entityManager.flush()
entityManager.clear()
Mockito.`when`(
creatorCommunityTranslationService.findDisplayContents(
listOf(translatedPost.id!!, fallbackPost.id!!),
"ja"
)
).thenReturn(
mapOf(
translatedPost.id!! to CreatorCommunityDisplayContent("translated popular body ja", true),
fallbackPost.id!! to CreatorCommunityDisplayContent("current fallback popular body ja", false)
)
)
mockMvc.perform(get("/api/v2/home/recommendations").header("Accept-Language", "ja"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.popularCommunityPosts[0].content").value("translated popular body ja"))
.andExpect(jsonPath("$.data.popularCommunityPosts[1].content").value("current fallback popular body ja"))
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(
listOf(translatedPost.id!!, fallbackPost.id!!),
"ja"
)
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
}
@Test @Test
@DisplayName("메인 홈 통합 조회는 인증 회원도 호출 가능하고 성공 응답한다") @DisplayName("메인 홈 통합 조회는 인증 회원도 호출 가능하고 성공 응답한다")
fun shouldReturnHomeRecommendationsForMember() { fun shouldReturnHomeRecommendationsForMember() {
@@ -590,6 +651,20 @@ class HomeRecommendationControllerTest @Autowired constructor(
) )
} }
private fun saveCommunityPost(creator: Member, content: String): CreatorCommunity {
val post = CreatorCommunity(
content = content,
price = 0,
isCommentAvailable = true,
isAdult = false,
isActive = true
).apply {
member = creator
}
entityManager.persist(post)
return post
}
private fun saveLiveRoom( private fun saveLiveRoom(
creator: Member, creator: Member,
beginDateTime: LocalDateTime, beginDateTime: LocalDateTime,
@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService 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.recommendation.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
@@ -27,10 +28,12 @@ import java.time.LocalDateTime
class HomeRecommendationFacadeTest { class HomeRecommendationFacadeTest {
private val queryPort = FakeHomeRecommendationQueryPort() private val queryPort = FakeHomeRecommendationQueryPort()
private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
private val queryService = HomeRecommendationQueryService( private val queryService = HomeRecommendationQueryService(
queryPort, queryPort,
EmptyRecommendationSnapshotPort(), EmptyRecommendationSnapshotPort(),
langContext = LangContext() langContext = LangContext(),
creatorCommunityTranslationService = creatorCommunityTranslationService
) )
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test") private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test")
@@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
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.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
@@ -20,6 +22,7 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapsho
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.Clock import java.time.Clock
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -29,7 +32,18 @@ class HomeRecommendationQueryServiceTest {
private val port = FakeHomeRecommendationQueryPort() private val port = FakeHomeRecommendationQueryPort()
private val snapshotPort = FakeHomeRecommendationSnapshotPort() private val snapshotPort = FakeHomeRecommendationSnapshotPort()
private val langContext = LangContext() private val langContext = LangContext()
private val service = HomeRecommendationQueryService(port, snapshotPort, langContext = langContext) private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
private val service = HomeRecommendationQueryService(
port,
snapshotPort,
langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
)
init {
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(Mockito.anyList(), Mockito.anyString()))
.thenReturn(emptyMap())
}
@Test @Test
@DisplayName("다시듣기 테마 콘텐츠는 AUDIO가 아니라 LIVE_REPLAY 활동으로 분류한다") @DisplayName("다시듣기 테마 콘텐츠는 AUDIO가 아니라 LIVE_REPLAY 활동으로 분류한다")
@@ -162,7 +176,13 @@ class HomeRecommendationQueryServiceTest {
@DisplayName("AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다") @DisplayName("AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다")
fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() { fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() {
val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList()) val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList())
val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) val service = HomeRecommendationQueryService(
port,
snapshotPort,
fallback,
langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
)
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
@@ -264,7 +284,13 @@ class HomeRecommendationQueryServiceTest {
val fallback = FakeAiCharacterSnapshotFallbackPort( val fallback = FakeAiCharacterSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
) )
val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) val service = HomeRecommendationQueryService(
port,
snapshotPort,
fallback,
langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
)
port.aiCharacterDetails = listOf( port.aiCharacterDetails = listOf(
HomeAiCharacterRecommendationRecord( HomeAiCharacterRecommendationRecord(
characterId = 10L, characterId = 10L,
@@ -289,7 +315,13 @@ class HomeRecommendationQueryServiceTest {
@DisplayName("AI 캐릭터 추천은 fallback 후에도 스냅샷이 없으면 빈 배열을 반환한다") @DisplayName("AI 캐릭터 추천은 fallback 후에도 스냅샷이 없으면 빈 배열을 반환한다")
fun shouldReturnEmptyAiCharactersWhenFallbackDoesNotCreateSnapshots() { fun shouldReturnEmptyAiCharactersWhenFallbackDoesNotCreateSnapshots() {
val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList()) val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList())
val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) val service = HomeRecommendationQueryService(
port,
snapshotPort,
fallback,
langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
)
val characters = service.findAiCharacterRecommendations() val characters = service.findAiCharacterRecommendations()
@@ -305,7 +337,13 @@ class HomeRecommendationQueryServiceTest {
val fallback = FakeAiCharacterSnapshotFallbackPort( val fallback = FakeAiCharacterSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
) )
val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) val service = HomeRecommendationQueryService(
port,
snapshotPort,
fallback,
langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER, RecommendedSectionType.AI_CHARACTER,
LocalDateTime.of(2026, 7, 9, 14, 59, 59), LocalDateTime.of(2026, 7, 9, 14, 59, 59),
@@ -328,7 +366,13 @@ class HomeRecommendationQueryServiceTest {
val fallback = FakeAiCharacterSnapshotFallbackPort( val fallback = FakeAiCharacterSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
) )
val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) val service = HomeRecommendationQueryService(
port,
snapshotPort,
fallback,
langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
)
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER, RecommendedSectionType.AI_CHARACTER,
@@ -355,7 +399,13 @@ class HomeRecommendationQueryServiceTest {
val fallback = FakeAiCharacterSnapshotFallbackPort( val fallback = FakeAiCharacterSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
) )
val service = HomeRecommendationQueryService(port, snapshotPort, fallback, langContext = langContext) val service = HomeRecommendationQueryService(
port,
snapshotPort,
fallback,
langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
)
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER, RecommendedSectionType.AI_CHARACTER,
@@ -380,7 +430,8 @@ class HomeRecommendationQueryServiceTest {
snapshotPort, snapshotPort,
cheerCreatorSnapshotFallbackService = fallback, cheerCreatorSnapshotFallbackService = fallback,
clock = fixedClock(), clock = fixedClock(),
langContext = langContext langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
) )
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
@@ -424,7 +475,8 @@ class HomeRecommendationQueryServiceTest {
snapshotPort, snapshotPort,
cheerCreatorSnapshotFallbackService = fallback, cheerCreatorSnapshotFallbackService = fallback,
clock = fixedClock(), clock = fixedClock(),
langContext = langContext langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
) )
port.cheerCreatorDetails = listOf( port.cheerCreatorDetails = listOf(
HomeCheerCreatorRecommendationRecord( HomeCheerCreatorRecommendationRecord(
@@ -454,7 +506,8 @@ class HomeRecommendationQueryServiceTest {
snapshotPort, snapshotPort,
cheerCreatorSnapshotFallbackService = fallback, cheerCreatorSnapshotFallbackService = fallback,
clock = fixedClock(), clock = fixedClock(),
langContext = langContext langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
) )
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59) val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
@@ -490,7 +543,8 @@ class HomeRecommendationQueryServiceTest {
snapshotPort, snapshotPort,
cheerCreatorSnapshotFallbackService = fallback, cheerCreatorSnapshotFallbackService = fallback,
clock = fixedClock(), clock = fixedClock(),
langContext = langContext langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
) )
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR, RecommendedSectionType.CHEER_CREATOR,
@@ -509,7 +563,13 @@ class HomeRecommendationQueryServiceTest {
@Test @Test
@DisplayName("인기 커뮤니티 추천은 대상일 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다") @DisplayName("인기 커뮤니티 추천은 대상일 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
fun shouldFindPopularCommunitiesFromTargetDaySnapshotsWithLimitDetailsAndCreatorUniqueness() { fun shouldFindPopularCommunitiesFromTargetDaySnapshotsWithLimitDetailsAndCreatorUniqueness() {
val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock(), langContext = langContext) val service = HomeRecommendationQueryService(
port,
snapshotPort,
clock = fixedClock(),
langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
)
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY, RecommendedSectionType.POPULAR_COMMUNITY,
@@ -583,7 +643,13 @@ class HomeRecommendationQueryServiceTest {
@Test @Test
@DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다") @DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다")
fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() { fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() {
val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock(), langContext = langContext) val service = HomeRecommendationQueryService(
port,
snapshotPort,
clock = fixedClock(),
langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
)
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY, RecommendedSectionType.POPULAR_COMMUNITY,
@@ -618,6 +684,47 @@ class HomeRecommendationQueryServiceTest {
assertEquals(communities.size, communities.map { it.creatorId }.toSet().size) assertEquals(communities.size, communities.map { it.creatorId }.toSet().size)
} }
@Test
@DisplayName("인기 커뮤니티 추천은 최종 노출 게시물만 일본어 표시 본문으로 일괄 치환하고 원문 폴백을 유지한다")
fun shouldTranslateOnlyFinalPopularCommunityRecommendationsInOneJapaneseBatch() {
langContext.setLang(Lang.JA)
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
snapshotAt,
(1L..4L).map { targetId ->
snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId, 100.0 - targetId, snapshotAt)
}
)
port.popularCommunityDetails = listOf(
popularCommunity(1L, creatorId = 10L, content = "original-1"),
popularCommunity(2L, creatorId = 10L, content = "original-2"),
popularCommunity(3L, creatorId = 11L, content = "historical-original-3"),
popularCommunity(4L, creatorId = 12L, content = "original-4")
)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(1L, 3L), "ja"))
.thenReturn(
mapOf(
1L to CreatorCommunityDisplayContent("translated-1-ja", true),
3L to CreatorCommunityDisplayContent("current-original-3", false)
)
)
val translatedService = HomeRecommendationQueryService(
port,
snapshotPort,
clock = fixedClock(),
langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
)
val communities = translatedService.findPopularCommunityRecommendations(limit = 2)
assertEquals(listOf(1L, 3L), communities.map { it.communityId })
assertEquals(listOf("translated-1-ja", "current-original-3"), communities.map { it.content })
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(1L, 3L), "ja")
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
}
@Test @Test
@DisplayName("인기 커뮤니티 추천은 대상일 스냅샷이 없고 오래된 스냅샷만 있으면 fallback refresh를 호출한다") @DisplayName("인기 커뮤니티 추천은 대상일 스냅샷이 없고 오래된 스냅샷만 있으면 fallback refresh를 호출한다")
fun shouldFallbackWhenOnlyStalePopularCommunitySnapshotsExist() { fun shouldFallbackWhenOnlyStalePopularCommunitySnapshotsExist() {
@@ -629,7 +736,8 @@ class HomeRecommendationQueryServiceTest {
snapshotPort, snapshotPort,
popularCommunitySnapshotFallbackService = fallback, popularCommunitySnapshotFallbackService = fallback,
clock = fixedClock(), clock = fixedClock(),
langContext = langContext langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
) )
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59) val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
@@ -675,7 +783,8 @@ class HomeRecommendationQueryServiceTest {
snapshotPort, snapshotPort,
popularCommunitySnapshotFallbackService = fallback, popularCommunitySnapshotFallbackService = fallback,
clock = fixedClock(), clock = fixedClock(),
langContext = langContext langContext = langContext,
creatorCommunityTranslationService = creatorCommunityTranslationService
) )
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY, RecommendedSectionType.POPULAR_COMMUNITY,
@@ -1212,6 +1321,28 @@ class HomeRecommendationQueryServiceTest {
} }
} }
private fun popularCommunity(
communityId: Long,
creatorId: Long,
content: String
): HomePopularCommunityRecommendationRecord {
return HomePopularCommunityRecommendationRecord(
communityId = communityId,
creatorId = creatorId,
creatorNickname = "creator-$creatorId",
creatorProfileImage = null,
imagePath = null,
audioPath = null,
content = content,
price = 0,
createdAt = LocalDateTime.of(2026, 7, 9, 1, 0),
likeCount = 0L,
commentCount = 0L,
existOrdered = false,
isLiked = false
)
}
private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort { private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
private val snapshots = mutableListOf<RecommendationSnapshotRecord>() private val snapshots = mutableListOf<RecommendationSnapshotRecord>()