diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/CreatorChannelAudioQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/CreatorChannelAudioQueryRepository.kt new file mode 100644 index 00000000..61603d16 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/CreatorChannelAudioQueryRepository.kt @@ -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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt new file mode 100644 index 00000000..9d8e9b34 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt @@ -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 { + 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 { + 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 { + 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.groupByAudioContentRow(): com.querydsl.jpa.impl.JPAQuery { + 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, + orderStatesByContentId: Map + ): 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, locale: String): Map { + 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, + now: LocalDateTime + ): Map { + 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 + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt new file mode 100644 index 00000000..44697d9b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt @@ -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() + } +}