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