feat(creator-channel): 시리즈 탭 repository를 추가한다
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesQueryPort
|
||||
|
||||
interface CreatorChannelSeriesQueryRepository : CreatorChannelSeriesQueryPort
|
||||
@@ -0,0 +1,288 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence
|
||||
|
||||
import com.querydsl.core.Tuple
|
||||
import com.querydsl.core.types.Projections
|
||||
import com.querydsl.core.types.dsl.BooleanExpression
|
||||
import com.querydsl.core.types.dsl.CaseBuilder
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.order.QOrder
|
||||
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
|
||||
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.member.QMember.member
|
||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesRecord
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class DefaultCreatorChannelSeriesQueryRepository(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : CreatorChannelSeriesQueryRepository {
|
||||
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord? {
|
||||
return queryFactory
|
||||
.select(
|
||||
Projections.constructor(
|
||||
CreatorChannelSeriesCreatorRecord::class.java,
|
||||
member.id,
|
||||
member.role,
|
||||
member.nickname
|
||||
)
|
||||
)
|
||||
.from(member)
|
||||
.where(
|
||||
member.id.eq(creatorId),
|
||||
member.isActive.isTrue
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
|
||||
val blockMember = QBlockMember("creatorChannelSeriesBlockMember")
|
||||
return queryFactory
|
||||
.select(blockMember.id)
|
||||
.from(blockMember)
|
||||
.where(
|
||||
blockMember.isActive.isTrue,
|
||||
blockMember.member.id.eq(viewerId).and(blockMember.blockedMember.id.eq(creatorId))
|
||||
.or(blockMember.member.id.eq(creatorId).and(blockMember.blockedMember.id.eq(viewerId)))
|
||||
)
|
||||
.fetchFirst() != null
|
||||
}
|
||||
|
||||
override fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int {
|
||||
return queryFactory
|
||||
.select(series.id.count())
|
||||
.from(series)
|
||||
.where(seriesCondition(creatorId, canViewAdultContent))
|
||||
.fetchOne()
|
||||
?.toInt()
|
||||
?: 0
|
||||
}
|
||||
|
||||
override fun findSeries(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
now: LocalDateTime,
|
||||
canViewAdultContent: Boolean,
|
||||
sort: ContentSort,
|
||||
locale: String,
|
||||
offset: Long,
|
||||
limit: Int
|
||||
): List<CreatorChannelSeriesRecord> {
|
||||
val seriesIds = findSeriesIds(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit)
|
||||
if (seriesIds.isEmpty()) return emptyList()
|
||||
|
||||
val seriesTranslation = QSeriesTranslation("creatorChannelSeriesTranslation")
|
||||
val rows = findSeriesRows(seriesIds, locale, seriesTranslation)
|
||||
val contentStats = contentStatsBySeriesIds(seriesIds, now, canViewAdultContent)
|
||||
val purchaseStats = purchaseStatsBySeriesIds(seriesIds, viewerId, now, canViewAdultContent)
|
||||
|
||||
return rows.sortedBy { seriesIds.indexOf(it.get(series)!!.id!!) }
|
||||
.map { row ->
|
||||
val targetSeries = row.get(series)!!
|
||||
val translatedTitle = row.get(seriesTranslation)
|
||||
?.renderedPayload
|
||||
?.title
|
||||
val contentStat = contentStats[targetSeries.id] ?: SeriesContentStats()
|
||||
CreatorChannelSeriesRecord(
|
||||
seriesId = targetSeries.id!!,
|
||||
title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: targetSeries.title,
|
||||
coverImagePath = targetSeries.coverImage,
|
||||
publishedDaysOfWeek = targetSeries.publishedDaysOfWeek,
|
||||
isOriginal = targetSeries.isOriginal,
|
||||
isAdult = targetSeries.isAdult,
|
||||
state = targetSeries.state,
|
||||
contentCount = contentStat.contentCount,
|
||||
purchasedContentCount = purchaseStats[targetSeries.id] ?: 0,
|
||||
paidContentCount = contentStat.paidContentCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findSeriesIds(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
now: LocalDateTime,
|
||||
canViewAdultContent: Boolean,
|
||||
sort: ContentSort,
|
||||
offset: Long,
|
||||
limit: Int
|
||||
): List<Long> {
|
||||
val revenueOrder = QOrder("seriesRevenueOrder")
|
||||
val ownedOrder = QOrder("seriesOwnedOrder")
|
||||
val latestReleaseDate = audioContent.releaseDate.max()
|
||||
val highestPrice = audioContent.price.max()
|
||||
val lowestPrice = audioContent.price.min()
|
||||
val revenue = revenueOrder.can.sum().coalesce(0)
|
||||
val ownedCount = ownedOrder.audioContent.id.countDistinct()
|
||||
val latestReleaseDateNullLast = CaseBuilder().`when`(latestReleaseDate.isNull).then(1).otherwise(0)
|
||||
val highestPriceNullLast = CaseBuilder().`when`(highestPrice.isNull).then(1).otherwise(0)
|
||||
val lowestPriceNullLast = CaseBuilder().`when`(lowestPrice.isNull).then(1).otherwise(0)
|
||||
|
||||
val query = queryFactory
|
||||
.select(series.id)
|
||||
.from(series)
|
||||
.leftJoin(seriesContent).on(seriesContent.series.id.eq(series.id))
|
||||
.leftJoin(audioContent).on(
|
||||
seriesContent.content.id.eq(audioContent.id),
|
||||
publicAudioContentCondition(now, canViewAdultContent)
|
||||
)
|
||||
.where(seriesCondition(creatorId, canViewAdultContent))
|
||||
.groupBy(series.id)
|
||||
|
||||
when (sort) {
|
||||
ContentSort.POPULAR -> {
|
||||
query
|
||||
.leftJoin(revenueOrder)
|
||||
.on(
|
||||
revenueOrder.audioContent.id.eq(audioContent.id),
|
||||
revenueOrder.isActive.isTrue
|
||||
)
|
||||
.orderBy(revenue.desc(), latestReleaseDate.desc(), series.id.desc())
|
||||
}
|
||||
ContentSort.OWNED -> {
|
||||
query
|
||||
.leftJoin(ownedOrder)
|
||||
.on(
|
||||
ownedOrder.audioContent.id.eq(audioContent.id),
|
||||
ownedOrder.member.id.eq(viewerId),
|
||||
ownedOrder.isActive.isTrue,
|
||||
validPurchasedOrderCondition(ownedOrder, now)
|
||||
)
|
||||
.orderBy(ownedCount.desc(), latestReleaseDate.desc(), series.id.desc())
|
||||
}
|
||||
ContentSort.LATEST -> query.orderBy(
|
||||
latestReleaseDateNullLast.asc(),
|
||||
latestReleaseDate.desc(),
|
||||
highestPrice.desc(),
|
||||
series.id.desc()
|
||||
)
|
||||
ContentSort.PRICE_HIGH -> query.orderBy(
|
||||
highestPriceNullLast.asc(),
|
||||
highestPrice.desc(),
|
||||
latestReleaseDate.desc(),
|
||||
series.id.desc()
|
||||
)
|
||||
ContentSort.PRICE_LOW -> query.orderBy(
|
||||
lowestPriceNullLast.asc(),
|
||||
lowestPrice.asc(),
|
||||
latestReleaseDate.desc(),
|
||||
series.id.desc()
|
||||
)
|
||||
}
|
||||
|
||||
return query.offset(offset).limit(limit.toLong()).fetch()
|
||||
}
|
||||
|
||||
private fun findSeriesRows(
|
||||
seriesIds: List<Long>,
|
||||
locale: String,
|
||||
seriesTranslation: QSeriesTranslation
|
||||
): List<Tuple> {
|
||||
return queryFactory
|
||||
.select(series, seriesTranslation)
|
||||
.from(series)
|
||||
.leftJoin(seriesTranslation)
|
||||
.on(
|
||||
seriesTranslation.seriesId.eq(series.id),
|
||||
seriesTranslation.locale.eq(locale)
|
||||
)
|
||||
.where(series.id.`in`(seriesIds))
|
||||
.fetch()
|
||||
}
|
||||
|
||||
private fun contentStatsBySeriesIds(
|
||||
seriesIds: List<Long>,
|
||||
now: LocalDateTime,
|
||||
canViewAdultContent: Boolean
|
||||
): Map<Long, SeriesContentStats> {
|
||||
val paidContentCount = CaseBuilder()
|
||||
.`when`(audioContent.price.gt(0))
|
||||
.then(audioContent.id)
|
||||
.otherwise(null as Long?)
|
||||
.countDistinct()
|
||||
return queryFactory
|
||||
.select(
|
||||
seriesContent.series.id,
|
||||
audioContent.id.countDistinct(),
|
||||
paidContentCount
|
||||
)
|
||||
.from(seriesContent)
|
||||
.innerJoin(seriesContent.content, audioContent)
|
||||
.where(
|
||||
seriesContent.series.id.`in`(seriesIds),
|
||||
publicAudioContentCondition(now, canViewAdultContent)
|
||||
)
|
||||
.groupBy(seriesContent.series.id)
|
||||
.fetch()
|
||||
.associate {
|
||||
it.get(seriesContent.series.id)!! to SeriesContentStats(
|
||||
contentCount = it.get(audioContent.id.countDistinct())?.toInt() ?: 0,
|
||||
paidContentCount = it.get(paidContentCount)?.toInt() ?: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun purchaseStatsBySeriesIds(
|
||||
seriesIds: List<Long>,
|
||||
viewerId: Long,
|
||||
now: LocalDateTime,
|
||||
canViewAdultContent: Boolean
|
||||
): Map<Long, Int> {
|
||||
val purchasedOrder = QOrder("seriesPurchasedOrder")
|
||||
return queryFactory
|
||||
.select(seriesContent.series.id, audioContent.id.countDistinct())
|
||||
.from(seriesContent)
|
||||
.innerJoin(seriesContent.content, audioContent)
|
||||
.innerJoin(purchasedOrder)
|
||||
.on(purchasedOrder.audioContent.id.eq(audioContent.id))
|
||||
.where(
|
||||
seriesContent.series.id.`in`(seriesIds),
|
||||
publicAudioContentCondition(now, canViewAdultContent),
|
||||
audioContent.price.gt(0),
|
||||
purchasedOrder.member.id.eq(viewerId),
|
||||
purchasedOrder.isActive.isTrue,
|
||||
validPurchasedOrderCondition(purchasedOrder, now)
|
||||
)
|
||||
.groupBy(seriesContent.series.id)
|
||||
.fetch()
|
||||
.associate { it.get(seriesContent.series.id)!! to (it.get(audioContent.id.countDistinct())?.toInt() ?: 0) }
|
||||
}
|
||||
|
||||
private fun seriesCondition(creatorId: Long, canViewAdultContent: Boolean): BooleanExpression {
|
||||
return series.member.id.eq(creatorId)
|
||||
.and(series.isActive.isTrue)
|
||||
.and(adultSeriesCondition(canViewAdultContent))
|
||||
}
|
||||
|
||||
private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||
return if (canViewAdultContent) null else series.isAdult.isFalse
|
||||
}
|
||||
|
||||
private fun publicAudioContentCondition(now: LocalDateTime, canViewAdultContent: Boolean): BooleanExpression {
|
||||
return audioContent.isActive.isTrue
|
||||
.and(audioContent.duration.isNotNull)
|
||||
.and(audioContent.releaseDate.isNotNull)
|
||||
.and(audioContent.releaseDate.loe(now))
|
||||
.and(adultAudioCondition(canViewAdultContent))
|
||||
}
|
||||
|
||||
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
|
||||
}
|
||||
|
||||
private fun validPurchasedOrderCondition(targetOrder: QOrder, now: LocalDateTime): BooleanExpression {
|
||||
return targetOrder.type.eq(OrderType.KEEP)
|
||||
.or(targetOrder.type.eq(OrderType.RENTAL).and(targetOrder.endDate.after(now)))
|
||||
}
|
||||
|
||||
private data class SeriesContentStats(
|
||||
val contentCount: Int = 0,
|
||||
val paidContentCount: Int = 0
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.series.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.order.Order
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
|
||||
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.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||
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.common.domain.ContentSort
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
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 DefaultCreatorChannelSeriesQueryRepositoryTest @Autowired constructor(
|
||||
private val entityManager: EntityManager,
|
||||
queryFactory: JPAQueryFactory
|
||||
) {
|
||||
private val repository = DefaultCreatorChannelSeriesQueryRepository(queryFactory)
|
||||
|
||||
@Test
|
||||
@DisplayName("활성 creator와 양방향 차단 관계를 조회한다")
|
||||
fun shouldFindCreatorAndBlockedRelationship() {
|
||||
val viewer = saveMember("series-viewer", MemberRole.USER)
|
||||
val creator = saveMember("series-creator", MemberRole.CREATOR)
|
||||
val inactiveCreator = saveMember("inactive-series-creator", MemberRole.CREATOR, isActive = false)
|
||||
saveBlock(creator, viewer)
|
||||
flushAndClear()
|
||||
|
||||
val record = repository.findCreator(creator.id!!, viewer.id!!)
|
||||
val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!)
|
||||
|
||||
assertEquals(creator.id, record!!.creatorId)
|
||||
assertEquals(MemberRole.CREATOR, record.role)
|
||||
assertEquals("series-creator", record.nickname)
|
||||
assertNull(inactiveRecord)
|
||||
assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("시리즈 count는 활성 시리즈, creator, 성인 노출 정책을 반영한다")
|
||||
fun shouldCountSeriesWithCreatorAndAdultVisibilityFilters() {
|
||||
val now = LocalDateTime.of(2026, 6, 20, 12, 0)
|
||||
val creator = saveMember("count-series-creator", MemberRole.CREATOR)
|
||||
val otherCreator = saveMember("count-series-other-creator", MemberRole.CREATOR)
|
||||
saveSeries("public-series", creator, isAdult = false)
|
||||
saveSeries("adult-series", creator, isAdult = true)
|
||||
saveSeries("inactive-series", creator, isAdult = false).isActive = false
|
||||
saveSeries("other-creator-series", otherCreator, isAdult = false)
|
||||
flushAndClear()
|
||||
|
||||
assertEquals(1, repository.countSeries(creator.id!!, now, canViewAdultContent = false))
|
||||
assertEquals(2, repository.countSeries(creator.id!!, now, canViewAdultContent = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("목록은 시리즈 필드, 번역 fallback, 공개 콘텐츠 통계와 구매 통계를 반환한다")
|
||||
fun shouldFindSeriesWithFieldsTranslationsAndStats() {
|
||||
val now = LocalDateTime.of(2026, 6, 20, 12, 0)
|
||||
val viewer = saveMember("field-series-viewer", MemberRole.USER)
|
||||
val creator = saveMember("field-series-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme("field-theme")
|
||||
val translated = saveSeries("translated-series", creator, isOriginal = true, state = SeriesState.PROCEEDING).apply {
|
||||
publishedDaysOfWeek.addAll(setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.THU))
|
||||
}
|
||||
val blankTranslated = saveSeries("blank-fallback-series", creator, state = SeriesState.COMPLETE).apply {
|
||||
publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.RANDOM)
|
||||
}
|
||||
val publicPaid = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 300)
|
||||
val publicFree = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 0)
|
||||
val future = saveAudioContent(creator, theme, now.plusDays(1), isAdult = false, price = 100)
|
||||
val nullRelease = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100).apply {
|
||||
releaseDate = null
|
||||
}
|
||||
val noDuration = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100).apply {
|
||||
duration = null
|
||||
}
|
||||
val adultContent = saveAudioContent(creator, theme, now.minusDays(1), isAdult = true, price = 100)
|
||||
saveSeriesContent(translated, publicPaid)
|
||||
saveSeriesContent(translated, publicFree)
|
||||
saveSeriesContent(translated, future)
|
||||
saveSeriesContent(translated, nullRelease)
|
||||
saveSeriesContent(translated, noDuration)
|
||||
saveSeriesContent(translated, adultContent)
|
||||
saveSeriesContent(blankTranslated, saveAudioContent(creator, theme, now.minusDays(4), isAdult = false, price = 100))
|
||||
saveSeriesTranslation(translated, "en", "Translated Series")
|
||||
saveSeriesTranslation(blankTranslated, "en", " ")
|
||||
saveOrder(viewer, creator, publicPaid, OrderType.KEEP)
|
||||
saveOrder(viewer, creator, publicPaid, OrderType.RENTAL, endDate = now.plusDays(1))
|
||||
saveOrder(viewer, creator, future, OrderType.KEEP)
|
||||
flushAndClear()
|
||||
|
||||
val records = repository.findSeries(
|
||||
creator.id!!,
|
||||
viewer.id!!,
|
||||
now,
|
||||
canViewAdultContent = false,
|
||||
ContentSort.LATEST,
|
||||
"en",
|
||||
offset = 0,
|
||||
limit = 20
|
||||
)
|
||||
|
||||
val translatedRecord = records.first { it.seriesId == translated.id }
|
||||
val blankRecord = records.first { it.seriesId == blankTranslated.id }
|
||||
assertEquals("Translated Series", translatedRecord.title)
|
||||
assertEquals("translated-series.png", translatedRecord.coverImagePath)
|
||||
assertEquals(setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.THU), translatedRecord.publishedDaysOfWeek)
|
||||
assertEquals(true, translatedRecord.isOriginal)
|
||||
assertEquals(false, translatedRecord.isAdult)
|
||||
assertEquals(SeriesState.PROCEEDING, translatedRecord.state)
|
||||
assertEquals(2, translatedRecord.contentCount)
|
||||
assertEquals(1, translatedRecord.paidContentCount)
|
||||
assertEquals(1, translatedRecord.purchasedContentCount)
|
||||
assertEquals("blank-fallback-series", blankRecord.title)
|
||||
assertEquals(1, blankRecord.contentCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("목록은 최신순과 가격순 대표값 정렬을 적용한다")
|
||||
fun shouldSortSeriesByLatestAndPriceRepresentatives() {
|
||||
val now = LocalDateTime.of(2026, 6, 20, 12, 0)
|
||||
val viewer = saveMember("sort-representative-viewer", MemberRole.USER)
|
||||
val creator = saveMember("sort-representative-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme("sort-representative-theme")
|
||||
val oldHigh = saveSeries("old-high", creator)
|
||||
val recentLow = saveSeries("recent-low", creator)
|
||||
val sameDateHigh = saveSeries("same-date-high", creator)
|
||||
saveSeriesContent(oldHigh, saveAudioContent(creator, theme, now.minusDays(5), isAdult = false, price = 500))
|
||||
saveSeriesContent(recentLow, saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100))
|
||||
saveSeriesContent(sameDateHigh, saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 300))
|
||||
flushAndClear()
|
||||
|
||||
val latest = findSortedSeriesIds(creator.id!!, viewer.id!!, now, ContentSort.LATEST)
|
||||
val priceHigh = findSortedSeriesIds(creator.id!!, viewer.id!!, now, ContentSort.PRICE_HIGH)
|
||||
val priceLow = findSortedSeriesIds(creator.id!!, viewer.id!!, now, ContentSort.PRICE_LOW)
|
||||
|
||||
assertEquals(listOf(sameDateHigh.id, recentLow.id, oldHigh.id), latest)
|
||||
assertEquals(listOf(oldHigh.id, sameDateHigh.id, recentLow.id), priceHigh)
|
||||
assertEquals(listOf(recentLow.id, sameDateHigh.id, oldHigh.id), priceLow)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("목록은 인기순 can 합계와 소장순 유효 구매 개수 정렬을 적용한다")
|
||||
fun shouldSortSeriesByPopularRevenueAndOwnedCount() {
|
||||
val now = LocalDateTime.of(2026, 6, 20, 12, 0)
|
||||
val viewer = saveMember("sort-order-viewer", MemberRole.USER)
|
||||
val creator = saveMember("sort-order-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme("sort-order-theme")
|
||||
val popular = saveSeries("popular-series", creator)
|
||||
val owned = saveSeries("owned-series", creator)
|
||||
val manyUnowned = saveSeries("many-unowned-series", creator)
|
||||
val inactiveRevenue = saveSeries("inactive-revenue-series", creator)
|
||||
val popularContent = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 100)
|
||||
val ownedKeep = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 100)
|
||||
val ownedRental = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100)
|
||||
val expiredRental = saveAudioContent(creator, theme, now.minusDays(4), isAdult = false, price = 100)
|
||||
val inactiveRevenueContent = saveAudioContent(creator, theme, now.minusDays(5), isAdult = false, price = 100)
|
||||
saveSeriesContent(popular, popularContent)
|
||||
saveSeriesContent(owned, ownedKeep)
|
||||
saveSeriesContent(owned, ownedRental)
|
||||
saveSeriesContent(owned, expiredRental)
|
||||
saveSeriesContent(manyUnowned, saveAudioContent(creator, theme, now.minusHours(1), isAdult = false, price = 100))
|
||||
saveSeriesContent(manyUnowned, saveAudioContent(creator, theme, now.minusHours(2), isAdult = false, price = 100))
|
||||
saveSeriesContent(manyUnowned, saveAudioContent(creator, theme, now.minusHours(3), isAdult = false, price = 100))
|
||||
saveSeriesContent(manyUnowned, saveAudioContent(creator, theme, now.minusHours(4), isAdult = false, price = 100))
|
||||
saveSeriesContent(inactiveRevenue, inactiveRevenueContent)
|
||||
saveOrder(viewer, creator, popularContent, OrderType.KEEP, can = 900)
|
||||
saveOrder(viewer, creator, inactiveRevenueContent, OrderType.KEEP, isActive = false, can = 1000)
|
||||
saveOrder(viewer, creator, ownedKeep, OrderType.KEEP)
|
||||
saveOrder(viewer, creator, ownedRental, OrderType.RENTAL, endDate = now.plusDays(1))
|
||||
saveOrder(viewer, creator, expiredRental, OrderType.RENTAL, endDate = now.minusDays(1))
|
||||
flushAndClear()
|
||||
|
||||
val popularSorted = findSortedSeriesIds(creator.id!!, viewer.id!!, now, ContentSort.POPULAR)
|
||||
val ownedSorted = findSortedSeriesIds(creator.id!!, viewer.id!!, now, ContentSort.OWNED)
|
||||
|
||||
assertEquals(popular.id, popularSorted.first())
|
||||
assertEquals(listOf(owned.id, popular.id, manyUnowned.id, inactiveRevenue.id), ownedSorted)
|
||||
assertEquals(inactiveRevenue.id, popularSorted.last())
|
||||
}
|
||||
|
||||
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
profileImage = "$nickname.png",
|
||||
role = role,
|
||||
isActive = isActive
|
||||
)
|
||||
entityManager.persist(member)
|
||||
return member
|
||||
}
|
||||
|
||||
private fun saveBlock(member: Member, blockedMember: Member): BlockMember {
|
||||
val block = BlockMember(isActive = true)
|
||||
block.member = member
|
||||
block.blockedMember = blockedMember
|
||||
entityManager.persist(block)
|
||||
return block
|
||||
}
|
||||
|
||||
private fun saveTheme(name: String): AudioContentTheme {
|
||||
val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = true, orders = 1)
|
||||
entityManager.persist(theme)
|
||||
return theme
|
||||
}
|
||||
|
||||
private fun saveAudioContent(
|
||||
creator: Member,
|
||||
theme: AudioContentTheme,
|
||||
releaseDate: LocalDateTime,
|
||||
isAdult: Boolean,
|
||||
price: Int = 0
|
||||
): AudioContent {
|
||||
val content = AudioContent(
|
||||
title = "audio-${creator.nickname}-$releaseDate",
|
||||
detail = "detail",
|
||||
languageCode = "ko",
|
||||
releaseDate = releaseDate,
|
||||
isAdult = isAdult,
|
||||
price = price
|
||||
)
|
||||
content.member = creator
|
||||
content.theme = theme
|
||||
content.isActive = true
|
||||
content.coverImage = "audio.png"
|
||||
content.duration = "00:10:00"
|
||||
entityManager.persist(content)
|
||||
return content
|
||||
}
|
||||
|
||||
private fun saveSeries(
|
||||
title: String,
|
||||
creator: Member,
|
||||
isAdult: Boolean = false,
|
||||
isOriginal: Boolean = false,
|
||||
state: SeriesState = SeriesState.PROCEEDING
|
||||
): Series {
|
||||
val series = Series(
|
||||
title = title,
|
||||
introduction = "introduction",
|
||||
languageCode = "ko",
|
||||
state = state,
|
||||
isAdult = isAdult,
|
||||
isOriginal = isOriginal
|
||||
)
|
||||
series.member = creator
|
||||
series.genre = saveSeriesGenre(title)
|
||||
series.coverImage = "$title.png"
|
||||
entityManager.persist(series)
|
||||
return series
|
||||
}
|
||||
|
||||
private fun saveSeriesGenre(name: String): SeriesGenre {
|
||||
val genre = SeriesGenre(genre = "genre-$name", isAdult = false, isActive = true)
|
||||
entityManager.persist(genre)
|
||||
return genre
|
||||
}
|
||||
|
||||
private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent {
|
||||
val seriesContent = SeriesContent()
|
||||
seriesContent.series = series
|
||||
seriesContent.content = content
|
||||
entityManager.persist(seriesContent)
|
||||
return seriesContent
|
||||
}
|
||||
|
||||
private fun saveSeriesTranslation(series: Series, locale: String, title: String): SeriesTranslation {
|
||||
val translation = SeriesTranslation(
|
||||
seriesId = series.id!!,
|
||||
locale = locale,
|
||||
renderedPayload = SeriesTranslationPayload(title = title, introduction = "", keywords = emptyList())
|
||||
)
|
||||
entityManager.persist(translation)
|
||||
entityManager.flush()
|
||||
val payload = "{\"title\":\"$title\",\"introduction\":\"\",\"keywords\":[]}"
|
||||
entityManager.createNativeQuery(
|
||||
"update series_translation set rendered_payload = '$payload' format json where id = :id"
|
||||
)
|
||||
.setParameter("id", translation.id)
|
||||
.executeUpdate()
|
||||
return translation
|
||||
}
|
||||
|
||||
private fun saveOrder(
|
||||
member: Member,
|
||||
creator: Member,
|
||||
content: AudioContent,
|
||||
type: OrderType,
|
||||
isActive: Boolean = true,
|
||||
endDate: LocalDateTime? = null,
|
||||
can: Int? = null
|
||||
): Order {
|
||||
val order = Order(type = type, isActive = isActive)
|
||||
order.member = member
|
||||
order.creator = creator
|
||||
order.audioContent = content
|
||||
can?.let { order.can = it }
|
||||
entityManager.persist(order)
|
||||
if (endDate != null) {
|
||||
entityManager.flush()
|
||||
order.endDate = endDate
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
private fun findSortedSeriesIds(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
now: LocalDateTime,
|
||||
sort: ContentSort
|
||||
): List<Long> {
|
||||
return repository.findSeries(
|
||||
creatorId,
|
||||
viewerId,
|
||||
now,
|
||||
canViewAdultContent = false,
|
||||
sort,
|
||||
"ko",
|
||||
offset = 0,
|
||||
limit = 20
|
||||
).map { it.seriesId }
|
||||
}
|
||||
|
||||
private fun flushAndClear() {
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user