refactor(recommendation): 추천 기능 패키지를 이동한다

This commit is contained in:
2026-06-08 21:21:42 +09:00
parent 890122296c
commit ae9bf0c45c
36 changed files with 123 additions and 123 deletions
@@ -0,0 +1,97 @@
package kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class CreatorContentViewHistoryPersistenceAdapterTest @Autowired constructor(
private val entityManager: EntityManager,
private val repository: CreatorContentViewHistoryRepository,
queryFactory: JPAQueryFactory
) {
private val adapter = CreatorContentViewHistoryPersistenceAdapter(repository, queryFactory)
@Test
@DisplayName("콘텐츠 조회 이력 저장용 genreId는 content_theme id를 조회한다")
fun shouldFindContentThemeIdByContentId() {
val creator = saveMember("history-theme-creator")
val theme = saveTheme("history-theme")
val content = saveAudioContent(creator, theme, isActive = true)
flushAndClear()
val themeId = adapter.findGenreIdByContentId(content.id!!)
assertEquals(theme.id, themeId)
}
@Test
@DisplayName("비활성 콘텐츠 또는 비활성 테마는 조회 이력 저장 대상 테마를 반환하지 않는다")
fun shouldNotFindThemeIdWhenContentOrThemeIsInactive() {
val creator = saveMember("history-inactive-creator")
val activeTheme = saveTheme("history-active-theme")
val inactiveTheme = saveTheme("history-inactive-theme", isActive = false)
val inactiveContent = saveAudioContent(creator, activeTheme, isActive = false)
val inactiveThemeContent = saveAudioContent(creator, inactiveTheme, isActive = true)
flushAndClear()
assertNull(adapter.findGenreIdByContentId(inactiveContent.id!!))
assertNull(adapter.findGenreIdByContentId(inactiveThemeContent.id!!))
}
private fun saveMember(nickname: String): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname
)
entityManager.persist(member)
return member
}
private fun saveTheme(name: String, isActive: Boolean = true): AudioContentTheme {
val theme = AudioContentTheme(
theme = name,
image = "$name.png",
isActive = isActive
)
entityManager.persist(theme)
return theme
}
private fun saveAudioContent(creator: Member, theme: AudioContentTheme, isActive: Boolean): AudioContent {
val content = AudioContent(
title = "content-${creator.nickname}-${theme.theme}",
detail = "detail",
languageCode = "ko",
releaseDate = LocalDateTime.of(2026, 5, 30, 10, 0)
)
content.member = creator
content.theme = theme
content.isActive = isActive
entityManager.persist(content)
return content
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}
@@ -0,0 +1,126 @@
package kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor(
private val repository: RecommendationSnapshotRepository
) {
private val adapter = RecommendationSnapshotPersistenceAdapter(repository)
@Test
fun shouldFindLatestSnapshotsByLatestSnapshotAtAndScoreDescendingTieBreakerAscending() {
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
repository.saveAll(
listOf(
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 1L, score = 999.0, snapshotAt = oldSnapshotAt),
snapshot(
RecommendedSectionType.AI_CHARACTER,
targetId = 2L,
score = 100.0,
snapshotAt = latestSnapshotAt,
randomTieBreaker = 0.9
),
snapshot(
RecommendedSectionType.AI_CHARACTER,
targetId = 3L,
score = 200.0,
snapshotAt = latestSnapshotAt,
randomTieBreaker = 0.8
),
snapshot(
RecommendedSectionType.AI_CHARACTER,
targetId = 4L,
score = 100.0,
snapshotAt = latestSnapshotAt,
randomTieBreaker = 0.1
),
snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 5L, score = 300.0, snapshotAt = latestSnapshotAt)
)
)
val latestSnapshots = adapter.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER)
assertEquals(listOf(3L, 4L, 2L), latestSnapshots.map { it.targetId })
assertEquals(listOf(latestSnapshotAt, latestSnapshotAt, latestSnapshotAt), latestSnapshots.map { it.snapshotAt })
}
@Test
fun shouldFindLatestSnapshotsWithOffsetAndLimit() {
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
repository.saveAll(
listOf(
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 1L, score = 400.0, snapshotAt = latestSnapshotAt),
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 2L, score = 300.0, snapshotAt = latestSnapshotAt),
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 3L, score = 200.0, snapshotAt = latestSnapshotAt),
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 4L, score = 100.0, snapshotAt = latestSnapshotAt)
)
)
val snapshots = adapter.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset = 1, limit = 2)
assertEquals(listOf(2L, 3L), snapshots.map { it.targetId })
}
@Test
fun shouldReplaceSnapshotsByDeletingSameSectionSnapshotAtOnly() {
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
repository.saveAll(
listOf(
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 1L, score = 100.0, snapshotAt = oldSnapshotAt),
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 2L, score = 200.0, snapshotAt = snapshotAt),
snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 3L, score = 300.0, snapshotAt = snapshotAt)
)
)
adapter.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
snapshotAt,
listOf(
RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.AI_CHARACTER,
targetId = 4L,
score = 400.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.4
)
)
)
assertEquals(listOf(4L), adapter.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER).map { it.targetId })
assertEquals(listOf(3L), adapter.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
assertEquals(3, repository.findAll().size)
}
private fun snapshot(
sectionType: RecommendedSectionType,
targetId: Long,
score: Double,
snapshotAt: LocalDateTime,
randomTieBreaker: Double = 0.1
): RecommendationSnapshot {
return RecommendationSnapshot(
sectionType = sectionType,
targetId = targetId,
score = score,
snapshotAt = snapshotAt,
randomTieBreaker = randomTieBreaker
)
}
}
@@ -0,0 +1,91 @@
package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.v2.recommendation.port.out.CreatorContentViewHistoryPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.CreatorContentViewHistoryRecord
import org.junit.jupiter.api.Assertions.assertEquals
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.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime
@ExtendWith(OutputCaptureExtension::class)
class CreatorContentViewHistoryServiceTest {
private val port = FakeCreatorContentViewHistoryPort()
private val service = CreatorContentViewHistoryService(port)
@Test
@DisplayName("인증 회원의 콘텐츠 상세 진입 시 memberId/contentId/genreId/viewedAt을 저장한다")
fun shouldRecordAuthenticatedMemberContentViewWithGenreAndViewedAt(output: CapturedOutput) {
val viewedAt = LocalDateTime.of(2026, 5, 31, 10, 0)
port.genreIdByContentId[20L] = 30L
service.recordView(memberId = 10L, contentId = 20L, viewedAt = viewedAt)
assertEquals(
listOf(
CreatorContentViewHistoryRecord(
memberId = 10L,
contentId = 20L,
genreId = 30L,
viewedAt = viewedAt
)
),
port.savedRecords
)
assertTrue(output.out.contains("event=creator_content_view_history_record_success"))
}
@Test
@DisplayName("조회 이력 저장 성공 로그는 트랜잭션 커밋 후 기록한다")
fun shouldLogRecordSuccessAfterTransactionCommit(output: CapturedOutput) {
port.genreIdByContentId[20L] = 30L
TransactionSynchronizationManager.initSynchronization()
try {
service.recordView(memberId = 10L, contentId = 20L)
assertEquals(false, output.out.contains("event=creator_content_view_history_record_success"))
TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() }
} finally {
TransactionSynchronizationManager.clearSynchronization()
}
assertTrue(output.out.contains("event=creator_content_view_history_record_success"))
}
@Test
@DisplayName("비회원 콘텐츠 상세 진입은 조회 이력을 저장하지 않는다")
fun shouldNotRecordAnonymousContentView(output: CapturedOutput) {
service.recordView(memberId = null, contentId = 20L)
assertTrue(port.savedRecords.isEmpty())
assertTrue(output.out.contains("event=creator_content_view_history_record_skipped"))
assertTrue(output.out.contains("reason=anonymous"))
}
@Test
@DisplayName("콘텐츠의 활성 장르를 찾지 못하면 조회 이력을 저장하지 않는다")
fun shouldNotRecordWhenContentGenreDoesNotExist(output: CapturedOutput) {
service.recordView(memberId = 10L, contentId = 20L)
assertTrue(port.savedRecords.isEmpty())
assertTrue(output.out.contains("event=creator_content_view_history_record_skipped"))
assertTrue(output.out.contains("reason=genre_not_found"))
}
private class FakeCreatorContentViewHistoryPort : CreatorContentViewHistoryPort {
val genreIdByContentId = mutableMapOf<Long, Long>()
val savedRecords = mutableListOf<CreatorContentViewHistoryRecord>()
override fun findGenreIdByContentId(contentId: Long): Long? {
return genreIdByContentId[contentId]
}
override fun save(record: CreatorContentViewHistoryRecord) {
savedRecords.add(record)
}
}
}
@@ -0,0 +1,848 @@
package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedActivityType
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.HomeBannerRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeCheerCreatorRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeGenreCreatorRecommendationGroup
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeGenreCreatorRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomePopularCommunityRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.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 port = FakeHomeRecommendationQueryPort()
private val snapshotPort = FakeHomeRecommendationSnapshotPort()
private val service = HomeRecommendationQueryService(port, snapshotPort)
@Test
@DisplayName("다시듣기 테마 콘텐츠는 AUDIO가 아니라 LIVE_REPLAY 활동으로 분류한다")
fun shouldClassifyLiveReplayThemeContentAsLiveReplay() {
val activityType = service.resolveAudioContentActivityType(theme = "다시듣기")
assertEquals(RecommendedActivityType.LIVE_REPLAY, activityType)
}
@Test
@DisplayName("다시듣기가 아닌 테마 콘텐츠는 AUDIO 활동으로 분류한다")
fun shouldClassifyNonLiveReplayThemeContentAsAudio() {
val activityType = service.resolveAudioContentActivityType(theme = "수면")
assertEquals(RecommendedActivityType.AUDIO, activityType)
}
@Test
@DisplayName("활동 타입 enum code는 앱 다국어 처리를 위해 영문 값과 동일하게 유지한다")
fun shouldKeepRecommendedActivityTypeCodeAsEnglishName() {
assertEquals("LIVE", RecommendedActivityType.LIVE.code)
assertEquals("AUDIO", RecommendedActivityType.AUDIO.code)
assertEquals("COMMUNITY", RecommendedActivityType.COMMUNITY.code)
assertEquals("LIVE_REPLAY", RecommendedActivityType.LIVE_REPLAY.code)
}
@Test
@DisplayName("섹션 타입 enum code는 앱 다국어 처리를 위해 영문 값과 동일하게 유지한다")
fun shouldKeepRecommendedSectionTypeCodeAsEnglishName() {
assertEquals("LIVE", RecommendedSectionType.LIVE.code)
assertEquals("BANNER", RecommendedSectionType.BANNER.code)
assertEquals("ACTIVE_CREATOR", RecommendedSectionType.ACTIVE_CREATOR.code)
assertEquals("DEBUT_CREATOR", RecommendedSectionType.DEBUT_CREATOR.code)
assertEquals("FIRST_AUDIO_CONTENT", RecommendedSectionType.FIRST_AUDIO_CONTENT.code)
assertEquals("AI_CHARACTER", RecommendedSectionType.AI_CHARACTER.code)
assertEquals("GENRE_CREATOR", RecommendedSectionType.GENRE_CREATOR.code)
assertEquals("CHEER_CREATOR", RecommendedSectionType.CHEER_CREATOR.code)
assertEquals("POPULAR_COMMUNITY", RecommendedSectionType.POPULAR_COMMUNITY.code)
}
@Test
@DisplayName("홈 라이브 추천은 기본 20개를 최신순 조회 포트에 위임한다")
fun shouldFindLatestLiveRecommendationsWithDefaultLimit() {
val recommendations = service.findLiveRecommendations(memberId = 100L)
assertEquals(20, port.liveLimit)
assertEquals(100L, port.liveMemberId)
assertEquals(port.liveRecommendations, recommendations)
}
@Test
@DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다")
fun shouldFindHomeBannersWithDefaultLimit() {
val banners = service.findHomeBanners(memberId = 100L)
assertEquals(20, port.bannerLimit)
assertEquals(100L, port.bannerMemberId)
assertEquals(port.banners, banners)
}
@Test
@DisplayName("최근 활동 크리에이터는 기본 10개를 최신 활동 조회 포트에 위임한다")
fun shouldFindRecentlyActiveCreatorsWithDefaultLimit() {
val creators = service.findRecentlyActiveCreators(memberId = 100L)
assertEquals(10, port.activeCreatorLimit)
assertEquals(100L, port.activeCreatorMemberId)
assertEquals(false, port.activeCreatorIncludeAdultActivities)
assertEquals(port.activeCreators, creators)
}
@Test
@DisplayName("최근 활동 크리에이터는 성인 활동 노출 여부를 조회 포트에 위임한다")
fun shouldFindRecentlyActiveCreatorsWithAdultVisibilityPolicy() {
val creators = service.findRecentlyActiveCreators(limit = 8, memberId = 101L, includeAdultActivities = true)
assertEquals(8, port.activeCreatorLimit)
assertEquals(101L, port.activeCreatorMemberId)
assertEquals(true, port.activeCreatorIncludeAdultActivities)
assertEquals(port.activeCreators, creators)
}
@Test
@DisplayName("최근 데뷔 크리에이터는 기본 10개를 기준 시각과 함께 조회 포트에 위임한다")
fun shouldFindRecentDebutCreatorsWithDefaultLimitAndNow() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val creators = service.findRecentDebutCreators(now, memberId = 102L)
assertEquals(now, port.recentDebutNow)
assertEquals(102L, port.recentDebutMemberId)
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, memberId = 103L)
assertEquals(now, port.firstAudioNow)
assertEquals(103L, port.firstAudioMemberId)
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",
profileImage = "profile/character-1.png",
totalChatCount = 3L,
originalWorkTitle = "original-work"
),
HomeAiCharacterRecommendationRecord(
characterId = 2L,
name = "character-2",
description = "description-2",
profileImage = null,
totalChatCount = 0L,
originalWorkTitle = null
)
)
val characters = service.findAiCharacterRecommendations()
assertEquals((1L..10L).toList(), port.aiCharacterDetailIds)
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
assertEquals("profile/character-1.png", characters.first().profileImage)
assertEquals(null, characters.last().profileImage)
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(memberId = 104L)
assertEquals((1L..9L).toList(), port.cheerCreatorDetailIds)
assertEquals(104L, port.cheerCreatorMemberId)
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",
imagePath = "community-1.png",
audioPath = "community-1.mp3",
content = "content-1",
price = 10,
createdAt = LocalDateTime.of(2026, 5, 29, 1, 0),
likeCount = 3L,
commentCount = 2L,
existOrdered = true
),
HomePopularCommunityRecommendationRecord(
communityId = 2L,
creatorId = 10L,
creatorNickname = "creator-10",
creatorProfileImage = "profile-10.png",
imagePath = null,
audioPath = null,
content = "content-2",
price = 0,
createdAt = LocalDateTime.of(2026, 5, 29, 2, 0),
likeCount = 1L,
commentCount = 1L,
existOrdered = false
),
HomePopularCommunityRecommendationRecord(
communityId = 3L,
creatorId = 11L,
creatorNickname = "creator-11",
creatorProfileImage = null,
imagePath = null,
audioPath = null,
content = "content-3",
price = 0,
createdAt = LocalDateTime.of(2026, 5, 29, 3, 0),
likeCount = 0L,
commentCount = 0L,
existOrdered = false
)
)
val communities = service.findPopularCommunityRecommendations(memberId = 105L, includeAdultCommunities = true)
assertEquals((1L..11L).toList(), port.popularCommunityDetailIds)
assertEquals(105L, port.popularCommunityMemberId)
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)
assertEquals("community-1.png", communities.first().imagePath)
assertEquals("community-1.mp3", communities.first().audioPath)
assertEquals(true, communities.first().existOrdered)
}
@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,
imagePath = null,
audioPath = null,
content = "content-$communityId",
price = 0,
createdAt = LocalDateTime.of(2026, 5, 29, 1, 0).plusMinutes(communityId),
likeCount = 0L,
commentCount = 0L,
existOrdered = false
)
}
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())
}
@Test
@DisplayName("장르 기반 크리에이터 추천은 기본 5개 장르와 장르별 8명을 조회하고 한 응답 안에서 크리에이터 중복을 제거한다")
fun shouldFindGenreCreatorRecommendationsWithDefaultLimitsAndCreatorUniqueness() {
port.genreCreatorRecommendations = listOf(
HomeGenreCreatorRecommendationGroup(
genreId = 1L,
genreName = "romance",
creators = listOf(
HomeGenreCreatorRecommendationRecord(
creatorId = 10L,
creatorNickname = "creator-10",
creatorProfileImage = null
),
HomeGenreCreatorRecommendationRecord(
creatorId = 11L,
creatorNickname = "creator-11",
creatorProfileImage = "11.png"
)
)
),
HomeGenreCreatorRecommendationGroup(
genreId = 2L,
genreName = "fantasy",
creators = listOf(
HomeGenreCreatorRecommendationRecord(
creatorId = 10L,
creatorNickname = "creator-10",
creatorProfileImage = null
),
HomeGenreCreatorRecommendationRecord(
creatorId = 12L,
creatorNickname = "creator-12",
creatorProfileImage = "12.png"
),
HomeGenreCreatorRecommendationRecord(
creatorId = 13L,
creatorNickname = "creator-13",
creatorProfileImage = "13.png"
)
)
)
)
val recommendations = service.findGenreCreatorRecommendations(
memberId = 100L,
includeAdultGenres = true
)
assertEquals(100L, port.genreCreatorMemberId)
assertEquals(true, port.genreCreatorIncludeAdultGenres)
assertEquals(5, port.genreCreatorGenreLimit)
assertEquals(8, port.genreCreatorCreatorLimit)
assertEquals(listOf(10L, 11L), recommendations[0].creators.map { it.creatorId })
assertEquals(listOf(12L, 13L), recommendations[1].creators.map { it.creatorId })
}
@Test
@DisplayName("장르 기반 크리에이터 추천은 중복 제거 후 빈 그룹을 제외하고 뒤 후보로 보충한다")
fun shouldSkipEmptyGenreCreatorGroupsAfterCreatorDeduplication() {
port.genreCreatorRecommendations = listOf(
HomeGenreCreatorRecommendationGroup(
genreId = 1L,
genreName = "theme-1",
creators = listOf(
HomeGenreCreatorRecommendationRecord(
creatorId = 10L,
creatorNickname = "creator-10",
creatorProfileImage = null
)
)
),
HomeGenreCreatorRecommendationGroup(
genreId = 2L,
genreName = "theme-2",
creators = listOf(
HomeGenreCreatorRecommendationRecord(
creatorId = 10L,
creatorNickname = "creator-10",
creatorProfileImage = null
)
)
),
HomeGenreCreatorRecommendationGroup(
genreId = 3L,
genreName = "theme-3",
creators = listOf(
HomeGenreCreatorRecommendationRecord(
creatorId = 11L,
creatorNickname = "creator-11",
creatorProfileImage = null
)
)
)
)
val recommendations = service.findGenreCreatorRecommendations(
memberId = 100L,
includeAdultGenres = false,
genreLimit = 2,
creatorLimit = 8
)
assertEquals(listOf(1L, 3L), recommendations.map { it.genreId })
assertEquals(false, recommendations.any { it.creators.isEmpty() })
}
@Test
@DisplayName("장르 기반 크리에이터 추천은 중복 제거 후 랜덤 보충 장르가 8명이 되지 않으면 뒤 후보로 대체한다")
fun shouldBackfillRandomThemesWhenCreatorDeduplicationMakesThemeUnderfilled() {
val sharedCreators = (1L..8L).map { creatorId ->
HomeGenreCreatorRecommendationRecord(
creatorId = creatorId,
creatorNickname = "shared-$creatorId",
creatorProfileImage = null
)
}
val uniqueCreators = (101L..108L).map { creatorId ->
HomeGenreCreatorRecommendationRecord(
creatorId = creatorId,
creatorNickname = "unique-$creatorId",
creatorProfileImage = null
)
}
port.genreCreatorRecommendations = listOf(
HomeGenreCreatorRecommendationGroup(
genreId = 1L,
genreName = "first-random-theme",
creators = sharedCreators
),
HomeGenreCreatorRecommendationGroup(
genreId = 2L,
genreName = "underfilled-after-dedup-theme",
creators = sharedCreators
),
HomeGenreCreatorRecommendationGroup(
genreId = 3L,
genreName = "backfill-random-theme",
creators = uniqueCreators
)
)
val recommendations = service.findGenreCreatorRecommendations(
memberId = null,
includeAdultGenres = false,
genreLimit = 2,
creatorLimit = 8
)
assertEquals(listOf(1L, 3L), recommendations.map { it.genreId })
assertEquals(true, recommendations.all { it.creators.size == 8 })
}
@Test
@DisplayName("장르 기반 크리에이터 추천은 보류된 부분 후보의 크리에이터를 뒤 충분 후보에서 중복 제거하지 않는다")
fun shouldNotConsumeCreatorsFromDeferredPartialFallbackCandidates() {
val firstCreators = (1L..8L).map { creatorId ->
HomeGenreCreatorRecommendationRecord(
creatorId = creatorId,
creatorNickname = "first-$creatorId",
creatorProfileImage = null
)
}
val partialUniqueCreator = HomeGenreCreatorRecommendationRecord(
creatorId = 101L,
creatorNickname = "partial-unique",
creatorProfileImage = null
)
val fullBackfillCreators = (101L..108L).map { creatorId ->
HomeGenreCreatorRecommendationRecord(
creatorId = creatorId,
creatorNickname = "full-$creatorId",
creatorProfileImage = null
)
}
port.genreCreatorRecommendations = listOf(
HomeGenreCreatorRecommendationGroup(
genreId = 1L,
genreName = "first-random-theme",
creators = firstCreators
),
HomeGenreCreatorRecommendationGroup(
genreId = 2L,
genreName = "deferred-partial-theme",
creators = firstCreators.take(7) + partialUniqueCreator
),
HomeGenreCreatorRecommendationGroup(
genreId = 3L,
genreName = "full-backfill-theme",
creators = fullBackfillCreators
)
)
val recommendations = service.findGenreCreatorRecommendations(
memberId = null,
includeAdultGenres = false,
genreLimit = 2,
creatorLimit = 8
)
assertEquals(listOf(1L, 3L), recommendations.map { it.genreId })
assertEquals((101L..108L).toList(), recommendations[1].creators.map { it.creatorId })
}
@Test
@DisplayName("장르 기반 크리에이터 추천은 장르의 추천 가능 크리에이터가 8명 미만이면 가능한 만큼 응답한다")
fun shouldReturnAvailableCreatorsWhenGenreCreatorCountIsUnderLimit() {
val availableCreators = (1L..7L).map { creatorId ->
HomeGenreCreatorRecommendationRecord(
creatorId = creatorId,
creatorNickname = "available-$creatorId",
creatorProfileImage = null
)
}
port.genreCreatorRecommendations = listOf(
HomeGenreCreatorRecommendationGroup(
genreId = 1L,
genreName = "partial-theme",
creators = availableCreators
)
)
val recommendations = service.findGenreCreatorRecommendations(
memberId = 100L,
includeAdultGenres = false,
genreLimit = 5,
creatorLimit = 8
)
assertEquals(listOf(1L), recommendations.map { it.genreId })
assertEquals((1L..7L).toList(), recommendations.single().creators.map { it.creatorId })
}
@Test
@DisplayName("장르 기반 크리에이터 추천은 조회 이력 장르는 중복 제거 후 8명 미만이어도 유지한다")
fun shouldKeepViewedThemeWhenCreatorDeduplicationMakesThemeUnderfilled() {
val sharedCreators = (1L..8L).map { creatorId ->
HomeGenreCreatorRecommendationRecord(
creatorId = creatorId,
creatorNickname = "shared-$creatorId",
creatorProfileImage = null
)
}
val uniqueCreators = (101L..108L).map { creatorId ->
HomeGenreCreatorRecommendationRecord(
creatorId = creatorId,
creatorNickname = "unique-$creatorId",
creatorProfileImage = null
)
}
port.genreCreatorRecommendations = listOf(
HomeGenreCreatorRecommendationGroup(
genreId = 1L,
genreName = "first-random-theme",
creators = sharedCreators
),
HomeGenreCreatorRecommendationGroup(
genreId = 2L,
genreName = "viewed-theme",
creators = sharedCreators + uniqueCreators.first(),
isViewedTheme = true
),
HomeGenreCreatorRecommendationGroup(
genreId = 3L,
genreName = "backfill-random-theme",
creators = uniqueCreators
)
)
val recommendations = service.findGenreCreatorRecommendations(
memberId = 100L,
includeAdultGenres = false,
genreLimit = 2,
creatorLimit = 8
)
assertEquals(listOf(1L, 2L), recommendations.map { it.genreId })
assertEquals(1, recommendations[1].creators.size)
}
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
var liveLimit: Int? = null
var liveOffset: Int? = null
var liveMemberId: Long? = null
var liveIncludeAdultLives: Boolean? = null
var bannerLimit: Int? = null
var bannerMemberId: Long? = null
var activeCreatorLimit: Int? = null
var activeCreatorMemberId: Long? = null
var activeCreatorIncludeAdultActivities: Boolean? = null
var recentDebutNow: LocalDateTime? = null
var recentDebutLimit: Int? = null
var recentDebutOffset: Int? = null
var recentDebutMemberId: Long? = null
var recentDebutIncludeAdultContents: Boolean? = null
var firstAudioNow: LocalDateTime? = null
var firstAudioLimit: Int? = null
var firstAudioOffset: Int? = null
var firstAudioMemberId: Long? = null
var firstAudioIncludeAdultContents: Boolean? = null
var aiCharacterDetailIds: List<Long> = emptyList()
var cheerCreatorDetailIds: List<Long> = emptyList()
var popularCommunityDetailIds: List<Long> = emptyList()
var popularCommunityIncludeAdultCommunities: Boolean? = null
var genreCreatorMemberId: Long? = null
var genreCreatorIncludeAdultGenres: Boolean? = null
var genreCreatorGenreLimit: Int? = null
var genreCreatorCreatorLimit: Int? = null
val liveRecommendations = listOf(
HomeLiveRecommendationRecord(
liveRoomId = 1L,
creatorNickname = "creator",
creatorProfileImage = "profile.png"
)
)
val banners = listOf(
HomeBannerRecommendationRecord(
thumbnailImage = "banner.png",
eventId = null,
eventThumbnailImage = null,
eventDetailImage = null,
eventLink = null,
creatorId = null,
seriesId = null,
link = "https://example.com"
)
)
val activeCreators = listOf(
RecentlyActiveCreatorRecord(
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"
)
)
val firstAudioContents = listOf(
HomeFirstAudioContentRecord(
contentId = 21L,
creatorId = 11L,
creatorNickname = "debut-creator",
creatorProfileImage = "debut-profile.png",
title = "first-audio",
price = 10,
coverImage = "first-audio.png",
isPointAvailable = true
)
)
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList()
var cheerCreatorMemberId: Long? = null
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
var popularCommunityMemberId: Long? = null
var genreCreatorRecommendations: List<HomeGenreCreatorRecommendationGroup> = emptyList()
override fun findLiveRecommendations(
offset: Int,
limit: Int,
memberId: Long?,
includeAdultLives: Boolean
): List<HomeLiveRecommendationRecord> {
liveOffset = offset
liveLimit = limit
liveMemberId = memberId
liveIncludeAdultLives = includeAdultLives
return liveRecommendations
}
override fun findHomeBanners(limit: Int, memberId: Long?): List<HomeBannerRecommendationRecord> {
bannerLimit = limit
bannerMemberId = memberId
return banners
}
override fun findRecentlyActiveCreators(
limit: Int,
memberId: Long?,
includeAdultActivities: Boolean
): List<RecentlyActiveCreatorRecord> {
activeCreatorLimit = limit
activeCreatorMemberId = memberId
activeCreatorIncludeAdultActivities = includeAdultActivities
return activeCreators
}
override fun findRecentDebutCreators(
now: LocalDateTime,
offset: Int,
limit: Int,
memberId: Long?,
includeAdultContents: Boolean
): List<RecentDebutCreatorRecord> {
recentDebutNow = now
recentDebutOffset = offset
recentDebutLimit = limit
recentDebutMemberId = memberId
recentDebutIncludeAdultContents = includeAdultContents
return recentDebutCreators
}
override fun findFirstAudioContents(
now: LocalDateTime,
offset: Int,
limit: Int,
memberId: Long?,
includeAdultContents: Boolean
): List<HomeFirstAudioContentRecord> {
firstAudioNow = now
firstAudioOffset = offset
firstAudioLimit = limit
firstAudioMemberId = memberId
firstAudioIncludeAdultContents = includeAdultContents
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>,
memberId: Long?
): List<HomeCheerCreatorRecommendationRecord> {
cheerCreatorDetailIds = creatorIds
cheerCreatorMemberId = memberId
return cheerCreatorDetails
}
override fun findPopularCommunityRecommendationDetails(
communityIds: List<Long>,
memberId: Long?,
includeAdultCommunities: Boolean
): List<HomePopularCommunityRecommendationRecord> {
popularCommunityDetailIds = communityIds
popularCommunityMemberId = memberId
popularCommunityIncludeAdultCommunities = includeAdultCommunities
return popularCommunityDetails
}
override fun findGenreCreatorRecommendations(
memberId: Long?,
includeAdultGenres: Boolean,
genreLimit: Int,
creatorLimit: Int
): List<HomeGenreCreatorRecommendationGroup> {
genreCreatorMemberId = memberId
genreCreatorIncludeAdultGenres = includeAdultGenres
genreCreatorGenreLimit = genreLimit
genreCreatorCreatorLimit = creatorLimit
return genreCreatorRecommendations
}
}
}
private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
override fun findLatestSnapshots(
sectionType: RecommendedSectionType,
offset: Int,
limit: Int
): List<RecommendationSnapshotRecord> {
val latestSnapshotAt = snapshots
.filter { it.sectionType == sectionType }
.maxOfOrNull { it.snapshotAt }
val all = snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0 && limit == Int.MAX_VALUE) return all
return all.drop(offset).take(limit)
}
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
)
}
@@ -0,0 +1,282 @@
package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.scheduler.RecommendationSnapshotScheduler
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito
import org.redisson.api.RLock
import org.redisson.api.RedissonClient
import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime
import java.util.concurrent.TimeUnit
@ExtendWith(OutputCaptureExtension::class)
class RecommendationSnapshotRefreshServiceTest {
@Test
@DisplayName("섹션의 최신 기준 시각 스냅샷만 반환하고 없으면 빈 배열을 반환한다")
fun shouldReadOnlyLatestSnapshotsOrEmptyList() {
val snapshotPort = FakeRecommendationSnapshotPort()
val service = service(snapshotPort = snapshotPort)
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, targetId = 1L, score = 10.0, snapshotAt = oldSnapshotAt))
)
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
latestSnapshotAt,
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 2L, score = 20.0, snapshotAt = latestSnapshotAt))
)
val latestSnapshots = service.getLatestSnapshots(RecommendedSectionType.AI_CHARACTER)
val emptySnapshots = service.getLatestSnapshots(RecommendedSectionType.CHEER_CREATOR)
assertEquals(listOf(2L), latestSnapshots.map { it.targetId })
assertEquals(emptyList<RecommendationSnapshotRecord>(), emptySnapshots)
}
@Test
@DisplayName("일 스냅샷 갱신은 전날 23시 59분 59초 기준으로 DB 점수 계산 스냅샷을 저장한다")
fun shouldRefreshDailySnapshotsWithPreviousDayEndSnapshotAt(output: CapturedOutput) {
val snapshotPort = FakeRecommendationSnapshotPort()
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn(
listOf(
RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.AI_CHARACTER,
targetId = 11L,
score = 78.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.1
)
)
)
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(
listOf(
RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.CHEER_CREATOR,
targetId = 22L,
score = 792.22,
snapshotAt = snapshotAt,
randomTieBreaker = 0.2
)
)
)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(
listOf(
RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
targetId = 33L,
score = 40.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.3
)
)
)
service.refreshDailySnapshots(now)
val aiSnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER)
val cheerSnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR)
val communitySnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY)
assertEquals(snapshotAt, aiSnapshots.single().snapshotAt)
assertEquals(11L, aiSnapshots.single().targetId)
assertEquals(78.0, aiSnapshots.single().score, 0.0001)
assertEquals(0.1, aiSnapshots.single().randomTieBreaker, 0.0001)
assertEquals(22L, cheerSnapshots.single().targetId)
assertEquals(792.22, cheerSnapshots.single().score, 0.0001)
assertEquals(33L, communitySnapshots.single().targetId)
assertEquals(40.0, communitySnapshots.single().score, 0.0001)
Mockito.verify(queryPort).findAiCharacterSnapshots(windowStart, snapshotAt, 20)
Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 16)
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20)
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
assertEquals(true, output.out.contains("aiCharacterCount=1"))
}
@Test
@DisplayName("일 스냅샷 갱신 성공 로그는 트랜잭션 커밋 후 기록한다")
fun shouldLogSnapshotRefreshSuccessAfterTransactionCommit(output: CapturedOutput) {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(queryPort = queryPort)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList())
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
TransactionSynchronizationManager.initSynchronization()
try {
service.refreshDailySnapshots(now)
assertEquals(false, output.out.contains("event=recommendation_snapshot_refresh_success"))
TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() }
} finally {
TransactionSynchronizationManager.clearSynchronization()
}
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
}
@Test
@DisplayName("일 스냅샷 갱신은 DB에서 최종 점수순으로 제한된 결과만 저장한다")
fun shouldStoreDbScoredSnapshotResultsWithoutServiceSideCandidateLimit() {
val snapshotPort = FakeRecommendationSnapshotPort()
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn(
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = snapshotAt))
)
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt))
)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 225L, score = 225.0, snapshotAt = snapshotAt))
)
service.refreshDailySnapshots(now)
assertEquals(listOf(25L), snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER).map { it.targetId })
assertEquals(listOf(120L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId })
}
@Test
@DisplayName("추천 스냅샷 스케줄러는 매일 06:00 KST cron으로 갱신 서비스를 호출한다")
fun shouldScheduleDailySnapshotRefreshAtKstSix() {
val scheduled = RecommendationSnapshotScheduler::class.java
.getDeclaredMethod("refreshDailySnapshots")
.getAnnotation(Scheduled::class.java)
val service = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock("lock:recommendation-snapshot-refresh")).thenReturn(lock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val scheduler = RecommendationSnapshotScheduler(service, redissonClient)
scheduler.refreshDailySnapshots()
assertEquals("0 0 6 * * *", scheduled.cron)
assertEquals("Asia/Seoul", scheduled.zone)
Mockito.verify(service).refreshDailySnapshots()
}
@Test
@DisplayName("추천 스냅샷 스케줄러는 Redisson lock을 획득한 인스턴스만 갱신을 실행한다")
fun shouldRefreshDailySnapshotsOnlyWhenRedissonLockAcquired() {
val service = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock("lock:recommendation-snapshot-refresh")).thenReturn(lock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val scheduler = RecommendationSnapshotScheduler(service, redissonClient)
scheduler.refreshDailySnapshots()
Mockito.verify(redissonClient).getLock("lock:recommendation-snapshot-refresh")
Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.verify(service).refreshDailySnapshots()
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("추천 스냅샷 스케줄러는 Redisson lock 획득 실패 시 갱신을 건너뛴다")
fun shouldSkipDailySnapshotRefreshWhenRedissonLockNotAcquired() {
val service = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock("lock:recommendation-snapshot-refresh")).thenReturn(lock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(false)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
val scheduler = RecommendationSnapshotScheduler(service, redissonClient)
scheduler.refreshDailySnapshots()
Mockito.verify(redissonClient).getLock("lock:recommendation-snapshot-refresh")
Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.verify(service, Mockito.never()).refreshDailySnapshots()
Mockito.verify(lock, Mockito.never()).unlock()
}
private fun service(
snapshotPort: RecommendationSnapshotPort = FakeRecommendationSnapshotPort(),
queryPort: HomeRecommendationQueryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
): RecommendationSnapshotRefreshService {
return RecommendationSnapshotRefreshService(
snapshotPort = snapshotPort,
queryPort = queryPort
)
}
private fun snapshot(
sectionType: RecommendedSectionType,
targetId: Long,
score: Double,
snapshotAt: LocalDateTime
): RecommendationSnapshotRecord {
return RecommendationSnapshotRecord(
sectionType = sectionType,
targetId = targetId,
score = score,
snapshotAt = snapshotAt,
randomTieBreaker = 0.1
)
}
}
private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
override fun findLatestSnapshots(
sectionType: RecommendedSectionType,
offset: Int,
limit: Int
): List<RecommendationSnapshotRecord> {
val latestSnapshotAt = snapshots
.filter { it.sectionType == sectionType }
.maxOfOrNull { it.snapshotAt }
val all = snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0 && limit == Int.MAX_VALUE) return all
return all.drop(offset).take(limit)
}
override fun replaceSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord>
) {
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.addAll(newSnapshots)
}
}
@@ -0,0 +1,201 @@
package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertThrows
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.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronizationManager
import javax.persistence.EntityManager
@SpringBootTest
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@ExtendWith(OutputCaptureExtension::class)
class RecommendedCreatorFollowServiceTest @Autowired constructor(
private val service: RecommendedCreatorFollowService,
private val memberRepository: MemberRepository,
private val creatorFollowingRepository: CreatorFollowingRepository,
private val entityManager: EntityManager
) {
@Test
@DisplayName("신규 크리에이터만 팔로우 저장하고 이미 팔로우/본인 id는 서버 내부에서 제외한다")
fun shouldFollowOnlyNewCreatorsAndSkipExistingAndSelf(output: CapturedOutput) {
val member = saveMember("viewer", MemberRole.USER)
val newCreator = saveMember("new-creator", MemberRole.CREATOR)
val followedCreator = saveMember("followed-creator", MemberRole.CREATOR)
saveFollowing(member = member, creator = followedCreator)
entityManager.flush()
entityManager.clear()
val beforeCount = TransactionSynchronizationManager.getSynchronizations().size
service.followCreators(
member = member,
creatorIds = listOf(newCreator.id!!, followedCreator.id!!, member.id!!)
)
entityManager.flush()
entityManager.clear()
TransactionSynchronizationManager.getSynchronizations().drop(beforeCount).forEach { it.afterCommit() }
assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(newCreator.id!!, member.id!!))
assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(followedCreator.id!!, member.id!!))
assertEquals(2, creatorFollowingRepository.findAll().size)
assertTrue(output.out.contains("event=recommended_creator_follow_success"))
assertTrue(output.out.contains("requestedCount=3"))
assertTrue(output.out.contains("savedCount=1"))
}
@Test
@DisplayName("추천 크리에이터 동시 팔로우 성공 로그는 트랜잭션 커밋 후 기록한다")
fun shouldLogFollowSuccessAfterTransactionCommit(output: CapturedOutput) {
val member = saveMember("after-commit-viewer", MemberRole.USER)
val creator = saveMember("after-commit-creator", MemberRole.CREATOR)
entityManager.flush()
entityManager.clear()
val beforeCount = TransactionSynchronizationManager.getSynchronizations().size
service.followCreators(member = member, creatorIds = listOf(creator.id!!))
assertEquals(false, output.out.contains("event=recommended_creator_follow_success"))
TransactionSynchronizationManager.getSynchronizations().drop(beforeCount).forEach { it.afterCommit() }
assertTrue(output.out.contains("event=recommended_creator_follow_success"))
}
@Test
@DisplayName("비활성 팔로우 이력이 있으면 신규 row를 만들지 않고 다시 활성화한다")
fun shouldReactivateInactiveFollowingWithoutCreatingDuplicateRow() {
val member = saveMember("viewer", MemberRole.USER)
val creator = saveMember("reactivate-creator", MemberRole.CREATOR)
val inactiveFollowing = saveFollowing(member = member, creator = creator).apply {
isNotify = false
isActive = false
}
entityManager.flush()
entityManager.clear()
service.followCreators(member = member, creatorIds = listOf(creator.id!!))
entityManager.flush()
entityManager.clear()
val reactivatedFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(creator.id!!, member.id!!)
assertNotNull(reactivatedFollowing)
assertEquals(inactiveFollowing.id, reactivatedFollowing!!.id)
assertTrue(reactivatedFollowing.isNotify)
assertTrue(reactivatedFollowing.isActive)
assertEquals(1, creatorFollowingRepository.findAll().size)
}
@Test
@DisplayName("이미 활성 팔로우 중이면 알림 설정을 바꾸지 않고 그대로 둔다")
fun shouldKeepActiveExistingFollowingNotificationSetting() {
val member = saveMember("viewer", MemberRole.USER)
val creator = saveMember("active-creator", MemberRole.CREATOR)
val existingFollowing = saveFollowing(member = member, creator = creator).apply {
isNotify = false
isActive = true
}
entityManager.flush()
entityManager.clear()
service.followCreators(member = member, creatorIds = listOf(creator.id!!))
entityManager.flush()
entityManager.clear()
val unchangedFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(creator.id!!, member.id!!)
assertNotNull(unchangedFollowing)
assertEquals(existingFollowing.id, unchangedFollowing!!.id)
assertFalse(unchangedFollowing.isNotify)
assertTrue(unchangedFollowing.isActive)
assertEquals(1, creatorFollowingRepository.findAll().size)
}
@Test
@DisplayName("같은 회원과 크리에이터 팔로우 row는 중복 저장할 수 없다")
fun shouldRejectDuplicateFollowingRowsForSameMemberAndCreator() {
val member = saveMember("viewer", MemberRole.USER)
val creator = saveMember("duplicate-creator", MemberRole.CREATOR)
saveFollowing(member = member, creator = creator)
entityManager.flush()
entityManager.clear()
val exception = assertThrows(DataIntegrityViolationException::class.java) {
saveFollowing(member = member, creator = creator)
entityManager.flush()
}
assertNotNull(exception)
}
@Test
@DisplayName("존재하지 않는 id가 하나라도 포함되면 전체 실패하고 신규 저장하지 않는다")
fun shouldFailAllAndSaveNothingWhenAnyCreatorIdDoesNotExist(output: CapturedOutput) {
val member = saveMember("viewer", MemberRole.USER)
val validCreator = saveMember("valid-creator", MemberRole.CREATOR)
entityManager.flush()
entityManager.clear()
val exception = assertThrows(SodaException::class.java) {
service.followCreators(member = member, creatorIds = listOf(validCreator.id!!, 999_999L))
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
assertEquals(0, creatorFollowingRepository.findAll().size)
assertTrue(output.out.contains("event=recommended_creator_follow_failure"))
}
@Test
@DisplayName("크리에이터가 아닌 회원 id가 하나라도 포함되면 전체 실패하고 신규 저장하지 않는다")
fun shouldFailAllAndSaveNothingWhenAnyMemberIdIsNotCreator() {
val member = saveMember("viewer", MemberRole.USER)
val validCreator = saveMember("valid-creator", MemberRole.CREATOR)
val nonCreator = saveMember("non-creator", MemberRole.USER)
entityManager.flush()
entityManager.clear()
val exception = assertThrows(SodaException::class.java) {
service.followCreators(member = member, creatorIds = listOf(validCreator.id!!, nonCreator.id!!))
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
assertEquals(0, creatorFollowingRepository.findAll().size)
}
private fun saveMember(seed: String, role: MemberRole): Member {
return memberRepository.saveAndFlush(
Member(
email = "$seed@test.com",
password = "password",
nickname = seed,
role = role
)
)
}
private fun saveFollowing(member: Member, creator: Member): CreatorFollowing {
return creatorFollowingRepository.saveAndFlush(
CreatorFollowing().apply {
this.member = member
this.creator = creator
}
)
}
}
@@ -0,0 +1,71 @@
package kr.co.vividnext.sodalive.v2.recommendation.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class CreatorDebutPolicyTest {
private val policy = CreatorDebutPolicy()
@Test
@DisplayName("첫 공개 콘텐츠만 있으면 콘텐츠 공개일을 데뷔일로 선택한다")
fun shouldResolveDebutAtFromFirstContentOnly() {
val firstContentPublishedAt = LocalDateTime.of(2026, 5, 1, 10, 0)
val debutAt = policy.resolveDebutAt(firstContentPublishedAt, firstLiveAt = null)
assertEquals(firstContentPublishedAt, debutAt)
}
@Test
@DisplayName("첫 라이브만 있으면 라이브 일시를 데뷔일로 선택한다")
fun shouldResolveDebutAtFromFirstLiveOnly() {
val firstLiveAt = LocalDateTime.of(2026, 5, 2, 10, 0)
val debutAt = policy.resolveDebutAt(firstContentPublishedAt = null, firstLiveAt)
assertEquals(firstLiveAt, debutAt)
}
@Test
@DisplayName("첫 공개 콘텐츠와 첫 라이브가 모두 있으면 빠른 일시를 데뷔일로 선택한다")
fun shouldResolveEarliestDebutAtWhenBothExist() {
val firstContentPublishedAt = LocalDateTime.of(2026, 5, 3, 10, 0)
val firstLiveAt = LocalDateTime.of(2026, 5, 2, 10, 0)
val debutAt = policy.resolveDebutAt(firstContentPublishedAt, firstLiveAt)
assertEquals(firstLiveAt, debutAt)
}
@Test
@DisplayName("첫 공개 콘텐츠와 첫 라이브가 모두 없으면 데뷔일은 null이다")
fun shouldReturnNullWhenDebutSourcesDoNotExist() {
val debutAt = policy.resolveDebutAt(firstContentPublishedAt = null, firstLiveAt = null)
assertNull(debutAt)
}
@Test
@DisplayName("데뷔 후 30일 이내이면 신규 크리에이터로 판정한다")
fun shouldReturnTrueWhenCreatorIsWithinThirtyDaysFromDebut() {
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
assertTrue(policy.isNewCreator(now.minusDays(0), now))
assertTrue(policy.isNewCreator(now.minusDays(30), now))
}
@Test
@DisplayName("데뷔 후 30일이 지났거나 데뷔일이 없으면 신규 크리에이터가 아니다")
fun shouldReturnFalseWhenCreatorIsNotWithinThirtyDaysFromDebut() {
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
assertFalse(policy.isNewCreator(now.minusDays(31), now))
assertFalse(policy.isNewCreator(now.plusDays(1), now))
assertFalse(policy.isNewCreator(debutAt = null, now))
}
}
@@ -0,0 +1,114 @@
package kr.co.vividnext.sodalive.v2.recommendation.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class RecommendationScorePolicyTest {
private val policy = RecommendationScorePolicy()
@Test
@DisplayName("데뷔일 기준 신규 부스트는 10일/20일/30일 구간을 적용한다")
fun shouldApplyCreatorNewBoostByDebutDays() {
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
assertEquals(10L, RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT)
assertEquals(20L, RecommendationScoreSpec.NEW_BOOST_20_DAY_LIMIT)
assertEquals(30L, RecommendationScoreSpec.NEW_BOOST_30_DAY_LIMIT)
assertEquals(1.5, policy.calculateCreatorNewBoost(now.minusDays(10), now), 0.0001)
assertEquals(1.3, policy.calculateCreatorNewBoost(now.minusDays(20), now), 0.0001)
assertEquals(1.2, policy.calculateCreatorNewBoost(now.minusDays(30), now), 0.0001)
assertEquals(1.0, policy.calculateCreatorNewBoost(now.minusDays(31), now), 0.0001)
}
@Test
@DisplayName("AI 캐릭터 생성일 기준 신규 부스트는 10일/20일/30일 구간을 적용한다")
fun shouldApplyAiCharacterNewBoostByCreatedDays() {
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
assertEquals(1.5, policy.calculateAiCharacterNewBoost(now.minusDays(10), now), 0.0001)
assertEquals(1.3, policy.calculateAiCharacterNewBoost(now.minusDays(20), now), 0.0001)
assertEquals(1.2, policy.calculateAiCharacterNewBoost(now.minusDays(30), now), 0.0001)
assertEquals(1.0, policy.calculateAiCharacterNewBoost(now.minusDays(31), now), 0.0001)
}
@Test
@DisplayName("최근 데뷔 크리에이터 추천 점수는 PRD 가중치와 신규 부스트를 적용한다")
fun shouldCalculateDebutCreatorScore() {
assertEquals(0.35, RecommendationScoreSpec.DEBUT_FOLLOW_INCREASE_WEIGHT, 0.0001)
assertEquals(0.3, RecommendationScoreSpec.DEBUT_CONTENT_ACTIVITY_WEIGHT, 0.0001)
assertEquals(0.2, RecommendationScoreSpec.DEBUT_COMMUNICATION_WEIGHT, 0.0001)
val score = policy.calculateDebutCreatorScore(
followIncrease = 10,
contentActivityScore = 20,
communicationScore = 30,
newBoost = 1.5
)
assertEquals(23.25, score, 0.0001)
}
@Test
@DisplayName("AI 채팅 추천 점수는 이번 스프린트에서 팔로우 증가량을 제외한다")
fun shouldCalculateAiChatScore() {
assertEquals(0.45, RecommendationScoreSpec.AI_RECENT_CHAT_WEIGHT, 0.0001)
assertEquals(0.35, RecommendationScoreSpec.AI_RECENT_ACTIVE_USER_WEIGHT, 0.0001)
val score = policy.calculateAiChatScore(
recentChatCount = 100,
recentActiveUserCount = 20,
newBoost = 1.3
)
assertEquals(67.6, score, 0.0001)
}
@Test
@DisplayName("최근 응원 추천 점수는 후원 금액, 팬 Talk 수, 후원 수에 가중치를 적용한다")
fun shouldCalculateCheerScore() {
assertEquals(0.6, RecommendationScoreSpec.CHEER_DONATION_AMOUNT_WEIGHT, 0.0001)
assertEquals(0.3, RecommendationScoreSpec.CHEER_FAN_TALK_WEIGHT, 0.0001)
assertEquals(0.1, RecommendationScoreSpec.CHEER_DONATION_COUNT_WEIGHT, 0.0001)
val score = policy.calculateCheerScore(
donationAmount = 1000,
fanTalkCount = 20,
donationCount = 10,
newBoost = 1.2
)
assertEquals(728.4, score, 0.0001)
}
@Test
@DisplayName("인기 커뮤니티 점수는 좋아요 수, 댓글 수, 팔로우 수에 가중치를 적용한다")
fun shouldCalculateCommunityScore() {
assertEquals(0.5, RecommendationScoreSpec.COMMUNITY_LIKE_WEIGHT, 0.0001)
assertEquals(0.5, RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT, 0.0001)
assertEquals(0.1, RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT, 0.0001)
val score = policy.calculateCommunityScore(
likeCount = 40,
commentCount = 20,
followerCount = 100,
newBoost = 1.2
)
assertEquals(48.0, score, 0.0001)
}
@Test
@DisplayName("첫 오디오 최신성 점수는 releaseDate 기준 경과일 구간을 적용한다")
fun shouldCalculateFirstAudioRecencyScore() {
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
assertEquals(100, policy.calculateFirstAudioRecencyScore(now.minusDays(3), now))
assertEquals(80, policy.calculateFirstAudioRecencyScore(now.minusDays(7), now))
assertEquals(60, policy.calculateFirstAudioRecencyScore(now.minusDays(14), now))
assertEquals(40, policy.calculateFirstAudioRecencyScore(now.minusDays(21), now))
assertEquals(20, policy.calculateFirstAudioRecencyScore(now.minusDays(30), now))
assertEquals(0, policy.calculateFirstAudioRecencyScore(now.minusDays(31), now))
}
}