diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt new file mode 100644 index 00000000..dac69826 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt @@ -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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt new file mode 100644 index 00000000..ba396213 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt @@ -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 { + 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 { + 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, + locale: String, + seriesTranslation: QSeriesTranslation + ): List { + 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, + now: LocalDateTime, + canViewAdultContent: Boolean + ): Map { + 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, + viewerId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean + ): Map { + 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 + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt new file mode 100644 index 00000000..b0a67dfa --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt @@ -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 { + return repository.findSeries( + creatorId, + viewerId, + now, + canViewAdultContent = false, + sort, + "ko", + offset = 0, + limit = 20 + ).map { it.seriesId } + } + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } +}