feat(recommend): 추천 스냅샷 갱신 서비스를 추가한다
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user