From 45d2d616e0f2ca75a06eee56787b1f23c38ecbfa Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Jun 2026 16:13:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(audio-recommendation):=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=B6=94=EC=B2=9C=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?repository=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudioRecommendationQueryRepository.kt | 5 + ...faultAudioRecommendationQueryRepository.kt | 302 ++++++++++++++++++ ...tAudioRecommendationQueryRepositoryTest.kt | 258 +++++++++++++++ 3 files changed, 565 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt new file mode 100644 index 00000000..3d34f992 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.audio.recommendation.port.out.AudioRecommendationQueryPort + +interface AudioRecommendationQueryRepository : AudioRecommendationQueryPort diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt new file mode 100644 index 00000000..3651cb32 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt @@ -0,0 +1,302 @@ +package kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence + +import com.querydsl.core.Tuple +import com.querydsl.core.types.Expression +import com.querydsl.core.types.Projections +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.JPAExpressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType +import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner +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.event.EventItem +import kr.co.vividnext.sodalive.event.QEvent.event +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.audio.recommendation.domain.AudioCard +import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.CommentedAudio +import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.OriginalSeries +import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner +import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class DefaultAudioRecommendationQueryRepository( + private val queryFactory: JPAQueryFactory, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) : AudioRecommendationQueryRepository { + override fun findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean): List { + val bannerCreator = QMember("audioRecommendationBannerCreator") + val seriesOwner = QMember("audioRecommendationSeriesOwner") + val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')") + + return queryFactory + .select( + audioContentBanner.thumbnailImage, + event.id, + event.thumbnailImage, + event.detailImage, + event.link, + bannerCreator.id, + series.id, + audioContentBanner.link + ) + .from(audioContentBanner) + .leftJoin(audioContentBanner.event, event) + .leftJoin(audioContentBanner.creator, bannerCreator) + .leftJoin(audioContentBanner.series, series) + .leftJoin(series.member, seriesOwner) + .where( + audioContentBanner.isActive.isTrue, + audioContentBanner.tab.isNull, + activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) + ) + .orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc()) + .limit(limit.toLong()) + .fetch() + .map { row -> + RecommendationBanner( + imageUrl = row.get(audioContentBanner.thumbnailImage).toCdnUrl(cloudFrontHost) ?: "", + eventItem = row.toEventItem(), + creatorId = row.get(bannerCreator.id), + seriesId = row.get(series.id), + link = row.get(audioContentBanner.link) + ) + } + } + + override fun findOriginalSeries( + limit: Int, + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime + ): List { + return queryFactory + .select(Projections.constructor(OriginalSeries::class.java, series.id, series.coverImage)) + .from(series) + .join(series.member, member) + .where( + series.isActive.isTrue, + series.isOriginal.isTrue, + member.isActive.isTrue, + adultSeriesCondition(canViewAdultContent), + notBlockedCreatorCondition(memberId, member.id) + ) + .orderBy(series.createdAt.desc(), series.id.desc()) + .limit(limit.toLong()) + .fetch() + .map { it.copy(coverImageUrl = it.coverImageUrl.toCdnUrl(cloudFrontHost)) } + } + + override fun findLatestAudios( + limit: Int, + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime + ): List { + val rows = audioRows(memberId, canViewAdultContent, now) { + orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong()) + } + return rows.toAudioCards(now, canViewAdultContent) + } + + override fun findFreeAudios( + limit: Int, + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime + ): List { + val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')") + val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) { + orderBy(randomTieBreaker.asc()).limit(limit.toLong()) + } + return rows.toAudioCards(now, canViewAdultContent) + } + + override fun findPointAudios( + limit: Int, + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime + ): List { + val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')") + val rows = audioRows(memberId, canViewAdultContent, now, audioContent.isPointAvailable.isTrue) { + orderBy(randomTieBreaker.asc()).limit(limit.toLong()) + } + return rows.toAudioCards(now, canViewAdultContent) + } + + override fun findAudioCardsByIds( + contentIds: List, + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime + ): List { + if (contentIds.isEmpty()) return emptyList() + val orderById = contentIds.withIndex().associate { it.value to it.index } + val rows = audioRows(memberId, canViewAdultContent, now, audioContent.id.`in`(contentIds)) { this } + return rows.toAudioCards(now, canViewAdultContent).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE } + } + + override fun findCommentedAudiosByIds( + contentIds: List, + memberId: Long?, + canViewAdultContent: Boolean + ): List { + return emptyList() + } + + private fun audioRows( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + extraCondition: BooleanExpression? = null, + customize: com.querydsl.jpa.impl.JPAQuery.() -> com.querydsl.jpa.impl.JPAQuery + ): List { + return queryFactory + .select( + audioContent.id, + audioContent.title, + audioContent.duration, + audioContent.coverImage, + audioContent.price, + audioContent.isAdult, + audioContent.isPointAvailable, + audioContent.member.id, + member.nickname + ) + .from(audioContent) + .join(audioContent.member, member) + .join(audioContent.theme, audioContentTheme) + .where(publicAudioCondition(memberId, canViewAdultContent, now), extraCondition) + .customize() + .fetch() + } + + private fun List.toAudioCards(now: LocalDateTime, canViewAdultContent: Boolean): List { + if (isEmpty()) return emptyList() + val contentIds = map { it.get(audioContent.id)!! } + val creatorIds = map { it.get(audioContent.member.id)!! }.distinct() + val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent) + val isOriginalSeriesByContentId = originalSeriesFlags(contentIds) + return map { row -> + val contentId = row.get(audioContent.id)!! + val creatorId = row.get(audioContent.member.id)!! + AudioCard( + audioContentId = contentId, + title = row.get(audioContent.title)!!, + duration = row.get(audioContent.duration), + 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 = isOriginalSeriesByContentId[contentId] ?: false, + creatorNickname = row.get(member.nickname)!! + ) + } + } + + 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(memberId = null, 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 publicAudioCondition(memberId: Long?, 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)) + .withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id)) + } + + private fun activeBannerTargetCondition(memberId: Long?, bannerCreator: QMember, seriesOwner: QMember): BooleanExpression { + val creatorCondition = audioContentBanner.type.eq(AudioContentBannerType.CREATOR) + .and(bannerCreator.isActive.isTrue) + .withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id)) + val seriesCondition = audioContentBanner.type.eq(AudioContentBannerType.SERIES) + .and(series.isActive.isTrue) + .and(seriesOwner.isActive.isTrue) + .withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id)) + + return audioContentBanner.type.eq(AudioContentBannerType.LINK) + .or(audioContentBanner.type.eq(AudioContentBannerType.EVENT).and(event.isActive.isTrue)) + .or(creatorCondition) + .or(seriesCondition) + } + + private fun Tuple.toEventItem(): EventItem? { + val eventId = get(event.id) ?: return null + val thumbnailImage = get(event.thumbnailImage) ?: return null + return EventItem( + id = eventId, + thumbnailImageUrl = thumbnailImage.toCdnUrl(cloudFrontHost) ?: thumbnailImage, + detailImageUrl = get(event.detailImage).toCdnUrl(cloudFrontHost), + popupImageUrl = null, + link = get(event.link) + ) + } + + private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? { + return if (canViewAdultContent) null else series.isAdult.isFalse + } + + private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? { + return if (canViewAdultContent) null else audioContent.isAdult.isFalse + } + + private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression): BooleanExpression? { + if (memberId == null) return null + val blockMember = QBlockMember("audioRecommendationBlockMember") + 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/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt new file mode 100644 index 00000000..d1876ffd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt @@ -0,0 +1,258 @@ +package kr.co.vividnext.sodalive.v2.audio.recommendation.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.main.banner.AudioContentBanner +import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType +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.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMember +import org.junit.jupiter.api.Assertions.assertEquals +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 DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( + private val entityManager: EntityManager, + queryFactory: JPAQueryFactory +) { + private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, "https://cdn.test") + + @Test + @DisplayName("배너는 홈 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다") + fun shouldFindBannersWithHomeBannerPolicy() { + val viewer = saveMember("viewer", MemberRole.USER) + val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR) + val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR) + val visibleBanner = saveBanner("visible.png", AudioContentBannerType.CREATOR, 1, creator = visibleCreator) + val adultBanner = saveBanner("adult.png", AudioContentBannerType.LINK, 2, isAdult = true, link = "https://adult.test") + saveBanner("inactive.png", AudioContentBannerType.LINK, 2, isActive = false, link = "https://inactive.test") + saveBanner("blocked.png", AudioContentBannerType.CREATOR, 3, creator = blockedCreator) + saveBlock(viewer, blockedCreator) + flushAndClear() + + val banners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false) + + assertEquals( + listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"), + banners.map { it.imageUrl } + ) + assertEquals(visibleCreator.id, banners.first().creatorId) + } + + @Test + @DisplayName("오리지널 시리즈는 활성 원본 시리즈를 최신순으로 반환하고 성인/차단 조건을 적용한다") + fun shouldFindOriginalSeriesWithVisibilityConditions() { + val viewer = saveMember("series-viewer", MemberRole.USER) + val visibleCreator = saveMember("series-visible", MemberRole.CREATOR) + val blockedCreator = saveMember("series-blocked", MemberRole.CREATOR) + val visibleSeries = (1..13).map { index -> + saveSeries("visible-series-$index", visibleCreator, isOriginal = true, coverImage = "series-$index.png") + } + saveSeries("normal-series", visibleCreator, isOriginal = false) + saveSeries("adult-series", visibleCreator, isOriginal = true, isAdult = true) + saveSeries("blocked-series", blockedCreator, isOriginal = true) + saveBlock(viewer, blockedCreator) + flushAndClear() + + val series = repository.findOriginalSeries(12, viewer.id, canViewAdultContent = false, now = LocalDateTime.now()) + + assertEquals(12, series.size) + assertEquals(visibleSeries.map { it.id }.asReversed().take(12), series.map { it.seriesId }) + assertEquals("https://cdn.test/series-13.png", series.first().coverImageUrl) + } + + @Test + @DisplayName("최신/무료/포인트 오디오는 공개 조건과 공통 AudioCard enrichment를 적용한다") + fun shouldFindRealtimeAudioCardsWithCommonEnrichment() { + val now = LocalDateTime.of(2026, 6, 23, 12, 0) + val creator = saveMember("audio-creator", MemberRole.CREATOR) + val theme = saveTheme() + val first = saveAudio( + creator = creator, + theme = theme, + title = "first", + releaseDate = now.minusDays(3), + price = 0, + isPointAvailable = true, + coverImage = "first.png" + ) + val latest = saveAudio( + creator = creator, + theme = theme, + title = "latest", + releaseDate = now.minusDays(1), + price = 10, + isPointAvailable = false, + coverImage = "latest.png" + ) + saveAudio(creator, theme, "adult", now.minusHours(1), isAdult = true) + saveAudio(creator, theme, "future", now.plusDays(1)) + saveAudio(creator, theme, "inactive", now.minusHours(2)).isActive = false + saveAudio(creator, theme, "no-duration", now.minusHours(3)).duration = null + saveAudio(creator, theme, "no-release-date", now.minusHours(4)).releaseDate = null + val inactiveCreator = saveMember("inactive-audio-creator", MemberRole.CREATOR, isActive = false) + saveAudio(inactiveCreator, theme, "inactive-creator", now.minusHours(5)) + val viewer = saveMember("blocked-audio-viewer", MemberRole.USER) + val blockedCreator = saveMember("blocked-audio-creator", MemberRole.CREATOR) + saveAudio(blockedCreator, theme, "blocked", now.minusHours(6)) + saveBlock(viewer, blockedCreator) + val originalSeries = saveSeries("original", creator, isOriginal = true) + saveSeriesContent(originalSeries, latest) + val limitCreator = saveMember("limit-audio-creator", MemberRole.CREATOR) + repeat(11) { index -> + saveAudio( + creator = limitCreator, + theme = theme, + title = "free-point-$index", + releaseDate = now.minusDays(10).minusMinutes(index.toLong()), + price = 0, + isPointAvailable = true + ) + } + flushAndClear() + + val latestAudios = repository.findLatestAudios(12, viewer.id, canViewAdultContent = false, now = now) + val freeAudios = repository.findFreeAudios(10, viewer.id, canViewAdultContent = false, now = now) + val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now) + + assertEquals(12, latestAudios.size) + assertEquals(listOf(latest.id, first.id), latestAudios.take(2).map { it.audioContentId }) + assertEquals(10, freeAudios.size) + assertEquals(true, freeAudios.all { it.price == 0 }) + assertEquals(10, pointAudios.size) + assertEquals(true, pointAudios.all { it.isPointAvailable }) + val latestCard = latestAudios.first() + assertEquals("latest", latestCard.title) + assertEquals("00:01", latestCard.duration) + assertEquals("https://cdn.test/latest.png", latestCard.imageUrl) + assertEquals(10, latestCard.price) + assertEquals(false, latestCard.isAdult) + assertEquals(false, latestCard.isPointAvailable) + assertEquals(false, latestCard.isFirstContent) + assertEquals(true, latestCard.isOriginalSeries) + assertEquals(creator.nickname, latestCard.creatorNickname) + assertEquals(true, latestAudios[1].isFirstContent) + assertEquals(false, latestAudios[1].isOriginalSeries) + } + + private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role, + isActive = isActive + ) + entityManager.persist(member) + return member + } + + private fun saveTheme(): AudioContentTheme { + val theme = AudioContentTheme(theme = "theme", image = "theme.png", isActive = true) + entityManager.persist(theme) + return theme + } + + private fun saveBanner( + thumbnailImage: String, + type: AudioContentBannerType, + orders: Int, + isActive: Boolean = true, + isAdult: Boolean = false, + creator: Member? = null, + link: String? = null + ): AudioContentBanner { + val banner = AudioContentBanner( + thumbnailImage = thumbnailImage, + type = type, + isAdult = isAdult, + isActive = isActive, + orders = orders + ) + banner.creator = creator + banner.link = link + entityManager.persist(banner) + return banner + } + + private fun saveSeries( + title: String, + creator: Member, + isOriginal: Boolean, + isAdult: Boolean = false, + coverImage: String? = null + ): Series { + val genre = SeriesGenre("genre-$title") + entityManager.persist(genre) + val series = Series(title = title, introduction = "intro", isOriginal = isOriginal, isAdult = isAdult, isActive = true) + series.genre = genre + series.member = creator + series.coverImage = coverImage + entityManager.persist(series) + return series + } + + private fun saveAudio( + creator: Member, + theme: AudioContentTheme, + title: String, + releaseDate: LocalDateTime, + price: Int = 0, + isAdult: Boolean = false, + isPointAvailable: Boolean = false, + coverImage: String? = null + ): AudioContent { + val audio = AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + price = price, + releaseDate = releaseDate, + isAdult = isAdult, + isPointAvailable = isPointAvailable + ) + audio.isActive = true + audio.duration = "00:01" + audio.coverImage = coverImage + audio.member = creator + audio.theme = theme + entityManager.persist(audio) + return audio + } + + private fun saveSeriesContent(series: Series, audio: AudioContent) { + val seriesContent = SeriesContent() + seriesContent.series = series + seriesContent.content = audio + entityManager.persist(seriesContent) + } + + private fun saveBlock(member: Member, blockedMember: Member) { + val block = BlockMember(isActive = true) + block.member = member + block.blockedMember = blockedMember + entityManager.persist(block) + } + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } +}