refactor(recommendation): 추천 기능 패키지를 이동한다
This commit is contained in:
+97
@@ -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()
|
||||
}
|
||||
}
|
||||
+2097
File diff suppressed because it is too large
Load Diff
+126
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+91
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+848
@@ -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
|
||||
)
|
||||
}
|
||||
+282
@@ -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)
|
||||
}
|
||||
}
|
||||
+201
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+71
@@ -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))
|
||||
}
|
||||
}
|
||||
+114
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user