fix(content): 무료 콘텐츠 포인트 사용 조건을 보정한다

This commit is contained in:
2026-07-31 20:00:58 +09:00
parent eb0ff7537e
commit 033044b4de
27 changed files with 1396 additions and 41 deletions

View File

@@ -967,7 +967,7 @@ class AudioContentService(
previousContent = previousContent,
nextContent = nextContent,
buyerList = buyerList,
isAvailableUsePoint = audioContent.isPointAvailable,
isAvailableUsePoint = audioContent.isPointAvailable && audioContent.price > 0,
translated = translated
)
}

View File

@@ -60,7 +60,7 @@ data class MainContentAudioResponse(
imageUrl = audio.imageUrl,
price = audio.price,
isAdult = audio.isAdult,
isPointAvailable = audio.isPointAvailable,
isPointAvailable = audio.isPointAvailable && audio.price > 0,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries,
creatorNickname = audio.creatorNickname

View File

@@ -46,7 +46,7 @@ data class ContentOverviewItemResponse(
title = audio.title,
coverImage = audio.imageUrl,
price = audio.price,
isPointAvailable = audio.isPointAvailable,
isPointAvailable = audio.isPointAvailable && audio.price > 0,
creatorNickname = audio.creatorNickname,
isAdult = audio.isAdult,
isFirstContent = audio.isFirstContent,
@@ -65,7 +65,7 @@ data class ContentOverviewItemResponse(
title = audio.title,
coverImage = coverImage,
price = audio.price,
isPointAvailable = audio.isPointAvailable,
isPointAvailable = audio.isPointAvailable && audio.price > 0,
creatorNickname = audio.creatorNickname,
isAdult = isAdult,
isFirstContent = true,

View File

@@ -69,7 +69,7 @@ data class AudioCardResponse(
imageUrl = audio.imageUrl,
price = audio.price,
isAdult = audio.isAdult,
isPointAvailable = audio.isPointAvailable,
isPointAvailable = audio.isPointAvailable && audio.price > 0,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries,
creatorNickname = audio.creatorNickname

View File

@@ -32,7 +32,7 @@ data class CreatorChannelAudioContentResponse(
imageUrl = audioContent.imageUrl,
price = audioContent.price,
isAdult = audioContent.isAdult,
isPointAvailable = audioContent.isPointAvailable,
isPointAvailable = audioContent.isPointAvailable && audioContent.price > 0,
isFirstContent = audioContent.isFirstContent,
seriesName = audioContent.seriesName,
isOriginalSeries = audioContent.isOriginalSeries,

View File

@@ -267,7 +267,7 @@ class HomeRecommendationFacade(
title = title,
price = price,
coverImage = imageUrl(cloudFrontHost, coverImage),
isPointAvailable = isPointAvailable
isPointAvailable = isPointAvailable && price > 0
)
private fun HomeAiCharacterRecommendationRecord.toItem() = HomeAiCharacterItem(

View File

@@ -406,7 +406,7 @@ class DefaultMainContentAllQueryRepository(
}
private fun optionalAudioPointCondition(onlyPointAvailable: Boolean): BooleanExpression? {
return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue else null
return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue.and(audioContent.price.gt(0)) else null
}
private fun optionalOriginalCondition(onlyOriginal: Boolean): BooleanExpression? {

View File

@@ -135,7 +135,12 @@ class DefaultAudioRecommendationQueryRepository(
now: LocalDateTime
): List<AudioCard> {
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.isPointAvailable.isTrue) {
val rows = audioRows(
memberId,
canViewAdultContent,
now,
audioContent.isPointAvailable.isTrue.and(audioContent.price.gt(0))
) {
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
}
return rows.toAudioCards(now, canViewAdultContent)

View File

@@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
@@ -282,6 +283,50 @@ class AudioContentServiceTest {
assertTrue(output.out.contains("contentId=${audioContent.id}"))
}
@Test
@DisplayName("콘텐츠 상세는 가격과 저장값을 함께 반영해 포인트 사용 가능 여부를 반환한다")
fun shouldReturnEffectivePointAvailabilityForAudioDetail() {
val viewer = createMember(id = 1004L, nickname = "free-detail-viewer")
val creator = createMember(id = 2004L, nickname = "free-detail-creator")
val audioContent = createAudioContent(creator)
audioContent.price = 0
audioContent.isPointAvailable = true
stubSuccessfulDetailDependencies(viewer, creator, audioContent)
val response = service.getDetail(
id = audioContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
assertFalse(response.isAvailableUsePoint)
audioContent.price = 100
audioContent.isPointAvailable = false
val paidUnavailableResponse = service.getDetail(
id = audioContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
assertFalse(paidUnavailableResponse.isAvailableUsePoint)
audioContent.price = 100
audioContent.isPointAvailable = true
val paidAvailableResponse = service.getDetail(
id = audioContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
assertTrue(paidAvailableResponse.isAvailableUsePoint)
}
@Test
@DisplayName("업로드 완료 시 즉시 공개 콘텐츠는 최근 소식을 발행한다")
fun shouldPublishNewsWhenUploadCompleteMakesContentPublicImmediately() {

View File

@@ -339,7 +339,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
val content = saveAudioContent(
owner = character.creatorMember!!,
title = "target night walk",
contentPath = "private/target-night-walk.mp3"
contentPath = "private/target-night-walk.mp3",
price = 0
)
val otherCharacter = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-other-character",
@@ -374,6 +375,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].audioContentId").value(content.id))
.andExpect(jsonPath("$.data.items[0].title").value("target night walk"))
.andExpect(jsonPath("$.data.items[0].isPointAvailable").value(true))
.andExpect(
jsonPath("$.data.items[0].contentUrl").value(
"https://signed.example.com/private/target-night-walk.mp3?Expires=1"
@@ -495,7 +497,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
val content = saveAudioContent(
owner = character.creatorMember!!,
title = "detail night walk",
contentPath = "private/detail-night-walk.mp3"
contentPath = "private/detail-night-walk.mp3",
price = 0
)
entityManager.flush()
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
@@ -899,7 +902,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
owner: Member,
title: String,
contentPath: String,
releaseDate: LocalDateTime = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime()
releaseDate: LocalDateTime = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime(),
price: Int = 100
): AudioContent {
val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png")
entityManager.persist(theme)
@@ -907,7 +911,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
title = title,
detail = "detail",
languageCode = "ko",
price = 100,
price = price,
isAdult = false,
isPointAvailable = true,
isCommentAvailable = true

View File

@@ -94,6 +94,27 @@ class MainContentAllEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.audios.length()").value(1))
}
@Test
@DisplayName("전체 탭 POINT API는 무료 포인트 콘텐츠를 목록과 페이징 후보에서 제외한다")
fun shouldExcludeFreePointAudioContentsForPointType() {
val fixture = createAudioFixture("main-all-point-paid-e2e", freeControl = true)
mockMvc.perform(
get("/api/v2/audio/contents")
.param("type", "POINT")
.param("sort", "LATEST")
.param("page", "0")
.param("size", "1")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.type").value("POINT"))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.audios.length()").value(1))
.andExpect(jsonPath("$.data.audios[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.audios[0].price").value(100))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("전체 탭 SERIES API는 dayOfWeek 조건으로 시리즈 응답과 빈 audios를 반환한다")
fun shouldReturnSeriesContentsFilteredByDayOfWeekThroughControllerServiceAndRepository() {

View File

@@ -67,6 +67,28 @@ class MainContentAllTabResponseTest {
isFirstContent = true,
isOriginalSeries = false,
creatorNickname = "creator"
),
MainContentAllAudio(
audioContentId = 2L,
title = "free audio",
imageUrl = "https://cdn/free-audio.jpg",
price = 0,
isAdult = false,
isPointAvailable = true,
isFirstContent = false,
isOriginalSeries = false,
creatorNickname = "creator"
),
MainContentAllAudio(
audioContentId = 3L,
title = "point unavailable audio",
imageUrl = "https://cdn/point-unavailable-audio.jpg",
price = 100,
isAdult = false,
isPointAvailable = false,
isFirstContent = false,
isOriginalSeries = false,
creatorNickname = "creator"
)
),
series = emptyList(),
@@ -83,6 +105,8 @@ class MainContentAllTabResponseTest {
assertEquals(true, json["audios"][0]["isPointAvailable"].asBoolean())
assertEquals(true, json["audios"][0]["isFirstContent"].asBoolean())
assertEquals(false, json["audios"][0]["isOriginalSeries"].asBoolean())
assertEquals(false, json["audios"][1]["isPointAvailable"].asBoolean())
assertEquals(false, json["audios"][2]["isPointAvailable"].asBoolean())
assertEquals(true, json["hasNext"].asBoolean())
assertFalse(json["audios"][0].has("duration"))
assertFalse(json["series"].any { it.has("publishedDaysOfWeek") })

View File

@@ -1,8 +1,11 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.dto
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
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
@@ -77,4 +80,71 @@ class ContentOverviewPageResponseTest {
assertEquals(true, response.isAdult)
assertEquals(true, response.isOriginalSeries)
}
@Test
@DisplayName("NEW_AND_HOT 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
fun shouldNormalizePointAvailabilityForFreeNewAndHotAudio() {
val freeAudio = audioCard(price = 0, isPointAvailable = true)
val paidAudio = audioCard(price = 100, isPointAvailable = true)
val paidUnavailableAudio = audioCard(price = 100, isPointAvailable = false)
assertFalse(ContentOverviewItemResponse.fromNewAndHot(freeAudio).isPointAvailable)
assertTrue(ContentOverviewItemResponse.fromNewAndHot(paidAudio).isPointAvailable)
assertFalse(ContentOverviewItemResponse.fromNewAndHot(paidUnavailableAudio).isPointAvailable)
}
@Test
@DisplayName("첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() {
val freeAudio = firstAudio(price = 0, isPointAvailable = true)
val paidAudio = firstAudio(price = 100, isPointAvailable = true)
val paidUnavailableAudio = firstAudio(price = 100, isPointAvailable = false)
assertFalse(
ContentOverviewItemResponse.fromFirstAudioContent(freeAudio, "https://cdn.test/free.png", false, false)
.isPointAvailable
)
assertTrue(
ContentOverviewItemResponse.fromFirstAudioContent(paidAudio, "https://cdn.test/paid.png", false, false)
.isPointAvailable
)
assertFalse(
ContentOverviewItemResponse.fromFirstAudioContent(
paidUnavailableAudio,
"https://cdn.test/paid-unavailable.png",
false,
false
).isPointAvailable
)
}
private fun audioCard(price: Int, isPointAvailable: Boolean): AudioCard {
return AudioCard(
audioContentId = price.toLong() + 1,
title = "audio",
duration = "00:01",
imageUrl = "https://cdn.test/audio.png",
price = price,
isAdult = false,
isPointAvailable = isPointAvailable,
isFirstContent = true,
isOriginalSeries = false,
creatorNickname = "creator"
)
}
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
return HomeFirstAudioContentRecord(
contentId = price.toLong() + 1,
creatorId = 10L,
creatorNickname = "creator",
creatorProfileImage = null,
title = "first audio",
price = price,
coverImage = "cover/audio.png",
isPointAvailable = isPointAvailable,
isAdult = false,
isOriginalSeries = false
)
}
}

View File

@@ -54,6 +54,9 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true))
.andExpect(jsonPath("$.data.recommendedAudios").isArray)
.andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.pointAudios.length()").value(1))
.andExpect(jsonPath("$.data.pointAudios[0].audioContentId").value(fixture.paidPointAudioContentId))
.andExpect(jsonPath("$.data.pointAudios[0].price").value(100))
.andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment"))
.andExpect(
jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl")
@@ -68,6 +71,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
val writer = saveMember("audio-recommendation-e2e-writer", MemberRole.USER, profileImage = "comment-writer.png")
val theme = saveTheme()
val audio = saveAudio(creator, theme, now)
val paidPointAudio = saveAudio(creator, theme, now.minusMinutes(10), "paid-point", 100)
val series = saveSeries(creator)
saveSeriesContent(series, audio)
saveComment(audio, writer, "latest e2e comment", now.plusMinutes(10))
@@ -79,7 +83,8 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
Fixture(
seriesId = series.id!!,
audioContentId = audio.id!!
audioContentId = audio.id!!,
paidPointAudioContentId = paidPointAudio.id!!
)
}!!
}
@@ -102,14 +107,20 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
return theme
}
private fun saveAudio(creator: Member, theme: AudioContentTheme, releaseDate: LocalDateTime): AudioContent {
private fun saveAudio(
creator: Member,
theme: AudioContentTheme,
releaseDate: LocalDateTime,
title: String = "audio-recommendation-e2e",
price: Int = 0
): AudioContent {
val audio = AudioContent(
title = "audio-recommendation-e2e",
title = title,
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate,
isAdult = false,
price = 0,
price = price,
isPointAvailable = true
)
audio.member = creator
@@ -174,6 +185,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
private data class Fixture(
val seriesId: Long,
val audioContentId: Long
val audioContentId: Long,
val paidPointAudioContentId: Long
)
}

View File

@@ -33,10 +33,13 @@ class AudioRecommendationFacadeTest {
assertEquals(1, response.mostCommentedAudios.size)
assertEquals(1, response.recommendedAudios.size)
assertEquals(false, response.latestAudios[0].isOriginalSeries)
assertEquals(false, response.latestAudios[0].isPointAvailable)
assertEquals(true, response.newAndHotAudios[0].isPointAvailable)
assertEquals(false, response.recommendedAudios[0].isPointAvailable)
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertEquals(false, json["latestAudios"][0]["isAdult"].asBoolean())
assertEquals(true, json["latestAudios"][0]["isPointAvailable"].asBoolean())
assertEquals(false, json["latestAudios"][0]["isPointAvailable"].asBoolean())
assertEquals(true, json["latestAudios"][0]["isFirstContent"].asBoolean())
assertEquals(false, json["latestAudios"][0]["isOriginalSeries"].asBoolean())
assertEquals(false, json["latestAudios"][0].has("adult"))
@@ -61,7 +64,7 @@ class AudioRecommendationFacadeTest {
banners = listOf(RecommendationBanner("https://cdn.test/banner.png", null, null, null, "https://link.test")),
originalSeries = listOf(OriginalSeries(2L, "https://cdn.test/series.png")),
latestAudios = listOf(card),
newAndHotAudios = listOf(card.copy(audioContentId = 3L)),
newAndHotAudios = listOf(card.copy(audioContentId = 3L, price = 100)),
freeAudios = listOf(card.copy(audioContentId = 4L)),
pointAudios = listOf(card.copy(audioContentId = 5L)),
mostCommentedAudios = listOf(
@@ -73,7 +76,7 @@ class AudioRecommendationFacadeTest {
latestCommentWriterProfileImageUrl = "https://cdn.test/profile.png"
)
),
recommendedAudios = listOf(card.copy(audioContentId = 7L))
recommendedAudios = listOf(card.copy(audioContentId = 7L, price = 100, isPointAvailable = false))
)
}
}

View File

@@ -0,0 +1,38 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
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
class CreatorChannelAudioContentResponseTest {
@Test
@DisplayName("크리에이터 채널 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
fun shouldNormalizePointAvailabilityForFreeAudioContent() {
val freeAudio = audioContent(price = 0, isPointAvailable = true)
val paidAudio = audioContent(price = 100, isPointAvailable = true)
val paidUnavailableAudio = audioContent(price = 100, isPointAvailable = false)
assertFalse(CreatorChannelAudioContentResponse.from(freeAudio).isPointAvailable)
assertTrue(CreatorChannelAudioContentResponse.from(paidAudio).isPointAvailable)
assertFalse(CreatorChannelAudioContentResponse.from(paidUnavailableAudio).isPointAvailable)
}
private fun audioContent(price: Int, isPointAvailable: Boolean): CreatorChannelAudioContent {
return CreatorChannelAudioContent(
audioContentId = price.toLong() + 1,
title = "audio",
duration = "00:10:00",
imageUrl = "audio.png",
price = price,
isAdult = false,
isPointAvailable = isPointAvailable,
isFirstContent = true,
seriesName = "series",
isOriginalSeries = true,
isOwned = false,
isRented = false
)
}
}

View File

@@ -0,0 +1,163 @@
package kr.co.vividnext.sodalive.v2.api.home.application
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeCheerCreatorRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeGenreCreatorRecommendationGroup
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomePopularCommunityRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
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.mockito.Mockito
import java.time.LocalDateTime
class HomeRecommendationFacadeTest {
private val queryPort = FakeHomeRecommendationQueryPort()
private val queryService = HomeRecommendationQueryService(queryPort, EmptyRecommendationSnapshotPort())
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test")
@Test
@DisplayName("홈 첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() {
queryPort.firstAudioContents = listOf(
firstAudio(price = 0, isPointAvailable = true),
firstAudio(price = 100, isPointAvailable = true),
firstAudio(price = 100, isPointAvailable = false)
)
val response = facade.getHomeRecommendations(null)
assertFalse(response.firstAudioContents[0].isPointAvailable)
assertTrue(response.firstAudioContents[1].isPointAvailable)
assertFalse(response.firstAudioContents[2].isPointAvailable)
}
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
return HomeFirstAudioContentRecord(
contentId = price.toLong() + 1,
creatorId = 10L,
creatorNickname = "creator",
creatorProfileImage = null,
title = "first audio",
price = price,
coverImage = "cover/audio.png",
isPointAvailable = isPointAvailable,
isAdult = false,
isOriginalSeries = false
)
}
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
var firstAudioContents: List<HomeFirstAudioContentRecord> = emptyList()
override fun findLiveRecommendations(
offset: Long,
limit: Int,
memberId: Long?,
includeAdultLives: Boolean
): List<HomeLiveRecommendationRecord> = emptyList()
override fun findHomeBanners(limit: Int, memberId: Long?): List<HomeBannerRecommendationRecord> = emptyList()
override fun findRecentlyActiveCreators(
limit: Int,
memberId: Long?,
includeAdultActivities: Boolean
): List<RecentlyActiveCreatorRecord> = emptyList()
override fun findRecentDebutCreators(
now: LocalDateTime,
offset: Long,
limit: Int,
memberId: Long?,
includeAdultContents: Boolean
): List<RecentDebutCreatorRecord> = emptyList()
override fun findFirstAudioContents(
now: LocalDateTime,
offset: Long,
limit: Int,
memberId: Long?,
includeAdultContents: Boolean
): List<HomeFirstAudioContentRecord> = firstAudioContents
override fun findAiCharacterSnapshots(
windowStart: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findCheerCreatorSnapshots(
windowStart: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findPopularCommunitySnapshots(
windowStart: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findAiCharacterRecommendationDetails(
characterIds: List<Long>
): List<HomeAiCharacterRecommendationRecord> = emptyList()
override fun findRandomAiCharacterRecommendationIds(excludeCharacterIds: List<Long>, limit: Int): List<Long> = emptyList()
override fun findCheerCreatorRecommendationDetails(
creatorIds: List<Long>,
memberId: Long?
): List<HomeCheerCreatorRecommendationRecord> = emptyList()
override fun findPopularCommunityRecommendationDetails(
communityIds: List<Long>,
memberId: Long?,
includeAdultCommunities: Boolean
): List<HomePopularCommunityRecommendationRecord> = emptyList()
override fun findGenreCreatorRecommendations(
memberId: Long?,
includeAdultGenres: Boolean,
genreLimit: Int,
creatorLimit: Int
): List<HomeGenreCreatorRecommendationGroup> = emptyList()
}
private class EmptyRecommendationSnapshotPort : RecommendationSnapshotPort {
override fun findLatestSnapshots(
sectionType: RecommendedSectionType,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean = true
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean = true
override fun replaceSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord>
) = Unit
}
}

View File

@@ -52,6 +52,7 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
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)
val freePoint = saveAudioContent(creator, theme, now.minusHours(12), isAdult = false, price = 0, 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)
@@ -66,11 +67,12 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
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(3, repository.countAudios(viewer.id, canViewAdultContent = false, now))
assertEquals(1, repository.countAudios(viewer.id, false, now, onlyPaid = true))
assertEquals(listOf(point.id, free.id), visible.map { it.audioContentId })
assertEquals(1, repository.countAudios(null, false, now, onlyPointAvailable = true))
assertEquals(listOf(freePoint.id, point.id, free.id), visible.map { it.audioContentId })
assertEquals(listOf(point.id), paidAudios.map { it.audioContentId })
assertEquals(listOf(free.id), freeAudios.map { it.audioContentId })
assertEquals(listOf(freePoint.id, free.id), freeAudios.map { it.audioContentId })
assertEquals(listOf(point.id), pointAudios.map { it.audioContentId })
assertEquals("https://cdn.test/audio.png", visible.first().imageUrl)
}

View File

@@ -90,7 +90,7 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val now = LocalDateTime.of(2026, 6, 23, 12, 0)
val creator = saveMember("audio-creator", MemberRole.CREATOR)
val theme = saveTheme()
val first = saveAudio(
saveAudio(
creator = creator,
theme = theme,
title = "first",
@@ -108,6 +108,15 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
isPointAvailable = false,
coverImage = "latest.png"
)
val paidPoint = saveAudio(
creator = creator,
theme = theme,
title = "paid-point",
releaseDate = now.minusDays(2),
price = 20,
isPointAvailable = true,
coverImage = "paid-point.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
@@ -139,11 +148,11 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
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(listOf(latest.id, paidPoint.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 })
assertEquals(listOf(paidPoint.id), pointAudios.map { it.audioContentId })
assertEquals(true, pointAudios.all { it.isPointAvailable && it.price > 0 })
val latestCard = latestAudios.first()
assertEquals("latest", latestCard.title)
assertEquals("00:01", latestCard.duration)
@@ -154,8 +163,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(false, latestCard.isFirstContent)
assertEquals(true, latestCard.isOriginalSeries)
assertEquals(creator.nickname, latestCard.creatorNickname)
assertEquals(true, latestAudios[1].isFirstContent)
assertEquals(false, latestAudios[1].isOriginalSeries)
assertEquals(true, latestAudios[2].isFirstContent)
assertEquals(false, latestAudios[2].isOriginalSeries)
}
@Test