Test #455

Merged
klaus merged 2 commits from test into main 2026-10-12 06:08:05 +00:00
6 changed files with 268 additions and 17 deletions
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.member.auth
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException 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.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.useraction.ActionType import kr.co.vividnext.sodalive.useraction.ActionType
@@ -34,7 +35,11 @@ class AuthController(
} }
val authResponse = service.authenticate(authenticateData, member.id!!) 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 { try {
userActionService.recordAction( userActionService.recordAction(
@@ -1,12 +1,17 @@
package kr.co.vividnext.sodalive.member.auth package kr.co.vividnext.sodalive.member.auth
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.content.ContentType
data class AuthVerifyRequest( data class AuthVerifyRequest(
@JsonProperty("receipt_id") @JsonProperty("receipt_id")
val receiptId: String, val receiptId: String,
@JsonProperty("version") @JsonProperty("version")
val version: String? val version: String?,
@JsonProperty("isAdultContentVisible")
val isAdultContentVisible: Boolean? = null,
@JsonProperty("contentType")
val contentType: ContentType? = null
) )
data class AuthVerifyCertificate( data class AuthVerifyCertificate(
@@ -129,14 +129,28 @@ class MemberContentPreferenceService(
} }
@Transactional @Transactional
fun markAdultVisibleAfterAuthVerify(memberId: Long) { fun markAdultVisibleAfterAuthVerify(
memberId: Long,
isAdultContentVisible: Boolean = true,
contentType: ContentType = ContentType.ALL
) {
val member = memberRepository.findByIdOrNull(memberId) val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials") ?: throw SodaException(messageKey = "common.error.bad_credentials")
val preference = initializeDefaultPreference(member) val preference = initializeDefaultPreference(member)
if (!preference.isAdultContentVisible) { val now = LocalDateTime.now()
preference.isAdultContentVisible = true var hasChanged = false
preference.adultContentVisibilityChangedAt = LocalDateTime.now() 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) evictRecommendLiveCacheAfterCommit(memberId)
} }
} }
@@ -1,14 +1,23 @@
package kr.co.vividnext.sodalive.member.auth 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.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.useraction.ActionType import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService 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.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test 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.mockito.Mockito
class AuthControllerTest { class AuthControllerTest {
@@ -31,7 +40,7 @@ class AuthControllerTest {
} }
@Test @Test
@DisplayName("authVerify 성공 시 성인노출 true 저장을 호출한다") @DisplayName("authVerify 성공 시 입력이 없으면 true/ALL 저장을 호출한다")
fun shouldSaveAdultPreferenceWhenAuthVerifySucceeds() { fun shouldSaveAdultPreferenceWhenAuthVerifySucceeds() {
val member = createMember(id = 10L) val member = createMember(id = 10L)
val request = AuthVerifyRequest(receiptId = "receipt-1", version = "v1") val request = AuthVerifyRequest(receiptId = "receipt-1", version = "v1")
@@ -47,9 +56,13 @@ class AuthControllerTest {
Mockito.`when`(authService.isBlockAuth(certificate)).thenReturn(false) Mockito.`when`(authService.isBlockAuth(certificate)).thenReturn(false)
Mockito.`when`(authService.authenticate(certificate, 10L)).thenReturn(AuthResponse(gender = 1)) 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( Mockito.verify(userActionService).recordAction(
memberId = 10L, memberId = 10L,
isAuth = true, 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<AuthVerifyRequest>(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 @Test
@DisplayName("차단 정책으로 authVerify가 실패하면 저장을 호출하지 않는다") @DisplayName("차단 정책으로 authVerify가 실패하면 저장을 호출하지 않는다")
fun shouldNotSaveAdultPreferenceWhenAuthIsBlocked() { fun shouldNotSaveAdultPreferenceWhenAuthIsBlocked() {
val member = createMember(id = 20L) 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( val certificate = AuthVerifyCertificate(
name = "홍길동", name = "홍길동",
birth = "19900101", birth = "19900101",
@@ -73,12 +153,14 @@ class AuthControllerTest {
Mockito.`when`(authService.certificate(request, memberId = 20L)).thenReturn(certificate) Mockito.`when`(authService.certificate(request, memberId = 20L)).thenReturn(certificate)
Mockito.`when`(authService.isBlockAuth(certificate)).thenReturn(true) Mockito.`when`(authService.isBlockAuth(certificate)).thenReturn(true)
assertThrows(SodaException::class.java) { val exception = assertThrows(SodaException::class.java) {
controller.authVerify(request, member) controller.authVerify(request, member)
} }
assertEquals("member.auth.blocked_policy", exception.messageKey)
Mockito.verify(authService).signOut(20L) 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 @Test
@@ -86,11 +168,12 @@ class AuthControllerTest {
fun shouldThrowWhenMemberIsNull() { fun shouldThrowWhenMemberIsNull() {
val request = AuthVerifyRequest(receiptId = "receipt-3", version = null) val request = AuthVerifyRequest(receiptId = "receipt-3", version = null)
assertThrows(SodaException::class.java) { val exception = assertThrows(SodaException::class.java) {
controller.authVerify(request, null) 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 { private fun createMember(id: Long): Member {
@@ -14,6 +14,8 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test 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.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.cache.concurrent.ConcurrentMapCacheManager import org.springframework.cache.concurrent.ConcurrentMapCacheManager
@@ -166,16 +168,50 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor(
} }
@Test @Test
@DisplayName("authVerify 성공 후 markAdultVisibleAfterAuthVerify를 호출하면 저장값이 true로 반영된다") @DisplayName("인증 후 입력이 없으면 기존 false/FEMALE 행이 true/ALL로 저장된다")
fun shouldMarkAdultVisibleAfterAuthVerify() { fun shouldMarkAdultVisibleAfterAuthVerify() {
val member = saveNonForcedMember("auth-verified-user") val member = saveNonForcedMember("auth-verified-user")
countryContext.setCountryCode("US") countryContext.setCountryCode("US")
service.updatePreference(member, isAdultContentVisible = false, contentType = ContentType.ALL) service.updatePreference(member, isAdultContentVisible = false, contentType = ContentType.FEMALE)
service.markAdultVisibleAfterAuthVerify(member.id!!) service.markAdultVisibleAfterAuthVerify(member.id!!)
entityManager.flush()
entityManager.clear()
val stored = service.getStoredPreference(member) val stored = preferenceRepository.findByMemberId(member.id!!)!!
assertTrue(stored.isAdultContentVisible) 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 @Test
@@ -13,10 +13,16 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test 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.mockito.Mockito
import org.springframework.cache.Cache import org.springframework.cache.Cache
import org.springframework.cache.CacheManager import org.springframework.cache.CacheManager
import org.springframework.cache.concurrent.ConcurrentMapCacheManager
import org.springframework.dao.DataIntegrityViolationException import org.springframework.dao.DataIntegrityViolationException
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.Optional import java.util.Optional
@@ -405,6 +411,108 @@ class MemberContentPreferenceServiceTest {
verifyRecommendLiveCacheEvicted(32L) 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 @Test
@DisplayName("기존 row가 있으면 legacy 조회 파라미터를 무시하고 저장값을 그대로 사용한다") @DisplayName("기존 row가 있으면 legacy 조회 파라미터를 무시하고 저장값을 그대로 사용한다")
fun shouldIgnoreLegacyParamsWhenPreferenceAlreadyExists() { fun shouldIgnoreLegacyParamsWhenPreferenceAlreadyExists() {