feat(recommend): 홈 추천 저장소 페이징 조건을 적용한다
This commit is contained in:
@@ -77,7 +77,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
}
|
||||
flushAndClear()
|
||||
|
||||
val lives = repository.findLiveRecommendations(limit = 20)
|
||||
val lives = repository.findLiveRecommendations(limit = 20, includeAdultLives = false)
|
||||
|
||||
assertEquals(20, lives.size)
|
||||
assertEquals(baseAt.plusDays(1).plusMinutes(20), lives.first().beginDateTime)
|
||||
@@ -87,6 +87,24 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
assertEquals(false, lives.any { it.creatorId == inactiveCreator.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("라이브 전체보기 조회는 offset/limit과 성인 노출 조건을 DB에서 적용한다")
|
||||
fun shouldFindPagedLiveRecommendationsWithAdultFilter() {
|
||||
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
val creator = saveMember("paged-live-creator", MemberRole.CREATOR)
|
||||
val newest = saveLiveRoom(creator, baseAt.plusMinutes(3), channelName = "paged-live-newest", isAdult = false)
|
||||
saveLiveRoom(creator, baseAt.plusMinutes(2), channelName = "paged-live-adult", isAdult = true)
|
||||
val middle = saveLiveRoom(creator, baseAt.plusMinutes(1), channelName = "paged-live-middle", isAdult = false)
|
||||
val oldest = saveLiveRoom(creator, baseAt, channelName = "paged-live-oldest", isAdult = false)
|
||||
flushAndClear()
|
||||
|
||||
val page0 = repository.findLiveRecommendations(offset = 0, limit = 3, includeAdultLives = false)
|
||||
val page1 = repository.findLiveRecommendations(offset = 2, limit = 3, includeAdultLives = false)
|
||||
|
||||
assertEquals(listOf(newest.id, middle.id, oldest.id), page0.map { it.liveRoomId })
|
||||
assertEquals(listOf(oldest.id), page1.map { it.liveRoomId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 배너는 활성 배너를 orders 오름차순 최대 20개로 조회하고 동일 orders는 랜덤 tie-breaker를 적용한다")
|
||||
fun shouldFindActiveHomeBannersWithOrderLimitAndRandomTieBreaker() {
|
||||
@@ -251,6 +269,39 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
assertEquals(community.id, byCreatorId[communityCreator.id]!!.targetId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 활동 크리에이터는 성인 노출 정책이 꺼져 있으면 성인 라이브/오디오/커뮤니티 활동을 제외한다")
|
||||
fun shouldExcludeAdultActivitiesFromRecentlyActiveCreatorsWhenAdultHidden() {
|
||||
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
val normalLiveCreator = saveMember("activity-normal-live", MemberRole.CREATOR)
|
||||
val adultLiveCreator = saveMember("activity-adult-live", MemberRole.CREATOR)
|
||||
val adultAudioCreator = saveMember("activity-adult-audio", MemberRole.CREATOR)
|
||||
val adultCommunityCreator = saveMember("activity-adult-community", MemberRole.CREATOR)
|
||||
saveLiveRoom(normalLiveCreator, baseAt.plusMinutes(3), channelName = "normal-live", isAdult = false)
|
||||
saveLiveRoom(adultLiveCreator, baseAt.plusMinutes(2), channelName = "adult-live", isAdult = true)
|
||||
val adultAudio = saveAudioContent(adultAudioCreator, baseAt.plusMinutes(1), isActive = true, isAdult = true)
|
||||
val adultCommunity = saveCommunity(adultCommunityCreator, isCommentAvailable = true, isAdult = true)
|
||||
updateCreatedAt("CreatorCommunity", adultCommunity.id!!, baseAt)
|
||||
flushAndClear()
|
||||
|
||||
val hiddenCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = false)
|
||||
val visibleCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = true)
|
||||
|
||||
assertEquals(listOf(normalLiveCreator.id), hiddenCreators.map { it.creatorId })
|
||||
assertEquals(
|
||||
listOf(normalLiveCreator.id, adultLiveCreator.id, adultAudioCreator.id, adultCommunityCreator.id),
|
||||
visibleCreators.map { it.creatorId }
|
||||
)
|
||||
assertEquals(null, visibleCreators[0].targetId)
|
||||
assertEquals(null, visibleCreators[1].targetId)
|
||||
assertEquals(adultAudio.id, visibleCreators[2].targetId)
|
||||
assertEquals(adultCommunity.id, visibleCreators[3].targetId)
|
||||
assertEquals(RecommendedActivityType.LIVE, visibleCreators[0].activityType)
|
||||
assertEquals(RecommendedActivityType.LIVE, visibleCreators[1].activityType)
|
||||
assertEquals(RecommendedActivityType.AUDIO, visibleCreators[2].activityType)
|
||||
assertEquals(RecommendedActivityType.COMMUNITY, visibleCreators[3].activityType)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 스냅샷은 AI 발화 수와 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다")
|
||||
fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() {
|
||||
@@ -786,6 +837,53 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
assertEquals(expectedLowScore, creators.last().score, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 데뷔 크리에이터 전체보기 조회는 offset/limit과 성인 콘텐츠 제외를 DB에서 적용한다")
|
||||
fun shouldFindPagedRecentDebutCreatorsWithAdultFilter() {
|
||||
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
val normalNewest = saveMember("paged-debut-normal-newest", MemberRole.CREATOR)
|
||||
val adultCreator = saveMember("paged-debut-adult", MemberRole.CREATOR)
|
||||
val normalOldest = saveMember("paged-debut-normal-oldest", MemberRole.CREATOR)
|
||||
val newestContent = saveAudioContent(normalNewest, now.minusDays(1), isActive = true, isAdult = false)
|
||||
saveAudioContent(adultCreator, now.minusDays(2), isActive = true, isAdult = true)
|
||||
saveAudioContent(normalOldest, now.minusDays(3), isActive = true, isAdult = false)
|
||||
val fan = saveMember("paged-debut-fan", MemberRole.USER)
|
||||
val following = saveFollowing(fan, normalNewest, isActive = true)
|
||||
val comment = saveAudioContentComment(fan, newestContent, isActive = true)
|
||||
val like = saveAudioContentLike(fan, newestContent, isActive = true)
|
||||
updateCreatedAt("CreatorFollowing", following.id!!, now.minusHours(1))
|
||||
updateCreatedAt("AudioContentComment", comment.id!!, now.minusHours(1))
|
||||
updateCreatedAt("AudioContentLike", like.id!!, now.minusHours(1))
|
||||
flushAndClear()
|
||||
|
||||
val page0 = repository.findRecentDebutCreators(now, offset = 0, limit = 2, includeAdultContents = false)
|
||||
val page1 = repository.findRecentDebutCreators(now, offset = 1, limit = 2, includeAdultContents = false)
|
||||
|
||||
assertEquals(listOf(normalNewest.id, normalOldest.id), page0.map { it.creatorId })
|
||||
assertEquals(listOf(normalOldest.id), page1.map { it.creatorId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 데뷔 크리에이터 전체보기 동점 후보는 page size 1에서도 안정적으로 겹치지 않는다")
|
||||
fun shouldFindPagedRecentDebutCreatorsWithStableTieOrdering() {
|
||||
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
val creator1 = saveMember("stable-paged-debut-1", MemberRole.CREATOR)
|
||||
val creator2 = saveMember("stable-paged-debut-2", MemberRole.CREATOR)
|
||||
val creator3 = saveMember("stable-paged-debut-3", MemberRole.CREATOR)
|
||||
saveAudioContent(creator1, now.minusDays(5), isActive = true)
|
||||
saveAudioContent(creator2, now.minusDays(5), isActive = true)
|
||||
saveAudioContent(creator3, now.minusDays(5), isActive = true)
|
||||
flushAndClear()
|
||||
|
||||
val page0 = repository.findRecentDebutCreators(now, offset = 0, limit = 1, includeAdultContents = false)
|
||||
val page1 = repository.findRecentDebutCreators(now, offset = 1, limit = 1, includeAdultContents = false)
|
||||
val page2 = repository.findRecentDebutCreators(now, offset = 2, limit = 1, includeAdultContents = false)
|
||||
|
||||
val pagedCreatorIds = (page0 + page1 + page2).map { it.creatorId }
|
||||
assertEquals(listOf(creator1.id, creator2.id, creator3.id), pagedCreatorIds)
|
||||
assertEquals(pagedCreatorIds, pagedCreatorIds.distinct())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 데뷔 크리에이터 동점은 랜덤 tie-breaker 오름차순으로 정렬한다")
|
||||
fun shouldOrderRecentDebutCreatorsByRandomTieBreakerWhenScoresTie() {
|
||||
@@ -853,6 +951,46 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
assertEquals(true, contents.zipWithNext().all { it.first.recencyScore >= it.second.recencyScore })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("첫 오디오 전체보기 조회는 offset/limit과 성인 콘텐츠 제외를 DB에서 적용한다")
|
||||
fun shouldFindPagedFirstAudioContentsWithAdultFilter() {
|
||||
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
val newestCreator = saveMember("paged-first-audio-newest", MemberRole.CREATOR)
|
||||
val adultCreator = saveMember("paged-first-audio-adult", MemberRole.CREATOR)
|
||||
val oldestCreator = saveMember("paged-first-audio-oldest", MemberRole.CREATOR)
|
||||
val newest = saveAudioContent(newestCreator, now.minusDays(1), isActive = true, isAdult = false)
|
||||
saveAudioContent(adultCreator, now.minusDays(2), isActive = true, isAdult = true)
|
||||
val oldest = saveAudioContent(oldestCreator, now.minusDays(21), isActive = true, isAdult = false)
|
||||
flushAndClear()
|
||||
|
||||
val page0 = repository.findFirstAudioContents(now, offset = 0, limit = 2, includeAdultContents = false)
|
||||
val page1 = repository.findFirstAudioContents(now, offset = 1, limit = 2, includeAdultContents = false)
|
||||
|
||||
assertEquals(listOf(newest.id, oldest.id), page0.map { it.contentId })
|
||||
assertEquals(listOf(oldest.id), page1.map { it.contentId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("첫 오디오 전체보기 동점 후보는 page size 1에서도 안정적으로 겹치지 않는다")
|
||||
fun shouldFindPagedFirstAudioContentsWithStableTieOrdering() {
|
||||
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
val creator1 = saveMember("stable-paged-first-audio-1", MemberRole.CREATOR)
|
||||
val creator2 = saveMember("stable-paged-first-audio-2", MemberRole.CREATOR)
|
||||
val creator3 = saveMember("stable-paged-first-audio-3", MemberRole.CREATOR)
|
||||
val content1 = saveAudioContent(creator1, now.minusDays(5), isActive = true)
|
||||
val content2 = saveAudioContent(creator2, now.minusDays(5), isActive = true)
|
||||
val content3 = saveAudioContent(creator3, now.minusDays(5), isActive = true)
|
||||
flushAndClear()
|
||||
|
||||
val page0 = repository.findFirstAudioContents(now, offset = 0, limit = 1, includeAdultContents = false)
|
||||
val page1 = repository.findFirstAudioContents(now, offset = 1, limit = 1, includeAdultContents = false)
|
||||
val page2 = repository.findFirstAudioContents(now, offset = 2, limit = 1, includeAdultContents = false)
|
||||
|
||||
val pagedContentIds = (page0 + page1 + page2).map { it.contentId }
|
||||
assertEquals(listOf(content1.id, content2.id, content3.id), pagedContentIds)
|
||||
assertEquals(pagedContentIds, pagedContentIds.distinct())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 상세는 활성 캐릭터의 이름 소개 전체 AI 발화 수와 연결된 원작명만 조회한다")
|
||||
fun shouldFindAiCharacterRecommendationDetailsWithOriginalWorkTitleOnlyWhenExists() {
|
||||
@@ -1440,13 +1578,18 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
return banner
|
||||
}
|
||||
|
||||
private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom {
|
||||
private fun saveLiveRoom(
|
||||
creator: Member,
|
||||
beginDateTime: LocalDateTime,
|
||||
channelName: String?,
|
||||
isAdult: Boolean = false
|
||||
): LiveRoom {
|
||||
val room = LiveRoom(
|
||||
title = "live-${creator.nickname}-$beginDateTime",
|
||||
notice = "notice",
|
||||
beginDateTime = beginDateTime,
|
||||
numberOfPeople = 0,
|
||||
isAdult = false
|
||||
isAdult = isAdult
|
||||
)
|
||||
room.member = creator
|
||||
room.channelName = channelName
|
||||
|
||||
@@ -60,6 +60,23 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor(
|
||||
assertEquals(listOf(latestSnapshotAt, latestSnapshotAt, latestSnapshotAt), latestSnapshots.map { it.snapshotAt })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldFindLatestSnapshotsWithOffsetAndLimit() {
|
||||
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
repository.saveAll(
|
||||
listOf(
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 1L, score = 400.0, snapshotAt = latestSnapshotAt),
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 2L, score = 300.0, snapshotAt = latestSnapshotAt),
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 3L, score = 200.0, snapshotAt = latestSnapshotAt),
|
||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 4L, score = 100.0, snapshotAt = latestSnapshotAt)
|
||||
)
|
||||
)
|
||||
|
||||
val snapshots = adapter.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset = 1, limit = 2)
|
||||
|
||||
assertEquals(listOf(2L, 3L), snapshots.map { it.targetId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldReplaceSnapshotsByDeletingSameSectionSnapshotAtOnly() {
|
||||
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
||||
|
||||
Reference in New Issue
Block a user