diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt index a3c7c44a..d800954a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.member.auth import kr.co.vividnext.sodalive.common.ApiResponse 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.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.useraction.ActionType @@ -34,7 +35,11 @@ class AuthController( } val authResponse = service.authenticate(authenticateData, member.id!!) - memberContentPreferenceService.markAdultVisibleAfterAuthVerify(member.id!!) + memberContentPreferenceService.markAdultVisibleAfterAuthVerify( + memberId = member.id!!, + isAdultContentVisible = request.isAdultContentVisible ?: true, + contentType = request.contentType ?: ContentType.ALL + ) try { userActionService.recordAction( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthVerifyRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthVerifyRequest.kt index ea9590ee..b9fa437b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthVerifyRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthVerifyRequest.kt @@ -1,12 +1,17 @@ package kr.co.vividnext.sodalive.member.auth import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.content.ContentType data class AuthVerifyRequest( @JsonProperty("receipt_id") val receiptId: String, @JsonProperty("version") - val version: String? + val version: String?, + @JsonProperty("isAdultContentVisible") + val isAdultContentVisible: Boolean? = null, + @JsonProperty("contentType") + val contentType: ContentType? = null ) data class AuthVerifyCertificate( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt index a7554525..76982a3f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt @@ -129,14 +129,28 @@ class MemberContentPreferenceService( } @Transactional - fun markAdultVisibleAfterAuthVerify(memberId: Long) { + fun markAdultVisibleAfterAuthVerify( + memberId: Long, + isAdultContentVisible: Boolean = true, + contentType: ContentType = ContentType.ALL + ) { val member = memberRepository.findByIdOrNull(memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") val preference = initializeDefaultPreference(member) - if (!preference.isAdultContentVisible) { - preference.isAdultContentVisible = true - preference.adultContentVisibilityChangedAt = LocalDateTime.now() + val now = LocalDateTime.now() + var hasChanged = false + if (preference.isAdultContentVisible != isAdultContentVisible) { + preference.isAdultContentVisible = isAdultContentVisible + preference.adultContentVisibilityChangedAt = now + hasChanged = true + } + if (preference.contentType != contentType) { + preference.contentType = contentType + preference.contentTypeChangedAt = now + hasChanged = true + } + if (hasChanged) { evictRecommendLiveCacheAfterCommit(memberId) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/auth/AuthControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/auth/AuthControllerTest.kt index bf9d79de..f599542a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/auth/AuthControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/auth/AuthControllerTest.kt @@ -1,14 +1,23 @@ package kr.co.vividnext.sodalive.member.auth +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue 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.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.useraction.ActionType import kr.co.vividnext.sodalive.useraction.UserActionService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame 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.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mockito class AuthControllerTest { @@ -31,7 +40,7 @@ class AuthControllerTest { } @Test - @DisplayName("authVerify 성공 시 성인노출 true 저장을 호출한다") + @DisplayName("authVerify 성공 시 입력이 없으면 true/ALL 저장을 호출한다") fun shouldSaveAdultPreferenceWhenAuthVerifySucceeds() { val member = createMember(id = 10L) val request = AuthVerifyRequest(receiptId = "receipt-1", version = "v1") @@ -47,9 +56,13 @@ class AuthControllerTest { Mockito.`when`(authService.isBlockAuth(certificate)).thenReturn(false) Mockito.`when`(authService.authenticate(certificate, 10L)).thenReturn(AuthResponse(gender = 1)) - controller.authVerify(request, member) + val response = controller.authVerify(request, member) - Mockito.verify(memberContentPreferenceService).markAdultVisibleAfterAuthVerify(10L) + assertTrue(response.success) + assertEquals(1, response.data?.gender) + val inOrder = Mockito.inOrder(authService, memberContentPreferenceService) + inOrder.verify(authService).authenticate(certificate, 10L) + inOrder.verify(memberContentPreferenceService).markAdultVisibleAfterAuthVerify(10L, true, ContentType.ALL) Mockito.verify(userActionService).recordAction( memberId = 10L, isAuth = true, @@ -57,11 +70,78 @@ class AuthControllerTest { ) } + @ParameterizedTest + @CsvSource( + value = [ + "{\"receipt_id\":\"receipt-json\",\"version\":\"v1\"}|true|ALL", + "{\"receipt_id\":\"receipt-json\",\"version\":null}|true|ALL", + "{\"receipt_id\":\"receipt-json\",\"isAdultContentVisible\":null,\"contentType\":null}|true|ALL", + "{\"receipt_id\":\"receipt-json\",\"isAdultContentVisible\":false}|false|ALL", + "{\"receipt_id\":\"receipt-json\",\"contentType\":\"ALL\"}|true|ALL", + "{\"receipt_id\":\"receipt-json\",\"contentType\":\"MALE\"}|true|MALE", + "{\"receipt_id\":\"receipt-json\",\"contentType\":\"FEMALE\"}|true|FEMALE", + "{\"receipt_id\":\"receipt-json\",\"isAdultContentVisible\":null,\"contentType\":\"MALE\"}|true|MALE", + "{\"receipt_id\":\"receipt-json\",\"isAdultContentVisible\":false,\"contentType\":null}|false|ALL", + "{\"receipt_id\":\"receipt-json\",\"isAdultContentVisible\":false,\"contentType\":\"ALL\"}|false|ALL", + "{\"receipt_id\":\"receipt-json\",\"isAdultContentVisible\":false,\"contentType\":\"MALE\"}|false|MALE", + "{\"receipt_id\":\"receipt-json\",\"isAdultContentVisible\":false,\"contentType\":\"FEMALE\"}|false|FEMALE", + "{\"receipt_id\":\"receipt-json\",\"isAdultContentVisible\":true,\"contentType\":\"FEMALE\"}|true|FEMALE" + ], + delimiter = '|' + ) + @DisplayName("인증 JSON의 camelCase 선호값을 전달하고 생략과 null은 필드별 true/ALL로 해석한다") + fun shouldApplyJsonPreferenceInputsAfterAuthentication( + json: String, + expectedAdultContentVisible: Boolean, + expectedContentType: ContentType + ) { + val member = createMember(id = 11L) + val request = jacksonObjectMapper().readValue(json) + val certificate = AuthVerifyCertificate("홍길동", "19900101", "unique-json", "di-json", 1) + Mockito.`when`(authService.certificate(request, 11L)).thenReturn(certificate) + Mockito.`when`(authService.authenticate(certificate, 11L)).thenReturn(AuthResponse(gender = 1)) + + val response = controller.authVerify(request, member) + + assertTrue(response.success) + assertEquals(1, response.data?.gender) + val inOrder = Mockito.inOrder(authService, memberContentPreferenceService) + inOrder.verify(authService).authenticate(certificate, 11L) + inOrder.verify(memberContentPreferenceService).markAdultVisibleAfterAuthVerify( + 11L, + expectedAdultContentVisible, + expectedContentType + ) + } + + @ParameterizedTest + @ValueSource(strings = ["certificate", "authenticate"]) + @DisplayName("인증서 검증 또는 authenticate 실패 시 선호 설정을 쓰지 않는다") + fun shouldNotWritePreferenceWhenAuthenticationFails(failingStep: String) { + val member = createMember(id = 12L) + val request = AuthVerifyRequest("receipt-failure", null, false, ContentType.FEMALE) + val certificate = AuthVerifyCertificate("홍길동", "19900101", "unique-failure", "di-failure", 1) + val failure = SodaException(messageKey = "member.auth.certificate_invalid_retry") + if (failingStep == "certificate") { + Mockito.`when`(authService.certificate(request, 12L)).thenThrow(failure) + } else { + Mockito.`when`(authService.certificate(request, 12L)).thenReturn(certificate) + Mockito.`when`(authService.authenticate(certificate, 12L)).thenThrow(failure) + } + + val exception = assertThrows(SodaException::class.java) { + controller.authVerify(request, member) + } + + assertSame(failure, exception) + Mockito.verifyNoInteractions(memberContentPreferenceService, userActionService) + } + @Test @DisplayName("차단 정책으로 authVerify가 실패하면 저장을 호출하지 않는다") fun shouldNotSaveAdultPreferenceWhenAuthIsBlocked() { val member = createMember(id = 20L) - val request = AuthVerifyRequest(receiptId = "receipt-2", version = null) + val request = AuthVerifyRequest("receipt-2", null, false, ContentType.MALE) val certificate = AuthVerifyCertificate( name = "홍길동", birth = "19900101", @@ -73,12 +153,14 @@ class AuthControllerTest { Mockito.`when`(authService.certificate(request, memberId = 20L)).thenReturn(certificate) Mockito.`when`(authService.isBlockAuth(certificate)).thenReturn(true) - assertThrows(SodaException::class.java) { + val exception = assertThrows(SodaException::class.java) { controller.authVerify(request, member) } + assertEquals("member.auth.blocked_policy", exception.messageKey) Mockito.verify(authService).signOut(20L) - Mockito.verify(memberContentPreferenceService, Mockito.never()).markAdultVisibleAfterAuthVerify(Mockito.anyLong()) + Mockito.verify(authService, Mockito.never()).authenticate(certificate, 20L) + Mockito.verifyNoInteractions(memberContentPreferenceService, userActionService) } @Test @@ -86,11 +168,12 @@ class AuthControllerTest { fun shouldThrowWhenMemberIsNull() { val request = AuthVerifyRequest(receiptId = "receipt-3", version = null) - assertThrows(SodaException::class.java) { + val exception = assertThrows(SodaException::class.java) { controller.authVerify(request, null) } - Mockito.verifyNoInteractions(memberContentPreferenceService) + assertEquals("common.error.bad_credentials", exception.messageKey) + Mockito.verifyNoInteractions(authService, memberContentPreferenceService, userActionService) } private fun createMember(id: Long): Member { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt index d2bbbe32..0a527dcb 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt @@ -14,6 +14,8 @@ 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.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.cache.concurrent.ConcurrentMapCacheManager @@ -166,16 +168,50 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor( } @Test - @DisplayName("authVerify 성공 후 markAdultVisibleAfterAuthVerify를 호출하면 저장값이 true로 반영된다") + @DisplayName("인증 후 입력이 없으면 기존 false/FEMALE 행이 true/ALL로 저장된다") fun shouldMarkAdultVisibleAfterAuthVerify() { val member = saveNonForcedMember("auth-verified-user") countryContext.setCountryCode("US") - service.updatePreference(member, isAdultContentVisible = false, contentType = ContentType.ALL) + service.updatePreference(member, isAdultContentVisible = false, contentType = ContentType.FEMALE) service.markAdultVisibleAfterAuthVerify(member.id!!) + entityManager.flush() + entityManager.clear() - val stored = service.getStoredPreference(member) + val stored = preferenceRepository.findByMemberId(member.id!!)!! assertTrue(stored.isAdultContentVisible) + assertEquals(ContentType.ALL, stored.contentType) + } + + @ParameterizedTest + @CsvSource( + "ALL, KR, true", "MALE, KR, true", "FEMALE, KR, true", + "ALL, US, true", "MALE, US, true", "FEMALE, US, true", + "ALL, KR, false", "MALE, KR, false", "FEMALE, KR, false", + "ALL, US, false", "MALE, US, false", "FEMALE, US, false" + ) + @DisplayName("인증 후 false와 모든 콘텐츠 유형은 KR과 해외의 기존 행 및 새 행에 영속화된다") + fun shouldPersistExplicitAuthPreferenceAfterReload( + contentType: ContentType, + countryCode: String, + existingRow: Boolean + ) { + val member = saveNonForcedMember("auth-input") + countryContext.setCountryCode(countryCode) + saveAuth(member) + val reloadedMember = memberRepository.findById(member.id!!).orElseThrow() + if (existingRow) { + service.initializeDefaultPreference(reloadedMember) + } + + assertEquals(existingRow, preferenceRepository.findByMemberId(member.id!!) != null) + service.markAdultVisibleAfterAuthVerify(member.id!!, false, contentType) + entityManager.flush() + entityManager.clear() + + val stored = preferenceRepository.findByMemberId(member.id!!)!! + assertFalse(stored.isAdultContentVisible) + assertEquals(contentType, stored.contentType) } @Test diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt index 914d70fc..22afcd12 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt @@ -13,10 +13,16 @@ 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.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mockito import org.springframework.cache.Cache import org.springframework.cache.CacheManager +import org.springframework.cache.concurrent.ConcurrentMapCacheManager import org.springframework.dao.DataIntegrityViolationException +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime import java.util.Optional @@ -405,6 +411,108 @@ class MemberContentPreferenceServiceTest { verifyRecommendLiveCacheEvicted(32L) } + @Test + @DisplayName("인증 후 입력이 없으면 기존 false/FEMALE 설정을 true/ALL로 변경한다") + fun shouldResetExistingPreferenceToTrueAndAllAfterAuthVerifyWithoutInputs() { + val member = createMember(id = 33L) + val preference = createPreference(member).apply { contentType = ContentType.FEMALE } + val baselineTime = preference.contentTypeChangedAt + Mockito.`when`(memberRepository.findById(33L)).thenReturn(Optional.of(member)) + Mockito.`when`(repository.findByMemberId(33L)).thenReturn(preference) + + service.markAdultVisibleAfterAuthVerify(33L) + + assertTrue(preference.isAdultContentVisible) + assertEquals(ContentType.ALL, preference.contentType) + assertTrue(preference.adultContentVisibilityChangedAt.isAfter(baselineTime)) + assertTrue(preference.contentTypeChangedAt.isAfter(baselineTime)) + verifyRecommendLiveCacheEvicted(33L) + } + + @ParameterizedTest + @CsvSource( + "false, FEMALE, false, false", + "true, FEMALE, true, false", + "false, MALE, false, true", + "false, ALL, false, true", + "true, MALE, true, true", + "true, ALL, true, true" + ) + @DisplayName("본인인증 후 설정 저장 메서드는 국가·인증 상태를 재검사하지 않고 요청값을 반영한다") + fun shouldApplyAuthInputsAndUpdateOnlyChangedFields( + isAdultContentVisible: Boolean, + contentType: ContentType, + adultVisibilityChanged: Boolean, + contentTypeChanged: Boolean + ) { + val member = createMember(id = 34L) + countryContext.setCountryCode("KR") + val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0) + val preference = MemberContentPreference(false, ContentType.FEMALE, baselineTime, baselineTime) + preference.member = member + Mockito.`when`(memberRepository.findById(34L)).thenReturn(Optional.of(member)) + Mockito.`when`(repository.findByMemberId(34L)).thenReturn(preference) + + service.markAdultVisibleAfterAuthVerify(34L, isAdultContentVisible, contentType) + + assertEquals(isAdultContentVisible, preference.isAdultContentVisible) + assertEquals(contentType, preference.contentType) + if (adultVisibilityChanged) { + assertTrue(preference.adultContentVisibilityChangedAt.isAfter(baselineTime)) + } else { + assertEquals(baselineTime, preference.adultContentVisibilityChangedAt) + } + if (contentTypeChanged) { + assertTrue(preference.contentTypeChangedAt.isAfter(baselineTime)) + } else { + assertEquals(baselineTime, preference.contentTypeChangedAt) + } + if (adultVisibilityChanged || contentTypeChanged) { + verifyRecommendLiveCacheEvicted(34L) + } else { + verifyRecommendLiveCacheNotEvicted(34L) + } + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + @DisplayName("인증 선호값 변경은 커밋 후에만 캐시를 제거하고 롤백 시 캐시를 유지한다") + fun shouldEvictAuthPreferenceCacheOnlyAfterCommit(committed: Boolean) { + val member = createMember(id = 35L) + val preference = createPreference(member) + Mockito.`when`(memberRepository.findById(35L)).thenReturn(Optional.of(member)) + Mockito.`when`(repository.findByMemberId(35L)).thenReturn(preference) + val realCacheManager = ConcurrentMapCacheManager("cache_ttl_3_hours") + val cache = realCacheManager.getCache("cache_ttl_3_hours")!! + val keys = listOf("getRecommendLive:35:false", "getRecommendLive:35:true", "getRecommendLive:35") + keys.forEach { cache.put(it, "cached") } + service = MemberContentPreferenceService(repository, memberRepository, countryContext, realCacheManager) + + TransactionSynchronizationManager.initSynchronization() + try { + service.markAdultVisibleAfterAuthVerify(35L, false, ContentType.MALE) + + assertFalse(preference.isAdultContentVisible) + assertEquals(ContentType.MALE, preference.contentType) + keys.forEach { assertEquals("cached", cache.get(it)?.get()) } + val synchronizations = TransactionSynchronizationManager.getSynchronizations() + assertEquals(1, synchronizations.size) + if (committed) { + synchronizations.forEach { it.afterCommit() } + } + val status = if (committed) { + TransactionSynchronization.STATUS_COMMITTED + } else { + TransactionSynchronization.STATUS_ROLLED_BACK + } + synchronizations.forEach { it.afterCompletion(status) } + + keys.forEach { assertEquals(if (committed) null else "cached", cache.get(it)?.get()) } + } finally { + TransactionSynchronizationManager.clearSynchronization() + } + } + @Test @DisplayName("기존 row가 있으면 legacy 조회 파라미터를 무시하고 저장값을 그대로 사용한다") fun shouldIgnoreLegacyParamsWhenPreferenceAlreadyExists() {