From 27e2cb97b52ca301111547692b04a6a246890019 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 29 Oct 2026 23:50:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=EC=8B=AC=EC=82=AC=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=BD=94=EB=93=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TEST_STRATEGY.md | 10 ++ .../datastore/token/TokenRepositoryImpl.kt | 19 +++ .../token/TokenRepositoryImplTest.kt | 106 ++++++++++++ .../ruleup/domain/token/TokenRepository.kt | 7 + .../ruleup/domain/test/FakeTokenRepository.kt | 13 ++ .../ruleup/network/auth/TokenAuthenticator.kt | 3 +- .../ruleup/network/di/ErrorBodyInterceptor.kt | 6 + .../com/ruleup/network/di/NetworkModule.kt | 17 +- .../onboarding/data/auth/api/AuthApi.kt | 6 + .../onboarding/data/auth/dto/AuthRequest.kt | 5 + .../onboarding/data/auth/dto/AuthResponse.kt | 2 + .../auth/repository/AuthRepositoryImpl.kt | 14 ++ .../data/auth/api/ReviewLoginApiTest.kt | 154 ++++++++++++++++++ .../auth/repository/TokenRefresherImplTest.kt | 2 + .../domain/auth/entity/AuthFailure.kt | 4 + .../domain/auth/repository/AuthRepository.kt | 2 + .../domain/auth/usecase/ReviewLoginUseCase.kt | 19 +++ .../auth/usecase/ReviewLoginUseCaseTest.kt | 43 +++++ .../ruleup/onboarding/domain/fake/Fakes.kt | 23 +++ onboarding/presentation/build.gradle.kts | 6 + .../screen/ReviewLoginDialogDeviceTest.kt | 63 +++++++ .../presentation/common/AuthFailureUi.kt | 4 + .../presentation/intro/screen/LoginScreen.kt | 24 ++- .../intro/screen/ReviewLoginDialog.kt | 44 +++++ .../intro/viewmodel/LoginIntent.kt | 4 + .../intro/viewmodel/LoginViewModel.kt | 22 +++ .../common/ReviewLoginFailureUiTest.kt | 22 +++ .../presentation/intro/LoginContentTest.kt | 12 ++ .../intro/viewmodel/LoginViewModelTest.kt | 71 +++++++- .../domain/usecase/BindLocationUseCaseTest.kt | 7 + 30 files changed, 730 insertions(+), 4 deletions(-) create mode 100644 onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/api/ReviewLoginApiTest.kt create mode 100644 onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/usecase/ReviewLoginUseCase.kt create mode 100644 onboarding/domain/src/test/kotlin/com/ruleup/onboarding/domain/auth/usecase/ReviewLoginUseCaseTest.kt create mode 100644 onboarding/presentation/src/androidTest/kotlin/com/ruleup/onboarding/presentation/intro/screen/ReviewLoginDialogDeviceTest.kt create mode 100644 onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/screen/ReviewLoginDialog.kt create mode 100644 onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/common/ReviewLoginFailureUiTest.kt diff --git a/TEST_STRATEGY.md b/TEST_STRATEGY.md index 7b9918b96..be9ed53dd 100644 --- a/TEST_STRATEGY.md +++ b/TEST_STRATEGY.md @@ -173,6 +173,16 @@ verification domain의 아래 테스트로 대체하고 생성·참여 연동 | 약관 재동의·철회가 사유별로 갈린다 | **인수 있음** | `AccountContractAcceptanceTest`(철회 금지·버전 불일치) · `AgreementsViewModelTest` · `ErrorBodyInterceptorTest` | | 인증 제출 → 오늘 상태가 바뀜 | 하위만 | `RunSyncUseCaseTest` · `SubmitDeviceIntroUseCaseTest` · `TodayStatusTest` | +### 심사 계정 로그인 (#645) + +- `ReviewLoginApiTest`: 코드만 전송하고 기존 Authorization 헤더와 디버그 로그를 남기지 않으며, 401에서 기존 토큰으로 재시도하지 않는지 확인한다. 잘못된 코드·횟수 제한·기능 비활성화 응답도 구분한다. +- `ReviewLoginUseCaseTest`, `TokenRepositoryImplTest`: 심사 세션 저장과 실패, 갱신 및 로그아웃 경로를 확인한다. 심사 세션 저장 중 로그아웃과 이전 세션의 늦은 갱신 결과도 확인한다. +- `ReviewLoginFailureUiTest`: 잘못된 코드, 횟수 제한, 기능 비활성화에 따른 안내를 구분한다. +- `LoginViewModelTest`: 심사 세션 저장 후 홈 진입, 코드 오류 안내와 중복 제출 방지를 확인한다. +- `LoginContentTest`: 로고 6회 탭으로 진입하는지 확인한다. +- `ReviewLoginDialogDeviceTest`: 오프라인 Android 에뮬레이터에서 빈 코드의 제출 차단, 입력값 전달, 취소 및 진행 중 중복 제출 방지를 확인한다. Robolectric에서는 텍스트 렌더링 중 메모리 부족으로 실행하지 못했다. +- 서버 심사 로그인 API는 아직 구현되지 않아 실제 서버 연동을 확인하지 못했다. 사용자 요청에 따라 Meta 이벤트 차단은 이번 작업에서 다루지 않는다. + ## 5. 돌리는 법 ```bash diff --git a/core/datastore/src/main/kotlin/com/ruleup/datastore/token/TokenRepositoryImpl.kt b/core/datastore/src/main/kotlin/com/ruleup/datastore/token/TokenRepositoryImpl.kt index 99384c3a7..6abc23e40 100644 --- a/core/datastore/src/main/kotlin/com/ruleup/datastore/token/TokenRepositoryImpl.kt +++ b/core/datastore/src/main/kotlin/com/ruleup/datastore/token/TokenRepositoryImpl.kt @@ -47,6 +47,9 @@ class TokenRepositoryImpl override val isLoggedIn: Flow = preferences.map { prefs -> prefs[KEY_REFRESH] != null } + override val isReviewSession: Flow = + dataStore.data.map { prefs -> prefs[KEY_REVIEW_SESSION] == true && prefs[KEY_REFRESH] != null } + // 갱신 응답이 userId 를 안 주는 배포본에서는 비어 있을 수 있다 override val userId: Flow = preferences.map { prefs -> prefs[KEY_USER_ID] } @@ -63,7 +66,22 @@ class TokenRepositoryImpl prefs[KEY_REFRESH] = token.refreshToken prefs[KEY_USER_ID] = userId prefs[KEY_EVER_LOGGED_IN] = true + prefs.remove(KEY_REVIEW_SESSION) + } + } + + override suspend fun saveReviewSession( + token: Token, + userId: String, + ) = mutationLock.withLock { + dataStore.edit { prefs -> + prefs[KEY_ACCESS] = token.accessToken + prefs[KEY_REFRESH] = token.refreshToken + prefs[KEY_USER_ID] = userId + prefs[KEY_EVER_LOGGED_IN] = true + prefs[KEY_REVIEW_SESSION] = true } + cachedAccess = token.accessToken } override suspend fun saveTokens( @@ -160,5 +178,6 @@ class TokenRepositoryImpl private val KEY_REFRESH = stringPreferencesKey("refresh_token") private val KEY_USER_ID = stringPreferencesKey("user_id") private val KEY_EVER_LOGGED_IN = booleanPreferencesKey("has_ever_logged_in") + private val KEY_REVIEW_SESSION = booleanPreferencesKey("review_session") } } diff --git a/core/datastore/src/test/kotlin/com/ruleup/datastore/token/TokenRepositoryImplTest.kt b/core/datastore/src/test/kotlin/com/ruleup/datastore/token/TokenRepositoryImplTest.kt index 05f20feba..656fbc7c7 100644 --- a/core/datastore/src/test/kotlin/com/ruleup/datastore/token/TokenRepositoryImplTest.kt +++ b/core/datastore/src/test/kotlin/com/ruleup/datastore/token/TokenRepositoryImplTest.kt @@ -9,6 +9,9 @@ import com.ruleup.observability.domain.event.DiagnosticPayload import com.ruleup.observability.domain.model.Severity import com.ruleup.observability.domain.test.RecordingSink import com.ruleup.observability.domain.test.testObservability +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -23,6 +26,91 @@ import java.io.IOException /** 저장소 고장이 앱 고장으로 번지지 않는지 검증한다. */ class TokenRepositoryImplTest { + @Test + fun `심사 세션 저장 중 로그아웃하면 최종 세션은 로그아웃 상태다`() = + runTest { + val store = BlockingDataStore() + val repository = repo(store) + repository.saveSession(token(), "normal-user") + store.blockNextWrite = true + + val login = + async( + start = CoroutineStart.UNDISPATCHED, + ) { repository.saveReviewSession(token("review", "review-refresh"), "review-user") } + store.writeStarted.await() + val logout = async(start = CoroutineStart.UNDISPATCHED) { repository.clear() } + assertFalse(logout.isCompleted) + + store.allowWrite.complete(Unit) + login.await() + logout.await() + assertNull(repository.cachedAccessToken()) + assertNull(repository.getRefreshToken()) + assertFalse(repository.isReviewSession.first()) + } + + @Test + fun `심사 세션 전 이전 세션의 갱신 결과는 심사 세션을 바꾸지 않는다`() = + runTest { + val repository = repo(FakeDataStore()) + repository.saveSession(token(), "normal-user") + repository.saveReviewSession(token("review", "review-refresh"), "review-user") + + assertFalse(repository.saveTokensIfRefreshTokenMatches("r1", token("stale", "stale-refresh"))) + assertFalse(repository.clearIfRefreshTokenMatches("r1")) + assertEquals("review", repository.cachedAccessToken()) + assertEquals("review-refresh", repository.getRefreshToken()) + assertTrue(repository.isReviewSession.first()) + } + + @Test + fun `심사 세션 갱신 실패로 로그아웃하면 심사 상태도 사라진다`() = + runTest { + val repository = repo(FakeDataStore()) + repository.saveReviewSession(token(), "review-user") + + assertTrue(repository.clearIfRefreshTokenMatches("r1")) + assertNull(repository.cachedAccessToken()) + assertFalse(repository.isReviewSession.first()) + } + + @Test + fun `심사 세션과 토큰을 한 번에 저장하고 갱신 뒤에도 유지한다`() = + runTest { + val store = CountingDataStore() + val repository = repo(store) + + repository.saveReviewSession(token(), "review-user") + + assertEquals(1, store.writeCount) + assertEquals("r1", repository.getRefreshToken()) + assertEquals("review-user", repository.getUserId()) + assertTrue(repository.isReviewSession.first()) + repository.saveTokens(token(access = "a2", refresh = "r2")) + assertTrue(repository.isReviewSession.first()) + repository.clear() + assertFalse(repository.isReviewSession.first()) + } + + @Test + fun `심사 세션 뒤 일반 로그인은 심사 상태를 해제한다`() = + runTest { + val repository = repo(FakeDataStore()) + repository.saveReviewSession(token(), "review-user") + repository.saveSession(token(access = "normal", refresh = "normal-refresh"), "normal-user") + assertFalse(repository.isReviewSession.first()) + } + + @Test + fun `심사 세션 저장에 실패하면 성공한 세션으로 취급하지 않는다`() = + runTest { + val repository = repo(FakeDataStore(failWritesWith = IOException("no space"))) + val error = runCatching { repository.saveReviewSession(token(), "review-user") }.exceptionOrNull() + assertTrue(error is IOException) + assertNull(repository.cachedAccessToken()) + } + private fun token( access: String = "a1", refresh: String = "r1", @@ -246,6 +334,24 @@ private class FakeDataStore( } } +private class BlockingDataStore : DataStore { + private val delegate = FakeDataStore() + val writeStarted = CompletableDeferred() + val allowWrite = CompletableDeferred() + var blockNextWrite = false + + override val data: Flow get() = delegate.data + + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences { + if (blockNextWrite) { + blockNextWrite = false + writeStarted.complete(Unit) + allowWrite.await() + } + return delegate.updateData(transform) + } +} + /** 쓰기 횟수를 세는 대역. */ private class CountingDataStore : DataStore { private val state = MutableStateFlow(emptyPreferences()) diff --git a/core/domain/src/main/kotlin/com/ruleup/domain/token/TokenRepository.kt b/core/domain/src/main/kotlin/com/ruleup/domain/token/TokenRepository.kt index 7e2487f21..9c2b3b4ac 100644 --- a/core/domain/src/main/kotlin/com/ruleup/domain/token/TokenRepository.kt +++ b/core/domain/src/main/kotlin/com/ruleup/domain/token/TokenRepository.kt @@ -10,6 +10,13 @@ interface TokenRepository { userId: String, ) + suspend fun saveReviewSession( + token: Token, + userId: String, + ) + + val isReviewSession: Flow + /** 토큰을 회전시킨다. */ suspend fun saveTokens( token: Token, diff --git a/core/domain/src/testFixtures/kotlin/com/ruleup/domain/test/FakeTokenRepository.kt b/core/domain/src/testFixtures/kotlin/com/ruleup/domain/test/FakeTokenRepository.kt index 76468f582..aca7b655c 100644 --- a/core/domain/src/testFixtures/kotlin/com/ruleup/domain/test/FakeTokenRepository.kt +++ b/core/domain/src/testFixtures/kotlin/com/ruleup/domain/test/FakeTokenRepository.kt @@ -13,6 +13,7 @@ class FakeTokenRepository( private val everLoggedIn: Boolean = false, ) : TokenRepository { private val loggedIn = MutableStateFlow(refreshToken != null) + private val reviewSession = MutableStateFlow(false) private val userIdFlow = MutableStateFlow(storedUserId) var savedToken: Token? = null @@ -22,6 +23,8 @@ class FakeTokenRepository( override val isLoggedIn: Flow = loggedIn.asStateFlow() + override val isReviewSession: Flow = reviewSession.asStateFlow() + override val userId: Flow = userIdFlow.asStateFlow() override suspend fun saveSession( @@ -33,6 +36,15 @@ class FakeTokenRepository( storedUserId = userId userIdFlow.value = userId loggedIn.value = true + reviewSession.value = false + } + + override suspend fun saveReviewSession( + token: Token, + userId: String, + ) { + saveSession(token, userId) + reviewSession.value = true } override suspend fun saveTokens( @@ -79,6 +91,7 @@ class FakeTokenRepository( storedUserId = null userIdFlow.value = null loggedIn.value = false + reviewSession.value = false } override suspend fun hasEverLoggedIn(): Boolean = everLoggedIn || savedToken != null diff --git a/core/network/src/main/kotlin/com/ruleup/network/auth/TokenAuthenticator.kt b/core/network/src/main/kotlin/com/ruleup/network/auth/TokenAuthenticator.kt index 571cb1bee..7408534c4 100644 --- a/core/network/src/main/kotlin/com/ruleup/network/auth/TokenAuthenticator.kt +++ b/core/network/src/main/kotlin/com/ruleup/network/auth/TokenAuthenticator.kt @@ -27,7 +27,7 @@ class TokenAuthenticator ): Request? { // refresh 자체의 401 까지 갱신하러 들어가면 무한 재귀에 빠진다. val requestPath = response.request.url.encodedPath - if (requestPath.contains(AUTH_REFRESH_PATH)) return null + if (requestPath.contains(AUTH_REFRESH_PATH) || requestPath.contains(AUTH_REVIEW_LOGIN_PATH)) return null if (responseCount(response) >= MAX_ATTEMPTS) return null synchronized(this) { @@ -94,6 +94,7 @@ class TokenAuthenticator private companion object { const val TAG = "TokenAuthenticator" const val AUTH_REFRESH_PATH = "/auth/refresh" + const val AUTH_REVIEW_LOGIN_PATH = "/auth/review-login" const val HEADER_AUTHORIZATION = "Authorization" const val BEARER_PREFIX = "Bearer " const val MAX_ATTEMPTS = 2 diff --git a/core/network/src/main/kotlin/com/ruleup/network/di/ErrorBodyInterceptor.kt b/core/network/src/main/kotlin/com/ruleup/network/di/ErrorBodyInterceptor.kt index 11d3036f2..c5e3e8ec1 100644 --- a/core/network/src/main/kotlin/com/ruleup/network/di/ErrorBodyInterceptor.kt +++ b/core/network/src/main/kotlin/com/ruleup/network/di/ErrorBodyInterceptor.kt @@ -8,6 +8,12 @@ class ErrorBodyInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) if (response.isSuccessful) return response + if (response.code == 404 && + response.request.url.encodedPath + .contains("/auth/review-login") + ) { + return response + } val body = response.body ?: return response val contentType = body.contentType() diff --git a/core/network/src/main/kotlin/com/ruleup/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/ruleup/network/di/NetworkModule.kt index f7d7322ae..60c95e0b4 100644 --- a/core/network/src/main/kotlin/com/ruleup/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/ruleup/network/di/NetworkModule.kt @@ -34,6 +34,7 @@ object NetworkModule { private val NO_AUTH_PATHS = listOf( "/auth/oauth", + REVIEW_LOGIN_PATH, "/auth/signup", "/auth/refresh", ) @@ -94,7 +95,19 @@ object NetworkModule { redactHeader("Authorization") redactHeader("Cookie") } - builder.addInterceptor(loggingInterceptor) + builder.addInterceptor( + Interceptor { chain -> + if (chain + .request() + .url.encodedPath + .contains(REVIEW_LOGIN_PATH) + ) { + chain.proceed(chain.request()) + } else { + loggingInterceptor.intercept(chain) + } + }, + ) } return builder.build() @@ -132,4 +145,6 @@ object NetworkModule { .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() } + + private const val REVIEW_LOGIN_PATH = "/auth/review-login" } diff --git a/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/api/AuthApi.kt b/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/api/AuthApi.kt index ac647d722..a8f251f22 100644 --- a/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/api/AuthApi.kt +++ b/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/api/AuthApi.kt @@ -3,6 +3,7 @@ package com.ruleup.onboarding.data.auth.api import com.ruleup.network.dto.BaseResponse import com.ruleup.network.dto.EmptyData import com.ruleup.onboarding.data.auth.dto.LogoutRequest +import com.ruleup.onboarding.data.auth.dto.ReviewLoginRequest import com.ruleup.onboarding.data.auth.dto.SignUpRequest import com.ruleup.onboarding.data.auth.dto.SignUpResponse import com.ruleup.onboarding.data.auth.dto.SocialLoginAuthRequest @@ -17,6 +18,11 @@ import retrofit2.http.POST import retrofit2.http.Path interface AuthApi { + @POST("v1/auth/review-login") + suspend fun reviewLogin( + @Body request: ReviewLoginRequest, + ): BaseResponse + // OAuth 로그인 (provider: kakao / google). @POST("v1/auth/oauth/{provider}") suspend fun socialLogin( diff --git a/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/dto/AuthRequest.kt b/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/dto/AuthRequest.kt index 35239e259..f23ad925e 100644 --- a/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/dto/AuthRequest.kt +++ b/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/dto/AuthRequest.kt @@ -6,6 +6,11 @@ import com.ruleup.onboarding.domain.auth.entity.PermissionSnapshot import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@Serializable +data class ReviewLoginRequest( + @SerialName("code") val code: String, +) + @Serializable data class SocialLoginAuthRequest( @SerialName("code") diff --git a/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/dto/AuthResponse.kt b/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/dto/AuthResponse.kt index a35324c67..84a7eb2e3 100644 --- a/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/dto/AuthResponse.kt +++ b/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/dto/AuthResponse.kt @@ -211,6 +211,8 @@ internal fun ApiException.toAuthFailure(): AuthFailure = AUTH_FAILURE_CODES[code private val AUTH_FAILURE_CODES: Map = mapOf( "LOGIN_FAILED" to AuthFailure.LOGIN_FAILED, + "INVALID_REVIEW_CODE" to AuthFailure.INVALID_REVIEW_CODE, + "TOO_MANY_ATTEMPTS" to AuthFailure.REVIEW_TOO_MANY_ATTEMPTS, "INVALID_REDIRECT_URI" to AuthFailure.INVALID_REDIRECT_URI, "INVALID_DEVICE_INFO" to AuthFailure.INVALID_DEVICE_INFO, "LOGIN_PROVIDER_UNAVAILABLE" to AuthFailure.PROVIDER_UNAVAILABLE, diff --git a/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/repository/AuthRepositoryImpl.kt b/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/repository/AuthRepositoryImpl.kt index a84f912e1..6458c2926 100644 --- a/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/repository/AuthRepositoryImpl.kt +++ b/onboarding/data/src/main/kotlin/com/ruleup/onboarding/data/auth/repository/AuthRepositoryImpl.kt @@ -9,6 +9,7 @@ import com.ruleup.network.dto.getOrThrow import com.ruleup.network.dto.throwOnError import com.ruleup.onboarding.data.auth.api.AuthApi import com.ruleup.onboarding.data.auth.dto.LogoutRequest +import com.ruleup.onboarding.data.auth.dto.ReviewLoginRequest import com.ruleup.onboarding.data.auth.dto.SignUpRequest import com.ruleup.onboarding.data.auth.dto.SocialLoginAuthRequest import com.ruleup.onboarding.data.auth.dto.WithdrawRequest @@ -26,6 +27,7 @@ import com.ruleup.onboarding.domain.auth.entity.PermissionSnapshot import com.ruleup.onboarding.domain.auth.entity.SignupForm import com.ruleup.onboarding.domain.auth.entity.Withdrawal import com.ruleup.onboarding.domain.auth.repository.AuthRepository +import retrofit2.HttpException import java.io.IOException import java.time.format.DateTimeFormatter import javax.inject.Inject @@ -38,6 +40,18 @@ class AuthRepositoryImpl private val tokenRefresher: TokenRefresher, private val signupInviteStore: com.ruleup.onboarding.domain.auth.repository.SignupInviteStore, ) : AuthRepository { + override suspend fun reviewLogin(code: String): AuthSession = + try { + mapAuthFailure { + val result = api.reviewLogin(ReviewLoginRequest(code)).getOrThrow().toOAuthResult() + (result as? OAuthResult.ExistingUser)?.session + ?: throw ApiException(code = "INVALID_REVIEW_RESPONSE", message = "심사 계정 응답이 올바르지 않습니다.") + } + } catch (error: HttpException) { + if (error.code() == 404) throw AuthException(AuthFailure.REVIEW_UNAVAILABLE, cause = error) + throw error + } + override suspend fun exchangeToken( authorization: OAuthAuthorization, device: DeviceIdentity, diff --git a/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/api/ReviewLoginApiTest.kt b/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/api/ReviewLoginApiTest.kt new file mode 100644 index 000000000..eeaa7e15b --- /dev/null +++ b/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/api/ReviewLoginApiTest.kt @@ -0,0 +1,154 @@ +package com.ruleup.onboarding.data.auth.api + +import com.ruleup.domain.device.DeviceInfoProvider +import com.ruleup.domain.entity.user.Token +import com.ruleup.domain.test.FakeTokenRepository +import com.ruleup.domain.token.TokenRefresher +import com.ruleup.network.auth.TokenAuthenticator +import com.ruleup.network.di.NetworkModule +import com.ruleup.observability.domain.event.DiagnosticPayload +import com.ruleup.observability.domain.test.RecordingSink +import com.ruleup.observability.domain.test.testObservability +import com.ruleup.onboarding.data.auth.dto.ReviewLoginRequest +import com.ruleup.onboarding.data.auth.repository.AuthRepositoryImpl +import com.ruleup.onboarding.domain.auth.entity.AuthException +import com.ruleup.onboarding.domain.auth.entity.AuthFailure +import com.ruleup.onboarding.domain.auth.repository.SignupInviteStore +import dagger.Lazy +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ReviewLoginApiTest { + @Test + fun `잘못된 코드와 횟수 제한 및 비활성화 오류를 구분한다`() = + runBlocking { + val tokens = FakeTokenRepository() + val observability = testObservability() + val authenticator = TokenAuthenticator(tokens, Lazy { error("갱신하면 안 됨") }, observability) + var status = 400 + var responseCode = "INVALID_REVIEW_CODE" + val client = + NetworkModule + .provideOkHttpClient(tokens, authenticator, false, observability) + .newBuilder() + .addInterceptor { chain -> + val body = """{"success":false,"error":{"code":"$responseCode","message":"error"}}""" + Response + .Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(status) + .message("error") + .body(body.toResponseBody("application/json".toMediaType())) + .build() + }.build() + val api = + NetworkModule + .provideAuthRetrofit(client, NetworkModule.provideJson(), "https://example.invalid/api/") + .create(AuthApi::class.java) + val repository = + AuthRepositoryImpl( + api = api, + deviceInfoProvider = DeviceInfoProvider { error("기기 정보 불필요") }, + tokenRefresher = + object : TokenRefresher { + override suspend fun refresh(refreshToken: String) = error("갱신 불필요") + }, + signupInviteStore = + object : SignupInviteStore { + override fun capture(link: String) = Unit + + override suspend fun currentLink(): String? = null + + override fun clear() = Unit + }, + ) + + assertEquals(AuthFailure.INVALID_REVIEW_CODE, assertFailsWith { repository.reviewLogin("bad") }.failure) + status = 429 + responseCode = "TOO_MANY_ATTEMPTS" + assertEquals(AuthFailure.REVIEW_TOO_MANY_ATTEMPTS, assertFailsWith { repository.reviewLogin("bad") }.failure) + status = 404 + responseCode = "NOT_FOUND" + assertEquals(AuthFailure.REVIEW_UNAVAILABLE, assertFailsWith { repository.reviewLogin("bad") }.failure) + } + + @Test + fun `심사 로그인 401은 기존 계정의 토큰으로 재시도하지 않는다`() = + runBlocking { + val tokens = FakeTokenRepository() + tokens.saveSession(Token("old-access", "old-refresh", "Bearer", 3600), "old-user") + val authenticator = TokenAuthenticator(tokens, Lazy { error("갱신하면 안 됨") }, testObservability()) + val request = Request.Builder().url("https://example.invalid/api/v1/auth/review-login").build() + val response = + Response + .Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(401) + .message("Unauthorized") + .build() + + assertNull(authenticator.authenticate(null, response)) + } + + @Test + fun `심사 로그인은 기존 토큰을 붙이지 않고 코드만 보낸다`() = + runBlocking { + val tokens = FakeTokenRepository() + tokens.saveSession(Token("old-access", "old-refresh", "Bearer", 3600), "old-user") + val sink = RecordingSink() + val observability = testObservability(sink) + val authenticator = TokenAuthenticator(tokens, Lazy { error("인증 갱신 불필요") }, observability) + var actualPath: String? = null + var authorization: String? = null + var body: String? = null + val client = + NetworkModule + .provideOkHttpClient(tokens, authenticator, true, observability) + .newBuilder() + .addInterceptor { chain -> + val request = chain.request() + actualPath = request.url.encodedPath + authorization = request.header("Authorization") + body = request.body?.let { requestBody -> Buffer().also { requestBody.writeTo(it) }.readUtf8() } + Response + .Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body( + """{"success":true,"data":{"isNewUser":false,"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":3600,"user":{"id":"review-user","nickname":"심사"}}}""" + .toResponseBody( + "application/json".toMediaType(), + ), + ).build() + }.build() + val api = + NetworkModule + .provideAuthRetrofit(client, NetworkModule.provideJson(), "https://example.invalid/api/") + .create(AuthApi::class.java) + + val response = api.reviewLogin(ReviewLoginRequest("TEST-CODE")) + + assertEquals("/api/v1/auth/review-login", actualPath) + assertNull(authorization) + assertEquals("""{"code":"TEST-CODE"}""", body) + assertEquals("new-access", response.data?.accessToken) + val messages = sink.payloads.filterIsInstance().map { it.message } + assertTrue(messages.none { "TEST-CODE" in it || "new-access" in it || "new-refresh" in it }) + client.newCall(Request.Builder().url("https://example.invalid/api/v1/profile").build()).execute().close() + assertTrue(sink.payloads.filterIsInstance().any { it.tag == "HttpClient" }) + } +} diff --git a/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImplTest.kt b/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImplTest.kt index 2c6ca3978..783d4afd8 100644 --- a/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImplTest.kt +++ b/onboarding/data/src/test/kotlin/com/ruleup/onboarding/data/auth/repository/TokenRefresherImplTest.kt @@ -333,6 +333,8 @@ class TokenRefresherImplTest { ) : AuthApi { var refreshCalls = 0 + override suspend fun reviewLogin(request: com.ruleup.onboarding.data.auth.dto.ReviewLoginRequest) = throw NotImplementedError() + override suspend fun refreshToken(request: TokenRefreshRequest): BaseResponse { refreshCalls++ return onRefresh() diff --git a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/entity/AuthFailure.kt b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/entity/AuthFailure.kt index 24d8ace86..b1a4087ec 100644 --- a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/entity/AuthFailure.kt +++ b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/entity/AuthFailure.kt @@ -5,6 +5,10 @@ enum class AuthFailure { /** 인가 코드·id_token 검증 실패. */ LOGIN_FAILED, + INVALID_REVIEW_CODE, + REVIEW_TOO_MANY_ATTEMPTS, + REVIEW_UNAVAILABLE, + /** redirectUri 불일치(구글). */ INVALID_REDIRECT_URI, diff --git a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/repository/AuthRepository.kt b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/repository/AuthRepository.kt index 4040b95cd..a4c71fddb 100644 --- a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/repository/AuthRepository.kt +++ b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/repository/AuthRepository.kt @@ -10,6 +10,8 @@ import com.ruleup.onboarding.domain.auth.entity.SignupForm import com.ruleup.onboarding.domain.auth.entity.Withdrawal interface AuthRepository { + suspend fun reviewLogin(code: String): AuthSession + /** 소셜 로그인. */ suspend fun exchangeToken( authorization: OAuthAuthorization, diff --git a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/usecase/ReviewLoginUseCase.kt b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/usecase/ReviewLoginUseCase.kt new file mode 100644 index 000000000..c8a5be585 --- /dev/null +++ b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/usecase/ReviewLoginUseCase.kt @@ -0,0 +1,19 @@ +package com.ruleup.onboarding.domain.auth.usecase + +import com.ruleup.domain.token.TokenRepository +import com.ruleup.onboarding.domain.auth.entity.AuthSession +import com.ruleup.onboarding.domain.auth.repository.AuthRepository +import javax.inject.Inject + +class ReviewLoginUseCase + @Inject + constructor( + private val authRepository: AuthRepository, + private val tokens: TokenRepository, + ) { + suspend operator fun invoke(code: String): AuthSession { + val session = authRepository.reviewLogin(code) + tokens.saveReviewSession(session.token, session.user.id) + return session + } + } diff --git a/onboarding/domain/src/test/kotlin/com/ruleup/onboarding/domain/auth/usecase/ReviewLoginUseCaseTest.kt b/onboarding/domain/src/test/kotlin/com/ruleup/onboarding/domain/auth/usecase/ReviewLoginUseCaseTest.kt new file mode 100644 index 000000000..9a56f8bf9 --- /dev/null +++ b/onboarding/domain/src/test/kotlin/com/ruleup/onboarding/domain/auth/usecase/ReviewLoginUseCaseTest.kt @@ -0,0 +1,43 @@ +package com.ruleup.onboarding.domain.auth.usecase + +import com.ruleup.domain.entity.user.Token +import com.ruleup.onboarding.domain.auth.entity.AuthSession +import com.ruleup.onboarding.domain.fake.FakeAuthRepository +import com.ruleup.onboarding.domain.fake.FakeTokenRepository +import com.ruleup.onboarding.domain.fake.testUser +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReviewLoginUseCaseTest { + @Test + fun `심사 로그인에 성공하면 토큰과 심사 세션을 저장한다`() = + runBlocking { + val session = AuthSession(Token("access", "refresh", "Bearer", 3600), testUser()) + val auth = FakeAuthRepository().apply { reviewResult = session } + val tokens = FakeTokenRepository() + + val result = ReviewLoginUseCase(auth, tokens)("TEST-CODE") + + assertEquals(session, result) + assertEquals("TEST-CODE", auth.reviewCode) + assertEquals(session.token, tokens.savedToken) + assertTrue(tokens.isReviewSession.first()) + } + + @Test + fun `심사 로그인 실패는 세션을 만들지 않는다`() = + runBlocking { + val auth = FakeAuthRepository().apply { reviewError = IllegalStateException("failed") } + val tokens = FakeTokenRepository() + + assertFailsWith { ReviewLoginUseCase(auth, tokens)("wrong") } + + assertEquals(0, tokens.saveCount) + assertFalse(tokens.isReviewSession.first()) + } +} diff --git a/onboarding/domain/src/testFixtures/kotlin/com/ruleup/onboarding/domain/fake/Fakes.kt b/onboarding/domain/src/testFixtures/kotlin/com/ruleup/onboarding/domain/fake/Fakes.kt index 9dda1ff3e..06ab9dc83 100644 --- a/onboarding/domain/src/testFixtures/kotlin/com/ruleup/onboarding/domain/fake/Fakes.kt +++ b/onboarding/domain/src/testFixtures/kotlin/com/ruleup/onboarding/domain/fake/Fakes.kt @@ -30,6 +30,9 @@ class FakeTokenRepository( private var refreshToken: String? = null, ) : TokenRepository { private val loggedIn = MutableStateFlow(refreshToken != null) + private val reviewSession = MutableStateFlow(false) + + override val isReviewSession: Flow = reviewSession var savedToken: Token? = null var savedUserId: String? = null @@ -47,6 +50,15 @@ class FakeTokenRepository( loggedIn.value = true everLoggedIn = true saveCount++ + reviewSession.value = false + } + + override suspend fun saveReviewSession( + token: Token, + userId: String, + ) { + saveSession(token, userId) + reviewSession.value = true } override suspend fun saveTokens( @@ -94,6 +106,7 @@ class FakeTokenRepository( savedUserId = null refreshToken = null loggedIn.value = false + reviewSession.value = false } override val isLoggedIn: Flow = loggedIn @@ -103,6 +116,16 @@ class FakeTokenRepository( /** 각 API 의 성공 결과/예외를 주입할 수 있는 인증 저장소 테스트 더블. */ class FakeAuthRepository : AuthRepository { + var reviewResult: AuthSession? = null + var reviewError: Throwable? = null + var reviewCode: String? = null + + override suspend fun reviewLogin(code: String): AuthSession { + reviewCode = code + reviewError?.let { throw it } + return requireNotNull(reviewResult) + } + var exchangeResult: OAuthResult? = null var exchangeError: Throwable? = null var exchangedWithDevice: DeviceIdentity? = null diff --git a/onboarding/presentation/build.gradle.kts b/onboarding/presentation/build.gradle.kts index fb3d92816..39739e52e 100644 --- a/onboarding/presentation/build.gradle.kts +++ b/onboarding/presentation/build.gradle.kts @@ -34,6 +34,7 @@ android { defaultConfig { minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" // 카카오·AppAuth 라이브러리 매니페스트가 요구하는 값. manifestPlaceholders["KAKAO_NATIVE_APP_KEY"] = kakaoNativeAppKey manifestPlaceholders["appAuthRedirectScheme"] = googleRedirectUri.substringBefore(":") @@ -133,6 +134,11 @@ dependencies { testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + // manifest 는 반드시 debugImplementation 이다. debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/onboarding/presentation/src/androidTest/kotlin/com/ruleup/onboarding/presentation/intro/screen/ReviewLoginDialogDeviceTest.kt b/onboarding/presentation/src/androidTest/kotlin/com/ruleup/onboarding/presentation/intro/screen/ReviewLoginDialogDeviceTest.kt new file mode 100644 index 000000000..2947728ca --- /dev/null +++ b/onboarding/presentation/src/androidTest/kotlin/com/ruleup/onboarding/presentation/intro/screen/ReviewLoginDialogDeviceTest.kt @@ -0,0 +1,63 @@ +package com.ruleup.onboarding.presentation.intro.screen + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ruleup.designsystem.theme.RuleUpTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ReviewLoginDialogDeviceTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun emptyCodeCannotBeSubmittedAndEnteredCodeIsReturned() { + var submitted: String? = null + compose.setContent { RuleUpTheme { ReviewLoginDialog(onDismiss = {}, onSubmit = { submitted = it }) } } + + compose.onNodeWithText("로그인").assertIsNotEnabled() + assertNull(submitted) + compose.onNodeWithText("심사 코드").performTextInput("REVIEW-123") + compose.onNodeWithText("로그인").assertIsEnabled().performClick() + + assertEquals("REVIEW-123", submitted) + } + + @Test + fun cancelDismissesWithoutSubmittingCode() { + var dismissed = false + var submitted: String? = null + compose.setContent { RuleUpTheme { ReviewLoginDialog(onDismiss = { dismissed = true }, onSubmit = { submitted = it }) } } + + compose.onNodeWithText("심사 코드").performTextInput("REVIEW-123") + compose.onNodeWithText("취소").performClick() + + assertTrue(dismissed) + assertNull(submitted) + } + + @Test + fun submissionInProgressCannotBeDismissedOrSubmittedAgain() { + var dismissed = false + var submitted = false + compose.setContent { + RuleUpTheme { + ReviewLoginDialog(onDismiss = { dismissed = true }, onSubmit = { submitted = true }, isSubmitting = true) + } + } + + compose.onNodeWithText("로그인").assertIsNotEnabled() + compose.onNodeWithText("취소").assertIsNotEnabled() + assertTrue(!dismissed && !submitted) + } +} diff --git a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/common/AuthFailureUi.kt b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/common/AuthFailureUi.kt index 43b7fd5df..952669267 100644 --- a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/common/AuthFailureUi.kt +++ b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/common/AuthFailureUi.kt @@ -34,6 +34,10 @@ fun Throwable.toAuthFailureUi(): AuthFailureUi { AuthFailure.UNKNOWN, -> AuthFailureUi.Toast("로그인에 실패했어요. 다시 시도해주세요") + AuthFailure.INVALID_REVIEW_CODE -> AuthFailureUi.Toast("코드를 다시 확인해주세요") + AuthFailure.REVIEW_TOO_MANY_ATTEMPTS -> AuthFailureUi.Toast("잠시 후 다시 시도해주세요") + AuthFailure.REVIEW_UNAVAILABLE -> AuthFailureUi.Toast("현재 심사 로그인을 사용할 수 없어요") + AuthFailure.PROVIDER_UNAVAILABLE -> AuthFailureUi.Dialog("지금은 연결이 어려워요. 다른 계정으로 로그인해보세요") diff --git a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/screen/LoginScreen.kt b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/screen/LoginScreen.kt index 82ed9520e..8eb8fafd4 100644 --- a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/screen/LoginScreen.kt +++ b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/screen/LoginScreen.kt @@ -3,6 +3,7 @@ package com.ruleup.onboarding.presentation.intro.screen import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,6 +20,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -30,6 +32,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ruleup.designsystem.singleClickable import com.ruleup.designsystem.theme.RuleUpColors import com.ruleup.designsystem.theme.RuleUpTheme @@ -48,8 +51,10 @@ import com.ruleup.ui.helper.LocalMessageHelper @Composable fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) { TtiScreenEffect() + val state by viewModel.uiState.collectAsStateWithLifecycle() val messageHelper = LocalMessageHelper.current var failure by remember { mutableStateOf(null) } + var showReviewLogin by remember { mutableStateOf(false) } val launcher = rememberOAuthLauncher { result -> result @@ -75,7 +80,15 @@ fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) { } LoginContent( onIntent = viewModel::onIntent, + onReviewEntry = { if (!state.isLoading) showReviewLogin = true }, ) + if (showReviewLogin) { + ReviewLoginDialog( + onDismiss = { showReviewLogin = false }, + onSubmit = { viewModel.onIntent(LoginIntent.ReviewLoginSubmitted(it)) }, + isSubmitting = state.isLoading, + ) + } AuthFailureHost(ui = failure, onDismiss = { failure = null }) } @@ -83,7 +96,9 @@ fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) { fun LoginContent( onIntent: (LoginIntent) -> Unit, modifier: Modifier = Modifier, + onReviewEntry: () -> Unit = {}, ) { + var logoTaps by remember { mutableIntStateOf(0) } Column( modifier = modifier @@ -107,7 +122,14 @@ fun LoginContent( .size(80.dp) .clip(RoundedCornerShape(22.dp)) .background(Color.White) - .border(1.dp, RuleUpTheme.colors.border, RoundedCornerShape(22.dp)), + .border(1.dp, RuleUpTheme.colors.border, RoundedCornerShape(22.dp)) + .clickable { + logoTaps++ + if (logoTaps == 6) { + logoTaps = 0 + onReviewEntry() + } + }, contentAlignment = Alignment.Center, ) { // 스플래시·앱 아이콘과 같은 체크 링(시안 D). diff --git a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/screen/ReviewLoginDialog.kt b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/screen/ReviewLoginDialog.kt new file mode 100644 index 000000000..0009720f1 --- /dev/null +++ b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/screen/ReviewLoginDialog.kt @@ -0,0 +1,44 @@ +package com.ruleup.onboarding.presentation.intro.screen + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.KeyboardCapitalization +import com.ruleup.designsystem.theme.RuleUpTheme + +@Composable +internal fun ReviewLoginDialog( + onDismiss: () -> Unit, + onSubmit: (String) -> Unit, + isSubmitting: Boolean = false, +) { + var code by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { if (!isSubmitting) onDismiss() }, + title = { Text("심사 계정 로그인") }, + text = { + OutlinedTextField( + value = code, + onValueChange = { code = it }, + label = { Text("심사 코드") }, + singleLine = true, + enabled = !isSubmitting, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters), + ) + }, + confirmButton = { + TextButton(onClick = { onSubmit(code) }, enabled = code.isNotBlank() && !isSubmitting) { + Text("로그인", color = RuleUpTheme.colors.textPrimary) + } + }, + dismissButton = { TextButton(onClick = onDismiss, enabled = !isSubmitting) { Text("취소") } }, + containerColor = RuleUpTheme.colors.surface, + ) +} diff --git a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginIntent.kt b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginIntent.kt index 71712dddd..edabc30ed 100644 --- a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginIntent.kt +++ b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginIntent.kt @@ -19,4 +19,8 @@ sealed interface LoginIntent : MviIntent { data class AuthFailed( val error: Throwable, ) : LoginIntent + + class ReviewLoginSubmitted( + val code: String, + ) : LoginIntent } diff --git a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginViewModel.kt b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginViewModel.kt index 62e0926ba..aab32c27e 100644 --- a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginViewModel.kt +++ b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginViewModel.kt @@ -15,6 +15,7 @@ import com.ruleup.onboarding.domain.auth.SignupSession import com.ruleup.onboarding.domain.auth.entity.AuthException import com.ruleup.onboarding.domain.auth.entity.LoginOutcome import com.ruleup.onboarding.domain.auth.entity.OAuthAuthorization +import com.ruleup.onboarding.domain.auth.usecase.ReviewLoginUseCase import com.ruleup.onboarding.domain.auth.usecase.SocialLoginUseCase import com.ruleup.onboarding.domain.logging.LoginEntryType import com.ruleup.onboarding.domain.logging.OnboardingEvents @@ -25,6 +26,7 @@ import com.ruleup.onboarding.presentation.common.toAuthFailureUi import com.ruleup.profile.domain.navigation.AccountLockedPage import com.ruleup.ui.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import javax.inject.Inject @@ -35,6 +37,7 @@ class LoginViewModel @Inject constructor( private val socialLoginUseCase: SocialLoginUseCase, + private val reviewLoginUseCase: ReviewLoginUseCase, private val navigationHelper: NavigationHelper, private val messageHelper: MessageHelper, private val observability: Observability, @@ -72,6 +75,8 @@ class LoginViewModel // IdP 화면에서 사용자가 취소한 경우가 대부분이라 조용히 되돌린다. dispatch(LoginReducerEvent.LoginFinished) } + + is LoginIntent.ReviewLoginSubmitted -> reviewLogin(intent.code) } } @@ -164,6 +169,23 @@ class LoginViewModel } } } + + private fun reviewLogin(code: String) { + if (currentState.isLoading || code.isBlank()) return + dispatch(LoginReducerEvent.LoginStarted) + viewModelScope.launch { + try { + reviewLoginUseCase(code) + dispatch(LoginReducerEvent.LoginFinished) + navigationHelper.goHomeOrPending(pendingDeepLink) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + dispatch(LoginReducerEvent.LoginFinished) + emitEffect(LoginEffect.ShowFailure(error.toAuthFailureUi())) + } + } + } } /** 로그인·가입을 마친 사용자를 보관된 딥링크 목적지로, 없으면 홈으로 보낸다. */ diff --git a/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/common/ReviewLoginFailureUiTest.kt b/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/common/ReviewLoginFailureUiTest.kt new file mode 100644 index 000000000..451657e5f --- /dev/null +++ b/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/common/ReviewLoginFailureUiTest.kt @@ -0,0 +1,22 @@ +package com.ruleup.onboarding.presentation.common + +import com.ruleup.onboarding.domain.auth.entity.AuthException +import com.ruleup.onboarding.domain.auth.entity.AuthFailure +import kotlin.test.Test +import kotlin.test.assertEquals + +class ReviewLoginFailureUiTest { + @Test + fun `심사 코드 오류의 종류에 따라 적절한 안내를 보여준다`() { + val cases = + mapOf( + AuthFailure.INVALID_REVIEW_CODE to "코드를 다시 확인해주세요", + AuthFailure.REVIEW_TOO_MANY_ATTEMPTS to "잠시 후 다시 시도해주세요", + AuthFailure.REVIEW_UNAVAILABLE to "현재 심사 로그인을 사용할 수 없어요", + ) + + cases.forEach { (failure, message) -> + assertEquals(AuthFailureUi.Toast(message), AuthException(failure).toAuthFailureUi()) + } + } +} diff --git a/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/intro/LoginContentTest.kt b/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/intro/LoginContentTest.kt index 72f772a22..e5b7dce45 100644 --- a/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/intro/LoginContentTest.kt +++ b/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/intro/LoginContentTest.kt @@ -3,6 +3,7 @@ package com.ruleup.onboarding.presentation.intro import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import com.ruleup.onboarding.domain.auth.entity.OAuthProvider import com.ruleup.onboarding.presentation.intro.screen.LoginContent import com.ruleup.onboarding.presentation.intro.viewmodel.LoginIntent @@ -21,6 +22,17 @@ class LoginContentTest { @get:Rule val compose = createComposeRule() + @Test + fun `로고를 여섯 번 누르면 심사 로그인 진입 이벤트가 발생한다`() { + var entries = 0 + compose.renderOnboarding { LoginContent(onIntent = {}, onReviewEntry = { entries++ }) } + + repeat(5) { compose.onNodeWithContentDescription("RuleUp").performClick() } + assertEquals(0, entries) + compose.onNodeWithContentDescription("RuleUp").performClick() + assertEquals(1, entries) + } + @Test fun `두 제공사로 시작할 수 있다`() { // 하나만 있으면 그 계정이 없는 사용자가 앱에 못 들어온다. diff --git a/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginViewModelTest.kt b/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginViewModelTest.kt index 9c19eb975..8d33ed8a7 100644 --- a/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginViewModelTest.kt +++ b/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginViewModelTest.kt @@ -14,11 +14,15 @@ import com.ruleup.logging.domain.test.RecordingBizLogger import com.ruleup.observability.domain.test.testObservability import com.ruleup.onboarding.domain.account.AccountRestrictionProvider import com.ruleup.onboarding.domain.auth.SignupSession +import com.ruleup.onboarding.domain.auth.entity.AuthException +import com.ruleup.onboarding.domain.auth.entity.AuthFailure import com.ruleup.onboarding.domain.auth.entity.AuthSession import com.ruleup.onboarding.domain.auth.entity.OAuthAuthorization import com.ruleup.onboarding.domain.auth.entity.OAuthProfile import com.ruleup.onboarding.domain.auth.entity.OAuthProvider import com.ruleup.onboarding.domain.auth.entity.OAuthResult +import com.ruleup.onboarding.domain.auth.repository.AuthRepository +import com.ruleup.onboarding.domain.auth.usecase.ReviewLoginUseCase import com.ruleup.onboarding.domain.auth.usecase.SocialLoginUseCase import com.ruleup.onboarding.domain.fake.FakeAuthRepository import com.ruleup.onboarding.domain.fake.FakeDeviceIdentityRepository @@ -27,8 +31,11 @@ import com.ruleup.onboarding.domain.fake.testUser import com.ruleup.onboarding.domain.logging.SignupTimer import com.ruleup.onboarding.domain.navigation.HomePage import com.ruleup.onboarding.domain.navigation.OnboardingNicknamePage +import com.ruleup.onboarding.presentation.common.AuthFailureUi +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -37,6 +44,7 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue /** 소셜 로그인. */ @@ -56,6 +64,66 @@ class LoginViewModelTest { @AfterTest fun tearDown() = Dispatchers.resetMain() + @Test + fun `심사 코드로 로그인하면 심사 세션을 저장하고 홈으로 간다`() = + runTest { + val auth = FakeAuthRepository().apply { reviewResult = AuthSession(token, testUser()) } + val tokens = FakeTokenRepository() + val nav = RecordingNavigationHelper() + val viewModel = viewModel(auth, nav = nav, tokens = tokens) + + viewModel.onIntent(LoginIntent.ReviewLoginSubmitted("TEST-CODE")) + + assertEquals("TEST-CODE", auth.reviewCode) + assertTrue(tokens.isReviewSession.first()) + assertEquals(listOf(HomePage), nav.pages) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `심사 코드가 틀리면 로그인 화면에 남고 안내를 보여준다`() = + runTest { + val auth = FakeAuthRepository().apply { reviewError = AuthException(AuthFailure.INVALID_REVIEW_CODE) } + val tokens = FakeTokenRepository() + val nav = RecordingNavigationHelper() + val viewModel = viewModel(auth, nav = nav, tokens = tokens) + + viewModel.onIntent(LoginIntent.ReviewLoginSubmitted("wrong")) + + assertTrue(nav.didNotMove) + assertFalse(tokens.isReviewSession.first()) + assertFalse(viewModel.uiState.value.isLoading) + assertEquals(LoginEffect.ShowFailure(AuthFailureUi.Toast("코드를 다시 확인해주세요")), viewModel.effect.first()) + } + + @Test + fun `심사 로그인을 기다리는 동안 중복 제출해도 한 번만 요청한다`() = + runTest { + val gate = CompletableDeferred() + val fake = FakeAuthRepository().apply { reviewResult = AuthSession(token, testUser()) } + var calls = 0 + val auth = + object : AuthRepository by fake { + override suspend fun reviewLogin(code: String): AuthSession { + calls++ + gate.await() + return fake.reviewLogin(code) + } + } + val nav = RecordingNavigationHelper() + val viewModel = viewModel(auth, nav = nav) + + viewModel.onIntent(LoginIntent.ReviewLoginSubmitted("TEST-CODE")) + viewModel.onIntent(LoginIntent.ReviewLoginSubmitted("TEST-CODE")) + assertEquals(1, calls) + assertTrue(viewModel.uiState.value.isLoading) + gate.complete(Unit) + testScheduler.runCurrent() + + assertEquals(listOf(HomePage), nav.pages) + assertFalse(viewModel.uiState.value.isLoading) + } + @Test fun `기존 사용자는 홈으로 보낸다`() = runTest { @@ -221,7 +289,7 @@ class LoginViewModelTest { } private fun viewModel( - auth: FakeAuthRepository, + auth: AuthRepository, nav: RecordingNavigationHelper = RecordingNavigationHelper(), messages: RecordingMessageHelper = RecordingMessageHelper(), signupSession: SignupSession = SignupSession(), @@ -230,6 +298,7 @@ class LoginViewModelTest { restriction: AccountRestriction = AccountRestriction.None, ) = LoginViewModel( socialLoginUseCase = SocialLoginUseCase(auth, FakeDeviceIdentityRepository(), tokens, testObservability()), + reviewLoginUseCase = ReviewLoginUseCase(auth, tokens), navigationHelper = nav, messageHelper = messages, observability = testObservability(), diff --git a/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/BindLocationUseCaseTest.kt b/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/BindLocationUseCaseTest.kt index f1a2b0870..958a43fab 100644 --- a/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/BindLocationUseCaseTest.kt +++ b/verification/domain/src/test/kotlin/com/ruleup/verification/domain/usecase/BindLocationUseCaseTest.kt @@ -75,6 +75,13 @@ class BindLocationUseCaseTest { private class FakeTokenRepository( private val storedUserId: String?, ) : TokenRepository { + override val isReviewSession: Flow = flowOf(false) + + override suspend fun saveReviewSession( + token: Token, + userId: String, + ) = Unit + override suspend fun saveSession( token: Token, userId: String,