test #433

Merged
klaus merged 41 commits from test into main 2026-07-10 07:20:07 +00:00
2 changed files with 175 additions and 6 deletions
Showing only changes of commit 7d0cf0a82a - Show all commits

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.recommendation.application package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
@@ -13,8 +14,10 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQue
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.LocalDateTime import java.time.LocalDateTime
@Service @Service
@@ -22,8 +25,12 @@ import java.time.LocalDateTime
class HomeRecommendationQueryService( class HomeRecommendationQueryService(
private val queryPort: HomeRecommendationQueryPort, private val queryPort: HomeRecommendationQueryPort,
private val snapshotPort: RecommendationSnapshotPort, private val snapshotPort: RecommendationSnapshotPort,
private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null,
private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null,
private val clock: Clock = Clock.systemUTC()
) { ) {
private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy()
fun findLiveRecommendations( fun findLiveRecommendations(
offset: Long = 0, offset: Long = 0,
limit: Int = DEFAULT_LIVE_LIMIT, limit: Int = DEFAULT_LIVE_LIMIT,
@@ -95,13 +102,28 @@ class HomeRecommendationQueryService(
limit: Int = DEFAULT_CHEER_CREATOR_LIMIT, limit: Int = DEFAULT_CHEER_CREATOR_LIMIT,
memberId: Long? = null memberId: Long? = null
): List<HomeCheerCreatorRecommendationRecord> { ): List<HomeCheerCreatorRecommendationRecord> {
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).take(CHEER_CREATOR_CANDIDATE_LIMIT) val snapshots = findCheerCreatorSnapshotsWithFallback()
val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }, memberId) val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }, memberId)
.associateBy { it.creatorId } .associateBy { it.creatorId }
return snapshots.mapNotNull { detailsById[it.targetId] }.take(limit) return snapshots.mapNotNull { detailsById[it.targetId] }.take(limit)
} }
private fun findCheerCreatorSnapshotsWithFallback(): List<RecommendationSnapshotRecord> {
val nowUtc = LocalDateTime.now(clock)
val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt
val snapshots = snapshotPort.findSnapshots(
RecommendedSectionType.CHEER_CREATOR,
snapshotAt,
limit = CHEER_CREATOR_CANDIDATE_LIMIT
)
if (snapshots.isNotEmpty()) return snapshots
if (snapshotPort.existsSnapshot(RecommendedSectionType.CHEER_CREATOR, snapshotAt)) return emptyList()
return cheerCreatorSnapshotFallbackService
?.refreshCheerCreatorIfMissing(offset = 0, limit = CHEER_CREATOR_CANDIDATE_LIMIT, nowUtc = nowUtc)
.orEmpty()
}
fun findPopularCommunityRecommendations( fun findPopularCommunityRecommendations(
limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT, limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT,
memberId: Long? = null, memberId: Long? = null,

View File

@@ -18,7 +18,10 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapsho
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
class HomeRecommendationQueryServiceTest { class HomeRecommendationQueryServiceTest {
private val port = FakeHomeRecommendationQueryPort() private val port = FakeHomeRecommendationQueryPort()
@@ -280,7 +283,14 @@ class HomeRecommendationQueryServiceTest {
@Test @Test
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다") @DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다")
fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() { fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() {
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val fallback = FakeCheerCreatorSnapshotFallbackPort(emptyList())
val service = HomeRecommendationQueryService(
port,
snapshotPort,
cheerCreatorSnapshotFallbackService = fallback,
clock = fixedClock()
)
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR, RecommendedSectionType.CHEER_CREATOR,
snapshotAt, snapshotAt,
@@ -303,12 +313,104 @@ class HomeRecommendationQueryServiceTest {
val creators = service.findCheerCreatorRecommendations(memberId = 104L) val creators = service.findCheerCreatorRecommendations(memberId = 104L)
assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit)
assertEquals((1L..9L).toList(), port.cheerCreatorDetailIds) assertEquals((1L..9L).toList(), port.cheerCreatorDetailIds)
assertEquals(104L, port.cheerCreatorMemberId) assertEquals(104L, port.cheerCreatorMemberId)
assertEquals(listOf(1L, 2L), creators.map { it.creatorId }) assertEquals(listOf(1L, 2L), creators.map { it.creatorId })
assertEquals(listOf("creator-1", "creator-2"), creators.map { it.creatorNickname }) assertEquals(listOf("creator-1", "creator-2"), creators.map { it.creatorNickname })
} }
@Test
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다")
fun shouldFindCheerCreatorsAfterFallbackWhenLatestSnapshotsDoNotExist() {
val fallback = FakeCheerCreatorSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(
port,
snapshotPort,
cheerCreatorSnapshotFallbackService = fallback,
clock = fixedClock()
)
port.cheerCreatorDetails = listOf(
HomeCheerCreatorRecommendationRecord(
creatorId = 10L,
creatorNickname = "fallback-creator",
creatorProfileImage = null
)
)
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
assertEquals(0L, fallback.offset)
assertEquals(16, fallback.limit)
assertEquals(LocalDateTime.of(2026, 7, 9, 21, 0), fallback.nowUtc)
assertEquals(listOf(10L), port.cheerCreatorDetailIds)
assertEquals(listOf(10L), creators.map { it.creatorId })
}
@Test
@DisplayName("최근 응원 크리에이터 추천은 오래된 스냅샷만 있으면 fallback refresh를 호출한다")
fun shouldFallbackWhenOnlyStaleCheerCreatorSnapshotsExist() {
val fallback = FakeCheerCreatorSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(
port,
snapshotPort,
cheerCreatorSnapshotFallbackService = fallback,
clock = fixedClock()
)
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
staleSnapshotAt,
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L, 99.0, staleSnapshotAt))
)
port.cheerCreatorDetails = listOf(
HomeCheerCreatorRecommendationRecord(
creatorId = 10L,
creatorNickname = "fallback-creator",
creatorProfileImage = null
)
)
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
assertEquals(0L, fallback.offset)
assertEquals(16, fallback.limit)
assertEquals(LocalDateTime.of(2026, 7, 9, 21, 0), fallback.nowUtc)
assertEquals(listOf(10L), port.cheerCreatorDetailIds)
assertEquals(listOf(10L), creators.map { it.creatorId })
}
@Test
@DisplayName("최근 응원 크리에이터 추천은 빈 스냅샷 refresh 완료 marker가 있으면 fallback refresh를 반복하지 않는다")
fun shouldNotFallbackWhenEmptyCheerCreatorSnapshotCycleExists() {
val fallback = FakeCheerCreatorSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(
port,
snapshotPort,
cheerCreatorSnapshotFallbackService = fallback,
clock = fixedClock()
)
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
emptyList()
)
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit)
assertEquals(emptyList<Long>(), port.cheerCreatorDetailIds)
assertEquals(emptyList<HomeCheerCreatorRecommendationRecord>(), creators)
}
@Test @Test
@DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다") @DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() { fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() {
@@ -861,7 +963,7 @@ class HomeRecommendationQueryServiceTest {
override fun findCheerCreatorSnapshots( override fun findCheerCreatorSnapshots(
windowStart: LocalDateTime, windowStart: LocalDateTime,
snapshotAt: LocalDateTime, windowEndExclusive: LocalDateTime,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> = emptyList() ): List<RecommendationSnapshotRecord> = emptyList()
@@ -931,17 +1033,35 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
return all.drop(offset.toInt()).take(limit) return all.drop(offset.toInt()).take(limit)
} }
override fun findSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> {
val all = snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == snapshotAt && it.targetId != 0L }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0L && limit == Int.MAX_VALUE) return all
return all.drop(offset.toInt()).take(limit)
}
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean { override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
return snapshots.any { it.sectionType == sectionType } 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( override fun replaceSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime, snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord> newSnapshots: List<RecommendationSnapshotRecord>
) { ) {
if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) { if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) {
snapshots.removeIf { it.sectionType == sectionType } snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.add(snapshot(sectionType, targetId = 0L, score = 0.0, snapshotAt = snapshotAt)) snapshots.add(snapshot(sectionType, targetId = 0L, score = 0.0, snapshotAt = snapshotAt))
return return
} }
@@ -949,6 +1069,33 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.addAll(newSnapshots) snapshots.addAll(newSnapshots)
} }
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean {
return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR
}
}
private fun fixedClock(): Clock {
return Clock.fixed(Instant.parse("2026-07-09T21:00:00Z"), ZoneOffset.UTC)
}
private class FakeCheerCreatorSnapshotFallbackPort(
private val snapshots: List<RecommendationSnapshotRecord>
) : CheerCreatorSnapshotFallbackPort {
var offset: Long? = null
var limit: Int? = null
var nowUtc: LocalDateTime? = null
override fun refreshCheerCreatorIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime
): List<RecommendationSnapshotRecord> {
this.offset = offset
this.limit = limit
this.nowUtc = nowUtc
return snapshots
}
} }
private class FakeAiCharacterSnapshotFallbackPort( private class FakeAiCharacterSnapshotFallbackPort(