test #408

Merged
klaus merged 2 commits from test into main 2026-03-24 10:41:42 +00:00
5 changed files with 363 additions and 14 deletions

View File

@@ -0,0 +1,39 @@
# 20260324 라이브 생성 시 19금 방 전환 로직 추가
## 목적
- 라이브 생성(createLiveRoom) 시 태그 기준으로 `room.isAdult` 전환 조건을 확장한다.
- 기존 문자열 매칭("음담패설") 조건은 유지하고, `tag.isAdult = true`인 경우에도 19금 방으로 전환한다.
## 범위
- `LiveRoomService.createLiveRoom`의 태그 처리 구간.
- 테스트/빌드 회귀 확인.
## 구현 체크리스트
- [x] 기존 문자열 조건 유지: `tag.tag.contains("음담패설")``room.isAdult = true`
- [x] 추가 조건 구현: `tag.isAdult == true``room.isAdult = true`
- [x] 리팩토링: `isAdultTag(LiveTag)` 보조 함수 추출 및 태그 루프 내 부수효과 제거
- [x] 리팩토링: 태그 기반 19금 여부를 누적 계산 후 최종 한 번만 `room.isAdult` 반영
- [x] 코드 스타일/네이밍/예외 규칙 준수(AGENTS.md)
- [x] `./gradlew test` 실행으로 회귀 확인
## 변경 파일
- `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt`
## 검증 계획
1차 구현
- 무엇을: 라이브 생성 시 태그에 `isAdult=true`가 포함되면 `room.isAdult`가 true로 설정되는지 확인
- 왜: 19금 태그를 구조적으로 식별해 19금 방 전환을 정확히 반영하기 위함
- 어떻게:
- 명령: `./gradlew test`
- 기대: 빌드 및 모든 테스트 통과(회귀 없음)
2차(수동) 확인
- 무엇을: 태그가 `음담패설` 또는 `isAdult=true`일 때 19금 전환되는지 로직 리뷰(보조 함수 경유)
- 왜: 런타임 리스크 없이 조건 충족 여부를 빠르게 확인
- 어떻게: 코드 라인 수동 점검
- 위치: `LiveRoomService.isAdultTag``createLiveRoom`의 태그 forEach 블록
- 기대: 두 조건 중 하나라도 만족 시 `room.isAdult = true`
## 정정/추가 메모
- 현 단계에서 공개 API 스키마 변경 없음.
- 도메인 예외/응답 포맷 변경 없음.

View 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`
- 결과: 성공

View File

@@ -538,7 +538,15 @@ class AudioContentService(
val creator = explorerQueryRepository.getMember(creatorId)
?: 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")
}
@@ -547,11 +555,6 @@ class AudioContentService(
memberId = member.id!!
)
val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType(
memberId = member.id!!,
contentId = audioContent.id!!
)
val orderSequence = if (isExistsAudioContent) {
limitedEditionOrderRepository.getOrderSequence(
contentId = audioContent.id!!,
@@ -561,7 +564,12 @@ class AudioContentService(
null
}
val seriesId = repository.findSeriesIdByContentId(audioContent.id!!, isAdult)
val seriesId = if (isBlockedAndPurchased) {
null
} else {
repository.findSeriesIdByContentId(audioContent.id!!, isAdult)
}
val previousContent = if (seriesId != null) {
repository.findPreviousContent(
seriesId = seriesId,
@@ -592,7 +600,7 @@ class AudioContentService(
}
// 댓글
val commentList = if (audioContent.isCommentAvailable) {
val commentList = if (!isBlockedAndPurchased && audioContent.isCommentAvailable) {
commentRepository.findByContentId(
cloudFrontHost = coverImageHost,
contentId = audioContent.id!!,
@@ -607,7 +615,7 @@ class AudioContentService(
}
// 댓글 수
val commentCount = if (audioContent.isCommentAvailable) {
val commentCount = if (!isBlockedAndPurchased && audioContent.isCommentAvailable) {
commentRepository.totalCountCommentByContentId(
contentId = audioContent.id!!,
memberId = member.id!!,

View File

@@ -55,6 +55,7 @@ import kr.co.vividnext.sodalive.live.room.menu.UpdateLiveMenuRequest
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService
import kr.co.vividnext.sodalive.live.roulette.NewRouletteRepository
import kr.co.vividnext.sodalive.live.signature.SignatureCanRepository
import kr.co.vividnext.sodalive.live.tag.LiveTag
import kr.co.vividnext.sodalive.live.tag.LiveTagRepository
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Gender
@@ -125,6 +126,12 @@ class LiveRoomService(
) {
private val tokenLocks: MutableMap<Long, ReentrantReadWriteLock> = mutableMapOf()
// 태그가 성인(19금) 판정에 해당하는지 여부를 계산한다.
private fun isAdultTag(tag: LiveTag): Boolean {
// 기존 문자열 기반 조건("음담패설")을 유지하고, 태그 속성의 isAdult도 함께 평가한다.
return tag.tag.contains("음담패설") || tag.isAdult
}
private fun formatMessage(key: String, vararg args: Any): String {
val template = messageSource.getMessage(key, langContext.lang).orEmpty()
return if (args.isNotEmpty()) {
@@ -425,17 +432,22 @@ class LiveRoomService(
"${beginDateTime.hour}_${beginDateTime.minute}"
}
request.tags.forEach {
val tag = tagRepository.findByTag(it)
var isAdultByTags = false
request.tags.forEach { tagText ->
val tag = tagRepository.findByTag(tagText)
if (tag != null) {
room.tags.add(LiveRoomTag(room, tag))
if (tag.tag.contains("음담패설")) {
room.isAdult = true
if (isAdultTag(tag)) {
isAdultByTags = true
}
}
}
// 태그 판정 결과를 한 번에 반영해 부수효과를 최소화한다.
if (isAdultByTags) {
room.isAdult = true
}
val createdRoom = repository.save(room)
// 이미지 업로드
if (coverImage != null) {

View File

@@ -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
}
}