test #443
@@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.member.MemberRole
|
|||||||
import kr.co.vividnext.sodalive.member.QMember
|
import kr.co.vividnext.sodalive.member.QMember
|
||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||||
|
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing
|
||||||
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.RecommendationScoreSpec
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScoreSpec
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
@@ -587,8 +588,7 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
union all
|
union all
|
||||||
select lr.member_id as creator_id, lr.begin_date_time as debut_at
|
select lr.member_id as creator_id, lr.begin_date_time as debut_at
|
||||||
from live_room lr
|
from live_room lr
|
||||||
where lr.is_active = true
|
where lr.channel_name is not null
|
||||||
and lr.channel_name is not null
|
|
||||||
and lr.channel_name <> ''
|
and lr.channel_name <> ''
|
||||||
and lr.begin_date_time <= :snapshotAt
|
and lr.begin_date_time <= :snapshotAt
|
||||||
) debut_events
|
) debut_events
|
||||||
@@ -816,7 +816,13 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(member)
|
.from(member)
|
||||||
.where(member.isActive.isTrue, member.id.`in`(creatorIds), notBlockedCreatorCondition(memberId, member.id))
|
.where(
|
||||||
|
member.isActive.isTrue,
|
||||||
|
member.id.`in`(creatorIds),
|
||||||
|
notViewerCondition(memberId, member.id),
|
||||||
|
notActiveFollowedCreatorCondition(memberId, member.id),
|
||||||
|
notBlockedCreatorCondition(memberId, member.id)
|
||||||
|
)
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1278,6 +1284,24 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
.notExists()
|
.notExists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun notViewerCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
|
||||||
|
return memberId?.let { Expressions.booleanTemplate("{0} <> {1}", creatorIdPath, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notActiveFollowedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
|
||||||
|
if (memberId == null) return null
|
||||||
|
val creatorFollowing = QCreatorFollowing("recommendationCreatorFollowing")
|
||||||
|
return JPAExpressions
|
||||||
|
.selectOne()
|
||||||
|
.from(creatorFollowing)
|
||||||
|
.where(
|
||||||
|
creatorFollowing.isActive.isTrue,
|
||||||
|
creatorFollowing.member.id.eq(memberId),
|
||||||
|
creatorFollowing.creator.id.eq(creatorIdPath)
|
||||||
|
)
|
||||||
|
.notExists()
|
||||||
|
}
|
||||||
|
|
||||||
private fun orderedCommunityPostCondition(memberId: Long?): BooleanExpression {
|
private fun orderedCommunityPostCondition(memberId: Long?): BooleanExpression {
|
||||||
if (memberId == null) return Expressions.FALSE
|
if (memberId == null) return Expressions.FALSE
|
||||||
return JPAExpressions
|
return JPAExpressions
|
||||||
|
|||||||
@@ -92,24 +92,38 @@ open class RecommendationSnapshotRefreshService(
|
|||||||
open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
|
open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
|
||||||
val startedAt = System.currentTimeMillis()
|
val startedAt = System.currentTimeMillis()
|
||||||
val window = windowPolicy.previousKstSevenDayUtcWindow(nowUtc)
|
val window = windowPolicy.previousKstSevenDayUtcWindow(nowUtc)
|
||||||
val snapshots = queryPort.findCheerCreatorSnapshots(
|
try {
|
||||||
window.startUtc,
|
val snapshots = queryPort.findCheerCreatorSnapshots(
|
||||||
window.endExclusiveUtc,
|
window.startUtc,
|
||||||
CHEER_CREATOR_SNAPSHOT_LIMIT
|
window.endExclusiveUtc,
|
||||||
)
|
CHEER_CREATOR_SNAPSHOT_LIMIT
|
||||||
snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, window.snapshotAt, snapshots)
|
)
|
||||||
afterCommit {
|
snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, window.snapshotAt, snapshots)
|
||||||
log.info(
|
afterCommit {
|
||||||
"event=cheer_creator_recommendation_snapshot_refresh_success " +
|
log.info(
|
||||||
"snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}",
|
"event=cheer_creator_recommendation_snapshot_refresh_success " +
|
||||||
|
"snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}",
|
||||||
|
window.snapshotAt,
|
||||||
|
window.startUtc,
|
||||||
|
window.endExclusiveUtc,
|
||||||
|
snapshots.size,
|
||||||
|
System.currentTimeMillis() - startedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return snapshots.size
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
log.warn(
|
||||||
|
"event=cheer_creator_recommendation_snapshot_refresh_failure " +
|
||||||
|
"snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} elapsedMs={} error={}",
|
||||||
window.snapshotAt,
|
window.snapshotAt,
|
||||||
window.startUtc,
|
window.startUtc,
|
||||||
window.endExclusiveUtc,
|
window.endExclusiveUtc,
|
||||||
snapshots.size,
|
System.currentTimeMillis() - startedAt,
|
||||||
System.currentTimeMillis() - startedAt
|
ex.message,
|
||||||
|
ex
|
||||||
)
|
)
|
||||||
|
throw ex
|
||||||
}
|
}
|
||||||
return snapshots.size
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
|||||||
import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade
|
import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot
|
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
||||||
|
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 org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
@@ -38,6 +39,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPat
|
|||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
import javax.persistence.EntityManager
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@@ -524,6 +526,28 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.items[0].creatorId").value(character.creatorMember!!.id))
|
.andExpect(jsonPath("$.data.items[0].creatorId").value(character.creatorMember!!.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("메인 홈 통합 조회는 응원 크리에이터 item의 기존 3개 필드만 노출한다")
|
||||||
|
fun shouldKeepCheerCreatorItemSchemaOnHomeRecommendations() {
|
||||||
|
val creator = saveMember("cheer-api-creator", MemberRole.CREATOR).apply {
|
||||||
|
profileImage = "cheer-api-profile.png"
|
||||||
|
}
|
||||||
|
val snapshotAt = RecommendationSnapshotWindowPolicy()
|
||||||
|
.previousKstDayUtcWindow(LocalDateTime.now(ZoneOffset.UTC))
|
||||||
|
.snapshotAt
|
||||||
|
saveCheerCreatorRecommendationSnapshot(creator.id!!, snapshotAt)
|
||||||
|
saveCheerCreatorRecommendationSnapshot(creator.id!!, snapshotAt.plusDays(1))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/v2/home/recommendations"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.cheerCreators[0].creatorId").value(creator.id))
|
||||||
|
.andExpect(jsonPath("$.data.cheerCreators[0].creatorNickname").value("cheer-api-creator"))
|
||||||
|
.andExpect(jsonPath("$.data.cheerCreators[0].creatorProfileImage").value("/cheer-api-profile.png"))
|
||||||
|
.andExpect(jsonPath("$.data.cheerCreators[0].length()").value(3))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("최근 활동 라이브 크리에이터는 creatorId와 라이브 상태별 targetId를 노출한다")
|
@DisplayName("최근 활동 라이브 크리에이터는 creatorId와 라이브 상태별 targetId를 노출한다")
|
||||||
fun shouldExposeNavigationIdsForRecentlyActiveLiveCreators() {
|
fun shouldExposeNavigationIdsForRecentlyActiveLiveCreators() {
|
||||||
@@ -619,4 +643,16 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveCheerCreatorRecommendationSnapshot(creatorId: Long, snapshotAt: LocalDateTime) {
|
||||||
|
entityManager.persist(
|
||||||
|
RecommendationSnapshot(
|
||||||
|
sectionType = RecommendedSectionType.CHEER_CREATOR,
|
||||||
|
targetId = creatorId,
|
||||||
|
score = 100.0,
|
||||||
|
snapshotAt = snapshotAt,
|
||||||
|
randomTieBreaker = 0.1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,13 @@ class HomeRecommendationResponseTest {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
genreCreators = emptyList(),
|
genreCreators = emptyList(),
|
||||||
cheerCreators = emptyList(),
|
cheerCreators = listOf(
|
||||||
|
HomeCreatorItem(
|
||||||
|
creatorId = 15L,
|
||||||
|
creatorNickname = "cheer-creator",
|
||||||
|
creatorProfileImage = "https://cdn.test/profile/cheer.png"
|
||||||
|
)
|
||||||
|
),
|
||||||
popularCommunityPosts = listOf(
|
popularCommunityPosts = listOf(
|
||||||
HomePopularCommunityPostItem(
|
HomePopularCommunityPostItem(
|
||||||
postId = 5L,
|
postId = 5L,
|
||||||
@@ -108,6 +114,13 @@ class HomeRecommendationResponseTest {
|
|||||||
assertEquals("https://cdn.test/profile/character.png", json["aiCharacters"][0]["profileImage"].asText())
|
assertEquals("https://cdn.test/profile/character.png", json["aiCharacters"][0]["profileImage"].asText())
|
||||||
assertEquals(13L, json["aiCharacters"][0]["creatorId"].asLong())
|
assertEquals(13L, json["aiCharacters"][0]["creatorId"].asLong())
|
||||||
assertEquals(true, json["aiCharacters"][1]["profileImage"].isNull)
|
assertEquals(true, json["aiCharacters"][1]["profileImage"].isNull)
|
||||||
|
assertEquals(15L, json["cheerCreators"][0]["creatorId"].asLong())
|
||||||
|
assertEquals("cheer-creator", json["cheerCreators"][0]["creatorNickname"].asText())
|
||||||
|
assertEquals("https://cdn.test/profile/cheer.png", json["cheerCreators"][0]["creatorProfileImage"].asText())
|
||||||
|
assertEquals(
|
||||||
|
setOf("creatorId", "creatorNickname", "creatorProfileImage"),
|
||||||
|
json["cheerCreators"][0].fieldNames().asSequence().toSet()
|
||||||
|
)
|
||||||
assertEquals(5L, json["popularCommunityPosts"][0]["postId"].asLong())
|
assertEquals(5L, json["popularCommunityPosts"][0]["postId"].asLong())
|
||||||
assertEquals("https://cdn.test/community/image.png", json["popularCommunityPosts"][0]["imageUrl"].asText())
|
assertEquals("https://cdn.test/community/image.png", json["popularCommunityPosts"][0]["imageUrl"].asText())
|
||||||
assertEquals("https://cdn.test/community/audio.mp3", json["popularCommunityPosts"][0]["audioUrl"].asText())
|
assertEquals("https://cdn.test/community/audio.mp3", json["popularCommunityPosts"][0]["audioUrl"].asText())
|
||||||
|
|||||||
@@ -940,6 +940,100 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(expectedScore, snapshots.single().score, 0.0001)
|
assertEquals(expectedScore, snapshots.single().score, 0.0001)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 응원 스냅샷은 활동 점수가 없는 후보와 미래 데뷔 또는 비활성 크리에이터를 제외한다")
|
||||||
|
fun shouldExcludeIneligibleCheerCreatorSnapshotCandidates() {
|
||||||
|
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
|
val windowEndExclusive = snapshotAt.plusSeconds(1)
|
||||||
|
val donor = saveMember("cheer-boundary-donor", MemberRole.USER)
|
||||||
|
val zeroActivityCreator = saveMember("zero-activity-cheer", MemberRole.CREATOR)
|
||||||
|
val futureDebutCreator = saveMember("future-debut-cheer", MemberRole.CREATOR)
|
||||||
|
val inactiveCreator = saveMember("inactive-cheer-candidate", MemberRole.CREATOR, isActive = false)
|
||||||
|
val visibleCreator = saveMember("visible-cheer-candidate", MemberRole.CREATOR)
|
||||||
|
saveLiveRoom(zeroActivityCreator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "zero-activity")
|
||||||
|
saveLiveRoom(futureDebutCreator, snapshotAt.plusSeconds(1), channelName = "future-debut")
|
||||||
|
saveLiveRoom(inactiveCreator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "inactive-cheer")
|
||||||
|
saveLiveRoom(visibleCreator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "visible-cheer")
|
||||||
|
listOf(futureDebutCreator, inactiveCreator, visibleCreator).forEach { creator ->
|
||||||
|
saveUseCanCalculate(
|
||||||
|
donor,
|
||||||
|
creator,
|
||||||
|
CanUsage.CHANNEL_DONATION,
|
||||||
|
10,
|
||||||
|
UseCanCalculateStatus.RECEIVED,
|
||||||
|
false,
|
||||||
|
windowStart.plusDays(1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 16)
|
||||||
|
|
||||||
|
assertEquals(listOf(visibleCreator.id), snapshots.map { it.targetId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 응원 스냅샷은 채널명이 있는 종료 라이브를 데뷔 이력으로 인정한다")
|
||||||
|
fun shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut() {
|
||||||
|
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
|
val windowEndExclusive = snapshotAt.plusSeconds(1)
|
||||||
|
val donor = saveMember("ended-live-cheer-donor", MemberRole.USER)
|
||||||
|
val endedLiveCreator = saveMember("ended-live-cheer", MemberRole.CREATOR)
|
||||||
|
val blankChannelCreator = saveMember("blank-ended-live-cheer", MemberRole.CREATOR)
|
||||||
|
saveLiveRoom(
|
||||||
|
endedLiveCreator,
|
||||||
|
LocalDateTime.of(2026, 5, 1, 0, 0),
|
||||||
|
channelName = "ended-live-cheer",
|
||||||
|
isActive = false
|
||||||
|
)
|
||||||
|
saveLiveRoom(
|
||||||
|
blankChannelCreator,
|
||||||
|
LocalDateTime.of(2026, 5, 1, 0, 0),
|
||||||
|
channelName = "",
|
||||||
|
isActive = false
|
||||||
|
)
|
||||||
|
val endedLiveCheer = saveCreatorCheers(donor, endedLiveCreator, isActive = true)
|
||||||
|
val blankChannelCheer = saveCreatorCheers(donor, blankChannelCreator, isActive = true)
|
||||||
|
updateCreatedAt("CreatorCheers", endedLiveCheer.id!!, windowStart.plusDays(1))
|
||||||
|
updateCreatedAt("CreatorCheers", blankChannelCheer.id!!, windowStart.plusDays(1))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 16)
|
||||||
|
|
||||||
|
assertEquals(listOf(endedLiveCreator.id), snapshots.map { it.targetId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 응원 스냅샷은 점수 내림차순 상위 16개만 반환한다")
|
||||||
|
fun shouldReturnTopSixteenCheerCreatorSnapshotsByScore() {
|
||||||
|
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
|
val windowEndExclusive = snapshotAt.plusSeconds(1)
|
||||||
|
val donor = saveMember("cheer-top-donor", MemberRole.USER)
|
||||||
|
val creators = (1..17).map { index ->
|
||||||
|
val creator = saveMember("cheer-top-$index", MemberRole.CREATOR)
|
||||||
|
saveLiveRoom(creator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "cheer-top-$index")
|
||||||
|
saveUseCanCalculate(
|
||||||
|
donor,
|
||||||
|
creator,
|
||||||
|
CanUsage.CHANNEL_DONATION,
|
||||||
|
index,
|
||||||
|
UseCanCalculateStatus.RECEIVED,
|
||||||
|
false,
|
||||||
|
windowStart.plusDays(1)
|
||||||
|
)
|
||||||
|
creator
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 16)
|
||||||
|
|
||||||
|
assertEquals(16, snapshots.size)
|
||||||
|
assertEquals(creators.drop(1).reversed().map { it.id }, snapshots.map { it.targetId })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("인기 커뮤니티 스냅샷은 좋아요와 댓글 수를 distinct로 집계하고 follower-only 게시글은 제외한다")
|
@DisplayName("인기 커뮤니티 스냅샷은 좋아요와 댓글 수를 distinct로 집계하고 follower-only 게시글은 제외한다")
|
||||||
fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() {
|
fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() {
|
||||||
@@ -1686,6 +1780,42 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(listOf(visibleCreator.id), details.map { it.creatorId })
|
assertEquals(listOf(visibleCreator.id), details.map { it.creatorId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 응원 크리에이터 상세는 조회자 본인과 활성 팔로우 크리에이터를 제외한다")
|
||||||
|
fun shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails() {
|
||||||
|
val viewer = saveMember("self-follow-cheer-viewer", MemberRole.CREATOR)
|
||||||
|
val activeFollowedCreator = saveMember("active-followed-cheer", MemberRole.CREATOR)
|
||||||
|
val inactiveFollowedCreator = saveMember("inactive-followed-cheer", MemberRole.CREATOR)
|
||||||
|
val visibleCreator = saveMember("unrelated-cheer", MemberRole.CREATOR)
|
||||||
|
saveFollowing(viewer, activeFollowedCreator, isActive = true)
|
||||||
|
saveFollowing(viewer, inactiveFollowedCreator, isActive = false)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val details = repository.findCheerCreatorRecommendationDetails(
|
||||||
|
listOf(viewer.id!!, activeFollowedCreator.id!!, inactiveFollowedCreator.id!!, visibleCreator.id!!),
|
||||||
|
memberId = viewer.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(inactiveFollowedCreator.id, visibleCreator.id), details.map { it.creatorId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 응원 크리에이터 상세는 비회원 조회에서 회원별 필터를 적용하지 않는다")
|
||||||
|
fun shouldKeepAnonymousCheerCreatorDetailsWithoutMemberFilters() {
|
||||||
|
val viewer = saveMember("anonymous-filter-source", MemberRole.CREATOR)
|
||||||
|
val activeFollowedCreator = saveMember("anonymous-active-followed-cheer", MemberRole.CREATOR)
|
||||||
|
val visibleCreator = saveMember("anonymous-unrelated-cheer", MemberRole.CREATOR)
|
||||||
|
saveFollowing(viewer, activeFollowedCreator, isActive = true)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val details = repository.findCheerCreatorRecommendationDetails(
|
||||||
|
listOf(viewer.id!!, activeFollowedCreator.id!!, visibleCreator.id!!),
|
||||||
|
memberId = null
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(viewer.id, activeFollowedCreator.id, visibleCreator.id), details.map { it.creatorId })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("최근 응원 크리에이터 상세는 빈 id 목록이면 빈 배열을 반환한다")
|
@DisplayName("최근 응원 크리에이터 상세는 빈 id 목록이면 빈 배열을 반환한다")
|
||||||
fun shouldReturnEmptyCheerCreatorRecommendationDetailsWhenIdsAreEmpty() {
|
fun shouldReturnEmptyCheerCreatorRecommendationDetailsWhenIdsAreEmpty() {
|
||||||
|
|||||||
@@ -108,6 +108,40 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor(
|
|||||||
assertEquals(listOf(snapshotAt, snapshotAt), snapshots.map { it.snapshotAt })
|
assertEquals(listOf(snapshotAt, snapshotAt), snapshots.map { it.snapshotAt })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldFindCheerCreatorSnapshotsWithScoreDescendingAndTieBreakerAscending() {
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
|
repository.saveAll(
|
||||||
|
listOf(
|
||||||
|
snapshot(
|
||||||
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
|
targetId = 1L,
|
||||||
|
score = 100.0,
|
||||||
|
snapshotAt = snapshotAt,
|
||||||
|
randomTieBreaker = 0.9
|
||||||
|
),
|
||||||
|
snapshot(
|
||||||
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
|
targetId = 2L,
|
||||||
|
score = 200.0,
|
||||||
|
snapshotAt = snapshotAt,
|
||||||
|
randomTieBreaker = 0.8
|
||||||
|
),
|
||||||
|
snapshot(
|
||||||
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
|
targetId = 3L,
|
||||||
|
score = 100.0,
|
||||||
|
snapshotAt = snapshotAt,
|
||||||
|
randomTieBreaker = 0.1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val snapshots = adapter.findSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, offset = 0, limit = 10)
|
||||||
|
|
||||||
|
assertEquals(listOf(2L, 3L, 1L), snapshots.map { it.targetId })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shouldCheckSnapshotExistenceByExactSnapshotAtIncludingEmptyMarker() {
|
fun shouldCheckSnapshotExistenceByExactSnapshotAtIncludingEmptyMarker() {
|
||||||
val staleSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
val staleSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import java.time.LocalDateTime
|
|||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.Future
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class RecommendationSnapshotFallbackServiceTest {
|
class RecommendationSnapshotFallbackServiceTest {
|
||||||
@@ -278,6 +280,80 @@ class RecommendationSnapshotFallbackServiceTest {
|
|||||||
assertEquals(listOf(99L), second.map { it.targetId })
|
assertEquals(listOf(99L), second.map { it.targetId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("응원 크리에이터 fallback 동시 요청은 하나의 refresh future를 공유한다")
|
||||||
|
fun shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests() {
|
||||||
|
val existsSnapshotEntered = CountDownLatch(2)
|
||||||
|
val existsSnapshotReturned = CountDownLatch(2)
|
||||||
|
val snapshotPort = FakeRecommendationFallbackSnapshotPort(
|
||||||
|
existsSnapshotEntered = existsSnapshotEntered,
|
||||||
|
existsSnapshotReturned = existsSnapshotReturned
|
||||||
|
)
|
||||||
|
val refreshService = FastCheerRefreshService(snapshotPort)
|
||||||
|
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 workerExecutor = CapturingExecutor()
|
||||||
|
val requestExecutor = Executors.newFixedThreadPool(2)
|
||||||
|
val service = RecommendationSnapshotFallbackService(
|
||||||
|
snapshotPort,
|
||||||
|
refreshService,
|
||||||
|
redissonClient,
|
||||||
|
workerExecutor,
|
||||||
|
homeWaitMillis = 1_000
|
||||||
|
)
|
||||||
|
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val first = requestExecutor.submitCheerRefresh(service, nowUtc)
|
||||||
|
val second = requestExecutor.submitCheerRefresh(service, nowUtc)
|
||||||
|
assertEquals(true, existsSnapshotEntered.await(1, TimeUnit.SECONDS))
|
||||||
|
assertEquals(true, existsSnapshotReturned.await(1, TimeUnit.SECONDS))
|
||||||
|
assertEquals(true, workerExecutor.taskSubmitted.await(1, TimeUnit.SECONDS))
|
||||||
|
assertEquals(false, first.isDone)
|
||||||
|
assertEquals(false, second.isDone)
|
||||||
|
assertEquals(1, workerExecutor.taskCount)
|
||||||
|
|
||||||
|
workerExecutor.runNext()
|
||||||
|
|
||||||
|
assertEquals(listOf(99L), first.get(1, TimeUnit.SECONDS).map { it.targetId })
|
||||||
|
assertEquals(listOf(99L), second.get(1, TimeUnit.SECONDS).map { it.targetId })
|
||||||
|
assertEquals(1, refreshService.cheerRefreshCount)
|
||||||
|
} finally {
|
||||||
|
requestExecutor.shutdownNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("응원 크리에이터 fallback은 lock 내부 double-check에서 최신 상태가 생기면 refresh를 생략한다")
|
||||||
|
fun shouldSkipCheerCreatorRefreshWhenSnapshotAppearsBeforeLockDoubleCheck() {
|
||||||
|
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)
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||||
|
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenAnswer {
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
|
snapshotAt,
|
||||||
|
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 88L))
|
||||||
|
)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
|
||||||
|
|
||||||
|
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
|
||||||
|
|
||||||
|
assertEquals(listOf(88L), snapshots.map { it.targetId })
|
||||||
|
Mockito.verify(refreshService, Mockito.never()).refreshCheerCreatorSnapshots(nowUtc)
|
||||||
|
Mockito.verify(lock).unlock()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("AI fallback이 오래 걸려도 응원 크리에이터 fallback은 같은 worker queue에서 대기하지 않는다")
|
@DisplayName("AI fallback이 오래 걸려도 응원 크리에이터 fallback은 같은 worker queue에서 대기하지 않는다")
|
||||||
fun shouldRunCheerCreatorFallbackWithoutWaitingForBlockedAiFallback() {
|
fun shouldRunCheerCreatorFallbackWithoutWaitingForBlockedAiFallback() {
|
||||||
@@ -321,6 +397,32 @@ class RecommendationSnapshotFallbackServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun directExecutor(): Executor = Executor { command -> command.run() }
|
private fun directExecutor(): Executor = Executor { command -> command.run() }
|
||||||
|
|
||||||
|
private fun java.util.concurrent.ExecutorService.submitCheerRefresh(
|
||||||
|
service: RecommendationSnapshotFallbackService,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): Future<List<RecommendationSnapshotRecord>> {
|
||||||
|
return submit<List<RecommendationSnapshotRecord>> {
|
||||||
|
service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CapturingExecutor : Executor {
|
||||||
|
val taskSubmitted = CountDownLatch(1)
|
||||||
|
private val tasks = LinkedBlockingQueue<Runnable>()
|
||||||
|
|
||||||
|
val taskCount: Int
|
||||||
|
get() = tasks.size
|
||||||
|
|
||||||
|
override fun execute(command: Runnable) {
|
||||||
|
tasks.add(command)
|
||||||
|
taskSubmitted.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runNext() {
|
||||||
|
tasks.poll(1, TimeUnit.SECONDS)!!.run()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class BlockingCheerRefreshService(
|
private class BlockingCheerRefreshService(
|
||||||
@@ -331,7 +433,10 @@ private class BlockingCheerRefreshService(
|
|||||||
snapshotPort,
|
snapshotPort,
|
||||||
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
|
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
|
||||||
) {
|
) {
|
||||||
|
var cheerRefreshCount: Int = 0
|
||||||
|
|
||||||
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
|
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
|
||||||
|
cheerRefreshCount += 1
|
||||||
refreshStarted.countDown()
|
refreshStarted.countDown()
|
||||||
allowRefreshComplete.await(1, TimeUnit.SECONDS)
|
allowRefreshComplete.await(1, TimeUnit.SECONDS)
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
@@ -343,6 +448,25 @@ private class BlockingCheerRefreshService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class FastCheerRefreshService(
|
||||||
|
private val snapshotPort: RecommendationSnapshotPort
|
||||||
|
) : RecommendationSnapshotRefreshService(
|
||||||
|
snapshotPort,
|
||||||
|
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
|
||||||
|
) {
|
||||||
|
var cheerRefreshCount: Int = 0
|
||||||
|
|
||||||
|
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
|
||||||
|
cheerRefreshCount += 1
|
||||||
|
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 class BlockingAiAndFastCheerRefreshService(
|
||||||
private val snapshotPort: RecommendationSnapshotPort,
|
private val snapshotPort: RecommendationSnapshotPort,
|
||||||
private val aiRefreshStarted: CountDownLatch,
|
private val aiRefreshStarted: CountDownLatch,
|
||||||
@@ -372,7 +496,10 @@ private class BlockingAiAndFastCheerRefreshService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPort {
|
private class FakeRecommendationFallbackSnapshotPort(
|
||||||
|
private val existsSnapshotEntered: CountDownLatch? = null,
|
||||||
|
private val existsSnapshotReturned: CountDownLatch? = null
|
||||||
|
) : RecommendationSnapshotPort {
|
||||||
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
|
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
|
||||||
|
|
||||||
override fun findLatestSnapshots(
|
override fun findLatestSnapshots(
|
||||||
@@ -404,7 +531,12 @@ private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPor
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
|
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
|
||||||
return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
existsSnapshotEntered?.countDown()
|
||||||
|
existsSnapshotEntered?.await(1, TimeUnit.SECONDS)
|
||||||
|
val exists = snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||||
|
existsSnapshotReturned?.countDown()
|
||||||
|
existsSnapshotReturned?.await(1, TimeUnit.SECONDS)
|
||||||
|
return exists
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun replaceSnapshots(
|
override fun replaceSnapshots(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQue
|
|||||||
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 kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
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.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
@@ -197,6 +199,29 @@ class RecommendationSnapshotRefreshServiceTest {
|
|||||||
assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId })
|
assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("응원 크리에이터 스냅샷 갱신 실패는 섹션 event와 window를 로그로 남기고 예외를 전파한다")
|
||||||
|
fun shouldLogCheerCreatorRefreshFailureWithWindow(output: CapturedOutput) {
|
||||||
|
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
|
||||||
|
val service = service(queryPort = queryPort)
|
||||||
|
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||||
|
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
|
||||||
|
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
|
||||||
|
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, windowEndExclusive, 16))
|
||||||
|
.thenThrow(IllegalStateException("cheer refresh failed"))
|
||||||
|
|
||||||
|
val exception = assertThrows(IllegalStateException::class.java) {
|
||||||
|
service.refreshCheerCreatorSnapshots(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("cheer refresh failed", exception.message)
|
||||||
|
assertTrue(output.out.contains("event=cheer_creator_recommendation_snapshot_refresh_failure"))
|
||||||
|
assertTrue(output.out.contains("snapshotAt=2026-05-29T14:59:59"))
|
||||||
|
assertTrue(output.out.contains("windowStartUtc=2026-05-22T15:00"))
|
||||||
|
assertTrue(output.out.contains("windowEndExclusiveUtc=2026-05-29T15:00"))
|
||||||
|
assertTrue(output.out.contains("error=cheer refresh failed"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("일 스냅샷 갱신은 AI 캐릭터 섹션 lock을 획득한 경우에만 AI refresh를 실행한다")
|
@DisplayName("일 스냅샷 갱신은 AI 캐릭터 섹션 lock을 획득한 경우에만 AI refresh를 실행한다")
|
||||||
fun shouldRefreshAiCharacterSectionOnlyWhenSectionLockAcquired() {
|
fun shouldRefreshAiCharacterSectionOnlyWhenSectionLockAcquired() {
|
||||||
|
|||||||
Reference in New Issue
Block a user