feat(creator-channel): 시리즈 탭 repository를 추가한다

This commit is contained in:
2026-06-20 05:20:22 +09:00
parent a67322b7fd
commit 67fe0ec497
3 changed files with 647 additions and 0 deletions

View File

@@ -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<Long> {
return repository.findSeries(
creatorId,
viewerId,
now,
canViewAdultContent = false,
sort,
"ko",
offset = 0,
limit = 20
).map { it.seriesId }
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}