feat(creator): 채널 라이브 다시듣기 저장소를 추가한다

This commit is contained in:
2026-06-17 19:16:50 +09:00
parent 108778d5d3
commit 3d843ac5d6
4 changed files with 736 additions and 5 deletions

View File

@@ -0,0 +1,355 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.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.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.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Gender
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.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 DefaultCreatorChannelLiveQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultCreatorChannelLiveQueryRepository(queryFactory)
@Test
@DisplayName("라이브 다시듣기 count는 공개 다시듣기 콘텐츠와 성인 노출 정책만 반영한다")
fun shouldCountPublicLiveReplayAudioContentsOnly() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val creator = saveMember("count-creator", MemberRole.CREATOR)
val liveReplayTheme = saveTheme("다시듣기")
saveAudioContent(creator, now.minusDays(2), isAdult = false, theme = liveReplayTheme)
saveAudioContent(creator, now.minusDays(1), isAdult = false, theme = liveReplayTheme)
saveAudioContent(creator, now.minusHours(1), isAdult = true, theme = liveReplayTheme)
saveAudioContent(creator, now.minusHours(2), isAdult = false, theme = saveTheme("수면"))
saveAudioContent(creator, now.plusHours(1), isAdult = false, theme = liveReplayTheme)
saveAudioContent(creator, now.minusHours(3), isAdult = false, theme = liveReplayTheme).isActive = false
saveAudioContent(creator, now.minusHours(4), isAdult = false, theme = saveTheme("inactive", isActive = false))
saveAudioContent(creator, now.minusHours(5), isAdult = false, theme = liveReplayTheme).duration = null
saveAudioContent(creator, now.minusHours(6), isAdult = false, theme = liveReplayTheme).releaseDate = null
flushAndClear()
val hiddenAdultCount = repository.countLiveReplayAudioContents(creator.id!!, now, canViewAdultContent = false)
val visibleAdultCount = repository.countLiveReplayAudioContents(creator.id!!, now, canViewAdultContent = true)
assertEquals(2, hiddenAdultCount)
assertEquals(3, visibleAdultCount)
}
@Test
@DisplayName("라이브 다시듣기 목록은 page 인자와 기본 정렬을 DB에서 적용하고 series/firstContent를 채운다")
fun shouldFindLiveReplayAudioContentsWithPaginationAndLatestSort() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val creator = saveMember("list-creator", MemberRole.CREATOR)
val theme = saveTheme("다시듣기")
val oldFirst = saveAudioContent(creator, now.minusDays(30), isAdult = false, theme = theme, price = 100)
repeat(20) { index ->
saveAudioContent(creator, now.minusDays(29L - index), isAdult = false, theme = theme, price = 100 + index)
}
val sameDateLowPrice = saveAudioContent(creator, now.minusHours(1), isAdult = false, theme = theme, price = 100)
val sameDateHighPrice = saveAudioContent(creator, now.minusHours(1), isAdult = false, theme = theme, price = 300)
val series = saveSeries("live-replay-series", creator, isOriginal = true)
saveSeriesContent(series, sameDateHighPrice)
flushAndClear()
val firstPage = repository.findLiveReplayAudioContents(
creatorId = creator.id!!,
viewerId = null,
now = now,
canViewAdultContent = false,
sort = ContentSort.LATEST,
offset = 0,
limit = 21
)
val secondPage = repository.findLiveReplayAudioContents(
creatorId = creator.id!!,
viewerId = null,
now = now,
canViewAdultContent = false,
sort = ContentSort.LATEST,
offset = 20,
limit = 21
)
assertEquals(21, firstPage.size)
assertEquals(listOf(sameDateHighPrice.id, sameDateLowPrice.id), firstPage.take(2).map { it.audioContentId })
assertEquals(3, secondPage.size)
assertEquals(firstPage[20].audioContentId, secondPage.first().audioContentId)
assertEquals(oldFirst.id, secondPage.last().audioContentId)
assertEquals("live-replay-series", firstPage.first().seriesName)
assertEquals(true, firstPage.first().isOriginalSeries)
assertTrue(secondPage.last().isFirstContent)
}
@Test
@DisplayName("라이브 다시듣기 목록은 가격 정렬을 적용한다")
fun shouldSortLiveReplayAudioContentsByPrice() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val creator = saveMember("price-creator", MemberRole.CREATOR)
val theme = saveTheme("다시듣기")
val low = saveAudioContent(creator, now.minusDays(1), isAdult = false, theme = theme, price = 100)
val high = saveAudioContent(creator, now.minusDays(2), isAdult = false, theme = theme, price = 300)
flushAndClear()
val highRecords = repository.findLiveReplayAudioContents(creator.id!!, null, now, false, ContentSort.PRICE_HIGH, 0, 20)
val lowRecords = repository.findLiveReplayAudioContents(creator.id!!, null, now, false, ContentSort.PRICE_LOW, 0, 20)
assertEquals(listOf(high.id, low.id), highRecords.map { it.audioContentId })
assertEquals(listOf(low.id, high.id), lowRecords.map { it.audioContentId })
}
@Test
@DisplayName("인기순은 활성 주문 can 합계를 기준으로 정렬하고 point와 비활성 주문을 제외한다")
fun shouldSortLiveReplayAudioContentsByPopularCanRevenue() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val viewer = saveMember("popular-viewer", MemberRole.USER)
val creator = saveMember("popular-creator", MemberRole.CREATOR)
val theme = saveTheme("다시듣기")
val olderHighRevenue = saveAudioContent(creator, now.minusDays(3), isAdult = false, theme = theme, price = 100)
val newerLowRevenue = saveAudioContent(creator, now.minusDays(1), isAdult = false, theme = theme, price = 100)
val inactiveRevenue = saveAudioContent(creator, now.minusDays(2), isAdult = false, theme = theme, price = 100)
saveOrder(viewer, creator, olderHighRevenue, OrderType.KEEP, can = 500, point = 900)
saveOrder(viewer, creator, newerLowRevenue, OrderType.KEEP, can = 100, point = 9000)
saveOrder(viewer, creator, inactiveRevenue, OrderType.KEEP, isActive = false, can = 1000)
flushAndClear()
val records = repository.findLiveReplayAudioContents(creator.id!!, viewer.id!!, now, false, ContentSort.POPULAR, 0, 20)
assertEquals(listOf(olderHighRevenue.id, newerLowRevenue.id, inactiveRevenue.id), records.map { it.audioContentId })
}
@Test
@DisplayName("소장순은 조회자 KEEP 콘텐츠를 먼저 정렬하고 소장/대여 상태를 함께 반환한다")
fun shouldSortLiveReplayAudioContentsByOwnedAndReturnOrderStates() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val viewer = saveMember("owned-viewer", MemberRole.USER)
val creator = saveMember("owned-creator", MemberRole.CREATOR)
val theme = saveTheme("다시듣기")
val keepAndRental = saveAudioContent(creator, now.minusDays(4), isAdult = false, theme = theme)
val expiredRental = saveAudioContent(creator, now.minusDays(3), isAdult = false, theme = theme)
val rentalOnly = saveAudioContent(creator, now.minusDays(2), isAdult = false, theme = theme)
val keepOnly = saveAudioContent(creator, now.minusDays(1), isAdult = false, theme = theme)
saveOrder(viewer, creator, keepOnly, OrderType.KEEP)
saveOrder(viewer, creator, rentalOnly, OrderType.RENTAL, endDate = now.plusDays(1))
saveOrder(viewer, creator, expiredRental, OrderType.RENTAL, endDate = now.minusDays(1))
saveOrder(viewer, creator, keepAndRental, OrderType.KEEP)
saveOrder(viewer, creator, keepAndRental, OrderType.RENTAL, endDate = now.plusDays(1))
flushAndClear()
val records = repository.findLiveReplayAudioContents(creator.id!!, viewer.id!!, now, false, ContentSort.OWNED, 0, 20)
assertEquals(listOf(keepOnly.id, keepAndRental.id, rentalOnly.id, expiredRental.id), records.map { it.audioContentId })
assertEquals(listOf(true, true, false, false), records.map { it.isOwned })
assertEquals(listOf(false, true, true, false), records.map { it.isRented })
}
@Test
@DisplayName("현재 라이브 조회는 홈 API와 같은 성인/성별/크리에이터 입장 정책을 적용한다")
fun shouldFindCurrentLiveWithHomePolicy() {
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
val creator = saveMember("current-live-creator", MemberRole.CREATOR)
val viewerCreator = saveMember("current-live-viewer", MemberRole.CREATOR)
saveLiveRoom(creator, now.minusMinutes(3), channelName = "adult", isAdult = true)
saveLiveRoom(
creator,
now.minusMinutes(4),
channelName = "male-only",
isAdult = false,
genderRestriction = GenderRestriction.MALE_ONLY
)
saveLiveRoom(
creator,
now.minusMinutes(5),
channelName = "creator-hidden",
isAdult = false,
isAvailableJoinCreator = false
)
val visible = saveLiveRoom(creator, now.minusMinutes(6), channelName = "visible", isAdult = false)
flushAndClear()
val live = repository.findCurrentLive(
creatorId = creator.id!!,
now = now,
canViewAdultContent = false,
viewerId = viewerCreator.id!!,
isViewerCreator = true,
effectiveViewerGender = Gender.FEMALE
)
assertEquals(visible.id, live!!.liveId)
}
@Test
@DisplayName("크리에이터 조회와 차단 관계 조회는 live service port 계약을 만족한다")
fun shouldFindCreatorAndBlockedRelationship() {
val viewer = saveMember("blocked-viewer", MemberRole.USER)
val creator = saveMember("blocked-creator", MemberRole.CREATOR)
saveBlock(creator, viewer)
flushAndClear()
val record = repository.findCreator(creator.id!!, viewer.id!!)
assertEquals(creator.id, record!!.creatorId)
assertEquals(MemberRole.CREATOR, record.role)
assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!))
}
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 saveLiveRoom(
creator: Member,
beginDateTime: LocalDateTime,
channelName: String?,
isAdult: Boolean,
isActive: Boolean = true,
genderRestriction: GenderRestriction = GenderRestriction.ALL,
isAvailableJoinCreator: Boolean = true
): LiveRoom {
val liveRoom = LiveRoom(
title = "live-${creator.nickname}-$beginDateTime",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
coverImage = "live.png",
isAdult = isAdult,
price = 50,
isAvailableJoinCreator = isAvailableJoinCreator,
genderRestriction = genderRestriction
)
liveRoom.member = creator
liveRoom.channelName = channelName
liveRoom.isActive = isActive
entityManager.persist(liveRoom)
return liveRoom
}
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 saveTheme(name: String, isActive: Boolean = true): AudioContentTheme {
val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = isActive)
entityManager.persist(theme)
return theme
}
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 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()
}
}