feat(recommend): 추천 스냅샷 갱신 서비스를 추가한다

This commit is contained in:
2026-05-31 00:58:17 +09:00
parent 58e59c5cb4
commit 82d935e63f
3 changed files with 276 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
package kr.co.vividnext.sodalive.v2.recommend.application
import kr.co.vividnext.sodalive.v2.recommend.adapter.out.scheduler.RecommendationSnapshotScheduler
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.scheduling.annotation.Scheduled
import java.time.LocalDateTime
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() {
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)
}
@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 scheduler = RecommendationSnapshotScheduler(service)
scheduler.refreshDailySnapshots()
assertEquals("0 0 6 * * *", scheduled.cron)
assertEquals("Asia/Seoul", scheduled.zone)
Mockito.verify(service).refreshDailySnapshots()
}
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): List<RecommendationSnapshotRecord> {
val latestSnapshotAt = snapshots
.filter { it.sectionType == sectionType }
.maxOfOrNull { it.snapshotAt }
return snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
}
override fun replaceSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord>
) {
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.addAll(newSnapshots)
}
}