fix(home-recommendation): 통합 응답의 AI 캐릭터 목록을 비운다

This commit is contained in:
2026-10-31 20:02:14 +09:00
parent f6df2e6e5c
commit 4a6b2ee9e5
3 changed files with 106 additions and 21 deletions
@@ -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
@@ -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")
@@ -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<RecommendationSnapshotRecord> = emptyList()
): List<RecommendationSnapshotRecord> {
aiSnapshotCalls++
return emptyList()
}
override fun findCheerCreatorSnapshots(
windowStart: LocalDateTime,
@@ -221,9 +249,15 @@ class HomeRecommendationFacadeTest {
override fun findAiCharacterRecommendationDetails(
characterIds: List<Long>
): List<HomeAiCharacterRecommendationRecord> = emptyList()
): List<HomeAiCharacterRecommendationRecord> {
aiDetailCalls++
return emptyList()
}
override fun findRandomAiCharacterRecommendationIds(excludeCharacterIds: List<Long>, limit: Int): List<Long> = emptyList()
override fun findRandomAiCharacterRecommendationIds(excludeCharacterIds: List<Long>, limit: Int): List<Long> {
aiRandomCalls++
return emptyList()
}
override fun findCheerCreatorRecommendationDetails(
creatorIds: List<Long>,
@@ -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<RecommendationSnapshotRecord> = emptyList()
): List<RecommendationSnapshotRecord> {
if (sectionType == RecommendedSectionType.AI_CHARACTER) aiSnapshotCalls++
return emptyList()
}
override fun findSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
): List<RecommendationSnapshotRecord> {
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<RecommendationSnapshotRecord>
) = Unit
}
private class FakeAiCharacterSnapshotFallbackPort : AiCharacterSnapshotFallbackPort {
var calls: Int = 0
override fun refreshIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime
): List<RecommendationSnapshotRecord> {
calls++
return emptyList()
}
}
}