test #426
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user