test #426

Merged
klaus merged 415 commits from test into main 2026-06-27 00:35:30 +00:00
2 changed files with 195 additions and 11 deletions
Showing only changes of commit cf29600ad3 - Show all commits

View File

@@ -5,10 +5,11 @@ import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingItem import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingItem
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingBlockPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.ZonedDateTime import java.time.ZonedDateTime
@@ -16,24 +17,27 @@ import java.time.ZonedDateTime
class AudioRankingQueryService( class AudioRankingQueryService(
private val snapshotPort: AudioRankingSnapshotPort, private val snapshotPort: AudioRankingSnapshotPort,
private val memberContentPreferenceService: MemberContentPreferenceService, private val memberContentPreferenceService: MemberContentPreferenceService,
private val blockPort: AudioRankingBlockPort,
private val jobService: AudioRankingSnapshotJobService,
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() } private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() }
) { ) {
@Transactional(readOnly = true) private val log = LoggerFactory.getLogger(javaClass)
fun getRankings(type: AudioRankingType, member: Member?): AudioRanking { fun getRankings(type: AudioRankingType, member: Member?): AudioRanking {
val nowUtc = nowProvider().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime() val nowUtc = nowProvider().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(type, nowUtc) val latestSnapshots = findLatestVisibleSnapshots(type, nowUtc)
if (latestSnapshots.isEmpty()) { if (latestSnapshots.isEmpty()) {
return AudioRanking(showRankChange = false, type = type, items = emptyList()) return AudioRanking(showRankChange = false, type = type, items = emptyList())
} }
val canViewAdultContent = canViewAdultContent(member) val canViewAdultContent = canViewAdultContent(member)
val latestVisibleSnapshots = latestSnapshots.visibleTo(canViewAdultContent).take(ITEM_LIMIT)
val previousSnapshots = snapshotPort.findPreviousVisibleSnapshots( val previousSnapshots = snapshotPort.findPreviousVisibleSnapshots(
rankingType = type, rankingType = type,
currentAggregationStartAtUtc = latestSnapshots.first().aggregationStartAtUtc, currentAggregationStartAtUtc = latestSnapshots.first().aggregationStartAtUtc,
nowUtc = nowUtc nowUtc = nowUtc
) )
val previousRankByContentId = previousSnapshots.visibleTo(canViewAdultContent) val blockedCreatorMemberIds = blockedCreatorMemberIds(member, latestSnapshots + previousSnapshots)
val latestVisibleSnapshots = latestSnapshots.visibleTo(canViewAdultContent, blockedCreatorMemberIds).take(ITEM_LIMIT)
val previousRankByContentId = previousSnapshots.visibleTo(canViewAdultContent, blockedCreatorMemberIds)
.take(ITEM_LIMIT) .take(ITEM_LIMIT)
.mapIndexed { index, snapshot -> snapshot.contentId to index + 1 } .mapIndexed { index, snapshot -> snapshot.contentId to index + 1 }
.toMap() .toMap()
@@ -48,13 +52,44 @@ class AudioRankingQueryService(
) )
} }
private fun findLatestVisibleSnapshots(
type: AudioRankingType,
nowUtc: java.time.LocalDateTime
): List<AudioRankingSnapshotRecord> {
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(type, nowUtc)
if (latestSnapshots.isNotEmpty()) return latestSnapshots
runCatching { jobService.refreshLastCompletedWeekByFallback(type) }
.onFailure { ex ->
log.warn(
"event=audio_ranking_query_fallback_failure rankingType={} error={}",
type,
ex.message,
ex
)
}
return snapshotPort.findLatestVisibleSnapshots(type, nowUtc)
}
private fun canViewAdultContent(member: Member?): Boolean { private fun canViewAdultContent(member: Member?): Boolean {
if (member == null) return false if (member == null) return false
return memberContentPreferenceService.canViewAdultContent(member) return memberContentPreferenceService.canViewAdultContent(member)
} }
private fun List<AudioRankingSnapshotRecord>.visibleTo(canViewAdultContent: Boolean): List<AudioRankingSnapshotRecord> { private fun blockedCreatorMemberIds(member: Member?, snapshots: List<AudioRankingSnapshotRecord>): Set<Long> {
return if (canViewAdultContent) this else filter { !it.isAdult } val memberId = member?.id ?: return emptySet()
val creatorMemberIds = snapshots.map { it.creatorMemberId }.toSet()
if (creatorMemberIds.isEmpty()) return emptySet()
return blockPort.findBlockedCreatorMemberIds(memberId, creatorMemberIds)
}
private fun List<AudioRankingSnapshotRecord>.visibleTo(
canViewAdultContent: Boolean,
blockedCreatorMemberIds: Set<Long>
): List<AudioRankingSnapshotRecord> {
return filter { snapshot ->
(canViewAdultContent || !snapshot.isAdult) && snapshot.creatorMemberId !in blockedCreatorMemberIds
}
} }
private fun AudioRankingSnapshotRecord.toItem( private fun AudioRankingSnapshotRecord.toItem(

View File

@@ -3,18 +3,35 @@ package kr.co.vividnext.sodalive.v2.content.ranking.application
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingBlockPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
@ExtendWith(OutputCaptureExtension::class)
class AudioRankingQueryServiceTest { class AudioRankingQueryServiceTest {
@Test
fun shouldNotWrapGetRankingsInTransactionSoFallbackRequeryUsesFreshSnapshot() {
val method = AudioRankingQueryService::class.java.getDeclaredMethod(
"getRankings",
AudioRankingType::class.java,
Member::class.java
)
assertEquals(null, method.getAnnotation(Transactional::class.java))
}
@Test @Test
fun shouldReturnLatestVisibleSnapshotsWithRankChangesAndNewFlags() { fun shouldReturnLatestVisibleSnapshotsWithRankChangesAndNewFlags() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort() val snapshotPort = FakeAudioRankingQuerySnapshotPort()
@@ -96,9 +113,113 @@ class AudioRankingQueryServiceTest {
assertEquals(listOf(1L), result.items.map { it.contentId }) assertEquals(listOf(1L), result.items.map { it.contentId })
} }
@Test
fun shouldFilterBlockedCreatorSnapshotsForMemberAndRecalculateRanks() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val blockPort = FakeAudioRankingBlockPort(blockedCreatorMemberIds = setOf(102L))
val member = member(id = 7L)
snapshotPort.latestSnapshots = listOf(
snapshot(contentId = 1L, rank = 1, creatorMemberId = 101L),
snapshot(contentId = 2L, rank = 2, creatorMemberId = 102L),
snapshot(contentId = 3L, rank = 3, creatorMemberId = 103L)
)
snapshotPort.previousSnapshots = listOf(
snapshot(contentId = 2L, rank = 1, creatorMemberId = 102L),
snapshot(contentId = 1L, rank = 2, creatorMemberId = 101L)
)
val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member)
assertEquals(listOf(1L, 3L), result.items.map { it.contentId })
assertEquals(listOf(1, 2), result.items.map { it.rank })
assertEquals(listOf(0, null), result.items.map { it.rankChange })
assertEquals(7L, blockPort.memberId)
assertEquals(setOf(101L, 102L, 103L), blockPort.creatorMemberIds)
}
@Test
fun shouldNotLookupBlockedCreatorsForAnonymousViewer() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val blockPort = FakeAudioRankingBlockPort(blockedCreatorMemberIds = setOf(101L))
snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1, creatorMemberId = 101L))
val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member = null)
assertEquals(listOf(1L), result.items.map { it.contentId })
assertEquals(0, blockPort.callCount)
}
@Test
fun shouldRunFallbackAndRequeryWhenLatestVisibleSnapshotDoesNotExist() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java)
snapshotPort.latestSnapshotsByCall = listOf(
emptyList(),
listOf(snapshot(contentId = 1L, rank = 1))
)
val result = service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.LIKE_COUNT, member = null)
assertEquals(listOf(1L), result.items.map { it.contentId })
assertEquals(2, snapshotPort.latestCallCount)
Mockito.verify(jobService).refreshLastCompletedWeekByFallback(AudioRankingType.LIKE_COUNT)
}
@Test
fun shouldReturnEmptyRankingWhenFallbackFails(output: CapturedOutput) {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java)
Mockito.doThrow(IllegalStateException("aggregate failed"))
.`when`(jobService).refreshLastCompletedWeekByFallback(AudioRankingType.LIKE_COUNT)
val result = service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.LIKE_COUNT, member = null)
assertFalse(result.showRankChange)
assertEquals(AudioRankingType.LIKE_COUNT, result.type)
assertEquals(emptyList<Any>(), result.items)
assertTrue(output.out.contains("event=audio_ranking_query_fallback_failure"))
assertTrue(output.out.contains("rankingType=LIKE_COUNT"))
assertTrue(output.out.contains("error=aggregate failed"))
}
@Test
fun shouldNotRunFallbackWhenLatestVisibleSnapshotExists() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java)
snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1))
service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.REVENUE, member = null)
Mockito.verify(jobService, Mockito.never()).refreshLastCompletedWeekByFallback(AudioRankingType.REVENUE)
}
@Test
fun shouldFilterPreviousOnlyBlockedCreatorWhenCalculatingRankChanges() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val blockPort = FakeAudioRankingBlockPort(blockedCreatorMemberIds = setOf(999L))
val member = member(id = 7L)
snapshotPort.latestSnapshots = listOf(
snapshot(contentId = 1L, rank = 1, creatorMemberId = 101L),
snapshot(contentId = 2L, rank = 2, creatorMemberId = 102L)
)
snapshotPort.previousSnapshots = listOf(
snapshot(contentId = 99L, rank = 1, creatorMemberId = 999L),
snapshot(contentId = 1L, rank = 2, creatorMemberId = 101L),
snapshot(contentId = 2L, rank = 3, creatorMemberId = 102L)
)
val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member)
assertEquals(setOf(101L, 102L, 999L), blockPort.creatorMemberIds)
assertEquals(listOf(0, 0), result.items.map { it.rankChange })
assertEquals(listOf(false, false), result.items.map { it.isNew })
}
private fun service( private fun service(
snapshotPort: FakeAudioRankingQuerySnapshotPort, snapshotPort: FakeAudioRankingQuerySnapshotPort,
adultMember: Member? = null adultMember: Member? = null,
blockPort: AudioRankingBlockPort = FakeAudioRankingBlockPort(),
jobService: AudioRankingSnapshotJobService = Mockito.mock(AudioRankingSnapshotJobService::class.java)
): AudioRankingQueryService { ): AudioRankingQueryService {
val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
if (adultMember != null) { if (adultMember != null) {
@@ -107,15 +228,22 @@ class AudioRankingQueryServiceTest {
return AudioRankingQueryService( return AudioRankingQueryService(
snapshotPort = snapshotPort, snapshotPort = snapshotPort,
memberContentPreferenceService = memberContentPreferenceService, memberContentPreferenceService = memberContentPreferenceService,
blockPort = blockPort,
jobService = jobService,
nowProvider = { ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")) } nowProvider = { ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")) }
) )
} }
private fun member(id: Long): Member {
return Member(password = "password", nickname = "member-$id").also { it.id = id }
}
private fun snapshot( private fun snapshot(
contentId: Long, contentId: Long,
rank: Int, rank: Int,
rankingType: AudioRankingType = AudioRankingType.REVENUE, rankingType: AudioRankingType = AudioRankingType.REVENUE,
isAdult: Boolean = false isAdult: Boolean = false,
creatorMemberId: Long = 100L + contentId
): AudioRankingSnapshotRecord { ): AudioRankingSnapshotRecord {
return AudioRankingSnapshotRecord( return AudioRankingSnapshotRecord(
rankingType = rankingType, rankingType = rankingType,
@@ -124,7 +252,7 @@ class AudioRankingQueryServiceTest {
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0), visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
contentId = contentId, contentId = contentId,
title = "audio-$contentId", title = "audio-$contentId",
creatorMemberId = 100L + contentId, creatorMemberId = creatorMemberId,
creatorNickname = "creator-$contentId", creatorNickname = "creator-$contentId",
coverImageUrl = "cover-$contentId.png", coverImageUrl = "cover-$contentId.png",
releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0), releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0),
@@ -137,15 +265,21 @@ class AudioRankingQueryServiceTest {
private class FakeAudioRankingQuerySnapshotPort : AudioRankingSnapshotPort { private class FakeAudioRankingQuerySnapshotPort : AudioRankingSnapshotPort {
var latestSnapshots: List<AudioRankingSnapshotRecord> = emptyList() var latestSnapshots: List<AudioRankingSnapshotRecord> = emptyList()
var latestSnapshotsByCall: List<List<AudioRankingSnapshotRecord>> = emptyList()
var previousSnapshots: List<AudioRankingSnapshotRecord> = emptyList() var previousSnapshots: List<AudioRankingSnapshotRecord> = emptyList()
var nowUtc: LocalDateTime? = null var nowUtc: LocalDateTime? = null
var currentAggregationStartAtUtc: LocalDateTime? = null var currentAggregationStartAtUtc: LocalDateTime? = null
var latestCallCount: Int = 0
override fun findLatestVisibleSnapshots( override fun findLatestVisibleSnapshots(
rankingType: AudioRankingType, rankingType: AudioRankingType,
nowUtc: LocalDateTime nowUtc: LocalDateTime
): List<AudioRankingSnapshotRecord> { ): List<AudioRankingSnapshotRecord> {
this.nowUtc = nowUtc this.nowUtc = nowUtc
latestCallCount += 1
if (latestSnapshotsByCall.isNotEmpty()) {
return latestSnapshotsByCall.getOrElse(latestCallCount - 1) { latestSnapshotsByCall.last() }
}
return latestSnapshots return latestSnapshots
} }
@@ -166,3 +300,18 @@ private class FakeAudioRankingQuerySnapshotPort : AudioRankingSnapshotPort {
newSnapshots: List<AudioRankingSnapshotRecord> newSnapshots: List<AudioRankingSnapshotRecord>
) = error("Query service test does not replace snapshots") ) = error("Query service test does not replace snapshots")
} }
private class FakeAudioRankingBlockPort(
private val blockedCreatorMemberIds: Set<Long> = emptySet()
) : AudioRankingBlockPort {
var memberId: Long? = null
var creatorMemberIds: Set<Long> = emptySet()
var callCount: Int = 0
override fun findBlockedCreatorMemberIds(memberId: Long, creatorMemberIds: Set<Long>): Set<Long> {
callCount += 1
this.memberId = memberId
this.creatorMemberIds = creatorMemberIds
return blockedCreatorMemberIds
}
}