feat(recommend): 홈 추천 조회 서비스를 추가한다
This commit is contained in:
@@ -2,12 +2,26 @@ package kr.co.vividnext.sodalive.v2.recommend.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentlyActiveCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class HomeRecommendationQueryServiceTest {
|
||||
private val service = HomeRecommendationQueryService()
|
||||
private val port = FakeHomeRecommendationQueryPort()
|
||||
private val snapshotPort = FakeHomeRecommendationSnapshotPort()
|
||||
private val service = HomeRecommendationQueryService(port, snapshotPort)
|
||||
|
||||
@Test
|
||||
@DisplayName("다시듣기 테마 콘텐츠는 AUDIO가 아니라 LIVE_REPLAY 활동으로 분류한다")
|
||||
@@ -47,4 +61,398 @@ class HomeRecommendationQueryServiceTest {
|
||||
assertEquals("CHEER_CREATOR", RecommendedSectionType.CHEER_CREATOR.code)
|
||||
assertEquals("POPULAR_COMMUNITY", RecommendedSectionType.POPULAR_COMMUNITY.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 라이브 추천은 기본 20개를 최신순 조회 포트에 위임한다")
|
||||
fun shouldFindLatestLiveRecommendationsWithDefaultLimit() {
|
||||
val recommendations = service.findLiveRecommendations()
|
||||
|
||||
assertEquals(20, port.liveLimit)
|
||||
assertEquals(port.liveRecommendations, recommendations)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다")
|
||||
fun shouldFindHomeBannersWithDefaultLimit() {
|
||||
val banners = service.findHomeBanners()
|
||||
|
||||
assertEquals(20, port.bannerLimit)
|
||||
assertEquals(port.banners, banners)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 활동 크리에이터는 기본 10개를 최신 활동 조회 포트에 위임한다")
|
||||
fun shouldFindRecentlyActiveCreatorsWithDefaultLimit() {
|
||||
val creators = service.findRecentlyActiveCreators()
|
||||
|
||||
assertEquals(10, port.activeCreatorLimit)
|
||||
assertEquals(port.activeCreators, creators)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 데뷔 크리에이터는 기본 10개를 기준 시각과 함께 조회 포트에 위임한다")
|
||||
fun shouldFindRecentDebutCreatorsWithDefaultLimitAndNow() {
|
||||
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
|
||||
val creators = service.findRecentDebutCreators(now)
|
||||
|
||||
assertEquals(now, port.recentDebutNow)
|
||||
assertEquals(10, port.recentDebutLimit)
|
||||
assertEquals(port.recentDebutCreators, creators)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("첫 오디오 콘텐츠는 기본 10개를 기준 시각과 함께 조회 포트에 위임한다")
|
||||
fun shouldFindFirstAudioContentsWithDefaultLimitAndNow() {
|
||||
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
|
||||
val contents = service.findFirstAudioContents(now)
|
||||
|
||||
assertEquals(now, port.firstAudioNow)
|
||||
assertEquals(10, port.firstAudioLimit)
|
||||
assertEquals(port.firstAudioContents, contents)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 추천은 최신 스냅샷 10개를 기준으로 순서를 유지해 상세를 조립한다")
|
||||
fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() {
|
||||
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
||||
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
oldSnapshotAt,
|
||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 999.0, oldSnapshotAt))
|
||||
)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
latestSnapshotAt,
|
||||
(1L..12L).map { targetId ->
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, latestSnapshotAt)
|
||||
}
|
||||
)
|
||||
port.aiCharacterDetails = listOf(
|
||||
HomeAiCharacterRecommendationRecord(
|
||||
characterId = 1L,
|
||||
name = "character-1",
|
||||
description = "description-1",
|
||||
totalChatCount = 3L,
|
||||
originalWorkTitle = "original-work"
|
||||
),
|
||||
HomeAiCharacterRecommendationRecord(
|
||||
characterId = 2L,
|
||||
name = "character-2",
|
||||
description = "description-2",
|
||||
totalChatCount = 0L,
|
||||
originalWorkTitle = null
|
||||
)
|
||||
)
|
||||
|
||||
val characters = service.findAiCharacterRecommendations()
|
||||
|
||||
assertEquals((1L..10L).toList(), port.aiCharacterDetailIds)
|
||||
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
|
||||
assertEquals("original-work", characters.first().originalWorkTitle)
|
||||
assertEquals(null, characters.last().originalWorkTitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다")
|
||||
fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() {
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.CHEER_CREATOR,
|
||||
snapshotAt,
|
||||
(1L..9L).map { targetId ->
|
||||
snapshot(RecommendedSectionType.CHEER_CREATOR, targetId, 100.0 - targetId, snapshotAt)
|
||||
}
|
||||
)
|
||||
port.cheerCreatorDetails = listOf(
|
||||
HomeCheerCreatorRecommendationRecord(
|
||||
creatorId = 1L,
|
||||
creatorNickname = "creator-1",
|
||||
creatorProfileImage = "profile-1.png"
|
||||
),
|
||||
HomeCheerCreatorRecommendationRecord(
|
||||
creatorId = 2L,
|
||||
creatorNickname = "creator-2",
|
||||
creatorProfileImage = null
|
||||
)
|
||||
)
|
||||
|
||||
val creators = service.findCheerCreatorRecommendations()
|
||||
|
||||
assertEquals((1L..8L).toList(), port.cheerCreatorDetailIds)
|
||||
assertEquals(listOf(1L, 2L), creators.map { it.creatorId })
|
||||
assertEquals(listOf("creator-1", "creator-2"), creators.map { it.creatorNickname })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
|
||||
fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() {
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
snapshotAt,
|
||||
(1L..11L).map { targetId ->
|
||||
snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId, 100.0 - targetId, snapshotAt)
|
||||
}
|
||||
)
|
||||
port.popularCommunityDetails = listOf(
|
||||
HomePopularCommunityRecommendationRecord(
|
||||
communityId = 1L,
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator-10",
|
||||
creatorProfileImage = "profile-10.png",
|
||||
content = "content-1",
|
||||
createdAt = LocalDateTime.of(2026, 5, 29, 1, 0),
|
||||
likeCount = 3L,
|
||||
commentCount = 2L
|
||||
),
|
||||
HomePopularCommunityRecommendationRecord(
|
||||
communityId = 2L,
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator-10",
|
||||
creatorProfileImage = "profile-10.png",
|
||||
content = "content-2",
|
||||
createdAt = LocalDateTime.of(2026, 5, 29, 2, 0),
|
||||
likeCount = 1L,
|
||||
commentCount = 1L
|
||||
),
|
||||
HomePopularCommunityRecommendationRecord(
|
||||
communityId = 3L,
|
||||
creatorId = 11L,
|
||||
creatorNickname = "creator-11",
|
||||
creatorProfileImage = null,
|
||||
content = "content-3",
|
||||
createdAt = LocalDateTime.of(2026, 5, 29, 3, 0),
|
||||
likeCount = 0L,
|
||||
commentCount = 0L
|
||||
)
|
||||
)
|
||||
|
||||
val communities = service.findPopularCommunityRecommendations(includeAdultCommunities = true)
|
||||
|
||||
assertEquals((1L..11L).toList(), port.popularCommunityDetailIds)
|
||||
assertEquals(true, port.popularCommunityIncludeAdultCommunities)
|
||||
assertEquals(listOf(1L, 3L), communities.map { it.communityId })
|
||||
assertEquals(listOf(10L, 11L), communities.map { it.creatorId })
|
||||
assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0), communities.first().createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다")
|
||||
fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() {
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||
snapshotAt,
|
||||
(1L..20L).map { targetId ->
|
||||
snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId, 100.0 - targetId, snapshotAt)
|
||||
}
|
||||
)
|
||||
port.popularCommunityDetails = (1L..20L).map { communityId ->
|
||||
HomePopularCommunityRecommendationRecord(
|
||||
communityId = communityId,
|
||||
creatorId = if (communityId <= 10L) 1L else communityId,
|
||||
creatorNickname = "creator-$communityId",
|
||||
creatorProfileImage = null,
|
||||
content = "content-$communityId",
|
||||
createdAt = LocalDateTime.of(2026, 5, 29, 1, 0).plusMinutes(communityId),
|
||||
likeCount = 0L,
|
||||
commentCount = 0L
|
||||
)
|
||||
}
|
||||
|
||||
val communities = service.findPopularCommunityRecommendations()
|
||||
|
||||
assertEquals(20, port.popularCommunityDetailIds.size)
|
||||
assertEquals(10, communities.size)
|
||||
assertEquals(listOf(1L) + (11L..19L).toList(), communities.map { it.communityId })
|
||||
assertEquals(communities.size, communities.map { it.creatorId }.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다")
|
||||
fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() {
|
||||
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), service.findAiCharacterRecommendations())
|
||||
assertEquals(emptyList<HomeCheerCreatorRecommendationRecord>(), service.findCheerCreatorRecommendations())
|
||||
assertEquals(emptyList<HomePopularCommunityRecommendationRecord>(), service.findPopularCommunityRecommendations())
|
||||
}
|
||||
|
||||
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
||||
var liveLimit: Int? = null
|
||||
var bannerLimit: Int? = null
|
||||
var activeCreatorLimit: Int? = null
|
||||
var recentDebutNow: LocalDateTime? = null
|
||||
var recentDebutLimit: Int? = null
|
||||
var firstAudioNow: LocalDateTime? = null
|
||||
var firstAudioLimit: Int? = null
|
||||
var aiCharacterDetailIds: List<Long> = emptyList()
|
||||
var cheerCreatorDetailIds: List<Long> = emptyList()
|
||||
var popularCommunityDetailIds: List<Long> = emptyList()
|
||||
var popularCommunityIncludeAdultCommunities: Boolean? = null
|
||||
val liveRecommendations = listOf(
|
||||
HomeLiveRecommendationRecord(
|
||||
liveRoomId = 1L,
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator",
|
||||
creatorProfileImage = "profile.png",
|
||||
title = "live",
|
||||
coverImage = "cover.png",
|
||||
beginDateTime = LocalDateTime.of(2026, 5, 31, 10, 0),
|
||||
channelName = "channel"
|
||||
)
|
||||
)
|
||||
val banners = listOf(
|
||||
HomeBannerRecommendationRecord(
|
||||
bannerId = 2L,
|
||||
type = "LINK",
|
||||
thumbnailImage = "banner.png",
|
||||
eventId = null,
|
||||
creatorId = null,
|
||||
seriesId = null,
|
||||
link = "https://example.com",
|
||||
orders = 1,
|
||||
randomTieBreaker = 0.1
|
||||
)
|
||||
)
|
||||
val activeCreators = listOf(
|
||||
RecentlyActiveCreatorRecord(
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator",
|
||||
creatorProfileImage = "profile.png",
|
||||
activityType = RecommendedActivityType.LIVE,
|
||||
activityAt = LocalDateTime.of(2026, 5, 31, 10, 0),
|
||||
targetId = null
|
||||
)
|
||||
)
|
||||
val recentDebutCreators = listOf(
|
||||
RecentDebutCreatorRecord(
|
||||
creatorId = 11L,
|
||||
creatorNickname = "debut-creator",
|
||||
creatorProfileImage = "debut-profile.png",
|
||||
debutAt = LocalDateTime.of(2026, 5, 20, 10, 0),
|
||||
score = 1.2,
|
||||
randomTieBreaker = 0.2
|
||||
)
|
||||
)
|
||||
val firstAudioContents = listOf(
|
||||
HomeFirstAudioContentRecord(
|
||||
contentId = 21L,
|
||||
creatorId = 11L,
|
||||
creatorNickname = "debut-creator",
|
||||
creatorProfileImage = "debut-profile.png",
|
||||
title = "first-audio",
|
||||
coverImage = "first-audio.png",
|
||||
releaseDate = LocalDateTime.of(2026, 5, 30, 10, 0),
|
||||
recencyScore = 100,
|
||||
randomTieBreaker = 0.3
|
||||
)
|
||||
)
|
||||
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
|
||||
var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList()
|
||||
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
|
||||
|
||||
override fun findLiveRecommendations(limit: Int): List<HomeLiveRecommendationRecord> {
|
||||
liveLimit = limit
|
||||
return liveRecommendations
|
||||
}
|
||||
|
||||
override fun findHomeBanners(limit: Int): List<HomeBannerRecommendationRecord> {
|
||||
bannerLimit = limit
|
||||
return banners
|
||||
}
|
||||
|
||||
override fun findRecentlyActiveCreators(limit: Int): List<RecentlyActiveCreatorRecord> {
|
||||
activeCreatorLimit = limit
|
||||
return activeCreators
|
||||
}
|
||||
|
||||
override fun findRecentDebutCreators(now: LocalDateTime, limit: Int): List<RecentDebutCreatorRecord> {
|
||||
recentDebutNow = now
|
||||
recentDebutLimit = limit
|
||||
return recentDebutCreators
|
||||
}
|
||||
|
||||
override fun findFirstAudioContents(now: LocalDateTime, limit: Int): List<HomeFirstAudioContentRecord> {
|
||||
firstAudioNow = now
|
||||
firstAudioLimit = limit
|
||||
return firstAudioContents
|
||||
}
|
||||
|
||||
override fun findAiCharacterSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
override fun findCheerCreatorSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
override fun findPopularCommunitySnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
override fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord> {
|
||||
aiCharacterDetailIds = characterIds
|
||||
return aiCharacterDetails
|
||||
}
|
||||
|
||||
override fun findCheerCreatorRecommendationDetails(creatorIds: List<Long>): List<HomeCheerCreatorRecommendationRecord> {
|
||||
cheerCreatorDetailIds = creatorIds
|
||||
return cheerCreatorDetails
|
||||
}
|
||||
|
||||
override fun findPopularCommunityRecommendationDetails(
|
||||
communityIds: List<Long>,
|
||||
includeAdultCommunities: Boolean
|
||||
): List<HomePopularCommunityRecommendationRecord> {
|
||||
popularCommunityDetailIds = communityIds
|
||||
popularCommunityIncludeAdultCommunities = includeAdultCommunities
|
||||
return popularCommunityDetails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
||||
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
|
||||
|
||||
override fun findLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> {
|
||||
val latestSnapshotAt = snapshots
|
||||
.filter { it.sectionType == sectionType }
|
||||
.maxOfOrNull { it.snapshotAt }
|
||||
|
||||
return snapshots
|
||||
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
|
||||
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
|
||||
}
|
||||
|
||||
override fun replaceSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshotAt: LocalDateTime,
|
||||
newSnapshots: List<RecommendationSnapshotRecord>
|
||||
) {
|
||||
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||
snapshots.addAll(newSnapshots)
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
sectionType: RecommendedSectionType,
|
||||
targetId: Long,
|
||||
score: Double,
|
||||
snapshotAt: LocalDateTime
|
||||
): RecommendationSnapshotRecord {
|
||||
return RecommendationSnapshotRecord(
|
||||
sectionType = sectionType,
|
||||
targetId = targetId,
|
||||
score = score,
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = targetId.toDouble() / 100
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user