refactor(audio-recommendation): 추천 패키지를 content 기준으로 이동한다

This commit is contained in:
2026-06-23 21:51:00 +09:00
parent ab67e36d96
commit cf73263505
22 changed files with 64 additions and 64 deletions
@@ -0,0 +1,454 @@
package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
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.theme.AudioContentTheme
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicy
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.CreatorContentViewHistory
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
@Test
@DisplayName("배너는 홈 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다")
fun shouldFindBannersWithHomeBannerPolicy() {
val viewer = saveMember("viewer", MemberRole.USER)
val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR)
val visibleBanner = saveBanner("visible.png", AudioContentBannerType.CREATOR, 1, creator = visibleCreator)
val adultBanner = saveBanner("adult.png", AudioContentBannerType.LINK, 2, isAdult = true, link = "https://adult.test")
saveBanner("inactive.png", AudioContentBannerType.LINK, 2, isActive = false, link = "https://inactive.test")
saveBanner("blocked.png", AudioContentBannerType.CREATOR, 3, creator = blockedCreator)
saveBlock(viewer, blockedCreator)
flushAndClear()
val banners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false)
assertEquals(
listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"),
banners.map { it.imageUrl }
)
assertEquals(visibleCreator.id, banners.first().creatorId)
}
@Test
@DisplayName("오리지널 시리즈는 활성 원본 시리즈를 최신순으로 반환하고 성인/차단 조건을 적용한다")
fun shouldFindOriginalSeriesWithVisibilityConditions() {
val viewer = saveMember("series-viewer", MemberRole.USER)
val visibleCreator = saveMember("series-visible", MemberRole.CREATOR)
val blockedCreator = saveMember("series-blocked", MemberRole.CREATOR)
val visibleSeries = (1..13).map { index ->
saveSeries("visible-series-$index", visibleCreator, isOriginal = true, coverImage = "series-$index.png")
}
saveSeries("normal-series", visibleCreator, isOriginal = false)
saveSeries("adult-series", visibleCreator, isOriginal = true, isAdult = true)
saveSeries("blocked-series", blockedCreator, isOriginal = true)
saveBlock(viewer, blockedCreator)
flushAndClear()
val series = repository.findOriginalSeries(12, viewer.id, canViewAdultContent = false, now = LocalDateTime.now())
assertEquals(12, series.size)
assertEquals(visibleSeries.map { it.id }.asReversed().take(12), series.map { it.seriesId })
assertEquals("https://cdn.test/series-13.png", series.first().coverImageUrl)
}
@Test
@DisplayName("최신/무료/포인트 오디오는 공개 조건과 공통 AudioCard enrichment를 적용한다")
fun shouldFindRealtimeAudioCardsWithCommonEnrichment() {
val now = LocalDateTime.of(2026, 6, 23, 12, 0)
val creator = saveMember("audio-creator", MemberRole.CREATOR)
val theme = saveTheme()
val first = saveAudio(
creator = creator,
theme = theme,
title = "first",
releaseDate = now.minusDays(3),
price = 0,
isPointAvailable = true,
coverImage = "first.png"
)
val latest = saveAudio(
creator = creator,
theme = theme,
title = "latest",
releaseDate = now.minusDays(1),
price = 10,
isPointAvailable = false,
coverImage = "latest.png"
)
saveAudio(creator, theme, "adult", now.minusHours(1), isAdult = true)
saveAudio(creator, theme, "future", now.plusDays(1))
saveAudio(creator, theme, "inactive", now.minusHours(2)).isActive = false
saveAudio(creator, theme, "no-duration", now.minusHours(3)).duration = null
saveAudio(creator, theme, "no-release-date", now.minusHours(4)).releaseDate = null
val inactiveCreator = saveMember("inactive-audio-creator", MemberRole.CREATOR, isActive = false)
saveAudio(inactiveCreator, theme, "inactive-creator", now.minusHours(5))
val viewer = saveMember("blocked-audio-viewer", MemberRole.USER)
val blockedCreator = saveMember("blocked-audio-creator", MemberRole.CREATOR)
saveAudio(blockedCreator, theme, "blocked", now.minusHours(6))
saveBlock(viewer, blockedCreator)
val originalSeries = saveSeries("original", creator, isOriginal = true)
saveSeriesContent(originalSeries, latest)
val limitCreator = saveMember("limit-audio-creator", MemberRole.CREATOR)
repeat(11) { index ->
saveAudio(
creator = limitCreator,
theme = theme,
title = "free-point-$index",
releaseDate = now.minusDays(10).minusMinutes(index.toLong()),
price = 0,
isPointAvailable = true
)
}
flushAndClear()
val latestAudios = repository.findLatestAudios(12, viewer.id, canViewAdultContent = false, now = now)
val freeAudios = repository.findFreeAudios(10, viewer.id, canViewAdultContent = false, now = now)
val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now)
assertEquals(12, latestAudios.size)
assertEquals(listOf(latest.id, first.id), latestAudios.take(2).map { it.audioContentId })
assertEquals(10, freeAudios.size)
assertEquals(true, freeAudios.all { it.price == 0 })
assertEquals(10, pointAudios.size)
assertEquals(true, pointAudios.all { it.isPointAvailable })
val latestCard = latestAudios.first()
assertEquals("latest", latestCard.title)
assertEquals("00:01", latestCard.duration)
assertEquals("https://cdn.test/latest.png", latestCard.imageUrl)
assertEquals(10, latestCard.price)
assertEquals(false, latestCard.isAdult)
assertEquals(false, latestCard.isPointAvailable)
assertEquals(false, latestCard.isFirstContent)
assertEquals(true, latestCard.isOriginalSeries)
assertEquals(creator.nickname, latestCard.creatorNickname)
assertEquals(true, latestAudios[1].isFirstContent)
assertEquals(false, latestAudios[1].isOriginalSeries)
}
@Test
@DisplayName("New & Hot 후보는 조회/좋아요/댓글/최신성 점수순으로 산정하고 SAFE는 성인을 제외한다")
fun shouldFindNewAndHotSnapshotsWithVisibility() {
val snapshotAt = LocalDateTime.now().plusDays(1)
val windowStart = snapshotAt.minusDays(2).toLocalDate().atStartOfDay()
val creator = saveMember("snapshot-creator", MemberRole.CREATOR)
val theme = saveTheme()
val visible = saveAudio(creator, theme, "visible-hot", snapshotAt.minusDays(1))
val adult = saveAudio(creator, theme, "adult-hot", snapshotAt.minusDays(1), isAdult = true)
repeat(2) { saveView(visible, snapshotAt.minusHours(it.toLong())) }
saveLike(visible, snapshotAt.minusHours(1))
saveComment(visible, creator, "visible-comment", snapshotAt.minusHours(1))
repeat(5) { saveView(adult, snapshotAt.minusHours(it.toLong())) }
flushAndClear()
val safe = repository.findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 12)
val all = repository.findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.ALL, 12)
assertEquals(listOf(visible.id), safe.map { it.targetId })
assertEquals(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, safe.first().sectionType)
assertEquals(listOf(adult.id, visible.id), all.map { it.targetId })
val expectedScore = AudioRecommendationScorePolicy().calculateNewAndHotScore(
viewCount = 2,
likeCount = 1,
commentCount = 1,
releaseDate = visible.releaseDate!!,
now = snapshotAt
)
assertEquals(expectedScore, safe.first().score)
}
@Test
@DisplayName("최근 댓글 많은 오디오는 댓글 점수 후보와 최신 댓글 상세를 반환한다")
fun shouldFindMostCommentedSnapshotsAndCommentedAudios() {
val snapshotAt = LocalDateTime.now().plusDays(1)
val windowStart = snapshotAt.minusDays(6).toLocalDate().atStartOfDay()
val viewer = saveMember("comment-viewer", MemberRole.USER)
val creator = saveMember("comment-creator", MemberRole.CREATOR)
val writer = saveMember("comment-writer", MemberRole.USER).apply { profileImage = "writer.png" }
val blockedWriter = saveMember("blocked-writer", MemberRole.USER)
val inactiveWriter = saveMember("inactive-comment-writer", MemberRole.USER, isActive = false)
val theme = saveTheme()
val first = saveAudio(creator, theme, "first-commented", snapshotAt.minusDays(2), coverImage = "commented.png")
val second = saveAudio(creator, theme, "second-commented", snapshotAt.minusDays(2))
val hiddenOnly = saveAudio(creator, theme, "hidden-only", snapshotAt.minusDays(2))
val invisibleOnly = saveAudio(creator, theme, "invisible-only", snapshotAt.minusDays(2))
saveComment(first, writer, "old", snapshotAt.minusDays(2))
saveComment(first, writer, "latest", snapshotAt.minusHours(1))
saveComment(first, blockedWriter, "blocked-latest", snapshotAt.minusMinutes(30))
saveComment(first, writer, "inactive", snapshotAt.minusMinutes(1), isActive = false)
saveComment(second, blockedWriter, "blocked", snapshotAt.minusHours(2))
val parent = saveComment(hiddenOnly, writer, "parent", snapshotAt.minusDays(1), isSecret = true)
saveComment(hiddenOnly, writer, "reply", snapshotAt.minusHours(1), parent = parent)
saveComment(invisibleOnly, inactiveWriter, "inactive-writer", snapshotAt.minusHours(2))
saveComment(invisibleOnly, blockedWriter, "blocked-writer", snapshotAt.minusHours(1))
saveBlock(creator, blockedWriter)
saveBlock(viewer, blockedWriter)
flushAndClear()
val snapshots = repository.findMostCommentedSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 5)
val commented = repository.findCommentedAudiosByIds(
contentIds = listOf(first.id!!, second.id!!),
memberId = viewer.id,
canViewAdultContent = false
)
assertEquals(listOf(first.id), snapshots.map { it.targetId })
assertEquals(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, snapshots.first().sectionType)
assertEquals(listOf(first.id), commented.map { it.audioContentId })
assertEquals("latest", commented.first().latestComment)
assertEquals("https://cdn.test/writer.png", commented.first().latestCommentWriterProfileImageUrl)
assertEquals("https://cdn.test/commented.png", commented.first().imageUrl)
}
@Test
@DisplayName("댓글 상세는 공개 최상위 댓글만 노출하고 동일 시각이면 id가 큰 댓글 하나를 선택한다")
fun shouldFindLatestVisibleTopLevelCommentWithIdTieBreaker() {
val now = LocalDateTime.now().plusDays(1)
val viewer = saveMember("tie-viewer", MemberRole.USER)
val creator = saveMember("tie-creator", MemberRole.CREATOR)
val writer = saveMember("tie-writer", MemberRole.USER)
val theme = saveTheme()
val audio = saveAudio(creator, theme, "tie-commented", now.minusDays(1))
val sameCreatedAt = now.minusHours(1)
saveComment(audio, writer, "same-time-first", sameCreatedAt)
saveComment(audio, writer, "same-time-second", sameCreatedAt)
saveComment(audio, writer, "secret-latest", now.minusMinutes(20), isSecret = true)
val parent = saveComment(audio, writer, "public-parent", now.minusHours(2))
saveComment(audio, writer, "reply-latest", now.minusMinutes(10), parent = parent)
flushAndClear()
val commented = repository.findCommentedAudiosByIds(
contentIds = listOf(audio.id!!),
memberId = viewer.id,
canViewAdultContent = false
)
assertEquals(1, commented.size)
assertEquals("same-time-second", commented.single().latestComment)
}
@Test
@DisplayName("댓글 상세는 크리에이터와 댓글 작성자 간 차단 댓글을 최신 댓글에서 제외한다")
fun shouldExcludeCreatorBlockedWriterFromLatestCommentDetail() {
val now = LocalDateTime.now().plusDays(1)
val viewer = saveMember("creator-block-comment-viewer", MemberRole.USER)
val creator = saveMember("creator-block-comment-creator", MemberRole.CREATOR)
val writer = saveMember("creator-block-comment-writer", MemberRole.USER)
val blockedWriter = saveMember("creator-block-comment-blocked-writer", MemberRole.USER)
val theme = saveTheme()
val audio = saveAudio(creator, theme, "creator-block-commented", now.minusDays(1))
saveComment(audio, writer, "visible-comment", now.minusHours(2))
saveComment(audio, blockedWriter, "creator-blocked-latest", now.minusHours(1))
saveBlock(creator, blockedWriter)
flushAndClear()
val commented = repository.findCommentedAudiosByIds(
contentIds = listOf(audio.id!!),
memberId = viewer.id,
canViewAdultContent = false
)
assertEquals(1, commented.size)
assertEquals("visible-comment", commented.single().latestComment)
}
@Test
@DisplayName("추천 오디오는 playCount가 아니라 조회 이력 기반 점수로 산정한다")
fun shouldFindRecommendedAudioSnapshotsWithoutPlayCount() {
val snapshotAt = LocalDateTime.now().plusDays(1)
val windowStart = snapshotAt.minusDays(6).toLocalDate().atStartOfDay()
val creator = saveMember("recommended-creator", MemberRole.CREATOR)
val theme = saveTheme()
val viewed = saveAudio(creator, theme, "viewed", snapshotAt.minusDays(1))
val playCountOnly = saveAudio(creator, theme, "play-count-only", snapshotAt.minusDays(1)).apply { playCount = 999 }
repeat(3) { saveView(viewed, snapshotAt.minusHours(it.toLong())) }
flushAndClear()
val snapshots = repository.findRecommendedAudioSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 10)
assertEquals(viewed.id, snapshots.first().targetId)
assertEquals(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, snapshots.first().sectionType)
assertEquals(
true,
snapshots.indexOfFirst { it.targetId == viewed.id } <
snapshots.indexOfFirst { it.targetId == playCountOnly.id }
)
}
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role,
isActive = isActive
)
entityManager.persist(member)
return member
}
private fun saveTheme(): AudioContentTheme {
val theme = AudioContentTheme(theme = "theme", image = "theme.png", isActive = true)
entityManager.persist(theme)
return theme
}
private fun saveBanner(
thumbnailImage: String,
type: AudioContentBannerType,
orders: Int,
isActive: Boolean = true,
isAdult: Boolean = false,
creator: Member? = null,
link: String? = null
): AudioContentBanner {
val banner = AudioContentBanner(
thumbnailImage = thumbnailImage,
type = type,
isAdult = isAdult,
isActive = isActive,
orders = orders
)
banner.creator = creator
banner.link = link
entityManager.persist(banner)
return banner
}
private fun saveSeries(
title: String,
creator: Member,
isOriginal: Boolean,
isAdult: Boolean = false,
coverImage: String? = null
): Series {
val genre = SeriesGenre("genre-$title")
entityManager.persist(genre)
val series = Series(title = title, introduction = "intro", isOriginal = isOriginal, isAdult = isAdult, isActive = true)
series.genre = genre
series.member = creator
series.coverImage = coverImage
entityManager.persist(series)
return series
}
private fun saveAudio(
creator: Member,
theme: AudioContentTheme,
title: String,
releaseDate: LocalDateTime,
price: Int = 0,
isAdult: Boolean = false,
isPointAvailable: Boolean = false,
coverImage: String? = null
): AudioContent {
val audio = AudioContent(
title = title,
detail = "detail",
languageCode = "ko",
price = price,
releaseDate = releaseDate,
isAdult = isAdult,
isPointAvailable = isPointAvailable
)
audio.isActive = true
audio.duration = "00:01"
audio.coverImage = coverImage
audio.member = creator
audio.theme = theme
entityManager.persist(audio)
return audio
}
private fun saveView(audio: AudioContent, viewedAt: LocalDateTime) {
entityManager.persist(
CreatorContentViewHistory(memberId = 1L, contentId = audio.id!!, genreId = 1L, viewedAt = viewedAt)
)
}
private fun saveLike(audio: AudioContent, createdAt: LocalDateTime) {
val like = AudioContentLike(memberId = 1L)
like.audioContent = audio
like.createdAt = createdAt
like.updatedAt = createdAt
entityManager.persist(like)
}
private fun saveComment(
audio: AudioContent,
writer: Member,
commentBody: String,
createdAt: LocalDateTime,
isActive: Boolean = true,
isSecret: Boolean = false,
parent: AudioContentComment? = null
): AudioContentComment {
val comment = AudioContentComment(
comment = commentBody,
languageCode = "ko",
isSecret = isSecret,
isActive = isActive
)
comment.audioContent = audio
comment.member = writer
comment.parent = parent
comment.createdAt = createdAt
comment.updatedAt = createdAt
entityManager.persist(comment)
entityManager.flush()
comment.createdAt = createdAt
comment.updatedAt = createdAt
return comment
}
private fun saveSeriesContent(series: Series, audio: AudioContent) {
val seriesContent = SeriesContent()
seriesContent.series = series
seriesContent.content = audio
entityManager.persist(seriesContent)
}
private fun saveBlock(member: Member, blockedMember: Member) {
val block = BlockMember(isActive = true)
block.member = member
block.blockedMember = blockedMember
entityManager.persist(block)
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}
@@ -0,0 +1,55 @@
package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.scheduler
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.redisson.api.RLock
import org.redisson.api.RedissonClient
import org.springframework.scheduling.annotation.Scheduled
import java.util.concurrent.TimeUnit
class AudioRecommendationSnapshotSchedulerTest {
private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
private val redissonClient = Mockito.mock(RedissonClient::class.java)
private val lock = Mockito.mock(RLock::class.java)
private val scheduler = AudioRecommendationSnapshotScheduler(refreshService, redissonClient)
@Test
@DisplayName("스케줄러는 매일 00:00 KST cron을 사용한다")
fun shouldUseMidnightKstCron() {
val annotation = AudioRecommendationSnapshotScheduler::class.java
.getDeclaredMethod("refreshDailySnapshots")
.getAnnotation(Scheduled::class.java)
assertEquals("0 0 0 * * *", annotation.cron)
assertEquals("Asia/Seoul", annotation.zone)
}
@Test
@DisplayName("락 획득 성공 시에만 refresh를 호출하고 보유 중이면 unlock한다")
fun shouldRefreshOnlyWhenLockAcquired() {
Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY)
Mockito.doReturn(true).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.doReturn(true).`when`(lock).isHeldByCurrentThread
scheduler.refreshDailySnapshots()
Mockito.verify(refreshService).refreshDailySnapshots()
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("락 획득 실패 시 refresh와 unlock을 호출하지 않는다")
fun shouldSkipWhenLockNotAcquired() {
Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY)
Mockito.doReturn(false).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.doReturn(false).`when`(lock).isHeldByCurrentThread
scheduler.refreshDailySnapshots()
Mockito.verify(refreshService, Mockito.never()).refreshDailySnapshots()
Mockito.verify(lock, Mockito.never()).unlock()
}
}
@@ -0,0 +1,219 @@
package kr.co.vividnext.sodalive.v2.content.recommendation.application
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.redisson.api.RBucket
import org.redisson.api.RedissonClient
import java.time.Duration
import java.time.LocalDateTime
class AudioRecommendationQueryServiceTest {
private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val snapshotPort = Mockito.mock(RecommendationSnapshotPort::class.java)
private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
private val redissonClient = Mockito.mock(RedissonClient::class.java)
private val lazyRefreshMarker = Mockito.mock(RBucket::class.java) as RBucket<String>
private val service = AudioRecommendationQueryService(
queryPort,
preferenceService,
snapshotPort,
refreshService,
redissonClient
)
@Test
@DisplayName("비회원은 SAFE visibility를 사용한다")
fun shouldResolveSafeVisibilityForAnonymous() {
assertEquals(AudioRecommendationVisibility.SAFE, service.resolveVisibility(null))
}
@Test
@DisplayName("조회 서비스는 SAFE 스냅샷을 lazy refresh 후 상세 섹션으로 조립한다")
fun shouldBuildRecommendationsFromSafeSnapshotsWithLazyRefresh() {
val snapshot = RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
targetId = 1L,
score = 10.0,
snapshotAt = LocalDateTime.now(),
randomTieBreaker = 1.0
)
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>(), listOf(snapshot))
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
)
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
)
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
)
allowLazyRefreshOnce()
val recommendations = service.getRecommendations(null)
assertEquals(0, recommendations.mostCommentedAudios.size)
Mockito.verify(refreshService).refreshDailySnapshots()
Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots(
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
)
Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots(
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
)
Mockito.verify(queryPort).findBanners(AudioRecommendationQueryService.BANNER_LIMIT, null, false)
Mockito.verify(queryPort).findAudioCardsByIds(
eqValue(listOf(1L)),
Mockito.isNull(),
eqValue(false),
anyLocalDateTime()
)
}
@Test
@DisplayName("New & Hot lazy refresh는 보강 후에도 비어 있으면 같은 KST 날짜에 다시 실행하지 않는다")
fun shouldAttemptEmptyNewAndHotLazyRefreshOncePerKstDate() {
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
)
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
)
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
)
allowLazyRefreshOnce()
service.getRecommendations(null)
service.getRecommendations(null)
Mockito.verify(refreshService, Mockito.times(1)).refreshDailySnapshots()
}
@Test
@DisplayName("인증 회원 성인 정책은 조회용 저장 preference를 사용한다")
fun shouldUseStoredPreferenceForMemberAdultVisibility() {
val member = kr.co.vividnext.sodalive.member.Member(
email = "adult@test.com",
password = "password",
nickname = "adult",
role = kr.co.vividnext.sodalive.member.MemberRole.USER
)
Mockito.doReturn(
ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = ContentType.ALL,
isAdult = true
)
).`when`(preferenceService).getStoredPreference(member)
Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L)))
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
)
service.getRecommendations(member)
Mockito.verify(preferenceService).getStoredPreference(member)
Mockito.verify(preferenceService, Mockito.never()).initializeDefaultPreference(member)
Mockito.verify(snapshotPort).findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
)
}
@Test
@DisplayName("visibility와 섹션 조합은 신규 RecommendedSectionType으로 매핑된다")
fun shouldMapVisibilityToAudioSectionTypes() {
assertEquals(
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
service.newAndHotSectionType(AudioRecommendationVisibility.SAFE)
)
assertEquals(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
service.newAndHotSectionType(AudioRecommendationVisibility.ALL)
)
assertEquals(
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
service.mostCommentedSectionType(AudioRecommendationVisibility.SAFE)
)
assertEquals(
RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL,
service.mostCommentedSectionType(AudioRecommendationVisibility.ALL)
)
assertEquals(
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
service.recommendedAudioSectionType(AudioRecommendationVisibility.SAFE)
)
assertEquals(
RecommendedSectionType.RECOMMENDED_AUDIO_ALL,
service.recommendedAudioSectionType(AudioRecommendationVisibility.ALL)
)
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
}
private fun snapshot(sectionType: RecommendedSectionType, targetId: Long): RecommendationSnapshotRecord {
return RecommendationSnapshotRecord(
sectionType = sectionType,
targetId = targetId,
score = 10.0,
snapshotAt = LocalDateTime.now(),
randomTieBreaker = 1.0
)
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
private fun allowLazyRefreshOnce() {
Mockito.doReturn(lazyRefreshMarker).`when`(redissonClient).getBucket<String>(Mockito.anyString())
Mockito.doReturn(true, false).`when`(lazyRefreshMarker).setIfAbsent(
eqValue("1"),
eqValue(Duration.ofDays(2))
)
}
}
@@ -0,0 +1,84 @@
package kr.co.vividnext.sodalive.v2.content.recommendation.application
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
class AudioRecommendationSnapshotRefreshServiceTest {
private val snapshotPort = Mockito.mock(RecommendationSnapshotPort::class.java)
private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
private val service = AudioRecommendationSnapshotRefreshService(snapshotPort, queryPort)
@Test
@DisplayName("일 배치는 KST 전날 23:59:59 기준으로 여섯 오디오 스냅샷을 교체한다")
fun shouldRefreshAllAudioSnapshotsWithKstPreviousDaySnapshotAt() {
val now = LocalDateTime.of(2026, 6, 24, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 6, 23, 23, 59, 59)
val newAndHotWindowStart = LocalDateTime.of(2026, 6, 21, 0, 0)
val mostCommentedWindowStart = LocalDateTime.of(2026, 6, 17, 0, 0)
service.refreshDailySnapshots(now)
Mockito.verify(queryPort).findNewAndHotSnapshots(
newAndHotWindowStart,
snapshotAt,
AudioRecommendationVisibility.SAFE,
AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT
)
Mockito.verify(queryPort).findMostCommentedSnapshots(
mostCommentedWindowStart,
snapshotAt,
AudioRecommendationVisibility.ALL,
AudioRecommendationSnapshotRefreshService.MOST_COMMENTED_LIMIT
)
Mockito.verify(queryPort).findRecommendedAudioSnapshots(
mostCommentedWindowStart,
snapshotAt,
AudioRecommendationVisibility.SAFE,
AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT
)
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, snapshotAt, emptyList())
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, snapshotAt, emptyList())
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, snapshotAt, emptyList())
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, snapshotAt, emptyList())
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, snapshotAt, emptyList())
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_ALL, snapshotAt, emptyList())
}
@Test
@DisplayName("일 배치는 ZonedDateTime 입력의 zone과 무관하게 KST 날짜 경계 기준으로 스냅샷 시각을 계산한다")
fun shouldRefreshSnapshotsByKstBoundaryFromZonedDateTime() {
val now = ZonedDateTime.of(2026, 6, 24, 0, 0, 0, 0, ZoneId.of("Asia/Seoul"))
val snapshotAt = LocalDateTime.of(2026, 6, 23, 23, 59, 59)
val newAndHotWindowStart = LocalDateTime.of(2026, 6, 21, 0, 0)
val mostCommentedWindowStart = LocalDateTime.of(2026, 6, 17, 0, 0)
service.refreshDailySnapshots(now)
Mockito.verify(queryPort).findNewAndHotSnapshots(
newAndHotWindowStart,
snapshotAt,
AudioRecommendationVisibility.SAFE,
AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT
)
Mockito.verify(queryPort).findMostCommentedSnapshots(
mostCommentedWindowStart,
snapshotAt,
AudioRecommendationVisibility.ALL,
AudioRecommendationSnapshotRefreshService.MOST_COMMENTED_LIMIT
)
Mockito.verify(queryPort).findRecommendedAudioSnapshots(
mostCommentedWindowStart,
snapshotAt,
AudioRecommendationVisibility.SAFE,
AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT
)
}
}
@@ -0,0 +1,55 @@
package kr.co.vividnext.sodalive.v2.content.recommendation.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class AudioRecommendationScorePolicyTest {
private val policy = AudioRecommendationScorePolicy()
private val now = LocalDateTime.of(2026, 6, 23, 12, 0)
@Test
@DisplayName("New & Hot 점수는 원본 count를 정규화하지 않고 가중합으로 계산한다")
fun shouldCalculateNewAndHotScoreWithoutNormalization() {
val score = policy.calculateNewAndHotScore(10, 4, 2, now.minusDays(2), now)
assertEquals(485.5, score)
}
@Test
@DisplayName("추천 오디오 점수는 원본 count와 최신성 배수를 가중합으로 계산한다")
fun shouldCalculateRecommendedAudioScoreWithoutNormalization() {
val score = policy.calculateRecommendedAudioScore(10, 4, 2, now.minusDays(10), now)
assertEquals(601.0, score)
}
@Test
@DisplayName("최신성 배수는 정책별 일수 경계를 적용한다")
fun shouldReturnRecencyMultipliersByPolicyBoundaries() {
assertEquals(1.3, policy.newAndHotRecencyMultiplier(now.minusDays(3), now))
assertEquals(1.15, policy.newAndHotRecencyMultiplier(now.minusDays(7), now))
assertEquals(1.0, policy.newAndHotRecencyMultiplier(now.minusDays(14), now))
assertEquals(0.8, policy.newAndHotRecencyMultiplier(now.minusDays(15), now))
assertEquals(1.3, policy.recommendedAudioRecencyMultiplier(now.minusDays(3), now))
assertEquals(1.15, policy.recommendedAudioRecencyMultiplier(now.minusDays(7), now))
assertEquals(1.1, policy.recommendedAudioRecencyMultiplier(now.minusDays(30), now))
assertEquals(1.0, policy.recommendedAudioRecencyMultiplier(now.minusDays(31), now))
assertEquals(1.3, policy.commentRecencyMultiplier(now.minusDays(3), now))
assertEquals(1.15, policy.commentRecencyMultiplier(now.minusDays(7), now))
assertEquals(1.0, policy.commentRecencyMultiplier(now.minusDays(14), now))
assertEquals(0.0, policy.commentRecencyMultiplier(now.minusDays(15), now))
}
@Test
@DisplayName("최신성 일수는 날짜 경계가 아니라 24시간 경과 기준으로 계산한다")
fun shouldCalculateRecencyDaysByElapsedTwentyFourHours() {
val releaseDate = LocalDateTime.of(2026, 6, 19, 23, 59, 59)
val snapshotAt = LocalDateTime.of(2026, 6, 23, 0, 0)
assertEquals(1.3, policy.newAndHotRecencyMultiplier(releaseDate, snapshotAt))
assertEquals(1.3, policy.recommendedAudioRecencyMultiplier(releaseDate, snapshotAt))
assertEquals(1.3, policy.commentRecencyMultiplier(releaseDate, snapshotAt))
}
}