fix(content): 차단된 구매자의 오디오 상세 조회를 허용한다
This commit is contained in:
40
docs/20260324_차단유저구매콘텐츠상세조회예외처리.md
Normal file
40
docs/20260324_차단유저구매콘텐츠상세조회예외처리.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 20260324 차단 유저 구매 콘텐츠 상세 조회 예외 처리
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
- 차단 관계가 있어도 조회자가 해당 콘텐츠를 구매한 경우에는 상세 조회를 허용한다.
|
||||||
|
- 차단 예외 경로에서는 댓글 및 시리즈 내 이전/다음 콘텐츠 정보를 노출하지 않는다.
|
||||||
|
|
||||||
|
## 구현 체크리스트
|
||||||
|
- [x] `AudioContentService.getDetail`에서 구매 여부(`isExistOrderedAndOrderType`)를 차단 판정보다 먼저 계산
|
||||||
|
- [x] 차단 + 미구매인 경우 기존 `content.error.blocked_access` 예외 유지
|
||||||
|
- [x] 차단 + 구매인 경우 상세 조회 허용
|
||||||
|
- [x] 차단 + 구매인 경우 댓글 목록/댓글 수 조회 쿼리 미실행 및 응답을 `[]`, `0`으로 반환
|
||||||
|
- [x] 차단 + 구매인 경우 `previousContent`, `nextContent` 조회 쿼리 미실행 및 응답을 `null`로 반환
|
||||||
|
- [x] 정적 진단/테스트/빌드 검증 수행
|
||||||
|
|
||||||
|
## 완료 기준 (Pass/Fail)
|
||||||
|
- [x] AC1: 차단 + 미구매 요청 시 `SodaException(messageKey = "content.error.blocked_access")`가 발생해야 한다.
|
||||||
|
- QA: `getDetail` 분기 코드 확인 및 관련 테스트/빌드 통과
|
||||||
|
- [x] AC2: 차단 + 구매 요청 시 상세 조회가 실패하지 않아야 한다.
|
||||||
|
- QA: `getDetail` 분기 코드 확인 및 관련 테스트/빌드 통과
|
||||||
|
- [x] AC3: 차단 + 구매 요청 시 댓글/이전/다음 콘텐츠 조회 로직이 실행되지 않아야 한다.
|
||||||
|
- QA: 조건문 가드로 `commentRepository.findByContentId`, `totalCountCommentByContentId`, `findPreviousContent`, `findNextContent` 호출 차단 확인
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
- 1차 구현: 진행 전
|
||||||
|
- 무엇을: 요구사항 분석 및 기존 패턴 탐색
|
||||||
|
- 왜: 차단/구매 예외 규칙을 기존 서비스 로직과 일관되게 반영하기 위해
|
||||||
|
- 어떻게: `grep`, `ast-grep`, explore/librarian 백그라운드 탐색 수행
|
||||||
|
|
||||||
|
- 2차 구현: 기능 반영 및 시나리오 검증
|
||||||
|
- 무엇을: `AudioContentService.getDetail`에서 차단+구매 예외를 허용하고, 해당 경로에서 댓글/이전·다음 조회를 생략하도록 분기 로직을 수정했다. 또한 `AudioContentServiceTest`를 추가해 차단+미구매/차단+구매 시나리오를 실제 메서드 호출로 검증했다.
|
||||||
|
- 왜: 요청사항(구매자 접근 허용 + 댓글/이전·다음 비조회)을 코드 레벨뿐 아니라 실행 가능한 테스트로 재현해 회귀를 방지하기 위해.
|
||||||
|
- 어떻게:
|
||||||
|
- 명령: `lsp_diagnostics` (`AudioContentService.kt`, `AudioContentServiceTest.kt`)
|
||||||
|
- 결과: 실패 (현재 실행 환경에 Kotlin LSP 미구성으로 `.kt` 진단 불가)
|
||||||
|
- 명령: `./gradlew test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"`
|
||||||
|
- 결과: 성공 (신규 2개 시나리오 테스트 통과)
|
||||||
|
- 명령: `./gradlew test`
|
||||||
|
- 결과: 성공
|
||||||
|
- 명령: `./gradlew build`
|
||||||
|
- 결과: 성공
|
||||||
@@ -538,7 +538,15 @@ class AudioContentService(
|
|||||||
val creator = explorerQueryRepository.getMember(creatorId)
|
val creator = explorerQueryRepository.getMember(creatorId)
|
||||||
?: throw SodaException(messageKey = "content.error.user_not_found")
|
?: throw SodaException(messageKey = "content.error.user_not_found")
|
||||||
|
|
||||||
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
|
val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType(
|
||||||
|
memberId = member.id!!,
|
||||||
|
contentId = audioContent.id!!
|
||||||
|
)
|
||||||
|
|
||||||
|
val isBlocked = isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)
|
||||||
|
val isBlockedAndPurchased = isBlocked && isExistsAudioContent
|
||||||
|
|
||||||
|
if (isBlocked && !isExistsAudioContent) {
|
||||||
throw SodaException(messageKey = "content.error.blocked_access")
|
throw SodaException(messageKey = "content.error.blocked_access")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,11 +555,6 @@ class AudioContentService(
|
|||||||
memberId = member.id!!
|
memberId = member.id!!
|
||||||
)
|
)
|
||||||
|
|
||||||
val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType(
|
|
||||||
memberId = member.id!!,
|
|
||||||
contentId = audioContent.id!!
|
|
||||||
)
|
|
||||||
|
|
||||||
val orderSequence = if (isExistsAudioContent) {
|
val orderSequence = if (isExistsAudioContent) {
|
||||||
limitedEditionOrderRepository.getOrderSequence(
|
limitedEditionOrderRepository.getOrderSequence(
|
||||||
contentId = audioContent.id!!,
|
contentId = audioContent.id!!,
|
||||||
@@ -561,7 +564,12 @@ class AudioContentService(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val seriesId = repository.findSeriesIdByContentId(audioContent.id!!, isAdult)
|
val seriesId = if (isBlockedAndPurchased) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
repository.findSeriesIdByContentId(audioContent.id!!, isAdult)
|
||||||
|
}
|
||||||
|
|
||||||
val previousContent = if (seriesId != null) {
|
val previousContent = if (seriesId != null) {
|
||||||
repository.findPreviousContent(
|
repository.findPreviousContent(
|
||||||
seriesId = seriesId,
|
seriesId = seriesId,
|
||||||
@@ -592,7 +600,7 @@ class AudioContentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 댓글
|
// 댓글
|
||||||
val commentList = if (audioContent.isCommentAvailable) {
|
val commentList = if (!isBlockedAndPurchased && audioContent.isCommentAvailable) {
|
||||||
commentRepository.findByContentId(
|
commentRepository.findByContentId(
|
||||||
cloudFrontHost = coverImageHost,
|
cloudFrontHost = coverImageHost,
|
||||||
contentId = audioContent.id!!,
|
contentId = audioContent.id!!,
|
||||||
@@ -607,7 +615,7 @@ class AudioContentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 댓글 수
|
// 댓글 수
|
||||||
val commentCount = if (audioContent.isCommentAvailable) {
|
val commentCount = if (!isBlockedAndPurchased && audioContent.isCommentAvailable) {
|
||||||
commentRepository.totalCountCommentByContentId(
|
commentRepository.totalCountCommentByContentId(
|
||||||
contentId = audioContent.id!!,
|
contentId = audioContent.id!!,
|
||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.order.LimitedEditionOrderRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.order.OrderRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
|
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import java.util.Optional
|
||||||
|
|
||||||
|
class AudioContentServiceTest {
|
||||||
|
private lateinit var repository: AudioContentRepository
|
||||||
|
private lateinit var explorerQueryRepository: ExplorerQueryRepository
|
||||||
|
private lateinit var blockMemberRepository: BlockMemberRepository
|
||||||
|
private lateinit var hashTagRepository: HashTagRepository
|
||||||
|
private lateinit var orderRepository: OrderRepository
|
||||||
|
private lateinit var limitedEditionOrderRepository: LimitedEditionOrderRepository
|
||||||
|
private lateinit var themeQueryRepository: AudioContentThemeQueryRepository
|
||||||
|
private lateinit var playbackTrackingRepository: PlaybackTrackingRepository
|
||||||
|
private lateinit var commentRepository: AudioContentCommentRepository
|
||||||
|
private lateinit var audioContentLikeRepository: AudioContentLikeRepository
|
||||||
|
private lateinit var pinContentRepository: PinContentRepository
|
||||||
|
private lateinit var translationService: PapagoTranslationService
|
||||||
|
private lateinit var contentTranslationRepository: ContentTranslationRepository
|
||||||
|
private lateinit var s3Uploader: S3Uploader
|
||||||
|
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||||
|
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||||
|
private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository
|
||||||
|
|
||||||
|
private lateinit var service: AudioContentService
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
repository = Mockito.mock(AudioContentRepository::class.java)
|
||||||
|
explorerQueryRepository = Mockito.mock(ExplorerQueryRepository::class.java)
|
||||||
|
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
|
||||||
|
hashTagRepository = Mockito.mock(HashTagRepository::class.java)
|
||||||
|
orderRepository = Mockito.mock(OrderRepository::class.java)
|
||||||
|
limitedEditionOrderRepository = Mockito.mock(LimitedEditionOrderRepository::class.java)
|
||||||
|
themeQueryRepository = Mockito.mock(AudioContentThemeQueryRepository::class.java)
|
||||||
|
playbackTrackingRepository = Mockito.mock(PlaybackTrackingRepository::class.java)
|
||||||
|
commentRepository = Mockito.mock(AudioContentCommentRepository::class.java)
|
||||||
|
audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java)
|
||||||
|
pinContentRepository = Mockito.mock(PinContentRepository::class.java)
|
||||||
|
translationService = Mockito.mock(PapagoTranslationService::class.java)
|
||||||
|
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
|
||||||
|
s3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||||
|
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
||||||
|
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||||
|
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java)
|
||||||
|
|
||||||
|
service = AudioContentService(
|
||||||
|
repository = repository,
|
||||||
|
explorerQueryRepository = explorerQueryRepository,
|
||||||
|
blockMemberRepository = blockMemberRepository,
|
||||||
|
hashTagRepository = hashTagRepository,
|
||||||
|
orderRepository = orderRepository,
|
||||||
|
limitedEditionOrderRepository = limitedEditionOrderRepository,
|
||||||
|
themeQueryRepository = themeQueryRepository,
|
||||||
|
playbackTrackingRepository = playbackTrackingRepository,
|
||||||
|
commentRepository = commentRepository,
|
||||||
|
audioContentLikeRepository = audioContentLikeRepository,
|
||||||
|
pinContentRepository = pinContentRepository,
|
||||||
|
translationService = translationService,
|
||||||
|
contentTranslationRepository = contentTranslationRepository,
|
||||||
|
s3Uploader = s3Uploader,
|
||||||
|
objectMapper = ObjectMapper(),
|
||||||
|
audioContentCloudFront = audioContentCloudFront,
|
||||||
|
applicationEventPublisher = applicationEventPublisher,
|
||||||
|
messageSource = SodaMessageSource(),
|
||||||
|
langContext = LangContext(),
|
||||||
|
contentThemeTranslationRepository = contentThemeTranslationRepository,
|
||||||
|
audioContentBucket = "audio-bucket",
|
||||||
|
coverImageBucket = "cover-bucket",
|
||||||
|
coverImageHost = "https://cdn.test"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("차단 + 미구매 사용자 요청은 콘텐츠 상세에서 차단 예외를 반환한다")
|
||||||
|
fun shouldThrowBlockedAccessWhenBlockedAndNotPurchased() {
|
||||||
|
val viewer = createMember(id = 1000L, nickname = "viewer")
|
||||||
|
val creator = createMember(id = 2000L, nickname = "creator")
|
||||||
|
val audioContent = createAudioContent(creator)
|
||||||
|
|
||||||
|
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
|
||||||
|
Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator)
|
||||||
|
Mockito.`when`(
|
||||||
|
orderRepository.isExistOrderedAndOrderType(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
contentId = audioContent.id!!
|
||||||
|
)
|
||||||
|
).thenReturn(Pair(false, null))
|
||||||
|
Mockito.`when`(blockMemberRepository.isBlocked(blockedMemberId = viewer.id!!, memberId = creator.id!!))
|
||||||
|
.thenReturn(true)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getDetail(
|
||||||
|
id = audioContent.id!!,
|
||||||
|
member = viewer,
|
||||||
|
isAdultContentVisible = false,
|
||||||
|
timezone = "Asia/Seoul"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("content.error.blocked_access", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("차단 + 구매 사용자 요청은 상세 조회를 허용하고 댓글/이전다음 조회를 생략한다")
|
||||||
|
fun shouldAllowDetailWhenBlockedAndPurchasedButSkipCommentAndNavigationQueries() {
|
||||||
|
val viewer = createMember(id = 1001L, nickname = "viewer")
|
||||||
|
val creator = createMember(id = 2001L, nickname = "creator")
|
||||||
|
val audioContent = createAudioContent(creator)
|
||||||
|
|
||||||
|
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
|
||||||
|
Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator)
|
||||||
|
Mockito.`when`(
|
||||||
|
orderRepository.isExistOrderedAndOrderType(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
contentId = audioContent.id!!
|
||||||
|
)
|
||||||
|
).thenReturn(Pair(true, OrderType.KEEP))
|
||||||
|
Mockito.`when`(blockMemberRepository.isBlocked(blockedMemberId = viewer.id!!, memberId = creator.id!!))
|
||||||
|
.thenReturn(true)
|
||||||
|
Mockito.`when`(explorerQueryRepository.getCreatorFollowing(creator.id!!, viewer.id!!)).thenReturn(null)
|
||||||
|
Mockito.`when`(
|
||||||
|
limitedEditionOrderRepository.getOrderSequence(
|
||||||
|
contentId = audioContent.id!!,
|
||||||
|
memberId = viewer.id!!
|
||||||
|
)
|
||||||
|
).thenReturn(null)
|
||||||
|
Mockito.`when`(
|
||||||
|
audioContentCloudFront.generateSignedURL(
|
||||||
|
resourcePath = audioContent.content!!,
|
||||||
|
expirationTime = 7_200_000L
|
||||||
|
)
|
||||||
|
).thenReturn("https://signed.test/audio")
|
||||||
|
Mockito.`when`(
|
||||||
|
repository.getCreatorOtherContentList(
|
||||||
|
cloudfrontHost = "https://cdn.test",
|
||||||
|
contentId = audioContent.id!!,
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
).thenReturn(emptyList())
|
||||||
|
Mockito.`when`(
|
||||||
|
repository.getSameThemeOtherContentList(
|
||||||
|
cloudfrontHost = "https://cdn.test",
|
||||||
|
contentId = audioContent.id!!,
|
||||||
|
themeId = audioContent.theme!!.id!!,
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
).thenReturn(emptyList())
|
||||||
|
Mockito.`when`(audioContentLikeRepository.totalCountAudioContentLike(audioContent.id!!)).thenReturn(0)
|
||||||
|
Mockito.`when`(audioContentLikeRepository.findByMemberIdAndContentId(viewer.id!!, audioContent.id!!)).thenReturn(null)
|
||||||
|
Mockito.`when`(
|
||||||
|
pinContentRepository.findByContentIdAndMemberId(
|
||||||
|
contentId = audioContent.id!!,
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
active = true
|
||||||
|
)
|
||||||
|
).thenReturn(null)
|
||||||
|
Mockito.`when`(pinContentRepository.getPinContentList(memberId = viewer.id!!, active = true)).thenReturn(emptyList())
|
||||||
|
Mockito.`when`(
|
||||||
|
contentThemeTranslationRepository.findByContentThemeIdAndLocale(
|
||||||
|
contentThemeId = audioContent.theme!!.id!!,
|
||||||
|
locale = "ko"
|
||||||
|
)
|
||||||
|
).thenReturn(null)
|
||||||
|
|
||||||
|
val response = service.getDetail(
|
||||||
|
id = audioContent.id!!,
|
||||||
|
member = viewer,
|
||||||
|
isAdultContentVisible = false,
|
||||||
|
timezone = "Asia/Seoul"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(response.existOrdered)
|
||||||
|
assertTrue(response.commentList.isEmpty())
|
||||||
|
assertEquals(0, response.commentCount)
|
||||||
|
assertNull(response.previousContent)
|
||||||
|
assertNull(response.nextContent)
|
||||||
|
|
||||||
|
Mockito.verify(repository, Mockito.never()).findSeriesIdByContentId(audioContent.id!!, false)
|
||||||
|
Mockito.verifyNoInteractions(commentRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(id: Long, nickname: String): Member {
|
||||||
|
val member = Member(
|
||||||
|
email = "$nickname@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = nickname
|
||||||
|
)
|
||||||
|
member.id = id
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAudioContent(creator: Member): AudioContent {
|
||||||
|
val theme = AudioContentTheme(theme = "수면", image = "sleep.png")
|
||||||
|
theme.id = 300L
|
||||||
|
|
||||||
|
val audioContent = AudioContent(
|
||||||
|
title = "테스트 제목",
|
||||||
|
detail = "테스트 상세 설명",
|
||||||
|
languageCode = null,
|
||||||
|
price = 100,
|
||||||
|
purchaseOption = PurchaseOption.BOTH,
|
||||||
|
isGeneratePreview = true,
|
||||||
|
isOnlyRental = false,
|
||||||
|
isAdult = false,
|
||||||
|
isPointAvailable = true,
|
||||||
|
isCommentAvailable = true,
|
||||||
|
isFullDetailVisible = true
|
||||||
|
)
|
||||||
|
audioContent.id = 500L
|
||||||
|
audioContent.member = creator
|
||||||
|
audioContent.theme = theme
|
||||||
|
audioContent.content = "output/500/content.mp3"
|
||||||
|
audioContent.coverImage = "audio_content_cover/500/cover.jpg"
|
||||||
|
audioContent.duration = "00:10:00"
|
||||||
|
audioContent.isActive = true
|
||||||
|
|
||||||
|
return audioContent
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user