Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 28 additions & 11 deletions data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,27 +23,27 @@ 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 {
val result =
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") {
Expand All @@ -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<Unit> {
return apiCallBuilder(
ioDispatcher = ioDispatcher,
Expand Down
135 changes: 135 additions & 0 deletions data/src/test/java/com/project200/data/impl/AuthRepositoryImplTest.kt
Original file line number Diff line number Diff line change
@@ -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() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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() }
}
}
Loading