feat(content-preference): 콘텐츠 조회 설정 서버 저장 전환을 반영한다

This commit is contained in:
2026-03-27 13:33:51 +09:00
parent 1ba3cb8a40
commit a87bd147dc
75 changed files with 3593 additions and 301 deletions

View File

@@ -100,6 +100,28 @@ class AudioContentServiceTest {
)
}
@Test
@DisplayName("비성인 정책 사용자가 성인 콘텐츠 상세를 조회하면 인증 필요 예외를 반환한다")
fun shouldThrowAdultVerificationRequiredWhenAdultContentRequestedByNonAdultPolicy() {
val viewer = createMember(id = 1002L, nickname = "viewer")
val creator = createMember(id = 2002L, nickname = "creator")
val adultContent = createAudioContent(creator = creator, isAdult = true)
Mockito.`when`(repository.findById(adultContent.id!!)).thenReturn(Optional.of(adultContent))
val exception = assertThrows(SodaException::class.java) {
service.getDetail(
id = adultContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
}
assertEquals("common.error.adult_verification_required", exception.messageKey)
Mockito.verifyNoInteractions(explorerQueryRepository)
}
@Test
@DisplayName("차단 + 미구매 사용자 요청은 콘텐츠 상세에서 차단 예외를 반환한다")
fun shouldThrowBlockedAccessWhenBlockedAndNotPurchased() {
@@ -220,7 +242,7 @@ class AudioContentServiceTest {
return member
}
private fun createAudioContent(creator: Member): AudioContent {
private fun createAudioContent(creator: Member, isAdult: Boolean = false): AudioContent {
val theme = AudioContentTheme(theme = "수면", image = "sleep.png")
theme.id = 300L
@@ -232,7 +254,7 @@ class AudioContentServiceTest {
purchaseOption = PurchaseOption.BOTH,
isGeneratePreview = true,
isOnlyRental = false,
isAdult = false,
isAdult = isAdult,
isPointAvailable = true,
isCommentAvailable = true,
isFullDetailVisible = true

View File

@@ -8,7 +8,9 @@ import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeRequest
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
@@ -36,6 +38,7 @@ import java.util.Optional
class CreatorCommunityServiceTest {
private lateinit var repository: CreatorCommunityRepository
private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var likeRepository: CreatorCommunityLikeRepository
private lateinit var commentRepository: CreatorCommunityCommentRepository
private lateinit var useCanRepository: UseCanRepository
private lateinit var applicationEventPublisher: ApplicationEventPublisher
@@ -45,6 +48,7 @@ class CreatorCommunityServiceTest {
fun setup() {
repository = Mockito.mock(CreatorCommunityRepository::class.java)
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java)
commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java)
useCanRepository = Mockito.mock(UseCanRepository::class.java)
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
@@ -53,7 +57,7 @@ class CreatorCommunityServiceTest {
canPaymentService = Mockito.mock(CanPaymentService::class.java),
repository = repository,
blockMemberRepository = blockMemberRepository,
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java),
likeRepository = likeRepository,
commentRepository = commentRepository,
useCanRepository = useCanRepository,
s3Uploader = Mockito.mock(S3Uploader::class.java),
@@ -68,6 +72,29 @@ class CreatorCommunityServiceTest {
)
}
@Test
@DisplayName("좋아요 처리 시 전달된 성인 여부를 기준으로 게시글을 조회한다")
fun shouldUseProvidedIsAdultForCommunityLikeAdultFilter() {
val member = createMember(id = 88L, role = MemberRole.USER, nickname = "viewer")
val post = CreatorCommunity(content = "adult-post", price = 0, isCommentAvailable = true, isAdult = true)
post.id = 801L
post.member = createMember(id = 99L, role = MemberRole.CREATOR, nickname = "creator")
Mockito.`when`(likeRepository.findByPostIdAndMemberId(postId = 801L, memberId = 88L)).thenReturn(null)
Mockito.`when`(repository.findByIdAndActive(801L, true)).thenReturn(post)
Mockito.`when`(likeRepository.save(Mockito.any(CreatorCommunityLike::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
val response = service.communityPostLike(
request = PostCommunityPostLikeRequest(postId = 801L),
member = member,
isAdult = true
)
assertTrue(response.like)
Mockito.verify(repository).findByIdAndActive(801L, true)
}
@Test
@DisplayName("크리에이터가 아닌 사용자가 댓글 작성 시 크리에이터 대상 커뮤니티 딥링크 알림 이벤트를 발행한다")
fun shouldPublishCreatorCommunityCommentNotificationEventWhenCommenterIsNotCreator() {
@@ -77,7 +104,7 @@ class CreatorCommunityServiceTest {
post.id = 301L
post.member = creator
Mockito.`when`(repository.findById(post.id!!)).thenReturn(Optional.of(post))
Mockito.`when`(repository.findByIdAndActive(post.id!!, true)).thenReturn(post)
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(post.id!!, commenter.id!!)).thenReturn(false)
Mockito.`when`(commentRepository.save(Mockito.any(CreatorCommunityComment::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
@@ -87,7 +114,8 @@ class CreatorCommunityServiceTest {
comment = "새 댓글",
postId = post.id!!,
parentId = null,
isSecret = false
isSecret = false,
isAdult = true
)
val captor = ArgumentCaptor.forClass(FcmEvent::class.java)
@@ -112,7 +140,7 @@ class CreatorCommunityServiceTest {
post.id = 401L
post.member = creator
Mockito.`when`(repository.findById(post.id!!)).thenReturn(Optional.of(post))
Mockito.`when`(repository.findByIdAndActive(post.id!!, true)).thenReturn(post)
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(post.id!!, creator.id!!)).thenReturn(false)
Mockito.`when`(commentRepository.save(Mockito.any(CreatorCommunityComment::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
@@ -122,12 +150,80 @@ class CreatorCommunityServiceTest {
comment = "내가 단 댓글",
postId = post.id!!,
parentId = null,
isSecret = false
isSecret = false,
isAdult = true
)
Mockito.verify(applicationEventPublisher, Mockito.never()).publishEvent(Mockito.any())
}
@Test
@DisplayName("비성인 정책 사용자가 성인 커뮤니티 게시글에 댓글 작성 시 예외가 발생한다")
fun shouldThrowExceptionWhenCommentingAdultPostWithNonAdultPolicy() {
val commenter = createMember(id = 23L, role = MemberRole.USER, nickname = "viewer")
Mockito.`when`(repository.findByIdAndActive(901L, false)).thenReturn(null)
val exception = assertThrows(SodaException::class.java) {
service.createCommunityPostComment(
member = commenter,
comment = "접근 불가 댓글",
postId = 901L,
isAdult = false
)
}
assertEquals("creator.community.invalid_request_retry", exception.messageKey)
}
@Test
@DisplayName("비성인 정책 사용자가 성인 게시글 댓글 목록을 조회하면 예외가 발생한다")
fun shouldThrowExceptionWhenFetchingAdultPostCommentsWithNonAdultPolicy() {
Mockito.`when`(repository.findByIdAndActive(902L, false)).thenReturn(null)
val exception = assertThrows(SodaException::class.java) {
service.getCommunityPostCommentList(
postId = 902L,
memberId = 23L,
timezone = "Asia/Seoul",
offset = 0,
limit = 10,
isAdult = false
)
}
assertEquals("creator.community.invalid_request_retry", exception.messageKey)
}
@Test
@DisplayName("비성인 정책 사용자가 성인 게시글 댓글의 답글 목록을 조회하면 예외가 발생한다")
fun shouldThrowExceptionWhenFetchingReplyOfAdultPostWithNonAdultPolicy() {
val creator = createMember(id = 31L, role = MemberRole.CREATOR, nickname = "creator")
val adultPost = CreatorCommunity(content = "adult-post", price = 0, isCommentAvailable = true, isAdult = true)
adultPost.id = 903L
adultPost.member = creator
val parentComment = CreatorCommunityComment(comment = "parent", isSecret = false)
parentComment.id = 1001L
parentComment.creatorCommunity = adultPost
parentComment.member = creator
Mockito.`when`(commentRepository.findById(1001L)).thenReturn(Optional.of(parentComment))
val exception = assertThrows(SodaException::class.java) {
service.getCommentReplyList(
commentId = 1001L,
memberId = 32L,
timezone = "Asia/Seoul",
offset = 0,
limit = 10,
isAdult = false
)
}
assertEquals("creator.community.invalid_request_retry", exception.messageKey)
}
@Test
@DisplayName("고정 게시물이 이미 3개면 추가 고정 시 예외가 발생한다")
fun shouldThrowExceptionWhenPinCountExceedsLimit() {

View File

@@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.live.recommend
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -11,13 +13,22 @@ import org.mockito.Mockito
class LiveRecommendServiceTest {
private lateinit var repository: LiveRecommendRepository
private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var liveRecommendCacheService: LiveRecommendCacheService
private lateinit var service: LiveRecommendService
@BeforeEach
fun setup() {
repository = Mockito.mock(LiveRecommendRepository::class.java)
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
service = LiveRecommendService(repository, blockMemberRepository)
memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
liveRecommendCacheService = Mockito.mock(LiveRecommendCacheService::class.java)
service = LiveRecommendService(
repository = repository,
blockMemberRepository = blockMemberRepository,
memberContentPreferenceService = memberContentPreferenceService,
liveRecommendCacheService = liveRecommendCacheService
)
}
@Test
@@ -39,24 +50,35 @@ class LiveRecommendServiceTest {
auth.member = member
val expected = listOf(GetRecommendLiveResponse(imageUrl = "https://cdn.test/recommend.png", creatorId = 77L))
Mockito.`when`(repository.getRecommendLive(memberId = member.id, isAdult = true)).thenReturn(expected)
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(
ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = kr.co.vividnext.sodalive.content.ContentType.ALL,
isAdult = true
)
)
Mockito.`when`(liveRecommendCacheService.getRecommendLive(memberId = member.id, isAdult = true)).thenReturn(expected)
val result = service.getRecommendLive(member)
assertEquals(expected, result)
Mockito.verify(repository).getRecommendLive(memberId = member.id, isAdult = true)
Mockito.verify(liveRecommendCacheService).getRecommendLive(memberId = member.id, isAdult = true)
Mockito.verifyNoInteractions(repository)
Mockito.verifyNoInteractions(blockMemberRepository)
}
@Test
fun shouldDelegateToRepositoryAsGuestWhenMemberIsNull() {
val expected = listOf(GetRecommendLiveResponse(imageUrl = "https://cdn.test/recommend-guest.png", creatorId = 88L))
Mockito.`when`(repository.getRecommendLive(memberId = null, isAdult = false)).thenReturn(expected)
Mockito.`when`(liveRecommendCacheService.getRecommendLive(memberId = null, isAdult = false)).thenReturn(expected)
val result = service.getRecommendLive(null)
assertEquals(expected, result)
Mockito.verify(repository).getRecommendLive(memberId = null, isAdult = false)
Mockito.verify(liveRecommendCacheService).getRecommendLive(memberId = null, isAdult = false)
Mockito.verifyNoInteractions(repository)
Mockito.verifyNoInteractions(blockMemberRepository)
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
}

View File

@@ -0,0 +1,87 @@
package kr.co.vividnext.sodalive.live.tag
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class LiveTagServiceTest {
private lateinit var repository: LiveTagRepository
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var service: LiveTagService
@BeforeEach
fun setup() {
repository = mock()
memberContentPreferenceService = mock()
service = LiveTagService(
repository = repository,
memberContentPreferenceService = memberContentPreferenceService,
objectMapper = ObjectMapper(),
s3Uploader = mock<S3Uploader>(),
coverImageBucket = "bucket",
cloudFrontHost = "https://cdn.test"
)
}
@Test
@DisplayName("일반 사용자는 저장된 성인 설정값으로 라이브 태그 필터를 적용한다")
fun shouldApplyStoredPreferenceForNonAdminMember() {
val member = createMember(id = 1L, role = MemberRole.USER)
val expected = listOf(GetLiveTagResponse(1L, "일반", "https://cdn.test/live1.png", false))
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(
ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = false,
contentType = ContentType.ALL,
isAdult = false
)
)
Mockito.`when`(repository.getTags(isAdult = false, cloudFrontHost = "https://cdn.test")).thenReturn(expected)
val actual = service.getTags(member)
assertEquals(expected, actual)
Mockito.verify(repository).getTags(isAdult = false, cloudFrontHost = "https://cdn.test")
}
@Test
@DisplayName("관리자는 저장 설정과 무관하게 성인 태그를 포함해 조회한다")
fun shouldAllowAdultTagsForAdmin() {
val admin = createMember(id = 2L, role = MemberRole.ADMIN)
val expected = listOf(GetLiveTagResponse(2L, "성인", "https://cdn.test/live2.png", true))
Mockito.`when`(repository.getTags(isAdult = true, cloudFrontHost = "https://cdn.test")).thenReturn(expected)
val actual = service.getTags(admin)
assertEquals(expected, actual)
Mockito.verify(repository).getTags(isAdult = true, cloudFrontHost = "https://cdn.test")
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
private fun createMember(id: Long, role: MemberRole): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
role = role
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -0,0 +1,130 @@
package kr.co.vividnext.sodalive.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.marketing.AdTrackingService
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.UpdateMemberContentPreferenceRequest
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.member.social.SocialAuthServiceResolver
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.junit.jupiter.api.Assertions.assertEquals
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
class MemberControllerTest {
private lateinit var memberService: MemberService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var socialAuthServiceResolver: SocialAuthServiceResolver
private lateinit var trackingService: AdTrackingService
private lateinit var userActionService: UserActionService
private lateinit var controller: MemberController
@BeforeEach
fun setup() {
memberService = mock()
memberContentPreferenceService = mock()
socialAuthServiceResolver = mock()
trackingService = mock()
userActionService = mock()
controller = MemberController(
service = memberService,
memberContentPreferenceService = memberContentPreferenceService,
socialAuthServiceResolver = socialAuthServiceResolver,
trackingService = trackingService,
userActionService = userActionService,
messageSource = SodaMessageSource(),
langContext = LangContext()
)
}
@Test
@DisplayName("PATCH /member/content-preference는 저장된 최신 설정을 응답한다")
fun shouldReturnUpdatedPreferenceWhenRequestIsValid() {
val member = createMember(1L)
val request = UpdateMemberContentPreferenceRequest(
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
val viewerPreference = ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = ContentType.FEMALE,
isAdult = true
)
Mockito.`when`(
memberContentPreferenceService.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
).thenReturn(viewerPreference)
val response = controller.updateContentPreference(request, member)
assertTrue(response.success)
assertEquals(true, response.data?.isAdultContentVisible)
assertEquals(ContentType.FEMALE, response.data?.contentType)
}
@Test
@DisplayName("비로그인 사용자는 PATCH /member/content-preference 호출 시 예외가 발생한다")
fun shouldThrowWhenMemberIsNullOnUpdateContentPreference() {
val request = UpdateMemberContentPreferenceRequest(
isAdultContentVisible = true,
contentType = ContentType.ALL
)
val exception = assertThrows(SodaException::class.java) {
controller.updateContentPreference(request, null)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
@Test
@DisplayName("두 필드 모두 누락된 PATCH 요청은 서비스 예외를 그대로 전파한다")
fun shouldPropagateServiceExceptionWhenBothFieldsAreMissing() {
val member = createMember(2L)
val request = UpdateMemberContentPreferenceRequest(
isAdultContentVisible = null,
contentType = null
)
Mockito.`when`(
memberContentPreferenceService.updatePreference(
member = member,
isAdultContentVisible = null,
contentType = null
)
).thenThrow(SodaException(messageKey = "common.error.invalid_request"))
val exception = assertThrows(SodaException::class.java) {
controller.updateContentPreference(request, member)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
private fun createMember(id: Long): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.block.MemberBlockRequest
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -42,7 +43,6 @@ class MemberServiceCacheEvictionTest {
stipulationAgreeRepository = mock(),
creatorFollowingRepository = mock(),
blockMemberRepository = blockMemberRepository,
authRepository = authRepository,
signOutRepository = mock(),
nicknameChangeLogRepository = mock(),
memberTagRepository = mock(),
@@ -63,6 +63,7 @@ class MemberServiceCacheEvictionTest {
messageSource = SodaMessageSource(),
langContext = LangContext(),
countryContext = CountryContext(),
memberContentPreferenceService = mock<MemberContentPreferenceService>(),
objectMapper = ObjectMapper(),
cacheManager = cacheManager,
s3Bucket = "test-bucket",
@@ -88,8 +89,8 @@ class MemberServiceCacheEvictionTest {
service.memberBlock(MemberBlockRequest(blockMemberId = blockedMemberId), memberId)
Mockito.verify(cache).evict("getRecommendLive:$memberId")
Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId")
verifyRecommendLiveCacheEvicted(memberId)
verifyRecommendLiveCacheEvicted(blockedMemberId)
Mockito.verifyNoInteractions(authRepository)
}
@@ -140,9 +141,9 @@ class MemberServiceCacheEvictionTest {
blockedMemberId = linkedMemberId,
memberId = memberId
)
Mockito.verify(cache).evict("getRecommendLive:$memberId")
Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId")
Mockito.verify(cache, Mockito.never()).evict("getRecommendLive:$linkedMemberId")
verifyRecommendLiveCacheEvicted(memberId)
verifyRecommendLiveCacheEvicted(blockedMemberId)
verifyRecommendLiveCacheNotEvicted(linkedMemberId)
Mockito.verifyNoInteractions(authRepository)
}
@@ -162,8 +163,20 @@ class MemberServiceCacheEvictionTest {
service.memberUnBlock(MemberBlockRequest(blockMemberId = blockedMemberId), memberId)
assertEquals(false, blockMember.isActive)
verifyRecommendLiveCacheEvicted(memberId)
verifyRecommendLiveCacheEvicted(blockedMemberId)
}
private fun verifyRecommendLiveCacheEvicted(memberId: Long) {
Mockito.verify(cache).evict("getRecommendLive:$memberId:false")
Mockito.verify(cache).evict("getRecommendLive:$memberId:true")
Mockito.verify(cache).evict("getRecommendLive:$memberId")
Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId")
}
private fun verifyRecommendLiveCacheNotEvicted(memberId: Long) {
Mockito.verify(cache, Mockito.never()).evict("getRecommendLive:$memberId:false")
Mockito.verify(cache, Mockito.never()).evict("getRecommendLive:$memberId:true")
Mockito.verify(cache, Mockito.never()).evict("getRecommendLive:$memberId")
}
private fun createMember(id: Long, nickname: String): Member {

View File

@@ -0,0 +1,165 @@
package kr.co.vividnext.sodalive.member
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.member.social.google.GoogleUserInfo
import kr.co.vividnext.sodalive.member.stipulation.Stipulation
import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository
import kr.co.vividnext.sodalive.member.stipulation.StipulationIds
import kr.co.vividnext.sodalive.member.stipulation.StipulationRepository
import org.junit.jupiter.api.Assertions.assertEquals
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.cache.CacheManager
import java.time.LocalDateTime
import java.util.Optional
class MemberServiceContentPreferenceTest {
private lateinit var repository: MemberRepository
private lateinit var stipulationRepository: StipulationRepository
private lateinit var stipulationAgreeRepository: StipulationAgreeRepository
private lateinit var nicknameGenerateService: kr.co.vividnext.sodalive.member.nickname.NicknameGenerateService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var chargeRepository: kr.co.vividnext.sodalive.can.charge.ChargeRepository
private lateinit var memberPointRepository: kr.co.vividnext.sodalive.point.MemberPointRepository
private lateinit var pushTokenService: kr.co.vividnext.sodalive.fcm.PushTokenService
private lateinit var service: MemberService
@BeforeEach
fun setup() {
repository = mock()
stipulationRepository = mock()
stipulationAgreeRepository = mock()
nicknameGenerateService = mock()
memberContentPreferenceService = mock()
chargeRepository = mock()
memberPointRepository = mock()
pushTokenService = mock()
service = MemberService(
repository = repository,
tokenRepository = mock(),
stipulationRepository = stipulationRepository,
stipulationAgreeRepository = stipulationAgreeRepository,
creatorFollowingRepository = mock(),
blockMemberRepository = mock(),
signOutRepository = mock(),
nicknameChangeLogRepository = mock(),
memberTagRepository = mock(),
liveReservationRepository = mock(),
chargeRepository = chargeRepository,
memberPointRepository = memberPointRepository,
orderService = mock(),
emailService = mock(),
pushTokenService = pushTokenService,
canPaymentService = mock(),
nicknameGenerateService = nicknameGenerateService,
memberNotificationService = mock(),
s3Uploader = mock(),
validator = mock(),
tokenProvider = mock(),
passwordEncoder = mock(),
authenticationManagerBuilder = mock(),
messageSource = SodaMessageSource(),
langContext = LangContext(),
countryContext = CountryContext(),
memberContentPreferenceService = memberContentPreferenceService,
objectMapper = ObjectMapper(),
cacheManager = mock<CacheManager>(),
s3Bucket = "test-bucket",
cloudFrontHost = "https://cdn.test"
)
}
@Test
@DisplayName("getMemberInfo는 저장된 콘텐츠 설정 필드를 그대로 반환한다")
fun shouldReturnStoredPreferenceFieldsInMemberInfo() {
val member = createMember(1L)
member.createdAt = LocalDateTime.of(2026, 1, 1, 0, 0)
val preference = ViewerContentPreference(
countryCode = "JP",
isAdultContentVisible = true,
contentType = ContentType.MALE,
isAdult = true
)
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(preference)
Mockito.`when`(chargeRepository.getChargeCount(1L)).thenReturn(3)
Mockito.`when`(
memberPointRepository.findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(
memberId = Mockito.eq(1L),
expiresAt = anyLocalDateTime()
)
).thenReturn(emptyList())
val response = service.getMemberInfo(member, "web")
assertEquals("JP", response.countryCode)
assertEquals(true, response.isAdultContentVisible)
assertEquals(ContentType.MALE, response.contentType)
}
@Test
@DisplayName("Google 소셜 회원 신규 생성 시 기본 콘텐츠 설정을 선저장한다")
fun shouldInitializePreferenceWhenGoogleMemberIsRegistered() {
var savedMember: Member? = null
val terms = Stipulation(title = "terms", description = "desc")
terms.id = StipulationIds.TERMS_OF_SERVICE_ID
val privacy = Stipulation(title = "privacy", description = "desc")
privacy.id = StipulationIds.PRIVACY_POLICY_ID
Mockito.`when`(repository.findByGoogleId("sub-1")).thenReturn(null)
Mockito.`when`(repository.findByEmail("google@test.com")).thenReturn(null)
Mockito.`when`(stipulationRepository.findById(StipulationIds.TERMS_OF_SERVICE_ID)).thenReturn(Optional.of(terms))
Mockito.`when`(stipulationRepository.findById(StipulationIds.PRIVACY_POLICY_ID)).thenReturn(Optional.of(privacy))
Mockito.`when`(nicknameGenerateService.generateUniqueNickname(anyLang())).thenReturn("newbie")
Mockito.`when`(repository.save(Mockito.any(Member::class.java))).thenAnswer { invocation ->
val saved = invocation.getArgument<Member>(0)
saved.id = 10L
savedMember = saved
saved
}
Mockito.`when`(stipulationAgreeRepository.save(Mockito.any())).thenAnswer { invocation -> invocation.getArgument(0) }
val result = service.findOrRegister(
googleUserInfo = GoogleUserInfo(sub = "sub-1", email = "google@test.com", name = "google-user"),
container = "web",
marketingPid = null,
pushToken = null
)
assertTrue(result.isNew)
Mockito.verify(memberContentPreferenceService).initializeDefaultPreference(savedMember!!)
assertEquals(10L, savedMember!!.id)
}
private fun createMember(id: Long): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
private fun anyLocalDateTime(): LocalDateTime =
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
private fun anyLang(): Lang =
Mockito.any(Lang::class.java) ?: Lang.KO
}

View File

@@ -0,0 +1,109 @@
package kr.co.vividnext.sodalive.member.auth
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class AuthControllerTest {
private lateinit var authService: AuthService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var userActionService: UserActionService
private lateinit var controller: AuthController
@BeforeEach
fun setup() {
authService = mock()
memberContentPreferenceService = mock()
userActionService = mock()
controller = AuthController(
service = authService,
memberContentPreferenceService = memberContentPreferenceService,
userActionService = userActionService
)
}
@Test
@DisplayName("authVerify 성공 시 성인노출 true 저장을 호출한다")
fun shouldSaveAdultPreferenceWhenAuthVerifySucceeds() {
val member = createMember(id = 10L)
val request = AuthVerifyRequest(receiptId = "receipt-1", version = "v1")
val certificate = AuthVerifyCertificate(
name = "홍길동",
birth = "19900101",
unique = "unique-ci",
di = "di-1",
gender = 1
)
Mockito.`when`(authService.certificate(request, memberId = 10L)).thenReturn(certificate)
Mockito.`when`(authService.isBlockAuth(certificate)).thenReturn(false)
Mockito.`when`(authService.authenticate(certificate, 10L)).thenReturn(AuthResponse(gender = 1))
controller.authVerify(request, member)
Mockito.verify(memberContentPreferenceService).markAdultVisibleAfterAuthVerify(10L)
Mockito.verify(userActionService).recordAction(
memberId = 10L,
isAuth = true,
actionType = ActionType.USER_AUTHENTICATION
)
}
@Test
@DisplayName("차단 정책으로 authVerify가 실패하면 저장을 호출하지 않는다")
fun shouldNotSaveAdultPreferenceWhenAuthIsBlocked() {
val member = createMember(id = 20L)
val request = AuthVerifyRequest(receiptId = "receipt-2", version = null)
val certificate = AuthVerifyCertificate(
name = "홍길동",
birth = "19900101",
unique = "unique-ci",
di = "di-2",
gender = 1
)
Mockito.`when`(authService.certificate(request, memberId = 20L)).thenReturn(certificate)
Mockito.`when`(authService.isBlockAuth(certificate)).thenReturn(true)
assertThrows(SodaException::class.java) {
controller.authVerify(request, member)
}
Mockito.verify(authService).signOut(20L)
Mockito.verify(memberContentPreferenceService, Mockito.never()).markAdultVisibleAfterAuthVerify(Mockito.anyLong())
}
@Test
@DisplayName("비로그인 사용자는 authVerify 요청 시 예외를 반환한다")
fun shouldThrowWhenMemberIsNull() {
val request = AuthVerifyRequest(receiptId = "receipt-3", version = null)
assertThrows(SodaException::class.java) {
controller.authVerify(request, null)
}
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
private fun createMember(id: Long): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -0,0 +1,205 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
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.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.cache.concurrent.ConcurrentMapCacheManager
import org.springframework.context.annotation.Import
import javax.persistence.EntityManager
@DataJpaTest(properties = ["spring.cache.type=none"])
@Import(QueryDslConfig::class)
class MemberContentPreferenceIntegrationTest @Autowired constructor(
private val memberRepository: MemberRepository,
private val authRepository: AuthRepository,
private val preferenceRepository: MemberContentPreferenceRepository,
private val entityManager: EntityManager
) {
companion object {
private val FORCED_MEMBER_IDS = setOf(2L, 16L, 17L, 29721L, 32050L, 40850L)
}
private lateinit var service: MemberContentPreferenceService
private lateinit var countryContext: CountryContext
@BeforeEach
fun setup() {
countryContext = CountryContext()
service = MemberContentPreferenceService(
repository = preferenceRepository,
memberRepository = memberRepository,
countryContext = countryContext,
cacheManager = ConcurrentMapCacheManager("cache_ttl_3_hours")
)
}
@Test
@DisplayName("legacy 파라미터 최초 호출 시 row를 생성하고 같은 흐름에서 저장값 조회에 즉시 반영한다")
fun shouldCreateRowAndReflectImmediatelyOnFirstLegacyResolveCall() {
val member = saveNonForcedMember("legacy-user")
countryContext.setCountryCode("US")
assertEquals(null, preferenceRepository.findByMemberId(member.id!!))
val resolved = service.resolveForQuery(
member = member,
isAdultContentVisible = true,
contentType = ContentType.MALE
)
val stored = service.getStoredPreference(member)
assertNotNull(preferenceRepository.findByMemberId(member.id!!))
assertTrue(resolved.isAdultContentVisible)
assertEquals(ContentType.MALE, resolved.contentType)
assertEquals("US", resolved.countryCode)
assertTrue(stored.isAdultContentVisible)
assertEquals(ContentType.MALE, stored.contentType)
assertTrue(stored.isAdult)
}
@Test
@DisplayName("직접 설정 저장(updatePreference) 후 즉시 getStoredPreference에 반영된다")
fun shouldPersistAndReflectAfterDirectUpdate() {
val member = saveNonForcedMember("patch-user")
countryContext.setCountryCode("US")
val updated = service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
val stored = service.getStoredPreference(member)
assertTrue(updated.isAdultContentVisible)
assertEquals(ContentType.FEMALE, updated.contentType)
assertTrue(stored.isAdultContentVisible)
assertEquals(ContentType.FEMALE, stored.contentType)
assertTrue(stored.isAdult)
}
@Test
@DisplayName("KR 헤더 누락 + 미인증 사용자는 요청값을 보내도 기본값을 유지한다")
fun shouldKeepDefaultValuesForKrUnauthenticatedWhenHeaderMissing() {
val member = saveNonForcedMember("kr-unauth-user")
countryContext.setCountryCode(null)
val resolved = service.resolveForQuery(
member = member,
isAdultContentVisible = true,
contentType = ContentType.MALE
)
val stored = service.getStoredPreference(member)
assertEquals("KR", resolved.countryCode)
assertFalse(resolved.isAdultContentVisible)
assertEquals(ContentType.ALL, resolved.contentType)
assertFalse(resolved.isAdult)
assertFalse(stored.isAdultContentVisible)
assertEquals(ContentType.ALL, stored.contentType)
}
@Test
@DisplayName("KR + 인증 사용자는 요청값이 저장되고 성인 조회값(isAdult)이 true로 계산된다")
fun shouldApplyRequestValuesForKrAuthenticatedMember() {
val member = saveNonForcedMember("kr-auth-user")
countryContext.setCountryCode(null)
saveAuth(member)
val reloadedMember = memberRepository.findById(member.id!!).orElseThrow()
val resolved = service.resolveForQuery(
member = reloadedMember,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
assertEquals("KR", resolved.countryCode)
assertTrue(resolved.isAdultContentVisible)
assertEquals(ContentType.FEMALE, resolved.contentType)
assertTrue(resolved.isAdult)
}
@Test
@DisplayName("authVerify 성공 후 markAdultVisibleAfterAuthVerify를 호출하면 저장값이 true로 반영된다")
fun shouldMarkAdultVisibleAfterAuthVerify() {
val member = saveNonForcedMember("auth-verified-user")
countryContext.setCountryCode("US")
service.updatePreference(member, isAdultContentVisible = false, contentType = ContentType.ALL)
service.markAdultVisibleAfterAuthVerify(member.id!!)
val stored = service.getStoredPreference(member)
assertTrue(stored.isAdultContentVisible)
}
@Test
@DisplayName("강제 매핑 회원 ID는 접속 국가 헤더보다 우선한다")
fun shouldReturnForcedCountryCodeRegardlessOfHeader() {
countryContext.setCountryCode("US")
val jpMember = Member(email = "jp@test.com", password = "password", nickname = "jp-member").apply { id = 2L }
val krMember = Member(email = "kr@test.com", password = "password", nickname = "kr-member").apply { id = 16L }
assertEquals("JP", service.resolveCountryCode(jpMember))
assertEquals("KR", service.resolveCountryCode(krMember))
}
@Test
@DisplayName("강제 매핑 대상이 아니면 국가 코드는 접속 국가 헤더를 기준으로 계산된다")
fun shouldResolveCountryCodeByConnectionCountryHeaderForNonForcedMember() {
val member = saveNonForcedMember("country-user")
countryContext.setCountryCode("US")
assertEquals("US", service.resolveCountryCode(member))
countryContext.setCountryCode(null)
assertEquals("KR", service.resolveCountryCode(member))
}
private fun saveMember(seed: String): Member {
return memberRepository.saveAndFlush(
Member(
email = "$seed@test.com",
password = "password",
nickname = seed
)
)
}
private fun saveNonForcedMember(seed: String): Member {
var index = 0
while (true) {
val candidate = saveMember("$seed-$index")
if (!FORCED_MEMBER_IDS.contains(candidate.id)) {
return candidate
}
index++
}
}
private fun saveAuth(member: Member) {
val auth = Auth(
name = "홍길동",
birth = "19900101",
uniqueCi = "unique-ci-${member.id}",
di = "di-${member.id}",
gender = 1
)
auth.member = member
authRepository.saveAndFlush(auth)
entityManager.flush()
entityManager.clear()
}
}

View File

@@ -0,0 +1,87 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.AfterEach
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
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
class MemberContentPreferencePolicyTest {
@AfterEach
fun cleanup() {
RequestContextHolder.resetRequestAttributes()
}
@Test
@DisplayName("요청 국가 헤더를 기준으로 국가 코드를 계산한다")
fun shouldResolveCountryCodeByRequestHeader() {
setRequestCountry(" us ")
val member = createMember(id = 200L, countryCode = "KR")
assertEquals("US", resolveCountryCodeByPolicy(member))
}
@Test
@DisplayName("강제 매핑 대상 회원 ID는 요청 국가 헤더보다 우선한다")
fun shouldPrioritizeForcedCountryMapping() {
setRequestCountry("US")
val forcedJpMember = createMember(id = 2L, countryCode = "KR")
val forcedKrMember = createMember(id = 16L, countryCode = "US")
assertEquals("JP", resolveCountryCodeByPolicy(forcedJpMember))
assertEquals("KR", resolveCountryCodeByPolicy(forcedKrMember))
}
@Test
@DisplayName("요청 국가가 KR이면 인증 미완료 사용자는 성인 노출이 false다")
fun shouldHideAdultContentForKrWithoutAuth() {
setRequestCountry("KR")
val member = createMember(id = 1L, countryCode = "US")
assertFalse(isAdultVisibleByPolicy(member, isAdultContentVisible = true))
}
@Test
@DisplayName("요청 국가가 KR이 아니면 멤버 countryCode와 무관하게 전달값을 사용한다")
fun shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr() {
setRequestCountry("US")
val member = createMember(id = 201L, countryCode = "KR")
assertTrue(isAdultVisibleByPolicy(member, isAdultContentVisible = true))
}
@Test
@DisplayName("요청 컨텍스트가 없으면 KR fallback 정책을 사용한다")
fun shouldFallbackToKrWhenRequestContextIsMissing() {
RequestContextHolder.resetRequestAttributes()
val member = createMember(id = 202L, countryCode = "US")
assertEquals("KR", resolveCountryCodeByPolicy(member))
assertFalse(isAdultVisibleByPolicy(member, isAdultContentVisible = true))
}
private fun setRequestCountry(countryCode: String?) {
val request = MockHttpServletRequest()
if (countryCode != null) {
request.addHeader("CloudFront-Viewer-Country", countryCode)
}
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
}
private fun createMember(id: Long, countryCode: String?): Member {
return Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
).apply {
this.id = id
this.countryCode = countryCode
}
}
}

View File

@@ -0,0 +1,468 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.auth.Auth
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
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.cache.Cache
import org.springframework.cache.CacheManager
import org.springframework.dao.DataIntegrityViolationException
import java.time.LocalDateTime
import java.util.Optional
class MemberContentPreferenceServiceTest {
private lateinit var repository: MemberContentPreferenceRepository
private lateinit var memberRepository: MemberRepository
private lateinit var countryContext: CountryContext
private lateinit var cacheManager: CacheManager
private lateinit var recommendLiveCache: Cache
private lateinit var service: MemberContentPreferenceService
@BeforeEach
fun setup() {
repository = mock()
memberRepository = mock()
countryContext = CountryContext()
cacheManager = mock()
recommendLiveCache = mock()
Mockito.`when`(cacheManager.getCache("cache_ttl_3_hours")).thenReturn(recommendLiveCache)
service = MemberContentPreferenceService(
repository = repository,
memberRepository = memberRepository,
countryContext = countryContext,
cacheManager = cacheManager
)
}
@Test
@DisplayName("회원 ID 강제 매핑(KR)이 헤더보다 우선 적용된다")
fun shouldResolveCountryCodeByForcedKrMappingFirst() {
val member = createMember(id = 16L)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(16L)).thenReturn(preference)
val result = service.getStoredPreference(member)
assertEquals("KR", result.countryCode)
}
@Test
@DisplayName("회원 ID 강제 매핑(JP)이 헤더보다 우선 적용된다")
fun shouldResolveCountryCodeByForcedJapanMappingFirst() {
val member = createMember(id = 2L)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(2L)).thenReturn(preference)
val result = service.getStoredPreference(member)
assertEquals("JP", result.countryCode)
}
@Test
@DisplayName("강제 매핑 대상이 아니면 접속 국가 헤더를 사용하고 헤더가 없으면 KR로 fallback 한다")
fun shouldResolveCountryCodeWithHeaderAndFallback() {
val member = createMember(id = 100L)
val preference = createPreference(member)
Mockito.`when`(repository.findByMemberId(100L)).thenReturn(preference)
countryContext.setCountryCode("JP")
val fromHeader = service.getStoredPreference(member)
assertEquals("JP", fromHeader.countryCode)
countryContext.setCountryCode(null)
val fromFallback = service.getStoredPreference(member)
assertEquals("KR", fromFallback.countryCode)
}
@Test
@DisplayName("한국 + 본인인증 미완료는 전달값으로 저장 갱신하지 않는다")
fun shouldNotApplyRequestValuesForKoreaWithoutAuth() {
val member = createMember(id = 1700L)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(1700L)).thenReturn(preference)
val result = service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
assertFalse(result.isAdultContentVisible)
assertEquals(ContentType.ALL, result.contentType)
assertEquals(baselineTime, preference.adultContentVisibilityChangedAt)
assertEquals(baselineTime, preference.contentTypeChangedAt)
}
@Test
@DisplayName("해외 + 본인인증 미완료는 전달값을 그대로 저장한다")
fun shouldApplyRequestValuesForNonKoreaWithoutAuth() {
val member = createMember(id = 1000L)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(1000L)).thenReturn(preference)
val result = service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
assertEquals("US", result.countryCode)
assertTrue(result.isAdultContentVisible)
assertEquals(ContentType.FEMALE, result.contentType)
}
@Test
@DisplayName("한국 + 본인인증 완료는 전달값을 저장한다")
fun shouldApplyRequestValuesForKoreaWithAuth() {
val member = createMember(id = 1701L, withAuth = true)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(1701L)).thenReturn(preference)
val result = service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
assertEquals("KR", result.countryCode)
assertTrue(result.isAdultContentVisible)
assertEquals(ContentType.FEMALE, result.contentType)
assertTrue(preference.adultContentVisibilityChangedAt.isAfter(baselineTime))
assertTrue(preference.contentTypeChangedAt.isAfter(baselineTime))
}
@Test
@DisplayName("필드별 변경 시 changedAt은 변경된 필드만 갱신된다")
fun shouldUpdateOnlyChangedFieldTimestamp() {
val member = createMember(id = 3000L, withAuth = true)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(3000L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.ALL
)
assertTrue(preference.adultContentVisibilityChangedAt.isAfter(baselineTime))
assertEquals(baselineTime, preference.contentTypeChangedAt)
}
@Test
@DisplayName("contentType만 변경하면 contentTypeChangedAt만 갱신된다")
fun shouldUpdateOnlyContentTypeChangedAtWhenContentTypeChanges() {
val member = createMember(id = 18L, withAuth = true)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(18L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = false,
contentType = ContentType.MALE
)
assertEquals(baselineTime, preference.adultContentVisibilityChangedAt)
assertTrue(preference.contentTypeChangedAt.isAfter(baselineTime))
}
@Test
@DisplayName("동일값 재저장 시 changedAt은 갱신되지 않는다")
fun shouldNotUpdateChangedAtWhenValuesAreSame() {
val member = createMember(id = 19L, withAuth = true)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = true,
contentType = ContentType.MALE,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(19L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.MALE
)
assertEquals(baselineTime, preference.adultContentVisibilityChangedAt)
assertEquals(baselineTime, preference.contentTypeChangedAt)
}
@Test
@DisplayName("getStoredPreference 호출 시 row가 없으면 기본값 row를 생성한다")
fun shouldCreateDefaultPreferenceWhenRowIsMissing() {
val member = createMember(id = 20L)
countryContext.setCountryCode(null)
val storedPreference = createPreference(member)
Mockito.`when`(repository.findByMemberId(20L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(20L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(20L)).thenReturn(null)
Mockito.`when`(repository.saveAndFlush(Mockito.any(MemberContentPreference::class.java)))
.thenReturn(storedPreference)
val result = service.getStoredPreference(member)
assertEquals("KR", result.countryCode)
assertEquals(storedPreference.isAdultContentVisible, result.isAdultContentVisible)
assertEquals(ContentType.ALL, result.contentType)
Mockito.verify(repository).saveAndFlush(Mockito.any(MemberContentPreference::class.java))
}
@Test
@DisplayName("초기 row 생성 경쟁 시 잠금 이후 재조회한 row를 반환한다")
fun shouldReturnReloadedPreferenceWhenRowIsCreatedByAnotherTransactionAfterLock() {
val member = createMember(id = 26L)
val existing = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(26L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(26L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(26L)).thenReturn(existing)
val result = service.getStoredPreference(member)
assertEquals(existing.isAdultContentVisible, result.isAdultContentVisible)
assertEquals(existing.contentType, result.contentType)
Mockito.verify(repository, Mockito.never()).saveAndFlush(Mockito.any(MemberContentPreference::class.java))
}
@Test
@DisplayName("동시 insert 충돌 발생 시 저장된 row를 재조회해 반환한다")
fun shouldReturnStoredRowWhenDuplicateInsertOccurs() {
val member = createMember(id = 27L)
val stored = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(27L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(27L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(27L)).thenReturn(null, stored)
Mockito.`when`(repository.saveAndFlush(Mockito.any(MemberContentPreference::class.java)))
.thenThrow(DataIntegrityViolationException("duplicate"))
val result = service.getStoredPreference(member)
assertEquals(stored.isAdultContentVisible, result.isAdultContentVisible)
assertEquals(stored.contentType, result.contentType)
Mockito.verify(repository).saveAndFlush(Mockito.any(MemberContentPreference::class.java))
}
@Test
@DisplayName("직접 설정으로 저장값이 변경되면 추천 라이브 캐시를 무효화한다")
fun shouldEvictRecommendLiveCacheWhenPreferenceChangesByUpdatePreference() {
val member = createMember(id = 30L, withAuth = true)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(30L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.ALL
)
verifyRecommendLiveCacheEvicted(30L)
}
@Test
@DisplayName("직접 설정 값이 동일하면 추천 라이브 캐시를 무효화하지 않는다")
fun shouldNotEvictRecommendLiveCacheWhenPreferenceIsUnchanged() {
val member = createMember(id = 31L, withAuth = true)
val preference = MemberContentPreference(
isAdultContentVisible = true,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = LocalDateTime.now().minusDays(1),
contentTypeChangedAt = LocalDateTime.now().minusDays(1)
)
preference.member = member
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(31L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.ALL
)
verifyRecommendLiveCacheNotEvicted(31L)
}
@Test
@DisplayName("authVerify 연동으로 성인 노출이 true로 바뀌면 추천 라이브 캐시를 무효화한다")
fun shouldEvictRecommendLiveCacheWhenMarkAdultVisibleAfterAuthVerifyChangesValue() {
val member = createMember(id = 32L)
val preference = createPreference(member)
Mockito.`when`(memberRepository.findById(32L)).thenReturn(Optional.of(member))
Mockito.`when`(repository.findByMemberId(32L)).thenReturn(preference)
service.markAdultVisibleAfterAuthVerify(32L)
verifyRecommendLiveCacheEvicted(32L)
}
@Test
@DisplayName("contentType 미전달 조회는 기존 contentType을 유지한다")
fun shouldKeepStoredContentTypeWhenContentTypeIsNotProvided() {
val member = createMember(id = 21L, withAuth = true)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.FEMALE,
adultContentVisibilityChangedAt = LocalDateTime.now().minusDays(2),
contentTypeChangedAt = LocalDateTime.now().minusDays(2)
)
preference.member = member
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(21L)).thenReturn(preference)
val result = service.resolveForQuery(
member = member,
isAdultContentVisible = true,
contentType = null
)
assertEquals(ContentType.FEMALE, result.contentType)
assertTrue(result.isAdultContentVisible)
}
@Test
@DisplayName("legacy 조회 파라미터로 저장값이 바뀌면 추천 라이브 캐시를 무효화한다")
fun shouldEvictRecommendLiveCacheWhenPreferenceChangesByLegacyResolveForQuery() {
val member = createMember(id = 25L, withAuth = true)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(25L)).thenReturn(preference)
service.resolveForQuery(
member = member,
isAdultContentVisible = true,
contentType = null
)
verifyRecommendLiveCacheEvicted(25L)
}
@Test
@DisplayName("한국/해외 조회 정책은 인증 여부와 국가코드에 따라 다르게 계산된다")
fun shouldCalculateIsAdultByCountryPolicy() {
val noAuthMember = createMember(id = 22L, withAuth = false)
val authMember = createMember(id = 23L, withAuth = true)
assertFalse(service.calculateIsAdultForQuery(noAuthMember, "KR", true))
assertTrue(service.calculateIsAdultForQuery(authMember, "KR", true))
assertTrue(service.calculateIsAdultForQuery(noAuthMember, "US", true))
}
@Test
@DisplayName("직접 설정 API 입력이 모두 누락되면 예외를 발생시킨다")
fun shouldThrowWhenAllPreferenceFieldsAreMissing() {
val member = createMember(id = 24L, withAuth = true)
val exception = assertThrows(SodaException::class.java) {
service.updatePreference(
member = member,
isAdultContentVisible = null,
contentType = null
)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
private fun createPreference(member: Member): MemberContentPreference {
val now = LocalDateTime.now().minusDays(1)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = now,
contentTypeChangedAt = now
)
preference.member = member
return preference
}
private fun createMember(id: Long, withAuth: Boolean = false): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
if (withAuth) {
val auth = Auth(
name = "홍길동",
birth = "19900101",
uniqueCi = "unique-$id",
di = "di-$id",
gender = 1
)
auth.member = member
}
return member
}
private fun verifyRecommendLiveCacheEvicted(memberId: Long) {
Mockito.verify(recommendLiveCache).evict("getRecommendLive:$memberId:false")
Mockito.verify(recommendLiveCache).evict("getRecommendLive:$memberId:true")
Mockito.verify(recommendLiveCache).evict("getRecommendLive:$memberId")
}
private fun verifyRecommendLiveCacheNotEvicted(memberId: Long) {
Mockito.verify(recommendLiveCache, Mockito.never()).evict("getRecommendLive:$memberId:false")
Mockito.verify(recommendLiveCache, Mockito.never()).evict("getRecommendLive:$memberId:true")
Mockito.verify(recommendLiveCache, Mockito.never()).evict("getRecommendLive:$memberId")
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -0,0 +1,159 @@
package kr.co.vividnext.sodalive.search
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class SearchServiceTest {
private val repository: SearchRepository = Mockito.mock(SearchRepository::class.java)
private val service = SearchService(repository)
@Test
@DisplayName("콘텐츠 검색은 전달받은 isAdult 값을 그대로 사용한다")
fun shouldUseProvidedIsAdultForContentSearch() {
val member = createMember(id = 101L, countryCode = "KR")
val contentItem = SearchResponseItem(
id = 10L,
imageUrl = "https://cdn.test/content.png",
title = "title",
nickname = "creator"
)
Mockito.`when`(
repository.searchContentTotalCount(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL
)
).thenReturn(1)
Mockito.`when`(
repository.searchContentList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 10
)
).thenReturn(listOf(contentItem))
val result = service.searchContentList(
keyword = "keyword",
isAdult = true,
contentType = ContentType.ALL,
member = member,
offset = 0,
limit = 10
)
assertEquals(1, result.totalCount)
assertEquals(SearchResponseType.CONTENT, result.items.first().type)
Mockito.verify(repository).searchContentTotalCount(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL
)
Mockito.verify(repository, Mockito.never()).searchContentTotalCount(
keyword = "keyword",
memberId = member.id!!,
isAdult = false,
contentType = ContentType.ALL
)
}
@Test
@DisplayName("통합 검색은 전달받은 isAdult 값으로 콘텐츠/시리즈 조회를 수행한다")
fun shouldUseProvidedIsAdultForUnifiedSearch() {
val member = createMember(id = 102L, countryCode = "KR")
val creatorItem = SearchResponseItem(
id = 20L,
imageUrl = "https://cdn.test/creator.png",
title = "creator",
nickname = "creator"
)
val contentItem = SearchResponseItem(
id = 21L,
imageUrl = "https://cdn.test/content.png",
title = "content",
nickname = "creator"
)
val seriesItem = SearchResponseItem(
id = 22L,
imageUrl = "https://cdn.test/series.png",
title = "series",
nickname = "creator"
)
Mockito.`when`(
repository.searchCreatorList(
keyword = "keyword",
memberId = member.id!!,
offset = 0,
limit = 3
)
).thenReturn(listOf(creatorItem))
Mockito.`when`(
repository.searchContentList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 3
)
).thenReturn(listOf(contentItem))
Mockito.`when`(
repository.searchSeriesList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 3
)
).thenReturn(listOf(seriesItem))
val result = service.searchUnified(
keyword = "keyword",
isAdult = true,
contentType = ContentType.ALL,
member = member
)
assertEquals(SearchResponseType.CREATOR, result.creatorList.first().type)
assertEquals(SearchResponseType.CONTENT, result.contentList.first().type)
assertEquals(SearchResponseType.SERIES, result.seriesList.first().type)
Mockito.verify(repository).searchContentList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 3
)
Mockito.verify(repository).searchSeriesList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 3
)
}
private fun createMember(id: Long, countryCode: String?): Member {
return Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
).apply {
this.id = id
this.countryCode = countryCode
}
}
}