diff --git a/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt index ec57917e..8dc4c040 100644 --- a/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.project200.data.dto.GetIsNicknameDuplicated import com.project200.data.dto.PostSignUpRequest import com.project200.data.local.PreferenceManager import com.project200.data.utils.apiCallBuilder +import com.project200.domain.manager.SessionDataCleaner import com.project200.domain.model.BaseResult import com.project200.domain.model.RegistrationStatus import com.project200.domain.repository.AuthRepository @@ -22,6 +23,7 @@ class AuthRepositoryImpl private val apiService: ApiService, private val spManager: PreferenceManager, private val authStateManager: AuthStateManager, + private val sessionDataCleaner: SessionDataCleaner, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : AuthRepository { override suspend fun checkIsRegistered(): RegistrationStatus { @@ -29,20 +31,19 @@ class AuthRepositoryImpl apiCallBuilder( ioDispatcher = ioDispatcher, apiCall = { apiService.getIsRegistered() }, - mapper = { data -> - // 가입 확인된 경우에만 저장 - if (data != null && data.isRegistered) { - spManager.saveMemberId(data.memberId) - true - } else { - false - } - }, + mapper = { data -> data?.takeIf { it.isRegistered }?.memberId }, ) return when (result) { - is BaseResult.Success -> - if (result.data) RegistrationStatus.Registered else RegistrationStatus.Unregistered + is BaseResult.Success -> { + val memberId = result.data + if (memberId != null) { + replaceMemberId(memberId) + RegistrationStatus.Registered + } else { + RegistrationStatus.Unregistered + } + } is BaseResult.Error -> // 미가입자는 인터셉터가 401 AUTHENTICATION_FAILED로 막는다 if (result.errorCode == "AUTHENTICATION_FAILED") { @@ -55,6 +56,22 @@ class AuthRepositoryImpl } } + /** + * 회원ID를 저장합니다. + * + * 저장 전에 이전 회원ID와 다르면 로컬 캐시를 지웁니다. 로그아웃을 거치지 않은 계정 전환에서 + * 이전 계정의 행이 기기에 남기 때문입니다 + * 이전 값이 없을 때도 지웁니다. 로그아웃 도중 캐시 삭제만 실패한 상태를 흡수합니다 + */ + private suspend fun replaceMemberId(memberId: String) = + withContext(ioDispatcher) { + if (spManager.getMemberId() != memberId) { + Timber.tag(TAG).i("계정 경계 변경 - 로컬 캐시 삭제") + sessionDataCleaner.clearAll() + } + spManager.saveMemberId(memberId) + } + override suspend fun logout(): BaseResult { return apiCallBuilder( ioDispatcher = ioDispatcher, diff --git a/data/src/test/java/com/project200/data/impl/AuthRepositoryImplTest.kt b/data/src/test/java/com/project200/data/impl/AuthRepositoryImplTest.kt new file mode 100644 index 00000000..29c881bc --- /dev/null +++ b/data/src/test/java/com/project200/data/impl/AuthRepositoryImplTest.kt @@ -0,0 +1,135 @@ +package com.project200.data.impl + +import com.google.common.truth.Truth.assertThat +import com.project200.data.api.ApiService +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.GetIsRegisteredData +import com.project200.data.local.PreferenceManager +import com.project200.domain.manager.SessionDataCleaner +import com.project200.domain.model.RegistrationStatus +import com.project200.undabang.oauth.AuthStateManager +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.just +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class AuthRepositoryImplTest { + @get:Rule + val mockkRule = MockKRule(this) + + @MockK + private lateinit var mockApiService: ApiService + + @MockK + private lateinit var mockPreferenceManager: PreferenceManager + + @MockK + private lateinit var mockAuthStateManager: AuthStateManager + + @MockK + private lateinit var mockSessionDataCleaner: SessionDataCleaner + + private lateinit var repository: AuthRepositoryImpl + + @Before + fun setUp() { + every { mockPreferenceManager.saveMemberId(any()) } just Runs + coEvery { mockSessionDataCleaner.clearAll() } just Runs + + repository = + AuthRepositoryImpl( + apiService = mockApiService, + spManager = mockPreferenceManager, + authStateManager = mockAuthStateManager, + sessionDataCleaner = mockSessionDataCleaner, + ioDispatcher = UnconfinedTestDispatcher(), + ) + } + + private fun stubIsRegistered( + memberId: String, + isRegistered: Boolean = true, + ) { + coEvery { mockApiService.getIsRegistered() } returns + BaseResponse( + succeed = true, + code = "SUCCESS", + message = "", + data = GetIsRegisteredData(memberId = memberId, isRegistered = isRegistered), + ) + } + + // ── 계정 경계 ──────────────────────────────── + + @Test + fun `가입 확인 - 다른 계정으로 바뀌면 저장 전에 로컬 캐시를 지운다`() = + runTest { + // Given: 이전 계정이 기기에 남아 있음 + every { mockPreferenceManager.getMemberId() } returns "member-a" + stubIsRegistered("member-b") + + // When + val result = repository.checkIsRegistered() + + // Then + assertThat(result).isEqualTo(RegistrationStatus.Registered) + coVerify(exactly = 1) { mockSessionDataCleaner.clearAll() } + verify(exactly = 1) { mockPreferenceManager.saveMemberId("member-b") } + } + + @Test + fun `가입 확인 - 같은 계정이면 로컬 캐시를 지우지 않는다`() = + runTest { + // Given + every { mockPreferenceManager.getMemberId() } returns "member-a" + stubIsRegistered("member-a") + + // When + val result = repository.checkIsRegistered() + + // Then + assertThat(result).isEqualTo(RegistrationStatus.Registered) + coVerify(exactly = 0) { mockSessionDataCleaner.clearAll() } + verify(exactly = 1) { mockPreferenceManager.saveMemberId("member-a") } + } + + @Test + fun `가입 확인 - 이전 회원ID가 없으면 로컬 캐시를 지운다`() = + runTest { + // Given: 로그아웃 도중 캐시 삭제만 실패한 상태를 흡수한다 + every { mockPreferenceManager.getMemberId() } returns null + stubIsRegistered("member-b") + + // When + repository.checkIsRegistered() + + // Then + coVerify(exactly = 1) { mockSessionDataCleaner.clearAll() } + } + + @Test + fun `가입 확인 - 미가입이면 회원ID를 저장하지 않고 캐시도 건드리지 않는다`() = + runTest { + // Given + stubIsRegistered("member-b", isRegistered = false) + + // When + val result = repository.checkIsRegistered() + + // Then + assertThat(result).isEqualTo(RegistrationStatus.Unregistered) + verify(exactly = 0) { mockPreferenceManager.saveMemberId(any()) } + coVerify(exactly = 0) { mockSessionDataCleaner.clearAll() } + } +} diff --git a/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt index 3c629f05..68e340fd 100644 --- a/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt @@ -12,11 +12,19 @@ constructor( private val fcmTokenSyncScheduler: FcmTokenSyncScheduler, private val sessionDataCleaner: SessionDataCleaner, ) { + /** + * 로컬 세션을 정리합니다. + * + * 캐시를 회원ID보다 먼저 지웁니다. 계정 스코프 삭제로 좁히면 회원ID가 없는 시점에는 + * 지울 대상을 특정할 수 없습니다 + * 캐시 삭제가 실패해도 세션은 끊습니다. 세션이 남으면 다음 진입에서 자동 로그인됩니다 + */ suspend operator fun invoke() { - authRepository.clearSession() - // 회원ID가 지워져 실행돼도 SKIPPED로 끝나지만 예약을 남겨둘 이유가 없습니다 - fcmTokenSyncScheduler.cancel() - // 다음 사용자가 이전 사용자의 캐시를 보면 안 됩니다 - sessionDataCleaner.clearAll() + try { + sessionDataCleaner.clearAll() + } finally { + fcmTokenSyncScheduler.cancel() + authRepository.clearSession() + } } } diff --git a/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt index 17a4043a..5a1ce6f7 100644 --- a/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt @@ -1,11 +1,13 @@ package com.project200.domain.usecase +import com.google.common.truth.Truth.assertThat import com.project200.domain.manager.FcmTokenSyncScheduler import com.project200.domain.manager.SessionDataCleaner import com.project200.domain.repository.AuthRepository import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.junit4.MockKRule @@ -80,4 +82,37 @@ class ClearSessionUseCaseTest { // Then coVerify(exactly = 1) { mockSessionDataCleaner.clearAll() } } + + @Test + fun `invoke 호출 시 캐시를 지운 뒤 세션을 정리한다`() = runTest { + // Given + coEvery { mockRepository.clearSession() } just Runs + every { mockFcmTokenSyncScheduler.cancel() } just Runs + coEvery { mockSessionDataCleaner.clearAll() } just Runs + + // When + useCase() + + // Then: 회원ID가 남아 있는 동안 지워야 계정 스코프 삭제로 좁힐 수 있다 + coVerifyOrder { + mockSessionDataCleaner.clearAll() + mockRepository.clearSession() + } + } + + @Test + fun `invoke 호출 시 캐시 삭제가 실패해도 예약과 세션을 정리한다`() = runTest { + // Given + coEvery { mockRepository.clearSession() } just Runs + every { mockFcmTokenSyncScheduler.cancel() } just Runs + coEvery { mockSessionDataCleaner.clearAll() } throws IllegalStateException("db") + + // When + val thrown = runCatching { useCase() }.exceptionOrNull() + + // Then + assertThat(thrown).isInstanceOf(IllegalStateException::class.java) + verify(exactly = 1) { mockFcmTokenSyncScheduler.cancel() } + coVerify(exactly = 1) { mockRepository.clearSession() } + } }