feat(recommend): 홈 추천 조회 쿼리를 추가한다

This commit is contained in:
2026-05-31 16:32:51 +09:00
parent 14822f351b
commit 6652984056
2 changed files with 1199 additions and 10 deletions

View File

@@ -1,28 +1,43 @@
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.room.ChatMessage
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
import kr.co.vividnext.sodalive.chat.room.ChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.like.AudioContentLike
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.event.Event
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendationScorePolicy
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@@ -40,11 +55,201 @@ import javax.persistence.EntityManager
)
@Import(QueryDslConfig::class)
class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultHomeRecommendationQueryRepository(entityManager)
private val repository = DefaultHomeRecommendationQueryRepository(queryFactory, entityManager)
private val scorePolicy = RecommendationScorePolicy()
@Test
@DisplayName("라이브 추천은 활성 크리에이터의 진행 라이브를 최신순 최대 20개 조회한다")
fun shouldFindLatestLiveRecommendationsWithLimit() {
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
val activeCreator = saveMember("live-active", MemberRole.CREATOR)
val inactiveCreator = saveMember("live-inactive", MemberRole.CREATOR, isActive = false)
val oldLive = saveLiveRoom(activeCreator, baseAt.minusHours(1), channelName = "old-live")
val latestLive = saveLiveRoom(activeCreator, baseAt, channelName = "latest-live")
saveLiveRoom(activeCreator, baseAt.plusHours(1), channelName = null)
saveLiveRoom(inactiveCreator, baseAt.plusHours(2), channelName = "inactive-creator-live")
repeat(21) { index ->
saveLiveRoom(activeCreator, baseAt.plusDays(1).plusMinutes(index.toLong()), channelName = "limit-live-$index")
}
flushAndClear()
val lives = repository.findLiveRecommendations(limit = 20)
assertEquals(20, lives.size)
assertEquals(baseAt.plusDays(1).plusMinutes(20), lives.first().beginDateTime)
assertEquals(baseAt.plusDays(1).plusMinutes(1), lives.last().beginDateTime)
assertEquals(false, lives.any { it.liveRoomId == oldLive.id })
assertEquals(false, lives.any { it.liveRoomId == latestLive.id })
assertEquals(false, lives.any { it.creatorId == inactiveCreator.id })
}
@Test
@DisplayName("홈 배너는 활성 배너를 orders 오름차순 최대 20개로 조회하고 동일 orders는 랜덤 tie-breaker를 적용한다")
fun shouldFindActiveHomeBannersWithOrderLimitAndRandomTieBreaker() {
val creator = saveMember("banner-creator", MemberRole.CREATOR)
val event = saveEvent("event-banner")
val laterBanner = saveBanner("later.png", AudioContentBannerType.CREATOR, orders = 2, isActive = true, creator = creator)
val sameOrderBanner1 = saveBanner(
"same-1.png",
AudioContentBannerType.LINK,
orders = 1,
isActive = true,
link = "https://same-1.test"
)
val sameOrderBanner2 = saveBanner(
"same-2.png",
AudioContentBannerType.EVENT,
orders = 1,
isActive = true,
event = event
)
saveBanner("inactive.png", AudioContentBannerType.LINK, orders = 0, isActive = false, link = "https://inactive.test")
repeat(20) { index ->
saveBanner(
"limit-$index.png",
AudioContentBannerType.LINK,
orders = 3 + index,
isActive = true,
link = "https://limit-$index.test"
)
}
flushAndClear()
val banners = repository.findHomeBanners(limit = 20)
assertEquals(20, banners.size)
assertEquals(listOf(1, 1, 2), banners.take(3).map { it.orders })
assertEquals(setOf(sameOrderBanner1.id, sameOrderBanner2.id), banners.take(2).map { it.bannerId }.toSet())
assertEquals(true, banners.take(2).zipWithNext().all { it.first.randomTieBreaker <= it.second.randomTieBreaker })
assertEquals(laterBanner.id, banners[2].bannerId)
assertEquals(creator.id, banners[2].creatorId)
assertEquals(event.id, banners.take(2).first { it.type == AudioContentBannerType.EVENT.name }.eventId)
assertEquals(false, banners.any { it.thumbnailImage == "inactive.png" })
}
@Test
@DisplayName("홈 배너는 기존 홈 배너처럼 탭 전용 배너를 제외한다")
fun shouldExcludeTabSpecificBannersFromHomeBanners() {
val tab = saveMainTab("tab-banner")
val homeBanner = saveBanner(
"home-banner.png",
AudioContentBannerType.LINK,
orders = 1,
isActive = true,
link = "https://home-banner.test"
)
saveBanner(
"tab-banner.png",
AudioContentBannerType.LINK,
orders = 2,
isActive = true,
tab = tab,
link = "https://tab-banner.test"
)
flushAndClear()
val banners = repository.findHomeBanners(limit = 20)
assertEquals(listOf(homeBanner.id), banners.map { it.bannerId })
}
@Test
@DisplayName("홈 배너는 비활성 대상 엔티티를 제외하고 LINK는 배너 자체 활성 상태만으로 조회한다")
fun shouldExcludeHomeBannersWithInactiveTargetsExceptLink() {
val activeCreator = saveMember("banner-active-creator", MemberRole.CREATOR)
val inactiveCreator = saveMember("banner-inactive-creator", MemberRole.CREATOR, isActive = false)
val inactiveSeriesOwner = saveMember("banner-inactive-series-owner", MemberRole.CREATOR, isActive = false)
val activeEvent = saveEvent("active-event-banner")
val inactiveEvent = saveEvent("inactive-event-banner", isActive = false)
val activeSeries = saveSeries("active-series-banner", activeCreator, isActive = true)
val inactiveSeries = saveSeries("inactive-series-banner", activeCreator, isActive = false)
val inactiveOwnerSeries = saveSeries("inactive-owner-series-banner", inactiveSeriesOwner, isActive = true)
val activeEventBanner = saveBanner(
"active-event.png",
AudioContentBannerType.EVENT,
orders = 1,
isActive = true,
event = activeEvent
)
val activeCreatorBanner = saveBanner(
"active-creator.png",
AudioContentBannerType.CREATOR,
orders = 2,
isActive = true,
creator = activeCreator
)
val activeSeriesBanner = saveBanner(
"active-series.png",
AudioContentBannerType.SERIES,
orders = 3,
isActive = true,
series = activeSeries
)
val linkBanner = saveBanner(
"link.png",
AudioContentBannerType.LINK,
orders = 4,
isActive = true,
link = "https://link.test"
)
saveBanner("inactive-event.png", AudioContentBannerType.EVENT, orders = 5, isActive = true, event = inactiveEvent)
saveBanner("inactive-creator.png", AudioContentBannerType.CREATOR, orders = 6, isActive = true, creator = inactiveCreator)
saveBanner("inactive-series.png", AudioContentBannerType.SERIES, orders = 7, isActive = true, series = inactiveSeries)
saveBanner(
"inactive-owner-series.png",
AudioContentBannerType.SERIES,
orders = 8,
isActive = true,
series = inactiveOwnerSeries
)
flushAndClear()
val banners = repository.findHomeBanners(limit = 20)
assertEquals(
listOf(activeEventBanner.id, activeCreatorBanner.id, activeSeriesBanner.id, linkBanner.id),
banners.map { it.bannerId }
)
}
@Test
@DisplayName("최근 활동 크리에이터는 크리에이터당 최신 활동 1개를 LIVE/AUDIO/COMMUNITY/LIVE_REPLAY로 분류한다")
fun shouldFindOneLatestActivityPerCreatorWithActivityType() {
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
val liveCreator = saveMember("activity-live", MemberRole.CREATOR)
val audioCreator = saveMember("activity-audio", MemberRole.CREATOR)
val replayCreator = saveMember("activity-replay", MemberRole.CREATOR)
val communityCreator = saveMember("activity-community", MemberRole.CREATOR)
saveAudioContent(liveCreator, baseAt.minusDays(2), isActive = true)
saveLiveRoom(liveCreator, baseAt, channelName = "activity-live-channel")
val audio = saveAudioContent(audioCreator, baseAt.minusHours(1), isActive = true)
val replay = saveAudioContent(replayCreator, baseAt.minusHours(2), isActive = true, themeName = "다시듣기")
val community = saveCommunity(communityCreator, isCommentAvailable = true)
updateCreatedAt("CreatorCommunity", community.id!!, baseAt.minusHours(3))
flushAndClear()
val creators = repository.findRecentlyActiveCreators(limit = 10)
val byCreatorId = creators.associateBy { it.creatorId }
assertEquals(4, creators.size)
assertEquals(
listOf(liveCreator.id, audioCreator.id, replayCreator.id, communityCreator.id),
creators.map { it.creatorId }
)
assertEquals(RecommendedActivityType.LIVE, byCreatorId[liveCreator.id]!!.activityType)
assertEquals(null, byCreatorId[liveCreator.id]!!.targetId)
assertEquals(baseAt, byCreatorId[liveCreator.id]!!.activityAt)
assertEquals(RecommendedActivityType.AUDIO, byCreatorId[audioCreator.id]!!.activityType)
assertEquals(audio.id, byCreatorId[audioCreator.id]!!.targetId)
assertEquals(RecommendedActivityType.LIVE_REPLAY, byCreatorId[replayCreator.id]!!.activityType)
assertEquals(replay.id, byCreatorId[replayCreator.id]!!.targetId)
assertEquals(RecommendedActivityType.COMMUNITY, byCreatorId[communityCreator.id]!!.activityType)
assertEquals(community.id, byCreatorId[communityCreator.id]!!.targetId)
}
@Test
@DisplayName("AI 캐릭터 스냅샷은 AI 발화 수와 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다")
fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() {
@@ -358,6 +563,29 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(expectedCommentDisabledScore, snapshots[commentDisabledPost.id]!!.score, 0.0001)
}
@Test
@DisplayName("인기 커뮤니티 스냅샷은 성인 게시글도 후보 점수 산정에 포함한다")
fun shouldIncludeAdultCommunitiesInPopularCommunitySnapshots() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val creator = saveMember("adult-community-creator", MemberRole.CREATOR)
val member = saveMember("adult-community-member", MemberRole.USER)
saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "adult-community-live")
val normalPost = saveCommunity(creator, isCommentAvailable = true, isAdult = false)
val adultPost = saveCommunity(creator, isCommentAvailable = true, isAdult = true)
val normalLike = saveCommunityLike(member, normalPost, isActive = true)
val adultLike = saveCommunityLike(member, adultPost, isActive = true)
updateCreatedAt("CreatorCommunity", normalPost.id!!, windowStart.plusDays(1))
updateCreatedAt("CreatorCommunity", adultPost.id!!, windowStart.plusDays(1))
updateCreatedAt("CreatorCommunityLike", normalLike.id!!, windowStart.plusDays(1))
updateCreatedAt("CreatorCommunityLike", adultLike.id!!, windowStart.plusDays(1))
flushAndClear()
val snapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10)
assertEquals(setOf(normalPost.id, adultPost.id), snapshots.map { it.targetId }.toSet())
}
@Test
@DisplayName("인기 커뮤니티 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다")
fun shouldFindPopularCommunitySnapshotsWithDbScoreOrderAndLimit() {
@@ -468,18 +696,306 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001)
}
private fun saveMember(nickname: String, role: MemberRole): Member {
@Test
@DisplayName("최근 응원과 인기 커뮤니티 스냅샷 데뷔일은 빈 채널명 라이브를 제외한다")
fun shouldExcludeBlankChannelNameLiveFromSnapshotDebutAt() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val blankLiveAt = LocalDateTime.of(2026, 5, 1, 12, 0)
val contentDebutAt = LocalDateTime.of(2026, 5, 20, 12, 0)
val creator = saveMember("blank-live-debut-creator", MemberRole.CREATOR)
val donor = saveMember("blank-live-debut-donor", MemberRole.USER)
saveLiveRoom(creator, blankLiveAt, channelName = "")
saveAudioContent(creator, contentDebutAt, isActive = true)
saveUseCanCalculate(
donor,
creator,
CanUsage.CHANNEL_DONATION,
can = 100,
status = UseCanCalculateStatus.RECEIVED,
isRefund = false,
createdAt = windowStart.plusDays(1)
)
val community = saveCommunity(creator, isCommentAvailable = true)
val like = saveCommunityLike(donor, community, isActive = true)
updateCreatedAt("CreatorCommunity", community.id!!, windowStart.plusDays(1))
updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1))
flushAndClear()
val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10).single()
val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10).single()
val expectedCheerScore = scorePolicy.calculateCheerScore(
donationAmount = 100,
fanTalkCount = 0,
donationCount = 1,
newBoost = scorePolicy.calculateCreatorNewBoost(contentDebutAt, snapshotAt)
)
val expectedCommunityScore = scorePolicy.calculateCommunityScore(
likeCount = 1,
commentCount = 0,
followerCount = 0,
newBoost = scorePolicy.calculateCreatorNewBoost(contentDebutAt, snapshotAt)
)
assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001)
assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001)
}
@Test
@DisplayName("최근 데뷔 크리에이터는 실제 데뷔일 30일 이내 후보를 PRD 산식 점수순으로 조회한다")
fun shouldFindRecentDebutCreatorsWithinThirtyDaysOrderedByScore() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val newHighScoreCreator = saveMember("new-high-debut", MemberRole.CREATOR)
val newLowScoreCreator = saveMember("new-low-debut", MemberRole.CREATOR)
val oldCreator = saveMember("old-debut", MemberRole.CREATOR)
val follower = saveMember("debut-follower", MemberRole.USER)
val commenter = saveMember("debut-commenter", MemberRole.USER)
val highContent = saveAudioContent(newHighScoreCreator, now.minusDays(20), isActive = true)
val lowContent = saveAudioContent(newLowScoreCreator, now.minusDays(5), isActive = true)
saveAudioContent(oldCreator, now.minusDays(31), isActive = true)
updateCreatedAt("AudioContent", highContent.id!!, now.minusDays(20))
updateCreatedAt("AudioContent", lowContent.id!!, now.minusDays(5))
val following = saveFollowing(follower, newHighScoreCreator, isActive = true)
val comment = saveAudioContentComment(commenter, highContent, isActive = true)
val like = saveAudioContentLike(commenter, highContent, isActive = true)
updateCreatedAt("CreatorFollowing", following.id!!, now.minusDays(1))
updateCreatedAt("AudioContentComment", comment.id!!, now.minusDays(1))
updateCreatedAt("AudioContentLike", like.id!!, now.minusDays(1))
updateCreatedAt("Member", newHighScoreCreator.id!!, now.minusDays(60))
flushAndClear()
val creators = repository.findRecentDebutCreators(now, limit = 10)
val expectedHighScore = scorePolicy.calculateDebutCreatorScore(
followIncrease = 1,
contentActivityScore = 1,
communicationScore = 2,
newBoost = scorePolicy.calculateCreatorNewBoost(now.minusDays(20), now)
)
val expectedLowScore = scorePolicy.calculateDebutCreatorScore(
followIncrease = 0,
contentActivityScore = 1,
communicationScore = 0,
newBoost = scorePolicy.calculateCreatorNewBoost(now.minusDays(5), now)
)
assertEquals(listOf(newHighScoreCreator.id, newLowScoreCreator.id), creators.map { it.creatorId })
assertEquals(now.minusDays(20), creators.first().debutAt)
assertEquals(expectedHighScore, creators.first().score, 0.0001)
assertEquals(expectedLowScore, creators.last().score, 0.0001)
}
@Test
@DisplayName("최근 데뷔 크리에이터 동점은 랜덤 tie-breaker 오름차순으로 정렬한다")
fun shouldOrderRecentDebutCreatorsByRandomTieBreakerWhenScoresTie() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val creator1 = saveMember("tie-debut-1", MemberRole.CREATOR)
val creator2 = saveMember("tie-debut-2", MemberRole.CREATOR)
saveAudioContent(creator1, now.minusDays(5), isActive = true)
saveAudioContent(creator2, now.minusDays(5), isActive = true)
flushAndClear()
val creators = repository.findRecentDebutCreators(now, limit = 10)
assertEquals(2, creators.size)
assertEquals(true, creators.zipWithNext().all { it.first.randomTieBreaker <= it.second.randomTieBreaker })
}
@Test
@DisplayName("첫 오디오 콘텐츠는 생성/공개일 기준 첫 3개 안의 활성 공개 콘텐츠만 조회하고 비활성 선행 콘텐츠 경계를 지킨다")
fun shouldFindFirstAudioContentsWithinFirstThreeUploadsAndInactiveBoundary() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val eligibleCreator = saveMember("first-audio-eligible", MemberRole.CREATOR)
val excludedCreator = saveMember("first-audio-excluded", MemberRole.CREATOR)
val eligibleInactive1 = saveAudioContent(eligibleCreator, now.minusDays(10), isActive = false)
val eligibleInactive2 = saveAudioContent(eligibleCreator, now.minusDays(9), isActive = false)
val eligibleActive = saveAudioContent(eligibleCreator, now.minusDays(2), isActive = true)
val excludedInactive1 = saveAudioContent(excludedCreator, now.minusDays(10), isActive = false)
val excludedInactive2 = saveAudioContent(excludedCreator, now.minusDays(9), isActive = false)
val excludedInactive3 = saveAudioContent(excludedCreator, now.minusDays(8), isActive = false)
val excludedActive = saveAudioContent(excludedCreator, now.minusDays(1), isActive = true)
listOf(
eligibleInactive1 to now.minusDays(10),
eligibleInactive2 to now.minusDays(9),
eligibleActive to now.minusDays(2),
excludedInactive1 to now.minusDays(10),
excludedInactive2 to now.minusDays(9),
excludedInactive3 to now.minusDays(8),
excludedActive to now.minusDays(1)
).forEach { (content, createdAt) -> updateCreatedAt("AudioContent", content.id!!, createdAt) }
flushAndClear()
val contents = repository.findFirstAudioContents(now, limit = 10)
assertEquals(listOf(eligibleActive.id), contents.map { it.contentId })
assertEquals(100, contents.single().recencyScore)
}
@Test
@DisplayName("첫 오디오 콘텐츠는 예약/미공개 콘텐츠를 제외하고 releaseDate 최신성 점수순으로 조회한다")
fun shouldFindFirstAudioContentsOrderedByReleaseDateRecencyScoreExcludingScheduledContents() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val freshCreator = saveMember("fresh-first-audio", MemberRole.CREATOR)
val oldCreator = saveMember("old-first-audio", MemberRole.CREATOR)
val scheduledCreator = saveMember("scheduled-first-audio", MemberRole.CREATOR)
val fresh = saveAudioContent(freshCreator, now.minusDays(3), isActive = true)
val old = saveAudioContent(oldCreator, now.minusDays(21), isActive = true)
saveAudioContent(scheduledCreator, now.plusDays(1), isActive = true)
updateCreatedAt("AudioContent", fresh.id!!, now.minusDays(20))
updateCreatedAt("AudioContent", old.id!!, now.minusDays(20))
flushAndClear()
val contents = repository.findFirstAudioContents(now, limit = 10)
assertEquals(listOf(fresh.id, old.id), contents.map { it.contentId })
assertEquals(listOf(100, 40), contents.map { it.recencyScore })
assertEquals(true, contents.zipWithNext().all { it.first.recencyScore >= it.second.recencyScore })
}
@Test
@DisplayName("AI 캐릭터 상세는 활성 캐릭터의 이름 소개 전체 AI 발화 수와 연결된 원작명만 조회한다")
fun shouldFindAiCharacterRecommendationDetailsWithOriginalWorkTitleOnlyWhenExists() {
val originalWork = saveOriginalWork("original-title")
val characterWithWork = saveCharacter("ai-detail-work", isActive = true, originalWork = originalWork)
val characterWithoutWork = saveCharacter("ai-detail-no-work", isActive = true)
val inactiveCharacter = saveCharacter("ai-detail-inactive", isActive = false)
val room = saveChatRoom("ai-detail-room")
val workParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = characterWithWork)
val noWorkParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = characterWithoutWork)
val inactiveParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = inactiveCharacter)
saveMessage(room, workParticipant, "work-1", isActive = true)
saveMessage(room, workParticipant, "work-2", isActive = true)
saveMessage(room, workParticipant, "inactive-work", isActive = false)
saveMessage(room, noWorkParticipant, "no-work", isActive = true)
saveMessage(room, inactiveParticipant, "inactive-character", isActive = true)
flushAndClear()
val details = repository.findAiCharacterRecommendationDetails(
listOf(characterWithWork.id!!, characterWithoutWork.id!!, inactiveCharacter.id!!, 999L)
)
.associateBy { it.characterId }
assertEquals(setOf(characterWithWork.id, characterWithoutWork.id), details.keys)
assertEquals("ai-detail-work", details[characterWithWork.id]!!.name)
assertEquals("description", details[characterWithWork.id]!!.description)
assertEquals(2L, details[characterWithWork.id]!!.totalChatCount)
assertEquals("original-title", details[characterWithWork.id]!!.originalWorkTitle)
assertEquals(1L, details[characterWithoutWork.id]!!.totalChatCount)
assertEquals(null, details[characterWithoutWork.id]!!.originalWorkTitle)
}
@Test
@DisplayName("AI 캐릭터 상세는 빈 id 목록이면 빈 배열을 반환한다")
fun shouldReturnEmptyAiCharacterRecommendationDetailsWhenIdsAreEmpty() {
assertEquals(
emptyList<HomeAiCharacterRecommendationRecord>(),
repository.findAiCharacterRecommendationDetails(emptyList())
)
}
@Test
@DisplayName("최근 응원 크리에이터 상세는 활성 크리에이터의 닉네임과 프로필만 조회한다")
fun shouldFindCheerCreatorRecommendationDetailsForActiveCreatorsOnly() {
val activeCreator = saveMember("cheer-detail-active", MemberRole.CREATOR)
val inactiveCreator = saveMember("cheer-detail-inactive", MemberRole.CREATOR, isActive = false)
flushAndClear()
val details = repository.findCheerCreatorRecommendationDetails(listOf(activeCreator.id!!, inactiveCreator.id!!, 999L))
val detailById = details.associateBy { it.creatorId }
assertEquals(setOf(activeCreator.id), detailById.keys)
assertEquals("cheer-detail-active", detailById[activeCreator.id]!!.creatorNickname)
}
@Test
@DisplayName("최근 응원 크리에이터 상세는 빈 id 목록이면 빈 배열을 반환한다")
fun shouldReturnEmptyCheerCreatorRecommendationDetailsWhenIdsAreEmpty() {
assertEquals(
emptyList<HomeCheerCreatorRecommendationRecord>(),
repository.findCheerCreatorRecommendationDetails(emptyList())
)
}
@Test
@DisplayName("인기 커뮤니티 상세는 노출 가능 게시글만 좋아요/댓글 수와 크리에이터 정보로 조회한다")
fun shouldFindPopularCommunityRecommendationDetailsWithEligibilityAndCounts() {
val creator = saveMember("community-detail-creator", MemberRole.CREATOR)
val inactiveCreator = saveMember("community-detail-inactive-creator", MemberRole.CREATOR, isActive = false)
val member = saveMember("community-detail-member", MemberRole.USER)
val eligible = saveCommunity(creator, isCommentAvailable = true)
val paid = saveCommunity(creator, isCommentAvailable = true, price = 10)
val fixed = saveCommunity(creator, isCommentAvailable = true, isFixed = true)
val adult = saveCommunity(creator, isCommentAvailable = true, isAdult = true)
val inactivePost = saveCommunity(creator, isCommentAvailable = true, isActive = false)
val inactiveCreatorPost = saveCommunity(inactiveCreator, isCommentAvailable = true)
val like1 = saveCommunityLike(member, eligible, isActive = true)
val like2 = saveCommunityLike(member, eligible, isActive = true)
saveCommunityLike(member, eligible, isActive = false)
val comment1 = saveCommunityComment(member, eligible, isActive = true)
saveCommunityComment(member, eligible, isActive = false)
updateCreatedAt("CreatorCommunity", eligible.id!!, LocalDateTime.of(2026, 5, 29, 1, 0))
updateCreatedAt("CreatorCommunityLike", like1.id!!, LocalDateTime.of(2026, 5, 29, 2, 0))
updateCreatedAt("CreatorCommunityLike", like2.id!!, LocalDateTime.of(2026, 5, 29, 3, 0))
updateCreatedAt("CreatorCommunityComment", comment1.id!!, LocalDateTime.of(2026, 5, 29, 4, 0))
flushAndClear()
val details = repository.findPopularCommunityRecommendationDetails(
listOf(eligible.id!!, paid.id!!, fixed.id!!, adult.id!!, inactivePost.id!!, inactiveCreatorPost.id!!, 999L),
includeAdultCommunities = false
)
val detailById = details.associateBy { it.communityId }
assertEquals(setOf(eligible.id), detailById.keys)
assertEquals("content", detailById[eligible.id]!!.content)
assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0), detailById[eligible.id]!!.createdAt)
assertEquals(2L, detailById[eligible.id]!!.likeCount)
assertEquals(1L, detailById[eligible.id]!!.commentCount)
assertEquals(creator.id, detailById[eligible.id]!!.creatorId)
assertEquals("community-detail-creator", detailById[eligible.id]!!.creatorNickname)
}
@Test
@DisplayName("인기 커뮤니티 상세는 성인 노출 가능 회원에게 성인 게시글을 포함한다")
fun shouldFindAdultPopularCommunityDetailsWhenAdultVisible() {
val creator = saveMember("adult-visible-community-creator", MemberRole.CREATOR)
val member = saveMember("adult-visible-community-member", MemberRole.USER)
val adult = saveCommunity(creator, isCommentAvailable = true, isAdult = true)
val like = saveCommunityLike(member, adult, isActive = true)
updateCreatedAt("CreatorCommunity", adult.id!!, LocalDateTime.of(2026, 5, 29, 1, 0))
updateCreatedAt("CreatorCommunityLike", like.id!!, LocalDateTime.of(2026, 5, 29, 2, 0))
flushAndClear()
val details = repository.findPopularCommunityRecommendationDetails(
listOf(adult.id!!),
includeAdultCommunities = true
)
val detailById = details.associateBy { it.communityId }
assertEquals(setOf(adult.id), detailById.keys)
assertEquals(1L, detailById[adult.id]!!.likeCount)
}
@Test
@DisplayName("인기 커뮤니티 상세는 빈 id 목록이면 빈 배열을 반환한다")
fun shouldReturnEmptyPopularCommunityRecommendationDetailsWhenIdsAreEmpty() {
assertEquals(
emptyList<HomePopularCommunityRecommendationRecord>(),
repository.findPopularCommunityRecommendationDetails(emptyList(), includeAdultCommunities = false)
)
}
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
role = role,
isActive = isActive
)
entityManager.persist(member)
return member
}
private fun saveCharacter(name: String, isActive: Boolean): ChatCharacter {
private fun saveCharacter(name: String, isActive: Boolean, originalWork: OriginalWork? = null): ChatCharacter {
val character = ChatCharacter(
characterUUID = "$name-uuid",
name = name,
@@ -487,10 +1003,23 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
systemPrompt = "system",
isActive = isActive
)
character.originalWork = originalWork
entityManager.persist(character)
return character
}
private fun saveOriginalWork(title: String): OriginalWork {
val originalWork = OriginalWork(
title = title,
contentType = "webtoon",
category = "romance",
isAdult = false,
description = "description"
)
entityManager.persist(originalWork)
return originalWork
}
private fun saveChatRoom(sessionId: String): ChatRoom {
val room = ChatRoom(sessionId = sessionId, title = sessionId)
entityManager.persist(room)
@@ -542,21 +1071,35 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
return cheers
}
private fun saveCommunity(creator: Member, isCommentAvailable: Boolean): CreatorCommunity {
private fun saveCommunity(
creator: Member,
isCommentAvailable: Boolean,
price: Int = 0,
isAdult: Boolean = false,
isActive: Boolean = true,
isFixed: Boolean = false
): CreatorCommunity {
val community = CreatorCommunity(
content = "content",
price = 0,
price = price,
isCommentAvailable = isCommentAvailable,
isAdult = false
isAdult = isAdult,
isActive = isActive,
isFixed = isFixed
)
community.member = creator
entityManager.persist(community)
return community
}
private fun saveAudioContent(creator: Member, releaseDate: LocalDateTime, isActive: Boolean): AudioContent {
private fun saveAudioContent(
creator: Member,
releaseDate: LocalDateTime,
isActive: Boolean,
themeName: String = "theme-${creator.nickname}-$releaseDate"
): AudioContent {
val theme = AudioContentTheme(
theme = "theme-${creator.nickname}-$releaseDate",
theme = themeName,
image = "theme-${creator.nickname}-$releaseDate.png"
)
entityManager.persist(theme)
@@ -574,6 +1117,86 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
return content
}
private fun saveAudioContentComment(member: Member, content: AudioContent, isActive: Boolean): AudioContentComment {
val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive)
comment.member = member
comment.audioContent = content
entityManager.persist(comment)
return comment
}
private fun saveAudioContentLike(member: Member, content: AudioContent, isActive: Boolean): AudioContentLike {
val like = AudioContentLike(memberId = member.id!!)
like.audioContent = content
like.isActive = isActive
entityManager.persist(like)
return like
}
private fun saveEvent(title: String, isActive: Boolean = true): Event {
val event = Event(
thumbnailImage = "$title-thumbnail.png",
detailImage = "$title-detail.png",
popupImage = null,
link = "https://$title.test",
title = title,
startDate = LocalDateTime.of(2026, 5, 1, 0, 0),
endDate = LocalDateTime.of(2026, 6, 1, 0, 0),
isActive = isActive
)
entityManager.persist(event)
return event
}
private fun saveSeries(title: String, owner: Member, isActive: Boolean): Series {
val genre = SeriesGenre(genre = "genre-$title")
entityManager.persist(genre)
val series = Series(
title = title,
introduction = "introduction",
languageCode = "ko",
isActive = isActive
)
series.member = owner
series.genre = genre
entityManager.persist(series)
return series
}
private fun saveMainTab(title: String): AudioContentMainTab {
val tab = AudioContentMainTab(title = title, isActive = true)
entityManager.persist(tab)
return tab
}
private fun saveBanner(
thumbnailImage: String,
type: AudioContentBannerType,
orders: Int,
isActive: Boolean,
creator: Member? = null,
event: Event? = null,
series: Series? = null,
tab: AudioContentMainTab? = null,
link: String? = null
): AudioContentBanner {
val banner = AudioContentBanner(
thumbnailImage = thumbnailImage,
type = type,
lang = Lang.KO,
isAdult = false,
isActive = isActive,
orders = orders
)
banner.creator = creator
banner.event = event
banner.series = series
banner.tab = tab
banner.link = link
entityManager.persist(banner)
return banner
}
private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom {
val room = LiveRoom(
title = "live-${creator.nickname}-$beginDateTime",