diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt index 2e359e79..d507354e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt @@ -97,10 +97,7 @@ class HomeRecommendationFacade( lang = lang ) .map { it.toItem() }, - aiCharacters = queryService.findAiCharacterRecommendations( - limit = HOME_AI_CHARACTER_LIMIT, - backfillRandom = true - ).map { it.toItem() }, + aiCharacters = emptyList(), genreCreators = queryService.findGenreCreatorRecommendations( memberId = member?.id, includeAdultGenres = includeAdult, @@ -357,7 +354,6 @@ class HomeRecommendationFacade( private const val HOME_ACTIVE_CREATOR_LIMIT = 10 private const val HOME_RECENT_DEBUT_CREATOR_LIMIT = 10 private const val HOME_FIRST_AUDIO_CONTENT_LIMIT = 10 - private const val HOME_AI_CHARACTER_LIMIT = 20 private const val HOME_GENRE_CREATOR_GENRE_LIMIT = 5 private const val HOME_GENRE_CREATOR_CREATOR_LIMIT = 8 private const val HOME_CHEER_CREATOR_LIMIT = 8 diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index 069deefd..8423b51b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberKind import kr.co.vividnext.sodalive.member.MemberRepository 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.member.following.CreatorFollowing import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository @@ -29,6 +30,8 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc @@ -350,9 +353,29 @@ class HomeRecommendationControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.lives").isArray) } - @Test - @DisplayName("메인 홈 통합 조회는 AI 캐릭터를 최신 스냅샷 기준 최대 20개까지 반환한다") - fun shouldReturnTwentyAiCharactersOnHomeRecommendations() { + @ParameterizedTest + @CsvSource( + "ANONYMOUS, ko", + "ANONYMOUS, ja", + "UNVERIFIED, ko", + "UNVERIFIED, ja", + "VERIFIED, ko", + "VERIFIED, ja", + "ADMIN, ko", + "ADMIN, ja" + ) + @DisplayName("메인 홈 통합 조회는 AI 스냅샷이 있어도 모든 조회자와 KO·JA 요청에 빈 AI 배열을 반환한다") + fun shouldReturnEmptyAiCharactersOnHomeRecommendations(viewerType: String, language: String) { + val member = when (viewerType) { + "ANONYMOUS" -> null + "ADMIN" -> saveMember("home-ai-admin", MemberRole.ADMIN) + else -> saveMember("home-ai-viewer", MemberRole.USER) + } + if (viewerType == "VERIFIED") { + entityManager.persist( + Auth("verified", "19900101", "home-ai-ci", "home-ai-di", 1).apply { this.member = member } + ) + } (1..22).forEach { index -> val character = saveAiCharacter("home-ai-character-$index") saveRecommendationSnapshot(character.id!!) @@ -360,10 +383,14 @@ class HomeRecommendationControllerTest @Autowired constructor( entityManager.flush() entityManager.clear() - mockMvc.perform(get("/api/v2/home/recommendations")) + val request = get("/api/v2/home/recommendations").header("Accept-Language", language) + member?.let { request.with(user(MemberAdapter(it))) } + + mockMvc.perform(request) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.aiCharacters.length()").value(20)) + .andExpect(jsonPath("$.data.aiCharacters").isArray) + .andExpect(jsonPath("$.data.aiCharacters").isEmpty) } @Test @@ -630,7 +657,7 @@ class HomeRecommendationControllerTest @Autowired constructor( } @Test - @DisplayName("AI 캐릭터 추천은 홈 통합과 전체보기 응답에 characterId와 creatorId를 함께 노출한다") + @DisplayName("홈 통합 AI 목록은 비어 있고 전용 전체보기는 characterId와 creatorId를 함께 노출한다") fun shouldExposeCreatorIdOnAiCharacterRecommendations() { val member = saveMember("ai-character-page-viewer", MemberRole.USER) val character = saveAiCharacter("ai-character-api") @@ -640,8 +667,8 @@ class HomeRecommendationControllerTest @Autowired constructor( mockMvc.perform(get("/api/v2/home/recommendations")) .andExpect(status().isOk) - .andExpect(jsonPath("$.data.aiCharacters[0].characterId").value(character.id)) - .andExpect(jsonPath("$.data.aiCharacters[0].creatorId").value(character.creatorMember!!.id)) + .andExpect(jsonPath("$.data.aiCharacters").isArray) + .andExpect(jsonPath("$.data.aiCharacters").isEmpty) mockMvc.perform( get("/api/v2/home/recommendations/ai-characters") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt index f927b102..2c445dda 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt @@ -7,6 +7,7 @@ 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.CreatorCommunityTranslationService +import kr.co.vividnext.sodalive.v2.recommendation.application.AiCharacterSnapshotFallbackPort 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.port.out.HomeAiCharacterRecommendationRecord @@ -22,6 +23,8 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreator import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName @@ -31,10 +34,13 @@ import java.time.LocalDateTime class HomeRecommendationFacadeTest { private val queryPort = FakeHomeRecommendationQueryPort() + private val snapshotPort = EmptyRecommendationSnapshotPort() + private val aiFallbackPort = FakeAiCharacterSnapshotFallbackPort() private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java) private val queryService = HomeRecommendationQueryService( queryPort, - EmptyRecommendationSnapshotPort(), + snapshotPort, + aiCharacterSnapshotFallbackService = aiFallbackPort, creatorCommunityTranslationService = creatorCommunityTranslationService ) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) @@ -45,6 +51,22 @@ class HomeRecommendationFacadeTest { "https://cdn.test" ) + @Test + @DisplayName("홈 추천은 AI 스냅샷과 상세 및 무작위 보충이나 fallback 조회 없이 빈 AI 목록을 반환한다") + fun shouldReturnEmptyAiCharactersWithoutQueryingAiRecommendations() { + val response = facade.getHomeRecommendations(null, Lang.KO) + + assertAll( + { assertTrue(response.aiCharacters.isEmpty()) }, + { assertEquals(0, snapshotPort.aiSnapshotCalls) }, + { assertEquals(0, snapshotPort.aiSnapshotExistenceCalls) }, + { assertEquals(0, queryPort.aiSnapshotCalls) }, + { assertEquals(0, queryPort.aiDetailCalls) }, + { assertEquals(0, queryPort.aiRandomCalls) }, + { assertEquals(0, aiFallbackPort.calls) } + ) + } + @Test @DisplayName("홈 첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다") fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() { @@ -150,6 +172,9 @@ class HomeRecommendationFacadeTest { var bannerLang: Lang? = null var liveIsViewerCreator: Boolean? = null var liveEffectiveViewerGender: Gender? = null + var aiSnapshotCalls: Int = 0 + var aiDetailCalls: Int = 0 + var aiRandomCalls: Int = 0 override fun findLiveRecommendations( offset: Long, @@ -205,7 +230,10 @@ class HomeRecommendationFacadeTest { windowStart: LocalDateTime, windowEndExclusive: LocalDateTime, limit: Int - ): List = emptyList() + ): List { + aiSnapshotCalls++ + return emptyList() + } override fun findCheerCreatorSnapshots( windowStart: LocalDateTime, @@ -221,9 +249,15 @@ class HomeRecommendationFacadeTest { override fun findAiCharacterRecommendationDetails( characterIds: List - ): List = emptyList() + ): List { + aiDetailCalls++ + return emptyList() + } - override fun findRandomAiCharacterRecommendationIds(excludeCharacterIds: List, limit: Int): List = emptyList() + override fun findRandomAiCharacterRecommendationIds(excludeCharacterIds: List, limit: Int): List { + aiRandomCalls++ + return emptyList() + } override fun findCheerCreatorRecommendationDetails( creatorIds: List, @@ -248,22 +282,37 @@ class HomeRecommendationFacadeTest { } private class EmptyRecommendationSnapshotPort : RecommendationSnapshotPort { + var aiSnapshotCalls: Int = 0 + var aiSnapshotExistenceCalls: Int = 0 + override fun findLatestSnapshots( sectionType: RecommendedSectionType, offset: Long, limit: Int - ): List = emptyList() + ): List { + if (sectionType == RecommendedSectionType.AI_CHARACTER) aiSnapshotCalls++ + return emptyList() + } override fun findSnapshots( sectionType: RecommendedSectionType, snapshotAt: LocalDateTime, offset: Long, limit: Int - ): List = emptyList() + ): List { + if (sectionType == RecommendedSectionType.AI_CHARACTER) aiSnapshotCalls++ + return emptyList() + } - override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean = true + override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean { + if (sectionType == RecommendedSectionType.AI_CHARACTER) aiSnapshotExistenceCalls++ + return sectionType != RecommendedSectionType.AI_CHARACTER + } - override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean = true + override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean { + if (sectionType == RecommendedSectionType.AI_CHARACTER) aiSnapshotExistenceCalls++ + return true + } override fun replaceSnapshots( sectionType: RecommendedSectionType, @@ -271,4 +320,17 @@ class HomeRecommendationFacadeTest { newSnapshots: List ) = Unit } + + private class FakeAiCharacterSnapshotFallbackPort : AiCharacterSnapshotFallbackPort { + var calls: Int = 0 + + override fun refreshIfMissing( + offset: Long, + limit: Int, + nowUtc: LocalDateTime + ): List { + calls++ + return emptyList() + } + } }