diff --git a/OBSERVABILITY.md b/OBSERVABILITY.md index d77dc106..ba52acd1 100644 --- a/OBSERVABILITY.md +++ b/OBSERVABILITY.md @@ -77,7 +77,7 @@ feature 가 부르지 않아도 파이프라인이 보내는 것들이다. |---|---|---| | `login_screen_view` | 로그인 화면 진입 | `entry_type` (`fresh` / `relogin`) | | `login_attempt` | 소셜 버튼 클릭 | `provider` | -| `login_result` | 토큰 발급 성공·실패 | `provider`, `success`, `error_code`?, `is_new_user`?, `restored`? | +| `login_result` | 토큰 발급 성공·실패 | `provider`(심사 코드 로그인은 `review`), `success`, `error_code`?, `is_new_user`?, `restored`? | | `onboarding_step_view` | 6단계 각 진입 | `step`, `step_index` (1부터) | | `onboarding_step_complete` | 6단계 각 완료 | `step`, `skipped` | | `nickname_check` | 닉네임 확인 응답 | `valid`, `available`, `reason`? | diff --git a/TEST_STRATEGY.md b/TEST_STRATEGY.md index c3511195..4f1d5c1d 100644 --- a/TEST_STRATEGY.md +++ b/TEST_STRATEGY.md @@ -190,15 +190,15 @@ verification domain의 아래 테스트로 대체하고 생성·참여 연동 | 약관 재동의·철회가 사유별로 갈린다 | **인수 있음** | `AccountContractAcceptanceTest`(철회 금지·버전 불일치) · `AgreementsViewModelTest` · `ErrorBodyInterceptorTest` | | 인증 제출 → 오늘 상태가 바뀜 | 하위만 | `RunSyncUseCaseTest` · `SubmitDeviceIntroUseCaseTest` · `TodayStatusTest` | -### 심사 계정 로그인 (#645) +### 심사 계정 로그인 (#645, #657) -- `ReviewLoginApiTest`: 코드만 전송하고 기존 Authorization 헤더와 디버그 로그를 남기지 않으며, 401에서 기존 토큰으로 재시도하지 않는지 확인한다. 잘못된 코드·횟수 제한·기능 비활성화 응답도 구분한다. -- `ReviewLoginUseCaseTest`, `TokenRepositoryImplTest`: 심사 세션 저장과 실패, 갱신 및 로그아웃 경로를 확인한다. 심사 세션 저장 중 로그아웃과 이전 세션의 늦은 갱신 결과도 확인한다. -- `ReviewLoginFailureUiTest`: 잘못된 코드, 횟수 제한, 기능 비활성화에 따른 안내를 구분한다. -- `LoginViewModelTest`: 심사 세션 저장 후 홈 진입, 코드 오류 안내와 중복 제출 방지를 확인한다. -- `LoginContentTest`: 로고 6회 탭으로 진입하는지 확인한다. +- `ReviewLoginApiTest`: 코드만 전송하고 기존 Authorization 헤더와 디버그 로그를 남기지 않으며, 401에서 기존 토큰으로 재시도하지 않는지 확인한다. 잘못된 코드·횟수 제한·기능 비활성화 응답도 구분하고, 횟수 제한의 `retryAfterSeconds` 를 넘긴다. +- `ReviewLoginUseCaseTest`, `TokenRepositoryImplTest`: 심사 세션 저장과 실패, 정지 계정의 제한 화면 분기, 갱신 및 로그아웃 경로를 확인한다. 심사 세션 저장 중 로그아웃과 이전 세션의 늦은 갱신 결과도 확인한다. +- `ReviewLoginFailureUiTest`: 기능 비활성화(404)는 잘못된 코드와 같은 안내라 종료 여부가 드러나지 않는다. 횟수 제한은 따로 안내한다. +- `LoginViewModelTest`: 심사 세션 저장 후 홈 진입, 코드 오류 안내와 중복 제출 방지, 정지 계정의 잠금 화면 진입, 횟수 제한 대기 시간 동안의 제출 차단을 확인한다. +- `LoginContentTest`: 로고 6회 탭으로 진입하고, 탭 사이가 2초를 넘으면 처음부터 다시 세는지 확인한다. - `ReviewLoginDialogDeviceTest`: 오프라인 Android 에뮬레이터에서 빈 코드의 제출 차단, 입력값 전달, 취소 및 진행 중 중복 제출 방지를 확인한다. Robolectric에서는 텍스트 렌더링 중 메모리 부족으로 실행하지 못했다. -- 서버 심사 로그인 API는 아직 구현되지 않아 실제 서버 연동을 확인하지 못했다. 사용자 요청에 따라 Meta 이벤트 차단은 이번 작업에서 다루지 않는다. +- 스테이징 서버에서 코드 로그인 성공(정규화 포함)·`/users/me`·토큰 재발급을 curl 로 확인했다(2026-11-02). 앱 화면을 거친 기기 확인은 아직 하지 않았다. Meta 이벤트 차단은 하지 않기로 했다. ## 5. 돌리는 법 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 6458c292..3ae1b4fa 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 @@ -40,11 +40,11 @@ class AuthRepositoryImpl private val tokenRefresher: TokenRefresher, private val signupInviteStore: com.ruleup.onboarding.domain.auth.repository.SignupInviteStore, ) : AuthRepository { - override suspend fun reviewLogin(code: String): AuthSession = + override suspend fun reviewLogin(code: String): OAuthResult.ExistingUser = try { mapAuthFailure { val result = api.reviewLogin(ReviewLoginRequest(code)).getOrThrow().toOAuthResult() - (result as? OAuthResult.ExistingUser)?.session + result as? OAuthResult.ExistingUser ?: throw ApiException(code = "INVALID_REVIEW_RESPONSE", message = "심사 계정 응답이 올바르지 않습니다.") } } catch (error: HttpException) { @@ -121,7 +121,7 @@ private suspend fun mapAuthFailure(block: suspend () -> T): T = try { block() } catch (e: ApiException) { - throw AuthException(e.toAuthFailure(), e.message, e) + throw AuthException(e.toAuthFailure(), e.message, e, e.retryAfterSeconds) } catch (e: IOException) { throw AuthException(AuthFailure.NETWORK, e.message, e) } 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 index eeaa7e15..bccb4f8b 100644 --- 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 @@ -37,12 +37,13 @@ class ReviewLoginApiTest { val authenticator = TokenAuthenticator(tokens, Lazy { error("갱신하면 안 됨") }, observability) var status = 400 var responseCode = "INVALID_REVIEW_CODE" + var retryAfter = "" val client = NetworkModule .provideOkHttpClient(tokens, authenticator, false, observability) .newBuilder() .addInterceptor { chain -> - val body = """{"success":false,"error":{"code":"$responseCode","message":"error"}}""" + val body = """{"success":false,"error":{"code":"$responseCode","message":"error"$retryAfter}}""" Response .Builder() .request(chain.request()) @@ -77,7 +78,11 @@ class ReviewLoginApiTest { 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) + retryAfter = ""","retryAfterSeconds":42""" + val tooMany = assertFailsWith { repository.reviewLogin("bad") } + assertEquals(AuthFailure.REVIEW_TOO_MANY_ATTEMPTS, tooMany.failure) + // 화면이 이 시간만큼 다시 제출을 막는다. + assertEquals(42, tooMany.retryAfterSeconds) status = 404 responseCode = "NOT_FOUND" assertEquals(AuthFailure.REVIEW_UNAVAILABLE, assertFailsWith { repository.reviewLogin("bad") }.failure) 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 b1a4087e..0fbb61ef 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 @@ -61,4 +61,5 @@ class AuthException( val failure: AuthFailure, message: String? = null, cause: Throwable? = null, + val retryAfterSeconds: Int? = null, ) : Exception(message, cause) diff --git a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/entity/LoginOutcome.kt b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/entity/LoginOutcome.kt index a020e7ea..313e1f43 100644 --- a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/entity/LoginOutcome.kt +++ b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/entity/LoginOutcome.kt @@ -1,6 +1,9 @@ package com.ruleup.onboarding.domain.auth.entity +import com.ruleup.domain.entity.user.AccountStatus import com.ruleup.domain.entity.user.LockInfo +import com.ruleup.domain.entity.user.NicknameStatus +import com.ruleup.domain.entity.user.UserAccount /** 소셜 로그인 후 화면이 가야 할 곳. */ sealed interface LoginOutcome { @@ -25,4 +28,21 @@ sealed interface LoginOutcome { val expiresInSeconds: Int, val profile: OAuthProfile, ) : LoginOutcome + + companion object { + /** 기존 회원은 로그인 경로와 무관하게 계정 상태로 행선지가 갈린다. */ + fun forExistingUser( + nickname: String, + account: UserAccount, + restored: Boolean, + ): LoginOutcome = + when { + account.nicknameStatus == NicknameStatus.CONFLICT -> ResetNickname(nickname) + + // 서버는 정지를 `SUSPENDED` 로 내린다. + account.accountStatus != AccountStatus.ACTIVE -> Restricted(account.lockInfo) + + else -> GoHome(restored = restored) + } + } } 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 a4c71fdd..d3bb806a 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,7 +10,7 @@ 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 reviewLogin(code: String): OAuthResult.ExistingUser /** 소셜 로그인. */ suspend fun exchangeToken( 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 index c8a5be58..f1edb539 100644 --- 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 @@ -1,7 +1,7 @@ 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.entity.LoginOutcome import com.ruleup.onboarding.domain.auth.repository.AuthRepository import javax.inject.Inject @@ -11,9 +11,10 @@ class ReviewLoginUseCase 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 + suspend operator fun invoke(code: String): LoginOutcome { + val result = authRepository.reviewLogin(code) + val user = result.session.user + tokens.saveReviewSession(result.session.token, user.id) + return LoginOutcome.forExistingUser(user.nickname, requireNotNull(user.account), result.restored) } } diff --git a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/usecase/SocialLoginUseCase.kt b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/usecase/SocialLoginUseCase.kt index 44a5c5ab..887ea6cf 100644 --- a/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/usecase/SocialLoginUseCase.kt +++ b/onboarding/domain/src/main/kotlin/com/ruleup/onboarding/domain/auth/usecase/SocialLoginUseCase.kt @@ -1,8 +1,6 @@ package com.ruleup.onboarding.domain.auth.usecase import com.ruleup.domain.device.DeviceIdentityRepository -import com.ruleup.domain.entity.user.AccountStatus -import com.ruleup.domain.entity.user.NicknameStatus import com.ruleup.domain.token.TokenRepository import com.ruleup.observability.domain.api.Observability import com.ruleup.observability.domain.api.w @@ -38,16 +36,7 @@ class SocialLoginUseCase if (!account.onboardingCompleted) { observability.w(TAG) { "기존 회원인데 onboardingCompleted=false — signupToken 이 없어 온보딩을 이어갈 수 없다" } } - when { - account.nicknameStatus == NicknameStatus.CONFLICT -> - LoginOutcome.ResetNickname(user.nickname) - - // 서버는 정지를 `SUSPENDED` 로 내린다. - account.accountStatus != AccountStatus.ACTIVE -> - LoginOutcome.Restricted(account.lockInfo) - - else -> LoginOutcome.GoHome(restored = result.restored) - } + LoginOutcome.forExistingUser(user.nickname, account, result.restored) } is OAuthResult.NewUser -> 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 index 9a56f8bf..f2487786 100644 --- 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 @@ -1,7 +1,10 @@ package com.ruleup.onboarding.domain.auth.usecase +import com.ruleup.domain.entity.user.AccountStatus import com.ruleup.domain.entity.user.Token import com.ruleup.onboarding.domain.auth.entity.AuthSession +import com.ruleup.onboarding.domain.auth.entity.LoginOutcome +import com.ruleup.onboarding.domain.auth.entity.OAuthResult import com.ruleup.onboarding.domain.fake.FakeAuthRepository import com.ruleup.onboarding.domain.fake.FakeTokenRepository import com.ruleup.onboarding.domain.fake.testUser @@ -14,21 +17,40 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class ReviewLoginUseCaseTest { + private val token = Token("access", "refresh", "Bearer", 3600) + @Test fun `심사 로그인에 성공하면 토큰과 심사 세션을 저장한다`() = runBlocking { - val session = AuthSession(Token("access", "refresh", "Bearer", 3600), testUser()) - val auth = FakeAuthRepository().apply { reviewResult = session } + val auth = + FakeAuthRepository().apply { + reviewResult = + OAuthResult.ExistingUser( + AuthSession(token, testUser()), + restored = true, + ) + } val tokens = FakeTokenRepository() val result = ReviewLoginUseCase(auth, tokens)("TEST-CODE") - assertEquals(session, result) + assertEquals(LoginOutcome.GoHome(restored = true), result) assertEquals("TEST-CODE", auth.reviewCode) - assertEquals(session.token, tokens.savedToken) + assertEquals(token, tokens.savedToken) assertTrue(tokens.isReviewSession.first()) } + @Test + fun `심사 계정도 정지 상태면 일반 로그인처럼 제한 화면으로 보낸다`() = + runBlocking { + val user = testUser(accountStatus = AccountStatus.SUSPENDED) + val auth = FakeAuthRepository().apply { reviewResult = OAuthResult.ExistingUser(AuthSession(token, user), restored = false) } + + val result = ReviewLoginUseCase(auth, FakeTokenRepository())("TEST-CODE") + + assertEquals(LoginOutcome.Restricted(lockInfo = null), result) + } + @Test fun `심사 로그인 실패는 세션을 만들지 않는다`() = runBlocking { 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 06ab9dc8..b3adadd8 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 @@ -116,11 +116,11 @@ class FakeTokenRepository( /** 각 API 의 성공 결과/예외를 주입할 수 있는 인증 저장소 테스트 더블. */ class FakeAuthRepository : AuthRepository { - var reviewResult: AuthSession? = null + var reviewResult: OAuthResult.ExistingUser? = null var reviewError: Throwable? = null var reviewCode: String? = null - override suspend fun reviewLogin(code: String): AuthSession { + override suspend fun reviewLogin(code: String): OAuthResult.ExistingUser { reviewCode = code reviewError?.let { throw it } return requireNotNull(reviewResult) 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 95266926..04c75a4e 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,9 +34,12 @@ fun Throwable.toAuthFailureUi(): AuthFailureUi { AuthFailure.UNKNOWN, -> AuthFailureUi.Toast("로그인에 실패했어요. 다시 시도해주세요") - AuthFailure.INVALID_REVIEW_CODE -> AuthFailureUi.Toast("코드를 다시 확인해주세요") + // 심사 종료(404)를 코드 오류와 구분해 보여주면 종료 여부가 드러난다. + AuthFailure.INVALID_REVIEW_CODE, + AuthFailure.REVIEW_UNAVAILABLE, + -> 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 8eb8fafd..a1a91bcd 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 @@ -47,6 +47,10 @@ import com.ruleup.onboarding.presentation.oauth.rememberOAuthLauncher import com.ruleup.tti.presentation.TtiScreenEffect import com.ruleup.tti.presentation.ttiContentDrawn import com.ruleup.ui.helper.LocalMessageHelper +import kotlinx.coroutines.delay + +private const val REVIEW_TAP_COUNT = 6 +private const val REVIEW_TAP_WINDOW_MS = 2_000L @Composable fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) { @@ -87,6 +91,7 @@ fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) { onDismiss = { showReviewLogin = false }, onSubmit = { viewModel.onIntent(LoginIntent.ReviewLoginSubmitted(it)) }, isSubmitting = state.isLoading, + isCoolingDown = state.isReviewCoolingDown, ) } AuthFailureHost(ui = failure, onDismiss = { failure = null }) @@ -99,6 +104,13 @@ fun LoginContent( onReviewEntry: () -> Unit = {}, ) { var logoTaps by remember { mutableIntStateOf(0) } + // 탭 사이가 벌어지면 처음부터 다시 센다 — 우연한 탭이 쌓여 열리지 않게. + LaunchedEffect(logoTaps) { + if (logoTaps > 0) { + delay(REVIEW_TAP_WINDOW_MS) + logoTaps = 0 + } + } Column( modifier = modifier @@ -125,7 +137,7 @@ fun LoginContent( .border(1.dp, RuleUpTheme.colors.border, RoundedCornerShape(22.dp)) .clickable { logoTaps++ - if (logoTaps == 6) { + if (logoTaps == REVIEW_TAP_COUNT) { logoTaps = 0 onReviewEntry() } 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 index 0009720f..a7b8d126 100644 --- 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 @@ -18,6 +18,7 @@ internal fun ReviewLoginDialog( onDismiss: () -> Unit, onSubmit: (String) -> Unit, isSubmitting: Boolean = false, + isCoolingDown: Boolean = false, ) { var code by remember { mutableStateOf("") } AlertDialog( @@ -34,7 +35,7 @@ internal fun ReviewLoginDialog( ) }, confirmButton = { - TextButton(onClick = { onSubmit(code) }, enabled = code.isNotBlank() && !isSubmitting) { + TextButton(onClick = { onSubmit(code) }, enabled = code.isNotBlank() && !isSubmitting && !isCoolingDown) { Text("로그인", color = RuleUpTheme.colors.textPrimary) } }, diff --git a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginReducerEvent.kt b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginReducerEvent.kt index 79667057..43d9d8f7 100644 --- a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginReducerEvent.kt +++ b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginReducerEvent.kt @@ -8,4 +8,8 @@ sealed interface LoginReducerEvent : ReducerEvent { data object LoginStarted : LoginReducerEvent data object LoginFinished : LoginReducerEvent + + data object ReviewCooldownStarted : LoginReducerEvent + + data object ReviewCooldownEnded : LoginReducerEvent } diff --git a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginState.kt b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginState.kt index a2b68e15..8baaa7ea 100644 --- a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginState.kt +++ b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/intro/viewmodel/LoginState.kt @@ -4,6 +4,8 @@ import com.ruleup.ui.mvi.UiState data class LoginState( val isLoading: Boolean = false, + // 심사 코드 시도 한도 초과 — 서버가 준 대기 시간 동안 다시 제출하지 못한다. + val isReviewCoolingDown: Boolean = false, ) : UiState { companion object { val initial = LoginState() 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 aab32c27..254f69b0 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 @@ -13,6 +13,7 @@ import com.ruleup.observability.domain.api.w 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.LoginOutcome import com.ruleup.onboarding.domain.auth.entity.OAuthAuthorization import com.ruleup.onboarding.domain.auth.usecase.ReviewLoginUseCase @@ -27,11 +28,15 @@ 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.delay import kotlinx.coroutines.launch import javax.inject.Inject private const val TAG = "[Login]" +// login_result 의 provider 값. 소셜 로그인 퍼널과 섞이지 않게 따로 둔다. +private const val REVIEW_PROVIDER = "review" + @HiltViewModel class LoginViewModel @Inject @@ -96,6 +101,14 @@ class LoginViewModel is LoginReducerEvent.LoginFinished -> { state.copy(isLoading = false) } + + is LoginReducerEvent.ReviewCooldownStarted -> { + state.copy(isReviewCoolingDown = true) + } + + is LoginReducerEvent.ReviewCooldownEnded -> { + state.copy(isReviewCoolingDown = false) + } } /** 제한 계정의 진입. */ @@ -134,26 +147,7 @@ class LoginViewModel restored = (result as? LoginOutcome.GoHome)?.restored, ), ) - when (result) { - is LoginOutcome.GoHome -> navigationHelper.goHomeOrPending(pendingDeepLink) - - // 제한이 걸린 계정도 로그인은 된다. - is LoginOutcome.Restricted -> enterRestricted(result) - - // 복원 중 닉네임을 선점당했다. - is LoginOutcome.ResetNickname -> { - messageHelper.showSnackBar( - messageText = "'${result.currentNickname}' 을(를) 다른 분이 쓰고 있어요. 새 닉네임을 정해주세요", - ) - navigationHelper.navigateTo(OnboardingNicknamePage) - } - - is LoginOutcome.GoSignup -> { - // 토큰은 백스택에 실지 않는다 - signupSession.start(result.signupToken, result.profile) - navigationHelper.navigateTo(OnboardingNicknamePage) - } - } + enter(result) }.onFailure { error -> dispatch(LoginReducerEvent.LoginFinished) // 원인은 로그로만 남긴다 @@ -170,22 +164,73 @@ class LoginViewModel } } + /** 로그인 결과에 따라 화면을 옮긴다. */ + private suspend fun enter(result: LoginOutcome) { + when (result) { + is LoginOutcome.GoHome -> navigationHelper.goHomeOrPending(pendingDeepLink) + + // 제한이 걸린 계정도 로그인은 된다. + is LoginOutcome.Restricted -> enterRestricted(result) + + // 복원 중 닉네임을 선점당했다. + is LoginOutcome.ResetNickname -> { + messageHelper.showSnackBar( + messageText = "'${result.currentNickname}' 을(를) 다른 분이 쓰고 있어요. 새 닉네임을 정해주세요", + ) + navigationHelper.navigateTo(OnboardingNicknamePage) + } + + is LoginOutcome.GoSignup -> { + // 토큰은 백스택에 실지 않는다 + signupSession.start(result.signupToken, result.profile) + navigationHelper.navigateTo(OnboardingNicknamePage) + } + } + } + private fun reviewLogin(code: String) { - if (currentState.isLoading || code.isBlank()) return + if (currentState.isLoading || currentState.isReviewCoolingDown || code.isBlank()) return dispatch(LoginReducerEvent.LoginStarted) viewModelScope.launch { try { - reviewLoginUseCase(code) + val result = reviewLoginUseCase(code) dispatch(LoginReducerEvent.LoginFinished) - navigationHelper.goHomeOrPending(pendingDeepLink) + bizLogger.record( + OnboardingEvents.loginResult( + provider = REVIEW_PROVIDER, + success = true, + isNewUser = false, + restored = (result as? LoginOutcome.GoHome)?.restored, + ), + ) + enter(result) } catch (cancelled: CancellationException) { throw cancelled } catch (error: Exception) { dispatch(LoginReducerEvent.LoginFinished) + observability.w(TAG, error) { "심사 로그인 실패" } + bizLogger.record( + OnboardingEvents.loginResult( + provider = REVIEW_PROVIDER, + success = false, + errorCode = (error as? AuthException)?.failure?.name, + ), + ) emitEffect(LoginEffect.ShowFailure(error.toAuthFailureUi())) + (error as? AuthException) + ?.takeIf { it.failure == AuthFailure.REVIEW_TOO_MANY_ATTEMPTS } + ?.retryAfterSeconds + ?.takeIf { it > 0 } + ?.let { coolDownReview(it) } } } } + + private suspend fun coolDownReview(seconds: Int) { + dispatch(LoginReducerEvent.ReviewCooldownStarted) + delay(seconds * 1_000L) + dispatch(LoginReducerEvent.ReviewCooldownEnded) + } } /** 로그인·가입을 마친 사용자를 보관된 딥링크 목적지로, 없으면 홈으로 보낸다. */ 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 index 451657e5..5ec9df0c 100644 --- 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 @@ -7,12 +7,12 @@ import kotlin.test.assertEquals class ReviewLoginFailureUiTest { @Test - fun `심사 코드 오류의 종류에 따라 적절한 안내를 보여준다`() { + fun `심사 종료는 코드 오류와 같은 안내로 보여 종료 여부를 드러내지 않는다`() { val cases = mapOf( AuthFailure.INVALID_REVIEW_CODE to "코드를 다시 확인해주세요", AuthFailure.REVIEW_TOO_MANY_ATTEMPTS to "잠시 후 다시 시도해주세요", - AuthFailure.REVIEW_UNAVAILABLE to "현재 심사 로그인을 사용할 수 없어요", + AuthFailure.REVIEW_UNAVAILABLE to "코드를 다시 확인해주세요", ) cases.forEach { (failure, message) -> 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 e5b7dce4..7610e1b0 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 @@ -33,6 +33,23 @@ class LoginContentTest { assertEquals(1, entries) } + @Test + fun `로고 탭 사이가 2초를 넘으면 처음부터 다시 센다`() { + // 우연히 몇 번 누른 것이 쌓여 심사 입력 창이 열리면 안 된다. + var entries = 0 + compose.mainClock.autoAdvance = false + compose.renderOnboarding { LoginContent(onIntent = {}, onReviewEntry = { entries++ }) } + val logo = compose.onNodeWithContentDescription("RuleUp") + + repeat(5) { logo.performClick() } + compose.mainClock.advanceTimeBy(2_100) + logo.performClick() + assertEquals(0, entries) + + repeat(5) { logo.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 8d33ed8a..b7796fa4 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 @@ -32,6 +32,7 @@ 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 com.ruleup.profile.domain.navigation.AccountLockedPage import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -67,7 +68,7 @@ class LoginViewModelTest { @Test fun `심사 코드로 로그인하면 심사 세션을 저장하고 홈으로 간다`() = runTest { - val auth = FakeAuthRepository().apply { reviewResult = AuthSession(token, testUser()) } + val auth = FakeAuthRepository().apply { reviewResult = reviewUser() } val tokens = FakeTokenRepository() val nav = RecordingNavigationHelper() val viewModel = viewModel(auth, nav = nav, tokens = tokens) @@ -100,11 +101,11 @@ class LoginViewModelTest { fun `심사 로그인을 기다리는 동안 중복 제출해도 한 번만 요청한다`() = runTest { val gate = CompletableDeferred() - val fake = FakeAuthRepository().apply { reviewResult = AuthSession(token, testUser()) } + val fake = FakeAuthRepository().apply { reviewResult = reviewUser() } var calls = 0 val auth = object : AuthRepository by fake { - override suspend fun reviewLogin(code: String): AuthSession { + override suspend fun reviewLogin(code: String): OAuthResult.ExistingUser { calls++ gate.await() return fake.reviewLogin(code) @@ -124,6 +125,39 @@ class LoginViewModelTest { assertFalse(viewModel.uiState.value.isLoading) } + @Test + fun `심사 계정이 정지 상태면 홈 대신 잠금 화면으로 보낸다`() = + runTest { + val auth = FakeAuthRepository().apply { reviewResult = reviewUser(AccountStatus.SUSPENDED) } + val nav = RecordingNavigationHelper() + val viewModel = viewModel(auth, nav = nav, restriction = AccountRestriction.Locked) + + viewModel.onIntent(LoginIntent.ReviewLoginSubmitted("TEST-CODE")) + + assertEquals(listOf(AccountLockedPage), nav.pages) + } + + @Test + fun `심사 코드 시도 한도를 넘으면 서버가 준 시간 동안 다시 제출할 수 없다`() = + runTest { + val auth = + FakeAuthRepository().apply { + reviewError = AuthException(AuthFailure.REVIEW_TOO_MANY_ATTEMPTS, retryAfterSeconds = 30) + } + val viewModel = viewModel(auth) + + viewModel.onIntent(LoginIntent.ReviewLoginSubmitted("wrong")) + assertTrue(viewModel.uiState.value.isReviewCoolingDown) + + auth.reviewCode = null + viewModel.onIntent(LoginIntent.ReviewLoginSubmitted("again")) + assertEquals(null, auth.reviewCode) + + testScheduler.advanceTimeBy(30_000) + testScheduler.runCurrent() + assertFalse(viewModel.uiState.value.isReviewCoolingDown) + } + @Test fun `기존 사용자는 홈으로 보낸다`() = runTest { @@ -273,6 +307,9 @@ class LoginViewModelTest { private val token = Token("a", "r", "Bearer", 3600) + private fun reviewUser(status: AccountStatus = AccountStatus.ACTIVE) = + OAuthResult.ExistingUser(AuthSession(token, testUser(accountStatus = status)), restored = false) + private fun existingUser() = FakeAuthRepository().apply { exchangeResult = OAuthResult.ExistingUser(AuthSession(token, testUser()), restored = false)