feat(audio-recommendation): 실시간 추천 조회 repository를 추가한다
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.audio.recommendation.port.out.AudioRecommendationQueryPort
|
||||||
|
|
||||||
|
interface AudioRecommendationQueryRepository : AudioRecommendationQueryPort
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence
|
||||||
|
|
||||||
|
import com.querydsl.core.Tuple
|
||||||
|
import com.querydsl.core.types.Expression
|
||||||
|
import com.querydsl.core.types.Projections
|
||||||
|
import com.querydsl.core.types.dsl.BooleanExpression
|
||||||
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.jpa.JPAExpressions
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
|
import kr.co.vividnext.sodalive.event.EventItem
|
||||||
|
import kr.co.vividnext.sodalive.event.QEvent.event
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
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.CommentedAudio
|
||||||
|
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.toCdnUrl
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class DefaultAudioRecommendationQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val cloudFrontHost: String
|
||||||
|
) : AudioRecommendationQueryRepository {
|
||||||
|
override fun findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean): List<RecommendationBanner> {
|
||||||
|
val bannerCreator = QMember("audioRecommendationBannerCreator")
|
||||||
|
val seriesOwner = QMember("audioRecommendationSeriesOwner")
|
||||||
|
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
audioContentBanner.thumbnailImage,
|
||||||
|
event.id,
|
||||||
|
event.thumbnailImage,
|
||||||
|
event.detailImage,
|
||||||
|
event.link,
|
||||||
|
bannerCreator.id,
|
||||||
|
series.id,
|
||||||
|
audioContentBanner.link
|
||||||
|
)
|
||||||
|
.from(audioContentBanner)
|
||||||
|
.leftJoin(audioContentBanner.event, event)
|
||||||
|
.leftJoin(audioContentBanner.creator, bannerCreator)
|
||||||
|
.leftJoin(audioContentBanner.series, series)
|
||||||
|
.leftJoin(series.member, seriesOwner)
|
||||||
|
.where(
|
||||||
|
audioContentBanner.isActive.isTrue,
|
||||||
|
audioContentBanner.tab.isNull,
|
||||||
|
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
||||||
|
)
|
||||||
|
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
|
||||||
|
.limit(limit.toLong())
|
||||||
|
.fetch()
|
||||||
|
.map { row ->
|
||||||
|
RecommendationBanner(
|
||||||
|
imageUrl = row.get(audioContentBanner.thumbnailImage).toCdnUrl(cloudFrontHost) ?: "",
|
||||||
|
eventItem = row.toEventItem(),
|
||||||
|
creatorId = row.get(bannerCreator.id),
|
||||||
|
seriesId = row.get(series.id),
|
||||||
|
link = row.get(audioContentBanner.link)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findOriginalSeries(
|
||||||
|
limit: Int,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime
|
||||||
|
): List<OriginalSeries> {
|
||||||
|
return queryFactory
|
||||||
|
.select(Projections.constructor(OriginalSeries::class.java, series.id, series.coverImage))
|
||||||
|
.from(series)
|
||||||
|
.join(series.member, member)
|
||||||
|
.where(
|
||||||
|
series.isActive.isTrue,
|
||||||
|
series.isOriginal.isTrue,
|
||||||
|
member.isActive.isTrue,
|
||||||
|
adultSeriesCondition(canViewAdultContent),
|
||||||
|
notBlockedCreatorCondition(memberId, member.id)
|
||||||
|
)
|
||||||
|
.orderBy(series.createdAt.desc(), series.id.desc())
|
||||||
|
.limit(limit.toLong())
|
||||||
|
.fetch()
|
||||||
|
.map { it.copy(coverImageUrl = it.coverImageUrl.toCdnUrl(cloudFrontHost)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findLatestAudios(
|
||||||
|
limit: Int,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime
|
||||||
|
): List<AudioCard> {
|
||||||
|
val rows = audioRows(memberId, canViewAdultContent, now) {
|
||||||
|
orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong())
|
||||||
|
}
|
||||||
|
return rows.toAudioCards(now, canViewAdultContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findFreeAudios(
|
||||||
|
limit: Int,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime
|
||||||
|
): List<AudioCard> {
|
||||||
|
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||||
|
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) {
|
||||||
|
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
||||||
|
}
|
||||||
|
return rows.toAudioCards(now, canViewAdultContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findPointAudios(
|
||||||
|
limit: Int,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime
|
||||||
|
): List<AudioCard> {
|
||||||
|
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||||
|
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.isPointAvailable.isTrue) {
|
||||||
|
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
||||||
|
}
|
||||||
|
return rows.toAudioCards(now, canViewAdultContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findAudioCardsByIds(
|
||||||
|
contentIds: List<Long>,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime
|
||||||
|
): List<AudioCard> {
|
||||||
|
if (contentIds.isEmpty()) return emptyList()
|
||||||
|
val orderById = contentIds.withIndex().associate { it.value to it.index }
|
||||||
|
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.id.`in`(contentIds)) { this }
|
||||||
|
return rows.toAudioCards(now, canViewAdultContent).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findCommentedAudiosByIds(
|
||||||
|
contentIds: List<Long>,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): List<CommentedAudio> {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun audioRows(
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
now: LocalDateTime,
|
||||||
|
extraCondition: BooleanExpression? = null,
|
||||||
|
customize: com.querydsl.jpa.impl.JPAQuery<Tuple>.() -> com.querydsl.jpa.impl.JPAQuery<Tuple>
|
||||||
|
): List<Tuple> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
audioContent.id,
|
||||||
|
audioContent.title,
|
||||||
|
audioContent.duration,
|
||||||
|
audioContent.coverImage,
|
||||||
|
audioContent.price,
|
||||||
|
audioContent.isAdult,
|
||||||
|
audioContent.isPointAvailable,
|
||||||
|
audioContent.member.id,
|
||||||
|
member.nickname
|
||||||
|
)
|
||||||
|
.from(audioContent)
|
||||||
|
.join(audioContent.member, member)
|
||||||
|
.join(audioContent.theme, audioContentTheme)
|
||||||
|
.where(publicAudioCondition(memberId, canViewAdultContent, now), extraCondition)
|
||||||
|
.customize()
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Tuple>.toAudioCards(now: LocalDateTime, canViewAdultContent: Boolean): List<AudioCard> {
|
||||||
|
if (isEmpty()) return emptyList()
|
||||||
|
val contentIds = map { it.get(audioContent.id)!! }
|
||||||
|
val creatorIds = map { it.get(audioContent.member.id)!! }.distinct()
|
||||||
|
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
|
||||||
|
val isOriginalSeriesByContentId = originalSeriesFlags(contentIds)
|
||||||
|
return map { row ->
|
||||||
|
val contentId = row.get(audioContent.id)!!
|
||||||
|
val creatorId = row.get(audioContent.member.id)!!
|
||||||
|
AudioCard(
|
||||||
|
audioContentId = contentId,
|
||||||
|
title = row.get(audioContent.title)!!,
|
||||||
|
duration = row.get(audioContent.duration),
|
||||||
|
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
|
||||||
|
price = row.get(audioContent.price)!!,
|
||||||
|
isAdult = row.get(audioContent.isAdult)!!,
|
||||||
|
isPointAvailable = row.get(audioContent.isPointAvailable)!!,
|
||||||
|
isFirstContent = firstContentIdByCreatorId[creatorId] == contentId,
|
||||||
|
isOriginalSeries = isOriginalSeriesByContentId[contentId] ?: false,
|
||||||
|
creatorNickname = row.get(member.nickname)!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firstAudioContentIds(
|
||||||
|
creatorIds: List<Long>,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): Map<Long, Long> {
|
||||||
|
return creatorIds.associateWith { creatorId ->
|
||||||
|
queryFactory
|
||||||
|
.select(audioContent.id)
|
||||||
|
.from(audioContent)
|
||||||
|
.join(audioContent.member, member)
|
||||||
|
.join(audioContent.theme, audioContentTheme)
|
||||||
|
.where(
|
||||||
|
audioContent.member.id.eq(creatorId),
|
||||||
|
publicAudioCondition(memberId = null, canViewAdultContent, now)
|
||||||
|
)
|
||||||
|
.orderBy(audioContent.releaseDate.asc(), audioContent.id.asc())
|
||||||
|
.fetchFirst()
|
||||||
|
}.filterValues { it != null }.mapValues { it.value!! }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun originalSeriesFlags(contentIds: List<Long>): Map<Long, Boolean> {
|
||||||
|
if (contentIds.isEmpty()) return emptyMap()
|
||||||
|
return queryFactory
|
||||||
|
.select(seriesContent.content.id, series.isOriginal)
|
||||||
|
.from(seriesContent)
|
||||||
|
.join(seriesContent.series, series)
|
||||||
|
.where(seriesContent.content.id.`in`(contentIds))
|
||||||
|
.fetch()
|
||||||
|
.associate { it.get(seriesContent.content.id)!! to it.get(series.isOriginal)!! }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publicAudioCondition(memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): BooleanExpression {
|
||||||
|
return audioContent.isActive.isTrue
|
||||||
|
.and(audioContent.duration.isNotNull)
|
||||||
|
.and(audioContent.releaseDate.isNotNull)
|
||||||
|
.and(audioContent.releaseDate.loe(now))
|
||||||
|
.and(audioContent.member.isActive.isTrue)
|
||||||
|
.and(audioContentTheme.isActive.isTrue)
|
||||||
|
.withOptionalAnd(adultAudioCondition(canViewAdultContent))
|
||||||
|
.withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun activeBannerTargetCondition(memberId: Long?, bannerCreator: QMember, seriesOwner: QMember): BooleanExpression {
|
||||||
|
val creatorCondition = audioContentBanner.type.eq(AudioContentBannerType.CREATOR)
|
||||||
|
.and(bannerCreator.isActive.isTrue)
|
||||||
|
.withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id))
|
||||||
|
val seriesCondition = audioContentBanner.type.eq(AudioContentBannerType.SERIES)
|
||||||
|
.and(series.isActive.isTrue)
|
||||||
|
.and(seriesOwner.isActive.isTrue)
|
||||||
|
.withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id))
|
||||||
|
|
||||||
|
return audioContentBanner.type.eq(AudioContentBannerType.LINK)
|
||||||
|
.or(audioContentBanner.type.eq(AudioContentBannerType.EVENT).and(event.isActive.isTrue))
|
||||||
|
.or(creatorCondition)
|
||||||
|
.or(seriesCondition)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Tuple.toEventItem(): EventItem? {
|
||||||
|
val eventId = get(event.id) ?: return null
|
||||||
|
val thumbnailImage = get(event.thumbnailImage) ?: return null
|
||||||
|
return EventItem(
|
||||||
|
id = eventId,
|
||||||
|
thumbnailImageUrl = thumbnailImage.toCdnUrl(cloudFrontHost) ?: thumbnailImage,
|
||||||
|
detailImageUrl = get(event.detailImage).toCdnUrl(cloudFrontHost),
|
||||||
|
popupImageUrl = null,
|
||||||
|
link = get(event.link)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||||
|
return if (canViewAdultContent) null else series.isAdult.isFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||||
|
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
|
||||||
|
if (memberId == null) return null
|
||||||
|
val blockMember = QBlockMember("audioRecommendationBlockMember")
|
||||||
|
return JPAExpressions
|
||||||
|
.selectOne()
|
||||||
|
.from(blockMember)
|
||||||
|
.where(
|
||||||
|
blockMember.isActive.isTrue,
|
||||||
|
blockMember.member.id.eq(memberId).and(blockMember.blockedMember.id.eq(creatorIdPath))
|
||||||
|
.or(blockMember.member.id.eq(creatorIdPath).and(blockMember.blockedMember.id.eq(memberId)))
|
||||||
|
)
|
||||||
|
.notExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BooleanExpression.withOptionalAnd(condition: BooleanExpression?): BooleanExpression {
|
||||||
|
return if (condition == null) this else and(condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.audio.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.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 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, "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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user