test #426

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

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioQueryPort
interface CreatorChannelAudioQueryRepository : CreatorChannelAudioQueryPort

View File

@@ -0,0 +1,407 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.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.order.QOrder.order
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.content.theme.translation.QContentThemeTranslation
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.audio.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioThemeRecord
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
class DefaultCreatorChannelAudioQueryRepository(
private val queryFactory: JPAQueryFactory
) : CreatorChannelAudioQueryRepository {
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? {
return queryFactory
.select(
Projections.constructor(
CreatorChannelAudioCreatorRecord::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("creatorChannelAudioBlockMember")
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 findActiveThemeId(themeId: Long): Long? {
return queryFactory
.select(audioContentTheme.id)
.from(audioContentTheme)
.where(
audioContentTheme.id.eq(themeId),
audioContentTheme.isActive.isTrue
)
.fetchFirst()
}
override fun findAudioThemes(locale: String): List<CreatorChannelAudioThemeRecord> {
val themeTranslation = QContentThemeTranslation("audioThemeTranslation")
return queryFactory
.select(audioContentTheme.id, audioContentTheme.theme, themeTranslation.theme)
.from(audioContentTheme)
.leftJoin(themeTranslation)
.on(
themeTranslation.contentThemeId.eq(audioContentTheme.id),
themeTranslation.locale.eq(locale)
)
.where(audioContentTheme.isActive.isTrue)
.orderBy(audioContentTheme.orders.asc(), audioContentTheme.id.asc())
.fetch()
.map {
CreatorChannelAudioThemeRecord(
themeId = it.get(audioContentTheme.id)!!,
themeName = it.get(themeTranslation.theme).takeUnless(String?::isNullOrBlank)
?: it.get(audioContentTheme.theme)!!
)
}
}
override fun countAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return queryFactory
.select(audioContent.id.count())
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(audioContentCondition(creatorId, themeId, now, canViewAdultContent))
.fetchOne()
?.toInt()
?: 0
}
override fun countPaidAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return queryFactory
.select(audioContent.id.count())
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(
audioContentCondition(creatorId, themeId, now, canViewAdultContent),
audioContent.price.gt(0)
)
.fetchOne()
?.toInt()
?: 0
}
override fun countPurchasedAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
val purchasedOrder = QOrder("audioPurchasedOrder")
return queryFactory
.select(audioContent.id.countDistinct())
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.innerJoin(purchasedOrder)
.on(purchasedOrder.audioContent.id.eq(audioContent.id))
.where(
audioContentCondition(creatorId, themeId, now, canViewAdultContent),
audioContent.price.gt(0),
purchasedOrder.member.id.eq(viewerId),
purchasedOrder.isActive.isTrue,
validPurchasedOrderCondition(purchasedOrder, now)
)
.fetchOne()
?.toInt()
?: 0
}
override fun findAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
locale: String,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord> {
val rows = findAudioContentRows(creatorId, viewerId, themeId, now, canViewAdultContent, sort, offset, limit)
val contentIds = rows.map { itAudioId(it) }
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
val seriesByContentId = audioSeriesByContentIds(contentIds, locale)
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
return rows.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) }
}
private fun findAudioContentRows(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
offset: Long,
limit: Int
): List<Tuple> {
val query = queryFactory
.select(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate
)
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(audioContentCondition(creatorId, themeId, now, canViewAdultContent))
when (sort) {
ContentSort.POPULAR -> {
val revenueOrder = QOrder("audioRevenueOrder")
query
.leftJoin(revenueOrder)
.on(
revenueOrder.audioContent.id.eq(audioContent.id),
revenueOrder.isActive.isTrue
)
.groupByAudioContentRow()
.orderBy(
revenueOrder.can.sum().coalesce(0).desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
ContentSort.OWNED -> {
val ownedOrder = QOrder("audioOwnedOrder")
query
.leftJoin(ownedOrder)
.on(
ownedOrder.audioContent.id.eq(audioContent.id),
ownedOrder.member.id.eq(viewerId),
ownedOrder.isActive.isTrue,
validPurchasedOrderCondition(ownedOrder, now)
)
.groupByAudioContentRow()
.orderBy(
CaseBuilder()
.`when`(ownedOrder.id.countDistinct().gt(0))
.then(1)
.otherwise(0)
.desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
ContentSort.LATEST -> query.orderBy(
audioContent.releaseDate.desc(),
audioContent.price.desc(),
audioContent.id.desc()
)
ContentSort.PRICE_HIGH -> query.orderBy(
audioContent.price.desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
ContentSort.PRICE_LOW -> query.orderBy(
audioContent.price.asc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
return query
.offset(offset)
.limit(limit.toLong())
.fetch()
}
private fun com.querydsl.jpa.impl.JPAQuery<Tuple>.groupByAudioContentRow(): com.querydsl.jpa.impl.JPAQuery<Tuple> {
return groupBy(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate
)
}
private fun audioContentCondition(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): BooleanExpression {
return audioContent.member.id.eq(creatorId)
.and(audioContent.member.isActive.isTrue)
.and(audioContent.isActive.isTrue)
.and(audioContentTheme.isActive.isTrue)
.and(themeCondition(themeId))
.and(audioContent.duration.isNotNull)
.and(audioContent.releaseDate.isNotNull)
.and(audioContent.releaseDate.loe(now))
.and(adultAudioCondition(canViewAdultContent))
}
private fun themeCondition(themeId: Long?): BooleanExpression? {
return themeId?.let { audioContentTheme.id.eq(it) }
}
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 fun itAudioId(row: Tuple): Long = row.get(audioContent.id)!!
private fun Tuple.toAudioRecord(
firstContentId: Long?,
seriesByContentId: Map<Long, AudioSeriesSummary>,
orderStatesByContentId: Map<Long, AudioOrderState>
): CreatorChannelAudioContentRecord {
val audioContentId = get(audioContent.id)!!
val seriesSummary = seriesByContentId[audioContentId]
val orderState = orderStatesByContentId[audioContentId]
return CreatorChannelAudioContentRecord(
audioContentId = audioContentId,
title = get(audioContent.title)!!,
duration = get(audioContent.duration),
imagePath = get(audioContent.coverImage),
price = get(audioContent.price)!!,
isAdult = get(audioContent.isAdult)!!,
isPointAvailable = get(audioContent.isPointAvailable)!!,
isFirstContent = firstContentId == audioContentId,
seriesName = seriesSummary?.title,
isOriginalSeries = seriesSummary?.isOriginal,
isOwned = orderState?.isOwned ?: false,
isRented = orderState?.isRented ?: false
)
}
private fun firstAudioContentId(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): Long? {
return queryFactory
.select(audioContent.id)
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(audioContentCondition(creatorId, themeId = null, now, canViewAdultContent))
.orderBy(audioContent.releaseDate.asc(), audioContent.id.asc())
.fetchFirst()
}
private fun audioSeriesByContentIds(contentIds: List<Long>, locale: String): Map<Long, AudioSeriesSummary> {
if (contentIds.isEmpty()) return emptyMap()
val seriesTranslation = QSeriesTranslation("audioSeriesTranslation")
return queryFactory
.select(
seriesContent.content.id,
series.title,
series.isOriginal,
seriesTranslation
)
.from(seriesContent)
.innerJoin(seriesContent.series, series)
.leftJoin(seriesTranslation)
.on(
seriesTranslation.seriesId.eq(series.id),
seriesTranslation.locale.eq(locale)
)
.where(seriesContent.content.id.`in`(contentIds))
.fetch()
.associate {
val originalTitle = it.get(series.title)!!
val translatedTitle = it.get(seriesTranslation)?.renderedPayload?.title
it.get(seriesContent.content.id)!! to AudioSeriesSummary(
title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: originalTitle,
isOriginal = it.get(series.isOriginal)!!
)
}
}
private fun orderStatesByContentIds(
viewerId: Long,
contentIds: List<Long>,
now: LocalDateTime
): Map<Long, AudioOrderState> {
if (contentIds.isEmpty()) return emptyMap()
return queryFactory
.select(order.audioContent.id, order.type)
.from(order)
.where(
order.member.id.eq(viewerId),
order.audioContent.id.`in`(contentIds),
order.isActive.isTrue,
validPurchasedOrderCondition(order, now)
)
.fetch()
.groupBy { it.get(order.audioContent.id)!! }
.mapValues { (_, rows) ->
val types = rows.map { it.get(order.type)!! }.toSet()
AudioOrderState(
isOwned = OrderType.KEEP in types,
isRented = OrderType.RENTAL in types
)
}
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
private data class AudioSeriesSummary(
val title: String,
val isOriginal: Boolean
)
private data class AudioOrderState(
val isOwned: Boolean,
val isRented: Boolean
)
}

View File

@@ -0,0 +1,391 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.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.content.theme.translation.ContentThemeTranslation
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.common.domain.ContentSort
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
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 DefaultCreatorChannelAudioQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultCreatorChannelAudioQueryRepository(queryFactory)
@Test
@DisplayName("크리에이터, 차단 관계, 활성 테마, 테마 번역 fallback을 조회한다")
fun shouldFindCreatorBlockAndThemesWithTranslationFallback() {
val viewer = saveMember("audio-viewer", MemberRole.USER)
val creator = saveMember("audio-creator", MemberRole.CREATOR)
val translatedTheme = saveTheme("수면", orders = 2)
val blankTranslatedTheme = saveTheme("집중", orders = 1)
val inactiveTheme = saveTheme("비활성", isActive = false)
saveThemeTranslation(translatedTheme, "en", "Sleep")
saveThemeTranslation(blankTranslatedTheme, "en", " ")
saveThemeTranslation(inactiveTheme, "en", "Inactive")
saveBlock(creator, viewer)
flushAndClear()
val record = repository.findCreator(creator.id!!, viewer.id!!)
val themes = repository.findAudioThemes("en")
assertEquals(creator.id, record!!.creatorId)
assertEquals(MemberRole.CREATOR, record.role)
assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!))
assertEquals(translatedTheme.id, repository.findActiveThemeId(translatedTheme.id!!))
assertEquals(null, repository.findActiveThemeId(inactiveTheme.id!!))
assertEquals(listOf(blankTranslatedTheme.id, translatedTheme.id), themes.map { it.themeId })
assertEquals(listOf("집중", "Sleep"), themes.map { it.themeName })
}
@Test
@DisplayName("오디오 콘텐츠 count는 공개 조건, 성인 노출 정책, 활성 themeId 필터를 공유한다")
fun shouldCountPublicAudioContentsWithFilters() {
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
val creator = saveMember("count-creator", MemberRole.CREATOR)
val theme = saveTheme("수면")
val otherTheme = saveTheme("집중")
val inactiveTheme = saveTheme("비활성", isActive = false)
saveAudioContent(creator, now.minusDays(2), false, theme, price = 0)
saveAudioContent(creator, now.minusDays(1), false, theme, price = 100)
saveAudioContent(creator, now.minusHours(1), true, theme, price = 200)
saveAudioContent(creator, now.minusHours(2), false, otherTheme, price = 0)
saveAudioContent(creator, now.plusHours(1), false, theme, price = 100)
saveAudioContent(creator, now.minusHours(3), false, theme, price = 100).isActive = false
saveAudioContent(creator, now.minusHours(4), false, inactiveTheme, price = 100)
saveAudioContent(creator, now.minusHours(5), false, theme, price = 100).duration = null
saveAudioContent(creator, now.minusHours(6), false, theme, price = 100).releaseDate = null
flushAndClear()
assertEquals(3, repository.countAudioContents(creator.id!!, null, now, canViewAdultContent = false))
assertEquals(4, repository.countAudioContents(creator.id!!, null, now, canViewAdultContent = true))
assertEquals(2, repository.countAudioContents(creator.id!!, theme.id, now, canViewAdultContent = false))
assertEquals(1, repository.countPaidAudioContents(creator.id!!, null, now, canViewAdultContent = false))
assertEquals(2, repository.countPaidAudioContents(creator.id!!, theme.id, now, canViewAdultContent = true))
}
@Test
@DisplayName("구매 count는 유료 콘텐츠의 활성 KEEP 또는 유효 RENTAL 주문을 distinct로 계산한다")
fun shouldCountPurchasedPaidAudioContentsOnly() {
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
val viewer = saveMember("purchase-viewer", MemberRole.USER)
val creator = saveMember("purchase-creator", MemberRole.CREATOR)
val theme = saveTheme("수면")
val keep = saveAudioContent(creator, now.minusDays(6), false, theme, price = 100)
val rental = saveAudioContent(creator, now.minusDays(5), false, theme, price = 100)
val duplicate = saveAudioContent(creator, now.minusDays(4), false, theme, price = 100)
val expiredRental = saveAudioContent(creator, now.minusDays(3), false, theme, price = 100)
val inactiveOrder = saveAudioContent(creator, now.minusDays(2), false, theme, price = 100)
val free = saveAudioContent(creator, now.minusDays(1), false, theme, price = 0)
saveOrder(viewer, creator, keep, OrderType.KEEP)
saveOrder(viewer, creator, rental, OrderType.RENTAL, endDate = now.plusDays(1))
saveOrder(viewer, creator, duplicate, OrderType.KEEP)
saveOrder(viewer, creator, duplicate, OrderType.RENTAL, endDate = now.plusDays(1))
saveOrder(viewer, creator, expiredRental, OrderType.RENTAL, endDate = now.minusDays(1))
saveOrder(viewer, creator, inactiveOrder, OrderType.KEEP, isActive = false)
saveOrder(viewer, creator, free, OrderType.KEEP)
flushAndClear()
val count = repository.countPurchasedAudioContents(creator.id!!, viewer.id!!, null, now, false)
assertEquals(3, count)
}
@Test
@DisplayName("목록은 limit 그대로 조회하고 최신순, 시리즈 번역, 주문 상태, 전체 첫 콘텐츠를 반환한다")
fun shouldFindAudioContentsWithLatestSortAndEnrichedFields() {
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
val viewer = saveMember("latest-viewer", MemberRole.USER)
val creator = saveMember("latest-creator", MemberRole.CREATOR)
val theme = saveTheme("수면")
val otherTheme = saveTheme("집중")
val firstContent = saveAudioContent(creator, now.minusDays(30), false, otherTheme, price = 0)
val oldSelected = saveAudioContent(creator, now.minusDays(3), false, theme, price = 100)
val sameDateLowPrice = saveAudioContent(creator, now.minusDays(1), false, theme, price = 100)
val sameDateHighPrice = saveAudioContent(creator, now.minusDays(1), false, theme, price = 300, isPointAvailable = true)
val series = saveSeries("original-series", creator, isOriginal = true)
saveSeriesContent(series, sameDateHighPrice)
saveSeriesTranslation(series, "en", "Translated Series")
saveOrder(viewer, creator, sameDateHighPrice, OrderType.KEEP)
saveOrder(viewer, creator, sameDateHighPrice, OrderType.RENTAL, endDate = now.plusDays(1))
flushAndClear()
val firstPage = repository.findAudioContents(
creator.id!!,
viewer.id!!,
theme.id,
now,
false,
ContentSort.LATEST,
"en",
offset = 0,
limit = 2
)
val allThemes = repository.findAudioContents(
creator.id!!,
viewer.id!!,
null,
now,
false,
ContentSort.LATEST,
"en",
offset = 0,
limit = 10
)
assertEquals(2, firstPage.size)
assertEquals(listOf(sameDateHighPrice.id, sameDateLowPrice.id), firstPage.map { it.audioContentId })
assertEquals("Translated Series", firstPage.first().seriesName)
assertEquals(true, firstPage.first().isOriginalSeries)
assertTrue(firstPage.first().isOwned)
assertTrue(firstPage.first().isRented)
assertTrue(firstPage.first().isPointAvailable)
assertEquals(firstContent.id, allThemes.last().audioContentId)
assertTrue(allThemes.last().isFirstContent)
assertFalse(firstPage.any { it.audioContentId == oldSelected.id && it.isFirstContent })
}
@Test
@DisplayName("목록은 가격순과 인기순 can 매출 정렬을 적용한다")
fun shouldSortAudioContentsByPriceAndPopularRevenue() {
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
val viewer = saveMember("sort-viewer", MemberRole.USER)
val creator = saveMember("sort-creator", MemberRole.CREATOR)
val theme = saveTheme("수면")
val low = saveAudioContent(creator, now.minusDays(3), false, theme, price = 100)
val high = saveAudioContent(creator, now.minusDays(2), false, theme, price = 300)
val noRevenue = saveAudioContent(creator, now.minusDays(1), false, theme, price = 200)
saveOrder(viewer, creator, low, OrderType.KEEP, can = 500, point = 9000)
saveOrder(viewer, creator, high, OrderType.KEEP, can = 100, point = 9999)
saveOrder(viewer, creator, noRevenue, OrderType.KEEP, isActive = false, can = 1000)
flushAndClear()
val highRecords = repository.findAudioContents(
creator.id!!,
viewer.id!!,
null,
now,
false,
ContentSort.PRICE_HIGH,
"ko",
0,
20
)
val lowRecords = repository.findAudioContents(
creator.id!!,
viewer.id!!,
null,
now,
false,
ContentSort.PRICE_LOW,
"ko",
0,
20
)
val popularRecords = repository.findAudioContents(
creator.id!!,
viewer.id!!,
null,
now,
false,
ContentSort.POPULAR,
"ko",
0,
20
)
assertEquals(listOf(high.id, noRevenue.id, low.id), highRecords.map { it.audioContentId })
assertEquals(listOf(low.id, noRevenue.id, high.id), lowRecords.map { it.audioContentId })
assertEquals(listOf(low.id, high.id, noRevenue.id), popularRecords.map { it.audioContentId })
}
@Test
@DisplayName("소장순은 KEEP 또는 유효 RENTAL 콘텐츠를 먼저 노출하고 시리즈명 blank 번역은 원문 fallback한다")
fun shouldSortAudioContentsByOwnedAndReturnOrderStatesWithSeriesFallback() {
val now = LocalDateTime.of(2026, 6, 19, 12, 0)
val viewer = saveMember("owned-viewer", MemberRole.USER)
val creator = saveMember("owned-creator", MemberRole.CREATOR)
val theme = saveTheme("수면")
val noOrder = saveAudioContent(creator, now.minusDays(5), false, theme, price = 100)
val expiredRental = saveAudioContent(creator, now.minusDays(4), false, theme, price = 100)
val keepAndRental = saveAudioContent(creator, now.minusDays(3), false, theme, price = 100)
val rentalOnly = saveAudioContent(creator, now.minusDays(2), false, theme, price = 100)
val keepOnly = saveAudioContent(creator, now.minusDays(1), false, theme, price = 100)
val series = saveSeries("fallback-series", creator, isOriginal = false)
saveSeriesContent(series, keepOnly)
saveSeriesTranslation(series, "en", " ")
saveOrder(viewer, creator, keepOnly, OrderType.KEEP)
saveOrder(viewer, creator, rentalOnly, OrderType.RENTAL, endDate = now.plusDays(1))
saveOrder(viewer, creator, keepAndRental, OrderType.KEEP)
saveOrder(viewer, creator, keepAndRental, OrderType.RENTAL, endDate = now.plusDays(1))
saveOrder(viewer, creator, expiredRental, OrderType.RENTAL, endDate = now.minusDays(1))
flushAndClear()
val records = repository.findAudioContents(creator.id!!, viewer.id!!, null, now, false, ContentSort.OWNED, "en", 0, 20)
assertEquals(
listOf(keepOnly.id, rentalOnly.id, keepAndRental.id, expiredRental.id, noOrder.id),
records.map { it.audioContentId }
)
assertEquals(listOf(true, false, true, false, false), records.map { it.isOwned })
assertEquals(listOf(false, true, true, false, false), records.map { it.isRented })
assertEquals("fallback-series", records.first().seriesName)
assertEquals(false, records.first().isOriginalSeries)
}
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, isActive: Boolean = true, orders: Int = 1): AudioContentTheme {
val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = isActive, orders = orders)
entityManager.persist(theme)
return theme
}
private fun saveThemeTranslation(theme: AudioContentTheme, locale: String, translatedTheme: String): ContentThemeTranslation {
val translation = ContentThemeTranslation(theme.id!!, locale, translatedTheme)
entityManager.persist(translation)
return translation
}
private fun saveAudioContent(
creator: Member,
releaseDate: LocalDateTime,
isAdult: Boolean,
theme: AudioContentTheme,
price: Int = 0,
isPointAvailable: Boolean = false
): AudioContent {
val content = AudioContent(
title = "audio-${creator.nickname}-$releaseDate",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate,
isAdult = isAdult,
price = price,
isPointAvailable = isPointAvailable
)
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, isOriginal: Boolean = false): Series {
val series = Series(title = title, introduction = "introduction", languageCode = "ko", 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,
point: Int = 0
): Order {
val order = Order(type = type, isActive = isActive)
order.member = member
order.creator = creator
order.audioContent = content
can?.let { order.can = it }
order.point = point
entityManager.persist(order)
if (endDate != null) {
entityManager.flush()
order.endDate = endDate
}
return order
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}