feat(content-all): 전체 탭 QueryDSL 조회를 추가한다

This commit is contained in:
2026-06-25 11:26:12 +09:00
parent 2bced956dc
commit 24556c1987
3 changed files with 824 additions and 0 deletions
@@ -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<Long> {
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()
}
}