feat(home): 추천 스냅샷 fallback을 공통화한다

This commit is contained in:
2026-07-10 06:06:56 +09:00
parent ce43cf2c67
commit 391acf9ee5
5 changed files with 625 additions and 488 deletions
@@ -1,351 +0,0 @@
package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
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.transaction.TransactionDefinition
import org.springframework.transaction.support.AbstractPlatformTransactionManager
import org.springframework.transaction.support.DefaultTransactionStatus
import java.time.LocalDateTime
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
@ExtendWith(OutputCaptureExtension::class)
class AiCharacterSnapshotFallbackServiceTest {
@Test
@DisplayName("AI fallback은 최신 스냅샷이 이미 있으면 lock과 refresh를 실행하지 않고 즉시 반환한다")
fun shouldReturnExistingSnapshotsWithoutLockOrRefresh() {
val snapshotPort = FakeFallbackSnapshotPort()
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, listOf(snapshot(10L)))
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshIfMissing(offset = 0, limit = 20)
assertEquals(listOf(10L), snapshots.map { it.targetId })
Mockito.verifyNoInteractions(redissonClient)
Mockito.verifyNoInteractions(refreshService)
}
@Test
@DisplayName("AI fallback은 worker thread 안에서 section lock을 잡고 공통 AI refresh 후 최신 스냅샷을 다시 조회한다")
fun shouldRefreshMissingAiSnapshotsWithSectionLock() {
val snapshotPort = FakeFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.doAnswer {
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(1L))
)
null
}.`when`(refreshService).refreshAiCharacterSnapshots(nowUtc)
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
assertEquals(listOf(1L), snapshots.map { it.targetId })
Mockito.verify(redissonClient).getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)
Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS)
Mockito.verify(refreshService).refreshAiCharacterSnapshots(nowUtc)
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("AI fallback의 snapshot 조회는 호출자 read transaction과 분리된 새 read transaction에서 실행한다")
fun shouldReadSnapshotsInRequiresNewReadOnlyTransactions() {
val snapshotPort = FakeFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val transactionManager = RecordingTransactionManager()
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.doAnswer {
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(1L))
)
null
}.`when`(refreshService).refreshAiCharacterSnapshots(nowUtc)
val service = AiCharacterSnapshotFallbackService(
snapshotPort = snapshotPort,
refreshService = refreshService,
redissonClient = redissonClient,
executor = directExecutor(),
transactionManager = transactionManager
)
val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
assertEquals(listOf(1L), snapshots.map { it.targetId })
assertEquals(
listOf(
TransactionDefinition.PROPAGATION_REQUIRES_NEW,
TransactionDefinition.PROPAGATION_REQUIRES_NEW,
TransactionDefinition.PROPAGATION_REQUIRES_NEW,
TransactionDefinition.PROPAGATION_REQUIRES_NEW
),
transactionManager.propagationBehaviors
)
assertEquals(listOf(true, true, true, true), transactionManager.readOnlyFlags)
}
@Test
@DisplayName("AI fallback은 lock 획득 실패 시 refresh를 중복 실행하지 않는다")
fun shouldSkipRefreshWhenSectionLockIsNotAcquired() {
val snapshotPort = FakeFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshIfMissing(offset = 0, limit = 20)
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
Mockito.verifyNoInteractions(refreshService)
Mockito.verify(lock, Mockito.never()).unlock()
}
@Test
@DisplayName("AI fallback은 빈 스냅샷 refresh 완료 marker가 있으면 refresh를 반복하지 않는다")
fun shouldSkipRefreshWhenEmptySnapshotMarkerExists() {
val snapshotPort = FakeFallbackSnapshotPort()
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, emptyList())
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshIfMissing(offset = 0, limit = 20)
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
Mockito.verifyNoInteractions(redissonClient)
Mockito.verifyNoInteractions(refreshService)
}
@Test
@DisplayName("AI fallback은 홈 대기 timeout 시 현재 요청은 빈 배열을 반환하고 worker는 취소하지 않아 다음 요청이 저장 결과를 읽는다")
fun shouldReturnEmptyOnTimeoutAndKeepWorkerRunning() {
val snapshotPort = FakeFallbackSnapshotPort()
val refreshStarted = CountDownLatch(1)
val allowRefreshComplete = CountDownLatch(1)
val refreshService = BlockingRefreshService(snapshotPort, refreshStarted, allowRefreshComplete)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val executor = Executors.newSingleThreadExecutor()
val service = AiCharacterSnapshotFallbackService(
snapshotPort,
refreshService,
redissonClient,
executor,
homeWaitMillis = 50
)
val first = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0))
assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS))
allowRefreshComplete.countDown()
executor.shutdown()
assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS))
val second = service.refreshIfMissing(offset = 0, limit = 20)
assertEquals(emptyList<RecommendationSnapshotRecord>(), first)
assertEquals(listOf(99L), second.map { it.targetId })
}
@Test
@DisplayName("AI fallback은 refresh 실패 시 예외를 던지지 않고 빈 배열과 실패 로그를 반환한다")
fun shouldReturnEmptyAndLogWhenRefreshFails(output: CapturedOutput) {
val snapshotPort = FakeFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.doThrow(IllegalStateException("boom")).`when`(refreshService).refreshAiCharacterSnapshots(nowUtc)
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
assertEquals(true, output.out.contains("event=ai_character_snapshot_fallback_refresh_failure"))
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("AI fallback은 최신 스냅샷이 pre-check에서 감지되면 lock과 refresh를 실행하지 않는다")
fun shouldSkipLockAndRefreshWhenLatestSnapshotAppearsBeforeWorkerStarts() {
val snapshotPort = FakeFallbackSnapshotPort().apply {
replaceOnSecondRead = listOf(snapshot(77L))
}
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshIfMissing(offset = 0, limit = 20)
assertEquals(listOf(77L), snapshots.map { it.targetId })
Mockito.verifyNoInteractions(redissonClient)
Mockito.verifyNoInteractions(refreshService)
}
@Test
@DisplayName("AI fallback은 lock 획득 후 최신 스냅샷을 재확인해 이미 생성됐으면 중복 refresh하지 않는다")
fun shouldDoubleCheckSnapshotsAfterLockBeforeRefresh() {
val snapshotPort = FakeFallbackSnapshotPort().apply {
replaceOnThirdRead = listOf(snapshot(88L))
}
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshIfMissing(offset = 0, limit = 20)
assertEquals(listOf(88L), snapshots.map { it.targetId })
Mockito.verifyNoInteractions(refreshService)
Mockito.verify(lock).unlock()
}
private fun directExecutor(): Executor = Executor { command -> command.run() }
}
private class FakeFallbackSnapshotPort : RecommendationSnapshotPort {
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
var replaceOnSecondRead: List<RecommendationSnapshotRecord>? = null
var replaceOnThirdRead: List<RecommendationSnapshotRecord>? = null
private var readCount = 0
override fun findLatestSnapshots(
sectionType: RecommendedSectionType,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> {
advanceReadScenario()
val latestSnapshotAt = snapshots.filter { it.sectionType == sectionType }.maxOfOrNull { it.snapshotAt }
return snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L }
.drop(offset.toInt())
.take(limit)
}
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
advanceReadScenario()
return snapshots.any { it.sectionType == sectionType }
}
private fun advanceReadScenario() {
readCount += 1
if (readCount == 2) {
replaceOnSecondRead?.let {
replaceSnapshots(RecommendedSectionType.AI_CHARACTER, LocalDateTime.of(2026, 7, 9, 14, 59, 59), it)
replaceOnSecondRead = null
}
}
if (readCount == 3) {
replaceOnThirdRead?.let {
replaceSnapshots(RecommendedSectionType.AI_CHARACTER, LocalDateTime.of(2026, 7, 9, 14, 59, 59), it)
replaceOnThirdRead = null
}
}
}
override fun replaceSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord>
) {
if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) {
snapshots.removeIf { it.sectionType == sectionType }
snapshots.add(snapshot(0L, snapshotAt))
return
}
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.addAll(newSnapshots)
}
}
private class BlockingRefreshService(
private val snapshotPort: RecommendationSnapshotPort,
private val refreshStarted: CountDownLatch,
private val allowRefreshComplete: CountDownLatch
) : RecommendationSnapshotRefreshService(
snapshotPort,
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
) {
override fun refreshAiCharacterSnapshots(nowUtc: LocalDateTime): Int {
refreshStarted.countDown()
allowRefreshComplete.await(1, TimeUnit.SECONDS)
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(99L))
)
return 1
}
}
private fun snapshot(
targetId: Long,
snapshotAt: LocalDateTime = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
): RecommendationSnapshotRecord {
return RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.AI_CHARACTER,
targetId = targetId,
score = 1.0,
snapshotAt = snapshotAt,
randomTieBreaker = 1.0
)
}
private class RecordingTransactionManager : AbstractPlatformTransactionManager() {
val propagationBehaviors = mutableListOf<Int>()
val readOnlyFlags = mutableListOf<Boolean>()
override fun doGetTransaction(): Any = Any()
override fun doBegin(transaction: Any, definition: TransactionDefinition) {
propagationBehaviors.add(definition.propagationBehavior)
readOnlyFlags.add(definition.isReadOnly)
}
override fun doCommit(status: DefaultTransactionStatus) {
}
override fun doRollback(status: DefaultTransactionStatus) {
}
}
@@ -0,0 +1,390 @@
package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
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.mockito.Mockito
import org.redisson.api.RLock
import org.redisson.api.RedissonClient
import java.time.LocalDateTime
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class RecommendationSnapshotFallbackServiceTest {
@Test
@DisplayName("AI fallback은 기대 스냅샷이 이미 있으면 lock과 refresh를 실행하지 않고 즉시 반환한다")
fun shouldReturnExistingAiSnapshotsWithoutLockOrRefresh() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
snapshotAt,
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 10L, snapshotAt))
)
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0))
assertEquals(listOf(10L), snapshots.map { it.targetId })
Mockito.verifyNoInteractions(redissonClient)
Mockito.verifyNoInteractions(refreshService)
}
@Test
@DisplayName("AI fallback은 section lock을 잡고 공통 refresh 후 기대 스냅샷을 다시 조회한다")
fun shouldRefreshMissingAiSnapshotsWithSectionLock() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.doAnswer {
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 1L))
)
1
}.`when`(refreshService).refreshAiCharacterSnapshots(nowUtc)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
assertEquals(listOf(1L), snapshots.map { it.targetId })
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)
Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS)
Mockito.verify(refreshService).refreshAiCharacterSnapshots(nowUtc)
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("응원 크리에이터 fallback은 section lock을 잡고 공통 refresh 후 최신 스냅샷을 다시 조회한다")
fun shouldRefreshMissingCheerCreatorSnapshotsWithSectionLock() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.doAnswer {
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 1L))
)
1
}.`when`(refreshService).refreshCheerCreatorSnapshots(nowUtc)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
assertEquals(listOf(1L), snapshots.map { it.targetId })
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)
Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS)
Mockito.verify(refreshService).refreshCheerCreatorSnapshots(nowUtc)
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("응원 크리에이터 fallback은 오래된 스냅샷만 있으면 refresh를 실행한다")
fun shouldRefreshCheerCreatorWhenOnlyStaleSnapshotsExist() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
staleSnapshotAt,
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L, staleSnapshotAt))
)
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.doAnswer {
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 1L))
)
1
}.`when`(refreshService).refreshCheerCreatorSnapshots(nowUtc)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
assertEquals(listOf(1L), snapshots.map { it.targetId })
Mockito.verify(refreshService).refreshCheerCreatorSnapshots(nowUtc)
}
@Test
@DisplayName("응원 크리에이터 fallback은 빈 스냅샷 marker가 있으면 refresh를 반복하지 않는다")
fun shouldSkipCheerCreatorRefreshWhenEmptySnapshotMarkerExists() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
emptyList()
)
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
Mockito.verifyNoInteractions(redissonClient)
Mockito.verifyNoInteractions(refreshService)
}
@Test
@DisplayName("응원 크리에이터 fallback은 lock 획득 실패 시 refresh를 중복 실행하지 않는다")
fun shouldSkipCheerCreatorRefreshWhenSectionLockIsNotAcquired() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
Mockito.verifyNoInteractions(refreshService)
Mockito.verify(lock, Mockito.never()).unlock()
}
@Test
@DisplayName("응원 크리에이터 fallback은 refresh 실패 시 예외를 던지지 않고 빈 배열을 반환한다")
fun shouldReturnEmptyWhenCheerCreatorRefreshFails() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.doThrow(IllegalStateException("boom")).`when`(refreshService).refreshCheerCreatorSnapshots(nowUtc)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("응원 크리에이터 fallback은 timeout 시 빈 배열을 반환하고 worker를 취소하지 않는다")
fun shouldReturnEmptyOnCheerCreatorTimeoutAndKeepWorkerRunning() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshStarted = CountDownLatch(1)
val allowRefreshComplete = CountDownLatch(1)
val refreshService = BlockingCheerRefreshService(snapshotPort, refreshStarted, allowRefreshComplete)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val executor = Executors.newFixedThreadPool(2)
val service = RecommendationSnapshotFallbackService(
snapshotPort,
refreshService,
redissonClient,
executor,
homeWaitMillis = 50
)
val first = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0))
assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS))
allowRefreshComplete.countDown()
executor.shutdown()
assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS))
val second = service.refreshCheerCreatorIfMissing(
offset = 0,
limit = 16,
nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
)
assertEquals(emptyList<RecommendationSnapshotRecord>(), first)
assertEquals(listOf(99L), second.map { it.targetId })
}
@Test
@DisplayName("AI fallback이 오래 걸려도 응원 크리에이터 fallback은 같은 worker queue에서 대기하지 않는다")
fun shouldRunCheerCreatorFallbackWithoutWaitingForBlockedAiFallback() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val aiRefreshStarted = CountDownLatch(1)
val allowAiRefreshComplete = CountDownLatch(1)
val refreshService = BlockingAiAndFastCheerRefreshService(snapshotPort, aiRefreshStarted, allowAiRefreshComplete)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val aiLock = Mockito.mock(RLock::class.java)
val cheerLock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(aiLock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(cheerLock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
val service = RecommendationSnapshotFallbackService(
snapshotPort,
refreshService,
redissonClient,
homeWaitMillis = 1_000
)
val aiExecutor = Executors.newSingleThreadExecutor()
val aiFuture = aiExecutor.submit<List<RecommendationSnapshotRecord>> {
service.refreshIfMissing(offset = 0, limit = 20, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0))
}
assertEquals(true, aiRefreshStarted.await(1, TimeUnit.SECONDS))
val cheerSnapshots = service.refreshCheerCreatorIfMissing(
offset = 0,
limit = 16,
nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
)
allowAiRefreshComplete.countDown()
aiExecutor.shutdown()
assertEquals(true, aiExecutor.awaitTermination(1, TimeUnit.SECONDS))
aiFuture.get(1, TimeUnit.SECONDS)
assertEquals(listOf(77L), cheerSnapshots.map { it.targetId })
}
private fun directExecutor(): Executor = Executor { command -> command.run() }
}
private class BlockingCheerRefreshService(
private val snapshotPort: RecommendationSnapshotPort,
private val refreshStarted: CountDownLatch,
private val allowRefreshComplete: CountDownLatch
) : RecommendationSnapshotRefreshService(
snapshotPort,
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
) {
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
refreshStarted.countDown()
allowRefreshComplete.await(1, TimeUnit.SECONDS)
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L))
)
return 1
}
}
private class BlockingAiAndFastCheerRefreshService(
private val snapshotPort: RecommendationSnapshotPort,
private val aiRefreshStarted: CountDownLatch,
private val allowAiRefreshComplete: CountDownLatch
) : RecommendationSnapshotRefreshService(
snapshotPort,
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
) {
override fun refreshAiCharacterSnapshots(nowUtc: LocalDateTime): Int {
aiRefreshStarted.countDown()
allowAiRefreshComplete.await(1, TimeUnit.SECONDS)
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 55L))
)
return 1
}
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 77L))
)
return 1
}
}
private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPort {
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
override fun findLatestSnapshots(
sectionType: RecommendedSectionType,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> {
val latestSnapshotAt = snapshots.filter { it.sectionType == sectionType }.maxOfOrNull { it.snapshotAt }
return snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L }
.drop(offset.toInt())
.take(limit)
}
override fun findSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> {
return snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == snapshotAt && it.targetId != 0L }
.drop(offset.toInt())
.take(limit)
}
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
return snapshots.any { it.sectionType == sectionType }
}
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
}
override fun replaceSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord>
) {
if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) {
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.add(snapshot(sectionType, 0L, snapshotAt))
return
}
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.addAll(newSnapshots)
}
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean {
return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR
}
}
private fun snapshot(
sectionType: RecommendedSectionType,
targetId: Long,
snapshotAt: LocalDateTime = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
): RecommendationSnapshotRecord {
return RecommendationSnapshotRecord(
sectionType = sectionType,
targetId = targetId,
score = 1.0,
snapshotAt = snapshotAt,
randomTieBreaker = 1.0
)
}