From 6115792dcd70a9d6a2f82fc0b30eecdeaabc9530 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 12 Oct 2026 13:27:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(contentpreference):=20=EB=B3=B8=EC=9D=B8?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=9B=84=20=EC=9A=94=EC=B2=AD=ED=95=9C=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=84=A0=ED=98=B8=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EC=A0=80=EC=9E=A5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MemberContentPreferenceService.kt | 22 +++- .../MemberContentPreferenceIntegrationTest.kt | 42 ++++++- .../MemberContentPreferenceServiceTest.kt | 108 ++++++++++++++++++ 3 files changed, 165 insertions(+), 7 deletions(-) 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/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() {