feat(content-ranking): 랭킹 스냅샷 job 서비스를 추가한다

This commit is contained in:
2026-06-24 19:02:11 +09:00
parent 90c5149df8
commit abeffb0a4f
2 changed files with 482 additions and 0 deletions
@@ -0,0 +1,307 @@
package kr.co.vividnext.sodalive.v2.content.ranking.application
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobRecord
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobTrigger
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.redisson.api.RLock
import org.redisson.api.RedissonClient
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.support.SimpleTransactionStatus
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.concurrent.TimeUnit
class AudioRankingSnapshotJobServiceTest {
@Test
fun shouldCreateScheduledJobAndMarkDoneWhenRefreshSucceeds() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
val now = now()
val service = service(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = periodLockRedissonClient(true)
) { now }
service.refreshLastCompletedWeekByScheduledJob(AudioRankingType.REVENUE)
val job = jobPort.jobs.single()
assertEquals(AudioRankingType.REVENUE, job.rankingType)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), job.aggregationStartAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), job.aggregationEndAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), job.visibleFromAtUtc)
assertEquals(AudioRankingSnapshotJobTrigger.SCHEDULED, job.trigger)
assertEquals(AudioRankingSnapshotJobStatus.DONE, job.status)
Mockito.verify(refreshService).refreshLastCompletedWeek(AudioRankingType.REVENUE, now)
}
@Test
fun shouldMarkScheduledJobFailedWhenRefreshFails() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
val now = now()
Mockito.doThrow(IllegalStateException("aggregate failed"))
.`when`(refreshService).refreshLastCompletedWeek(AudioRankingType.RISING, now)
val service = service(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = periodLockRedissonClient(true)
) { now }
val exception = assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeekByScheduledJob(AudioRankingType.RISING)
}
assertEquals("aggregate failed", exception.message)
assertEquals(AudioRankingSnapshotJobStatus.FAILED, jobPort.jobs.single().status)
assertEquals("aggregate failed", jobPort.jobs.single().lastError)
}
@Test
fun shouldCommitFailedJobStatusWhenRefreshFails() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
val now = now()
val transactionManager = transactionManager()
Mockito.doThrow(IllegalStateException("aggregate failed"))
.`when`(refreshService).refreshLastCompletedWeek(AudioRankingType.RISING, now)
val service = AudioRankingSnapshotJobService(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = periodLockRedissonClient(true),
transactionManager = transactionManager,
nowProvider = { now }
)
assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeekByScheduledJob(AudioRankingType.RISING)
}
Mockito.verify(transactionManager, Mockito.times(4))
.getTransaction(Mockito.any(TransactionDefinition::class.java))
Mockito.verify(transactionManager, Mockito.times(3))
.commit(Mockito.any(SimpleTransactionStatus::class.java))
Mockito.verify(transactionManager)
.rollback(Mockito.any(SimpleTransactionStatus::class.java))
}
@Test
fun shouldSkipScheduledJobWhenPeriodLockIsNotAcquired() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
val now = now()
val service = service(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = periodLockRedissonClient(false)
) { now }
service.refreshLastCompletedWeekByScheduledJob(AudioRankingType.WEEKLY_POPULAR)
assertTrue(jobPort.jobs.isEmpty())
Mockito.verify(refreshService, Mockito.never()).refreshLastCompletedWeek(
AudioRankingType.WEEKLY_POPULAR,
now
)
}
@Test
fun shouldCreateFallbackJobWhenFallbackCountIsBelowLimit() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
val now = now()
val service = service(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = periodLockRedissonClient(true)
) { now }
val refreshed = service.refreshLastCompletedWeekByFallback(AudioRankingType.LIKE_COUNT)
assertEquals(true, refreshed)
assertEquals(AudioRankingSnapshotJobTrigger.FALLBACK, jobPort.jobs.single().trigger)
assertEquals(AudioRankingSnapshotJobStatus.DONE, jobPort.jobs.single().status)
Mockito.verify(refreshService).refreshLastCompletedWeek(AudioRankingType.LIKE_COUNT, now)
}
@Test
fun shouldSkipFallbackJobWhenFallbackCountReachedLimit() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
repeat(3) {
jobPort.save(jobRecord(trigger = AudioRankingSnapshotJobTrigger.FALLBACK))
}
val now = now()
val service = service(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = periodLockRedissonClient(true)
) { now }
val refreshed = service.refreshLastCompletedWeekByFallback(AudioRankingType.REVENUE)
assertEquals(false, refreshed)
assertEquals(3, jobPort.jobs.size)
Mockito.verify(refreshService, Mockito.never()).refreshLastCompletedWeek(AudioRankingType.REVENUE, now)
}
@Test
fun shouldUseTypeAndPeriodScopedLock() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
val redissonClient = periodLockRedissonClient(true)
val now = now()
val service = service(refreshService = refreshService, jobPort = jobPort, redissonClient = redissonClient) { now }
service.refreshLastCompletedWeekByScheduledJob(AudioRankingType.REVENUE)
Mockito.verify(redissonClient).getLock(
"lock:content-ranking-snapshot-refresh:REVENUE:2026-05-31T15:00:2026-06-07T15:00"
)
}
private fun service(
refreshService: AudioRankingSnapshotRefreshService,
jobPort: AudioRankingSnapshotJobPort,
redissonClient: RedissonClient,
nowProvider: () -> ZonedDateTime
): AudioRankingSnapshotJobService {
return AudioRankingSnapshotJobService(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = redissonClient,
transactionManager = transactionManager(),
nowProvider = nowProvider
)
}
private fun now(): ZonedDateTime {
return ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
}
private fun periodLockRedissonClient(lockAcquired: Boolean): RedissonClient {
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val lockName = "lock:content-ranking-snapshot-refresh:REVENUE:2026-05-31T15:00:2026-06-07T15:00"
Mockito.`when`(redissonClient.getLock(Mockito.anyString())).thenReturn(lock)
Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(lockAcquired)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(lockAcquired)
return redissonClient
}
}
private fun transactionManager(): PlatformTransactionManager {
val transactionManager = Mockito.mock(PlatformTransactionManager::class.java)
Mockito.`when`(transactionManager.getTransaction(Mockito.any(TransactionDefinition::class.java)))
.thenAnswer { SimpleTransactionStatus() }
return transactionManager
}
private fun jobRecord(
rankingType: AudioRankingType = AudioRankingType.REVENUE,
trigger: AudioRankingSnapshotJobTrigger = AudioRankingSnapshotJobTrigger.SCHEDULED,
status: AudioRankingSnapshotJobStatus = AudioRankingSnapshotJobStatus.PENDING
): AudioRankingSnapshotJobRecord {
return AudioRankingSnapshotJobRecord(
rankingType = rankingType,
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
trigger = trigger,
status = status,
lastError = null,
processingStartedAt = null,
processedAt = null
)
}
private class FakeAudioRankingSnapshotJobPort : AudioRankingSnapshotJobPort {
val jobs = mutableListOf<AudioRankingSnapshotJobRecord>()
private var nextId = 1L
override fun save(job: AudioRankingSnapshotJobRecord): AudioRankingSnapshotJobRecord {
val saved = job.copy(id = job.id ?: nextId++)
jobs.add(saved)
return saved
}
override fun findById(jobId: Long): AudioRankingSnapshotJobRecord? = jobs.firstOrNull { it.id == jobId }
override fun findByRankingTypeAndPeriodAndStatuses(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
statuses: List<AudioRankingSnapshotJobStatus>
): List<AudioRankingSnapshotJobRecord> {
return jobs.filter {
it.rankingType == rankingType &&
it.aggregationStartAtUtc == aggregationStartAtUtc &&
it.aggregationEndAtUtc == aggregationEndAtUtc &&
it.status in statuses
}
}
override fun countByRankingTypeAndPeriodAndTrigger(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
trigger: AudioRankingSnapshotJobTrigger
): Long {
return jobs.count {
it.rankingType == rankingType &&
it.aggregationStartAtUtc == aggregationStartAtUtc &&
it.aggregationEndAtUtc == aggregationEndAtUtc &&
it.trigger == trigger
}.toLong()
}
override fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): AudioRankingSnapshotJobRecord? {
return update(jobId) {
it.copy(
status = AudioRankingSnapshotJobStatus.PROCESSING,
processingStartedAt = processingStartedAt
)
}
}
override fun markDone(jobId: Long, processedAt: LocalDateTime): AudioRankingSnapshotJobRecord? {
return update(jobId) {
it.copy(
status = AudioRankingSnapshotJobStatus.DONE,
processedAt = processedAt,
lastError = null
)
}
}
override fun markFailed(jobId: Long, processedAt: LocalDateTime, lastError: String?): AudioRankingSnapshotJobRecord? {
return update(jobId) {
it.copy(
status = AudioRankingSnapshotJobStatus.FAILED,
processedAt = processedAt,
lastError = lastError
)
}
}
private fun update(
jobId: Long,
transform: (AudioRankingSnapshotJobRecord) -> AudioRankingSnapshotJobRecord
): AudioRankingSnapshotJobRecord? {
val index = jobs.indexOfFirst { it.id == jobId }
if (index < 0) return null
val updated = transform(jobs[index])
jobs[index] = updated
return updated
}
}