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
2 changes: 1 addition & 1 deletion OBSERVABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`? |
Expand Down
14 changes: 7 additions & 7 deletions TEST_STRATEGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 돌리는 법

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -121,7 +121,7 @@ private suspend fun <T> 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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -77,7 +78,11 @@ class ReviewLoginApiTest {
assertEquals(AuthFailure.INVALID_REVIEW_CODE, assertFailsWith<AuthException> { repository.reviewLogin("bad") }.failure)
status = 429
responseCode = "TOO_MANY_ATTEMPTS"
assertEquals(AuthFailure.REVIEW_TOO_MANY_ATTEMPTS, assertFailsWith<AuthException> { repository.reviewLogin("bad") }.failure)
retryAfter = ""","retryAfterSeconds":42"""
val tooMany = assertFailsWith<AuthException> { 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<AuthException> { repository.reviewLogin("bad") }.failure)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,5 @@ class AuthException(
val failure: AuthFailure,
message: String? = null,
cause: Throwable? = null,
val retryAfterSeconds: Int? = null,
) : Exception(message, cause)
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("지금은 연결이 어려워요. 다른 계정으로 로그인해보세요")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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 })
Expand All @@ -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
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ internal fun ReviewLoginDialog(
onDismiss: () -> Unit,
onSubmit: (String) -> Unit,
isSubmitting: Boolean = false,
isCoolingDown: Boolean = false,
) {
var code by remember { mutableStateOf("") }
AlertDialog(
Expand All @@ -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)
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ sealed interface LoginReducerEvent : ReducerEvent {
data object LoginStarted : LoginReducerEvent

data object LoginFinished : LoginReducerEvent

data object ReviewCooldownStarted : LoginReducerEvent

data object ReviewCooldownEnded : LoginReducerEvent
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading