feat(creator-channel): 오디오 탭 repository를 추가한다

This commit is contained in:
2026-06-19 18:07:11 +09:00
parent cffd50c33f
commit 76cc6e6557
3 changed files with 803 additions and 0 deletions

View File

@@ -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()
}
}