test #426

Merged
klaus merged 415 commits from test into main 2026-06-27 00:35:30 +00:00
3 changed files with 498 additions and 2 deletions
Showing only changes of commit 70346b911f - Show all commits

View File

@@ -19,17 +19,24 @@ 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.v2.audio.recommendation.domain.AudioCard import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.CommentedAudio import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.OriginalSeries import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.OriginalSeries
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.math.BigDecimal
import java.math.BigInteger
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.persistence.EntityManager
@Repository @Repository
class DefaultAudioRecommendationQueryRepository( class DefaultAudioRecommendationQueryRepository(
private val queryFactory: JPAQueryFactory, private val queryFactory: JPAQueryFactory,
private val entityManager: EntityManager,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String private val cloudFrontHost: String
) : AudioRecommendationQueryRepository { ) : AudioRecommendationQueryRepository {
@@ -151,7 +158,239 @@ class DefaultAudioRecommendationQueryRepository(
memberId: Long?, memberId: Long?,
canViewAdultContent: Boolean canViewAdultContent: Boolean
): List<CommentedAudio> { ): List<CommentedAudio> {
return emptyList() if (contentIds.isEmpty()) return emptyList()
val contentOrder = contentIds.withIndex().associate { it.value to it.index }
val sql = """
select c.id, c.title, c.cover_image, latest.comment, writer.profile_image
from content c
join member creator on creator.id = c.member_id
join content_theme theme on theme.id = c.theme_id
join content_comment latest on latest.content_id = c.id
and latest.is_active = true
and latest.parent_id is null
and latest.is_secret = false
join member writer on writer.id = latest.member_id and writer.is_active = true
where c.id in (:contentIds)
and c.is_active = true
and c.duration is not null
and c.release_date is not null
and c.release_date <= CURRENT_TIMESTAMP
and creator.is_active = true
and theme.is_active = true
and (:canViewAdultContent = true or c.is_adult = false)
and (:memberId is null or not exists (
select 1 from block_member bm
where bm.is_active = true
and ((bm.member_id = :memberId and bm.blocked_member_id = creator.id)
or (bm.member_id = creator.id and bm.blocked_member_id = :memberId))
))
and (:memberId is null or not exists (
select 1 from block_member bm
where bm.is_active = true
and ((bm.member_id = :memberId and bm.blocked_member_id = writer.id)
or (bm.member_id = writer.id and bm.blocked_member_id = :memberId))
))
and not exists (
select 1 from block_member bm
where bm.is_active = true
and ((bm.member_id = creator.id and bm.blocked_member_id = writer.id)
or (bm.member_id = writer.id and bm.blocked_member_id = creator.id))
)
and not exists (
select 1
from content_comment newer
join member newer_writer on newer_writer.id = newer.member_id and newer_writer.is_active = true
where newer.content_id = c.id
and newer.is_active = true
and newer.parent_id is null
and newer.is_secret = false
and (
newer.created_at > latest.created_at
or (newer.created_at = latest.created_at and newer.id > latest.id)
)
and (:memberId is null or not exists (
select 1 from block_member bm
where bm.is_active = true
and ((bm.member_id = :memberId and bm.blocked_member_id = newer_writer.id)
or (bm.member_id = newer_writer.id and bm.blocked_member_id = :memberId))
))
and not exists (
select 1 from block_member bm
where bm.is_active = true
and ((bm.member_id = creator.id and bm.blocked_member_id = newer_writer.id)
or (bm.member_id = newer_writer.id and bm.blocked_member_id = creator.id))
)
)
""".trimIndent()
return entityManager.createNativeQuery(sql)
.setParameter("contentIds", contentIds)
.setParameter("memberId", memberId)
.setParameter("canViewAdultContent", canViewAdultContent)
.resultList
.map { row ->
val values = row as Array<*>
CommentedAudio(
audioContentId = values[0].toLongValue(),
title = values[1] as String,
imageUrl = (values[2] as String?).toCdnUrl(cloudFrontHost),
latestComment = values[3] as String,
latestCommentWriterProfileImageUrl = (values[4] as String?).toCdnUrl(cloudFrontHost)
?: "$cloudFrontHost/profile/default-profile.png"
)
}
.sortedBy { contentOrder[it.audioContentId] ?: Int.MAX_VALUE }
}
override fun findNewAndHotSnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
visibility: AudioRecommendationVisibility,
limit: Int
): List<RecommendationSnapshotRecord> {
return findScoredSnapshots(
windowStart = windowStart,
snapshotAt = snapshotAt,
visibility = visibility,
limit = limit,
sectionType = visibility.newAndHotSectionType(),
scoreExpression = """
coalesce(v.view_count, 0) * 35.0
+ coalesce(l.like_count, 0) * 15.0
+ coalesce(cm.comment_count, 0) * 15.0
+ case
when timestampdiff(day, c.release_date, :snapshotAt) <= 3 then 1.3
when timestampdiff(day, c.release_date, :snapshotAt) <= 7 then 1.15
when timestampdiff(day, c.release_date, :snapshotAt) <= 14 then 1.0
else 0.8
end * 35.0
""".trimIndent()
)
}
override fun findMostCommentedSnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
visibility: AudioRecommendationVisibility,
limit: Int
): List<RecommendationSnapshotRecord> {
return findScoredSnapshots(
windowStart = windowStart,
snapshotAt = snapshotAt,
visibility = visibility,
limit = limit,
sectionType = visibility.mostCommentedSectionType(),
scoreExpression = """
coalesce(cm.comment_count, 0) * 80.0
+ case
when timestampdiff(day, cm.latest_comment_at, :snapshotAt) <= 3 then 1.3
when timestampdiff(day, cm.latest_comment_at, :snapshotAt) <= 7 then 1.15
when timestampdiff(day, cm.latest_comment_at, :snapshotAt) <= 14 then 1.0
else 0.0
end * 20.0
""".trimIndent(),
requireComments = true
)
}
override fun findRecommendedAudioSnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
visibility: AudioRecommendationVisibility,
limit: Int
): List<RecommendationSnapshotRecord> {
return findScoredSnapshots(
windowStart = windowStart,
snapshotAt = snapshotAt,
visibility = visibility,
limit = limit,
sectionType = visibility.recommendedAudioSectionType(),
scoreExpression = """
coalesce(v.view_count, 0) * 45.0
+ coalesce(l.like_count, 0) * 25.0
+ coalesce(cm.comment_count, 0) * 20.0
+ case
when timestampdiff(day, c.release_date, :snapshotAt) <= 3 then 1.3
when timestampdiff(day, c.release_date, :snapshotAt) <= 7 then 1.15
when timestampdiff(day, c.release_date, :snapshotAt) <= 30 then 1.1
else 1.0
end * 10.0
""".trimIndent()
)
}
private fun findScoredSnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
visibility: AudioRecommendationVisibility,
limit: Int,
sectionType: RecommendedSectionType,
scoreExpression: String,
requireComments: Boolean = false
): List<RecommendationSnapshotRecord> {
val commentJoin = if (requireComments) "join" else "left join"
val commentRequirement = if (requireComments) "and cm.comment_count is not null" else ""
val sql = """
select c.id, ($scoreExpression) score, rand() random_tie_breaker
from content c
join member creator on creator.id = c.member_id
join content_theme theme on theme.id = c.theme_id
left join (
select content_id, count(*) view_count
from creator_content_view_history
where viewed_at >= :windowStart and viewed_at <= :snapshotAt
group by content_id
) v on v.content_id = c.id
left join (
select content_id, count(*) like_count
from content_like
where is_active = true and created_at >= :windowStart and created_at <= :snapshotAt
group by content_id
) l on l.content_id = c.id
$commentJoin (
select cc.content_id, count(*) comment_count, max(cc.created_at) latest_comment_at
from content_comment cc
join content comment_content on comment_content.id = cc.content_id
join member comment_writer on comment_writer.id = cc.member_id
where cc.is_active = true
and cc.parent_id is null
and cc.is_secret = false
and comment_writer.is_active = true
and not exists (
select 1 from block_member bm
where bm.is_active = true
and ((bm.member_id = comment_content.member_id and bm.blocked_member_id = comment_writer.id)
or (bm.member_id = comment_writer.id and bm.blocked_member_id = comment_content.member_id))
)
and cc.created_at >= :windowStart and cc.created_at <= :snapshotAt
group by cc.content_id
) cm on cm.content_id = c.id
where c.is_active = true
and c.duration is not null
and c.release_date is not null
and c.release_date <= :snapshotAt
and creator.is_active = true
and theme.is_active = true
and (:includeAdult = true or c.is_adult = false)
$commentRequirement
order by score desc, random_tie_breaker asc
limit :limit
""".trimIndent()
return entityManager.createNativeQuery(sql)
.setParameter("windowStart", windowStart)
.setParameter("snapshotAt", snapshotAt)
.setParameter("includeAdult", visibility == AudioRecommendationVisibility.ALL)
.setParameter("limit", limit)
.resultList
.map { row ->
val values = row as Array<*>
RecommendationSnapshotRecord(
sectionType = sectionType,
targetId = values[0].toLongValue(),
score = values[1].toDoubleValue(),
snapshotAt = snapshotAt,
randomTieBreaker = values[2].toDoubleValue()
)
}
} }
private fun audioRows( private fun audioRows(
@@ -300,3 +539,44 @@ class DefaultAudioRecommendationQueryRepository(
return if (condition == null) this else and(condition) return if (condition == null) this else and(condition)
} }
} }
private fun AudioRecommendationVisibility.newAndHotSectionType(): RecommendedSectionType {
return when (this) {
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE
AudioRecommendationVisibility.ALL -> RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL
}
}
private fun AudioRecommendationVisibility.mostCommentedSectionType(): RecommendedSectionType {
return when (this) {
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE
AudioRecommendationVisibility.ALL -> RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL
}
}
private fun AudioRecommendationVisibility.recommendedAudioSectionType(): RecommendedSectionType {
return when (this) {
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.RECOMMENDED_AUDIO_SAFE
AudioRecommendationVisibility.ALL -> RecommendedSectionType.RECOMMENDED_AUDIO_ALL
}
}
private fun Any?.toLongValue(): Long {
return when (this) {
is Long -> this
is Int -> toLong()
is BigInteger -> toLong()
is Number -> toLong()
else -> error("Unsupported numeric value: $this")
}
}
private fun Any?.toDoubleValue(): Double {
return when (this) {
is Double -> this
is Float -> toDouble()
is BigDecimal -> toDouble()
is Number -> toDouble()
else -> error("Unsupported numeric value: $this")
}
}

View File

@@ -1,9 +1,11 @@
package kr.co.vividnext.sodalive.v2.audio.recommendation.port.out package kr.co.vividnext.sodalive.v2.audio.recommendation.port.out
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioCard import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.CommentedAudio import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.OriginalSeries import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.OriginalSeries
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import java.time.LocalDateTime import java.time.LocalDateTime
interface AudioRecommendationQueryPort { interface AudioRecommendationQueryPort {
@@ -19,4 +21,22 @@ interface AudioRecommendationQueryPort {
now: LocalDateTime now: LocalDateTime
): List<AudioCard> ): List<AudioCard>
fun findCommentedAudiosByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<CommentedAudio> fun findCommentedAudiosByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<CommentedAudio>
fun findNewAndHotSnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
visibility: AudioRecommendationVisibility,
limit: Int
): List<RecommendationSnapshotRecord>
fun findMostCommentedSnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
visibility: AudioRecommendationVisibility,
limit: Int
): List<RecommendationSnapshotRecord>
fun findRecommendedAudioSnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
visibility: AudioRecommendationVisibility,
limit: Int
): List<RecommendationSnapshotRecord>
} }

View File

@@ -4,6 +4,8 @@ import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent 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.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
@@ -12,6 +14,10 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicy
import kr.co.vividnext.sodalive.v2.audio.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.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -32,7 +38,7 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager, private val entityManager: EntityManager,
queryFactory: JPAQueryFactory queryFactory: JPAQueryFactory
) { ) {
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, "https://cdn.test") private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
@Test @Test
@DisplayName("배너는 홈 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다") @DisplayName("배너는 홈 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다")
@@ -152,6 +158,155 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(false, latestAudios[1].isOriginalSeries) 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 { private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
val member = Member( val member = Member(
email = "$nickname@test.com", email = "$nickname@test.com",
@@ -237,6 +392,47 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
return 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) { private fun saveSeriesContent(series: Series, audio: AudioContent) {
val seriesContent = SeriesContent() val seriesContent = SeriesContent()
seriesContent.series = series seriesContent.series = series