From 24556c19874ea8793dc8c998796ea789bb4eb26a Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 25 Jun 2026 11:26:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(content-all):=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=ED=83=AD=20QueryDSL=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultMainContentAllQueryRepository.kt | 436 ++++++++++++++++++ .../MainContentAllQueryRepository.kt | 5 + ...efaultMainContentAllQueryRepositoryTest.kt | 383 +++++++++++++++ 3 files changed, 824 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt new file mode 100644 index 00000000..77431f12 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt @@ -0,0 +1,436 @@ +package kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence + +import com.querydsl.core.Tuple +import com.querydsl.core.types.Expression +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.core.types.dsl.CaseBuilder +import com.querydsl.jpa.JPAExpressions +import com.querydsl.jpa.impl.JPAQuery +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.order.QOrder +import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation +import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme +import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme +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.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.member.QMember +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.common.domain.toCdnUrl +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class DefaultMainContentAllQueryRepository( + private val queryFactory: JPAQueryFactory, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) : MainContentAllQueryRepository { + override fun countAudios( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + onlyFree: Boolean, + onlyPointAvailable: Boolean + ): Int { + return queryFactory + .select(audioContent.id.count()) + .from(audioContent) + .join(audioContent.member, member) + .join(audioContent.theme, audioContentTheme) + .where(audioCondition(memberId, canViewAdultContent, now, onlyFree, onlyPointAvailable)) + .fetchOne() + ?.toInt() + ?: 0 + } + + override fun findAudios( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + sort: ContentSort, + offset: Long, + limit: Int, + onlyFree: Boolean, + onlyPointAvailable: Boolean + ): List { + val rows = findAudioRows(memberId, canViewAdultContent, now, sort, offset, limit, onlyFree, onlyPointAvailable) + if (rows.isEmpty()) return emptyList() + + val contentIds = rows.map { it.get(audioContent.id)!! } + val creatorIds = rows.map { it.get(audioContent.member.id)!! }.distinct() + val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent) + val originalSeriesByContentId = originalSeriesFlags(contentIds) + + return rows.map { row -> + val contentId = row.get(audioContent.id)!! + val creatorId = row.get(audioContent.member.id)!! + MainContentAllAudio( + audioContentId = contentId, + title = row.get(audioContent.title)!!, + imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost), + price = row.get(audioContent.price)!!, + isAdult = row.get(audioContent.isAdult)!!, + isPointAvailable = row.get(audioContent.isPointAvailable)!!, + isFirstContent = firstContentIdByCreatorId[creatorId] == contentId, + isOriginalSeries = originalSeriesByContentId[contentId] ?: false, + creatorNickname = row.get(member.nickname)!! + ) + } + } + + override fun countSeries( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + onlyOriginal: Boolean, + dayOfWeek: SeriesPublishedDaysOfWeek? + ): Int { + return queryFactory + .select(series.id.count()) + .from(series) + .join(series.member, member) + .where(seriesCondition(memberId, canViewAdultContent, onlyOriginal, dayOfWeek)) + .fetchOne() + ?.toInt() + ?: 0 + } + + override fun findSeries( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + sort: ContentSort, + offset: Long, + limit: Int, + onlyOriginal: Boolean, + dayOfWeek: SeriesPublishedDaysOfWeek?, + locale: String + ): List { + val seriesIds = findSeriesIds(memberId, canViewAdultContent, now, sort, offset, limit, onlyOriginal, dayOfWeek) + if (seriesIds.isEmpty()) return emptyList() + + val seriesTranslation = QSeriesTranslation("mainContentAllSeriesTranslation") + return queryFactory + .select( + series.id, + series.title, + seriesTranslation.renderedPayload, + series.coverImage, + member.nickname, + series.isOriginal, + series.isAdult + ) + .from(series) + .join(series.member, member) + .leftJoin(seriesTranslation) + .on( + seriesTranslation.seriesId.eq(series.id), + seriesTranslation.locale.eq(locale) + ) + .where(series.id.`in`(seriesIds)) + .fetch() + .sortedBy { seriesIds.indexOf(it.get(series.id)!!) } + .map { row -> + val translatedTitle = row.get(seriesTranslation.renderedPayload)?.title + MainContentAllSeries( + seriesId = row.get(series.id)!!, + title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: row.get(series.title)!!, + coverImageUrl = row.get(series.coverImage).toCdnUrl(cloudFrontHost), + creatorNickname = row.get(member.nickname)!!, + isOriginal = row.get(series.isOriginal)!!, + isAdult = row.get(series.isAdult)!! + ) + } + } + + private fun findAudioRows( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + sort: ContentSort, + offset: Long, + limit: Int, + onlyFree: Boolean, + onlyPointAvailable: Boolean + ): List { + val query = queryFactory + .select( + audioContent.id, + audioContent.title, + audioContent.coverImage, + audioContent.price, + audioContent.isAdult, + audioContent.isPointAvailable, + audioContent.member.id, + member.nickname, + audioContent.releaseDate + ) + .from(audioContent) + .join(audioContent.member, member) + .join(audioContent.theme, audioContentTheme) + .where(audioCondition(memberId, canViewAdultContent, now, onlyFree, onlyPointAvailable)) + + when (sort) { + ContentSort.POPULAR -> { + val revenueOrder = QOrder("mainContentAllAudioRevenueOrder") + query + .leftJoin(revenueOrder) + .on( + revenueOrder.audioContent.id.eq(audioContent.id), + revenueOrder.isActive.isTrue + ) + .groupByAudioRow() + .orderBy( + revenueOrder.can.sum().coalesce(0).desc(), + audioContent.releaseDate.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() + ) + ContentSort.LATEST, + ContentSort.OWNED -> query.orderBy( + audioContent.releaseDate.desc(), + audioContent.id.desc() + ) + } + + return query.offset(offset).limit(limit.toLong()).fetch() + } + + private fun findSeriesIds( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + sort: ContentSort, + offset: Long, + limit: Int, + onlyOriginal: Boolean, + dayOfWeek: SeriesPublishedDaysOfWeek? + ): List { + val audioCreator = QMember("mainContentAllSeriesAudioCreator") + val audioTheme = QAudioContentTheme("mainContentAllSeriesAudioTheme") + val revenueOrder = QOrder("mainContentAllSeriesRevenueOrder") + val publicSeriesAudioCondition = publicSeriesAudioCondition(canViewAdultContent, now, audioCreator, audioTheme) + val latestReleaseDate = CaseBuilder() + .`when`(publicSeriesAudioCondition) + .then(audioContent.releaseDate) + .otherwise(null as LocalDateTime?) + .max() + val highestPrice = CaseBuilder() + .`when`(publicSeriesAudioCondition) + .then(audioContent.price) + .otherwise(null as Int?) + .max() + val lowestPrice = CaseBuilder() + .`when`(publicSeriesAudioCondition) + .then(audioContent.price) + .otherwise(null as Int?) + .min() + val revenue = CaseBuilder() + .`when`(publicSeriesAudioCondition) + .then(revenueOrder.can) + .otherwise(0) + .sum() + .coalesce(0) + 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) + .join(series.member, member) + .leftJoin(seriesContent).on(seriesContent.series.id.eq(series.id)) + .leftJoin(audioContent).on(seriesContent.content.id.eq(audioContent.id)) + .leftJoin(audioContent.member, audioCreator) + .leftJoin(audioContent.theme, audioTheme) + .where(seriesCondition(memberId, canViewAdultContent, onlyOriginal, dayOfWeek)) + .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.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() + ) + ContentSort.LATEST, + ContentSort.OWNED -> query.orderBy( + latestReleaseDateNullLast.asc(), + latestReleaseDate.desc(), + series.id.desc() + ) + } + + return query.offset(offset).limit(limit.toLong()).fetch() + } + + private fun JPAQuery.groupByAudioRow(): JPAQuery { + return groupBy( + audioContent.id, + audioContent.title, + audioContent.coverImage, + audioContent.price, + audioContent.isAdult, + audioContent.isPointAvailable, + audioContent.member.id, + member.nickname, + audioContent.releaseDate + ) + } + + private fun firstAudioContentIds( + creatorIds: List, + now: LocalDateTime, + canViewAdultContent: Boolean + ): Map { + return creatorIds.associateWith { creatorId -> + queryFactory + .select(audioContent.id) + .from(audioContent) + .join(audioContent.member, member) + .join(audioContent.theme, audioContentTheme) + .where( + audioContent.member.id.eq(creatorId), + publicAudioCondition(canViewAdultContent, now) + ) + .orderBy(audioContent.releaseDate.asc(), audioContent.id.asc()) + .fetchFirst() + }.filterValues { it != null }.mapValues { it.value!! } + } + + private fun originalSeriesFlags(contentIds: List): Map { + if (contentIds.isEmpty()) return emptyMap() + return queryFactory + .select(seriesContent.content.id, series.isOriginal) + .from(seriesContent) + .join(seriesContent.series, series) + .where(seriesContent.content.id.`in`(contentIds)) + .fetch() + .associate { it.get(seriesContent.content.id)!! to it.get(series.isOriginal)!! } + } + + private fun audioCondition( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + onlyFree: Boolean, + onlyPointAvailable: Boolean + ): BooleanExpression { + return publicAudioCondition(canViewAdultContent, now) + .and(optionalAudioFreeCondition(onlyFree)) + .and(optionalAudioPointCondition(onlyPointAvailable)) + .withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id)) + } + + private fun publicAudioCondition(canViewAdultContent: Boolean, now: LocalDateTime): BooleanExpression { + return audioContent.isActive.isTrue + .and(audioContent.duration.isNotNull) + .and(audioContent.releaseDate.isNotNull) + .and(audioContent.releaseDate.loe(now)) + .and(audioContent.member.isActive.isTrue) + .and(audioContentTheme.isActive.isTrue) + .withOptionalAnd(adultAudioCondition(canViewAdultContent)) + } + + private fun publicSeriesAudioCondition( + canViewAdultContent: Boolean, + now: LocalDateTime, + audioCreator: QMember, + audioTheme: QAudioContentTheme + ): BooleanExpression { + return audioContent.isActive.isTrue + .and(audioContent.duration.isNotNull) + .and(audioContent.releaseDate.isNotNull) + .and(audioContent.releaseDate.loe(now)) + .and(audioCreator.isActive.isTrue) + .and(audioTheme.isActive.isTrue) + .withOptionalAnd(adultAudioCondition(canViewAdultContent)) + } + + private fun seriesCondition( + memberId: Long?, + canViewAdultContent: Boolean, + onlyOriginal: Boolean, + dayOfWeek: SeriesPublishedDaysOfWeek? + ): BooleanExpression { + return series.isActive.isTrue + .and(member.isActive.isTrue) + .and(optionalOriginalCondition(onlyOriginal)) + .withOptionalAnd(dayOfWeekCondition(dayOfWeek)) + .withOptionalAnd(adultSeriesCondition(canViewAdultContent)) + .withOptionalAnd(notBlockedCreatorCondition(memberId, series.member.id)) + } + + private fun optionalAudioFreeCondition(onlyFree: Boolean): BooleanExpression? { + return if (onlyFree) audioContent.price.eq(0) else null + } + + private fun optionalAudioPointCondition(onlyPointAvailable: Boolean): BooleanExpression? { + return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue else null + } + + private fun optionalOriginalCondition(onlyOriginal: Boolean): BooleanExpression? { + return if (onlyOriginal) series.isOriginal.isTrue else null + } + + private fun dayOfWeekCondition(dayOfWeek: SeriesPublishedDaysOfWeek?): BooleanExpression? { + return dayOfWeek?.let { series.publishedDaysOfWeek.contains(it) } + } + + private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? { + return if (canViewAdultContent) null else audioContent.isAdult.isFalse + } + + private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? { + return if (canViewAdultContent) null else series.isAdult.isFalse + } + + private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression): BooleanExpression? { + if (memberId == null) return null + val blockMember = QBlockMember("mainContentAllBlockMember") + return JPAExpressions + .selectOne() + .from(blockMember) + .where( + blockMember.isActive.isTrue, + blockMember.member.id.eq(memberId).and(blockMember.blockedMember.id.eq(creatorIdPath)) + .or(blockMember.member.id.eq(creatorIdPath).and(blockMember.blockedMember.id.eq(memberId))) + ) + .notExists() + } + + private fun BooleanExpression.withOptionalAnd(condition: BooleanExpression?): BooleanExpression { + return if (condition == null) this else and(condition) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt new file mode 100644 index 00000000..bf006173 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.content.all.port.out.MainContentAllQueryPort + +interface MainContentAllQueryRepository : MainContentAllQueryPort diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt new file mode 100644 index 00000000..a0b6fa43 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt @@ -0,0 +1,383 @@ +package kr.co.vividnext.sodalive.v2.content.all.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.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 DefaultMainContentAllQueryRepositoryTest @Autowired constructor( + private val entityManager: EntityManager, + queryFactory: JPAQueryFactory +) { + private val repository = DefaultMainContentAllQueryRepository(queryFactory, "https://cdn.test") + + @Test + @DisplayName("오디오는 공개 조건, 성인 노출 정책, 차단 관계, 무료/포인트 필터를 반영한다") + fun shouldFindPublicAudiosWithVisibilityAndTypeFilters() { + val now = LocalDateTime.of(2026, 6, 25, 12, 0) + val viewer = saveMember("audio-viewer", MemberRole.USER) + val creator = saveMember("audio-creator", MemberRole.CREATOR) + val blockedCreator = saveMember("blocked-audio-creator", MemberRole.CREATOR) + val inactiveCreator = saveMember("inactive-audio-creator", MemberRole.CREATOR, isActive = false) + val theme = saveTheme("audio-theme") + val inactiveTheme = saveTheme("inactive-audio-theme", isActive = false) + val free = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 0) + val point = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 100, isPointAvailable = true) + saveAudioContent(creator, theme, now.minusDays(1), isAdult = true, price = 200) + saveAudioContent(blockedCreator, theme, now.minusDays(1), isAdult = false, price = 100) + saveAudioContent(inactiveCreator, theme, now.minusDays(1), isAdult = false, price = 100) + saveAudioContent(creator, inactiveTheme, now.minusDays(1), isAdult = false, price = 100) + saveAudioContent(creator, theme, now.plusDays(1), isAdult = false, price = 100) + saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100).duration = null + saveBlock(viewer, blockedCreator) + flushAndClear() + + val visible = repository.findAudios(viewer.id, canViewAdultContent = false, now, ContentSort.LATEST, 0, 20) + val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyFree = true) + val pointAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyPointAvailable = true) + + assertEquals(2, repository.countAudios(viewer.id, canViewAdultContent = false, now)) + assertEquals(listOf(point.id, free.id), visible.map { it.audioContentId }) + assertEquals(listOf(free.id), freeAudios.map { it.audioContentId }) + assertEquals(listOf(point.id), pointAudios.map { it.audioContentId }) + assertEquals("https://cdn.test/audio.png", visible.first().imageUrl) + } + + @Test + @DisplayName("오디오 목록은 가격순과 인기순 can 매출 정렬, 첫 콘텐츠, 오리지널 시리즈 여부를 반환한다") + fun shouldSortAudiosAndReturnEnrichedFields() { + val now = LocalDateTime.of(2026, 6, 25, 12, 0) + val buyer = saveMember("audio-buyer", MemberRole.USER) + val creator = saveMember("audio-sort-creator", MemberRole.CREATOR) + val theme = saveTheme("audio-sort-theme") + val first = saveAudioContent(creator, theme, now.minusDays(10), isAdult = false, price = 100) + val low = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 100) + val high = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 300) + val middle = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 200) + val original = saveSeries("original-audio-series", creator, isOriginal = true) + saveSeriesContent(original, high) + saveOrder(buyer, creator, low, can = 500, point = 9000) + saveOrder(buyer, creator, high, can = 100, point = 9999) + saveOrder(buyer, creator, middle, can = 1000, isActive = false) + flushAndClear() + + val priceHigh = repository.findAudios(null, false, now, ContentSort.PRICE_HIGH, 0, 20) + val priceLow = repository.findAudios(null, false, now, ContentSort.PRICE_LOW, 0, 20) + val popular = repository.findAudios(null, false, now, ContentSort.POPULAR, 0, 20) + + assertEquals(listOf(high.id, middle.id, low.id, first.id), priceHigh.map { it.audioContentId }) + assertEquals(listOf(low.id, first.id, middle.id, high.id), priceLow.map { it.audioContentId }) + assertEquals(listOf(low.id, high.id, middle.id, first.id), popular.map { it.audioContentId }) + assertTrue(priceHigh.last().isFirstContent) + assertTrue(priceHigh.first().isOriginalSeries) + assertFalse(priceHigh[1].isOriginalSeries) + assertEquals("audio-sort-creator", priceHigh.first().creatorNickname) + } + + @Test + @DisplayName("오디오 최신순은 동일 공개일에서 가격이 아니라 id desc로 정렬한다") + fun shouldSortAudiosByLatestReleaseDateAndIdOnly() { + val now = LocalDateTime.of(2026, 6, 25, 12, 0) + val creator = saveMember("audio-latest-creator", MemberRole.CREATOR) + val theme = saveTheme("audio-latest-theme") + val sameDateHighPrice = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 500) + val sameDateLowPrice = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100) + flushAndClear() + + val latest = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20) + + assertEquals(listOf(sameDateLowPrice.id, sameDateHighPrice.id), latest.map { it.audioContentId }) + } + + @Test + @DisplayName("시리즈는 활성 creator, 성인 노출 정책, 차단 관계, 요일과 오리지널 필터를 반영한다") + fun shouldFindSeriesWithVisibilityDayOfWeekAndOriginalFilters() { + val now = LocalDateTime.of(2026, 6, 25, 12, 0) + val viewer = saveMember("series-viewer", MemberRole.USER) + val creator = saveMember("series-creator", MemberRole.CREATOR) + val blockedCreator = saveMember("blocked-series-creator", MemberRole.CREATOR) + val inactiveCreator = saveMember("inactive-series-creator", MemberRole.CREATOR, isActive = false) + val mon = saveSeries("mon-series", creator).apply { publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.MON) } + val random = saveSeries("random-series", creator).apply { publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.RANDOM) } + val original = saveSeries("original-series", creator, isOriginal = true).apply { + publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.TUE) + } + saveSeries("adult-series", creator, isAdult = true).publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.MON) + saveSeries("blocked-series", blockedCreator).publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.MON) + saveSeries("inactive-creator-series", inactiveCreator).publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.MON) + saveSeries("inactive-series", creator).apply { + isActive = false + publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.MON) + } + saveBlock(viewer, blockedCreator) + flushAndClear() + + val monSeries = repository.findSeries( + viewer.id, + false, + now, + ContentSort.LATEST, + 0, + 20, + dayOfWeek = SeriesPublishedDaysOfWeek.MON, + locale = "ko" + ) + val randomSeries = repository.findSeries( + null, + false, + now, + ContentSort.LATEST, + 0, + 20, + dayOfWeek = SeriesPublishedDaysOfWeek.RANDOM, + locale = "ko" + ) + val originalSeries = repository.findSeries( + null, + false, + now, + ContentSort.LATEST, + 0, + 20, + onlyOriginal = true, + dayOfWeek = null, + locale = "ko" + ) + + assertEquals(1, repository.countSeries(viewer.id, false, now, dayOfWeek = SeriesPublishedDaysOfWeek.MON)) + assertEquals(listOf(mon.id), monSeries.map { it.seriesId }) + assertEquals(listOf(random.id), randomSeries.map { it.seriesId }) + assertEquals(listOf(original.id), originalSeries.map { it.seriesId }) + assertEquals("https://cdn.test/mon-series.png", monSeries.first().coverImageUrl) + } + + @Test + @DisplayName("시리즈 제목은 locale 번역값을 사용하고 blank 번역은 원문으로 fallback한다") + fun shouldFindSeriesWithTranslatedTitleFallback() { + val now = LocalDateTime.of(2026, 6, 25, 12, 0) + val creator = saveMember("series-translation-creator", MemberRole.CREATOR) + val translated = saveSeries("origin-translated-series", creator) + val blankTranslated = saveSeries("origin-blank-series", creator) + saveSeriesTranslation(translated, "en", "Translated Series") + saveSeriesTranslation(blankTranslated, "en", " ") + flushAndClear() + + val records = repository.findSeries(null, false, now, ContentSort.LATEST, 0, 20, locale = "en") + + assertEquals("Translated Series", records.first { it.seriesId == translated.id }.title) + assertEquals("origin-blank-series", records.first { it.seriesId == blankTranslated.id }.title) + } + + @Test + @DisplayName("시리즈 목록은 공개 오디오 대표값으로 최신순, 가격순, 인기순 can 매출 정렬을 적용한다") + fun shouldSortSeriesByPublicAudioRepresentatives() { + val now = LocalDateTime.of(2026, 6, 25, 12, 0) + val buyer = saveMember("series-buyer", MemberRole.USER) + val creator = saveMember("series-sort-creator", MemberRole.CREATOR) + val inactiveAudioCreator = saveMember("inactive-audio-creator-for-series", MemberRole.CREATOR, isActive = false) + val theme = saveTheme("series-sort-theme") + val inactiveTheme = saveTheme("inactive-series-sort-theme", isActive = false) + val oldHigh = saveSeries("old-high", creator) + val recentLow = saveSeries("recent-low", creator) + val sameDateHigh = saveSeries("same-date-high", creator) + val sameDateLow = saveSeries("same-date-low", creator) + val popular = saveSeries("popular", creator) + val inactiveRevenue = saveSeries("inactive-revenue", creator) + val inactiveThemeOnly = saveSeries("inactive-theme-only", creator) + val inactiveCreatorOnly = saveSeries("inactive-creator-only", creator) + val oldHighAudio = saveAudioContent(creator, theme, now.minusDays(5), isAdult = false, price = 500) + val recentLowAudio = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100) + val sameDateHighAudio = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 300) + val sameDateLowAudio = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 50) + val popularAudio = saveAudioContent(creator, theme, now.minusDays(4), isAdult = false, price = 100) + val inactiveRevenueAudio = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 100) + val inactiveThemeAudio = saveAudioContent(creator, inactiveTheme, now, isAdult = false, price = 1000) + val inactiveCreatorAudio = saveAudioContent(inactiveAudioCreator, theme, now, isAdult = false, price = 1000) + saveSeriesContent(oldHigh, oldHighAudio) + saveSeriesContent(recentLow, recentLowAudio) + saveSeriesContent(sameDateHigh, sameDateHighAudio) + saveSeriesContent(sameDateLow, sameDateLowAudio) + saveSeriesContent(popular, popularAudio) + saveSeriesContent(inactiveRevenue, inactiveRevenueAudio) + saveSeriesContent(inactiveThemeOnly, inactiveThemeAudio) + saveSeriesContent(inactiveCreatorOnly, inactiveCreatorAudio) + saveOrder(buyer, creator, popularAudio, can = 900) + saveOrder(buyer, creator, inactiveThemeAudio, can = 5000) + saveOrder(buyer, inactiveAudioCreator, inactiveCreatorAudio, can = 5000) + saveOrder(buyer, creator, inactiveRevenueAudio, can = 1000, isActive = false) + flushAndClear() + + val latest = findSeriesIds(now, ContentSort.LATEST) + val priceHigh = findSeriesIds(now, ContentSort.PRICE_HIGH) + val priceLow = findSeriesIds(now, ContentSort.PRICE_LOW) + val popularSorted = findSeriesIds(now, ContentSort.POPULAR) + + assertEquals(listOf(sameDateLow.id, sameDateHigh.id, recentLow.id), latest.take(3)) + assertEquals(oldHigh.id, priceHigh.first()) + assertEquals(sameDateLow.id, priceLow.first()) + assertEquals(popular.id, popularSorted.first()) + assertEquals(listOf(inactiveCreatorOnly.id, inactiveThemeOnly.id), latest.takeLast(2)) + assertEquals(listOf(inactiveCreatorOnly.id, inactiveThemeOnly.id), priceHigh.takeLast(2)) + assertEquals(listOf(inactiveCreatorOnly.id, inactiveThemeOnly.id), popularSorted.takeLast(2)) + } + + private fun findSeriesIds(now: LocalDateTime, sort: ContentSort): List { + return repository.findSeries(null, false, now, sort, 0, 20, locale = "ko").map { it.seriesId } + } + + 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): AudioContentTheme { + val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = isActive) + entityManager.persist(theme) + return theme + } + + private fun saveAudioContent( + creator: Member, + theme: AudioContentTheme, + releaseDate: LocalDateTime, + isAdult: Boolean, + price: Int, + 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, + isAdult: Boolean = false, + isOriginal: Boolean = false + ): Series { + val series = Series( + title = title, + introduction = "introduction", + languageCode = "ko", + 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, + can: Int, + point: Int = 0, + isActive: Boolean = true + ): Order { + val order = Order(type = OrderType.KEEP, isActive = isActive) + order.member = member + order.creator = creator + order.audioContent = content + order.can = can + order.point = point + entityManager.persist(order) + return order + } + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } +}