diff --git a/TEST_STRATEGY.md b/TEST_STRATEGY.md index 6b88a8c79..d005c3dfe 100644 --- a/TEST_STRATEGY.md +++ b/TEST_STRATEGY.md @@ -150,6 +150,11 @@ verification domain의 아래 테스트로 대체하고 생성·참여 연동 - `ChallengeTargetsViewModelTest`: 데이터 없음·조회 실패도 초기 로딩을 종료하는지 검사한다. - `assembleDebug`, `test`, `lint` 통과. 서버 인수 테스트는 기본 실행에서 제외되며, OS 권한창·FCM·실제 지도·Health Connect·릴리즈 설치는 이번에 실행하지 않았다. +### 온보딩 닉네임 중복 확인 (#643) + +- ViewModel 테스트에서 입력 후 250ms 대기, 이전 요청 취소, 잘못된 값을 거쳐 같은 닉네임을 다시 입력하는 경로와 서버 확인 실패 후 재시도를 검증한다. +- 서버 응답 속도에 따른 체감 지연은 실제 서버와 기기에서 확인해야 한다. + ## 4. 인수 시나리오 ↔ 하위 테스트 인수가 깨졌는데 하위 층이 전부 초록이었다면 **하위 층에 구멍이 있다**는 뜻이고, 그게 다음 작업이다. diff --git a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/onboarding/viewmodel/OnboardingViewModel.kt b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/onboarding/viewmodel/OnboardingViewModel.kt index b7fe9d857..c9146d274 100644 --- a/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/onboarding/viewmodel/OnboardingViewModel.kt +++ b/onboarding/presentation/src/main/kotlin/com/ruleup/onboarding/presentation/onboarding/viewmodel/OnboardingViewModel.kt @@ -26,10 +26,12 @@ import com.ruleup.profile.domain.entity.NicknameCheckReason import com.ruleup.profile.domain.repository.ProfileRepository import com.ruleup.ui.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import javax.inject.Inject @@ -50,14 +52,16 @@ class OnboardingViewModel private val pendingDeepLink: PendingDeepLink, ) : MviViewModel(OnboardingState.initial) { /** 닉네임 입력 스트림. */ - private val nicknameInput = MutableSharedFlow(extraBufferCapacity = 1) + private val nicknameInput = + MutableSharedFlow(replay = 1, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) init { viewModelScope.launch { nicknameInput .debounce(NICKNAME_DEBOUNCE_MS) - .distinctUntilChanged() - .collect { checkNickname(it) } + .collectLatest { name -> + if (NickNameUtil.validate(name).isValid) checkNickname(name) + } } // IdP 닉네임을 채워 준다. signupSession @@ -141,6 +145,7 @@ class OnboardingViewModel private fun enterNickname(name: String) { dispatch(OnboardingReducerEvent.NicknameEntered(name)) + nicknameInput.tryEmit(name) // 형식이 틀린 건 서버에 묻지 않고 바로 알려 준다. val validation = NickNameUtil.validate(name) if (!validation.isValid) { @@ -152,7 +157,6 @@ class OnboardingViewModel ) return } - nicknameInput.tryEmit(name) } private suspend fun checkNickname(name: String) { @@ -174,6 +178,7 @@ class OnboardingViewModel ), ) }.onFailure { + if (it is CancellationException) throw it if (currentState.nickname != name) return@onFailure // 확인 실패는 "쓸 수 없음"이 아니다. dispatch( @@ -296,7 +301,7 @@ class OnboardingViewModel } private companion object { - const val NICKNAME_DEBOUNCE_MS = 500L + const val NICKNAME_DEBOUNCE_MS = 250L } } diff --git a/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/onboarding/viewmodel/OnboardingViewModelTest.kt b/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/onboarding/viewmodel/OnboardingViewModelTest.kt index 840e1d9d8..c5485999e 100644 --- a/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/onboarding/viewmodel/OnboardingViewModelTest.kt +++ b/onboarding/presentation/src/test/kotlin/com/ruleup/onboarding/presentation/onboarding/viewmodel/OnboardingViewModelTest.kt @@ -16,13 +16,18 @@ import com.ruleup.onboarding.domain.fake.FakeIntroRepository import com.ruleup.onboarding.domain.fake.FakeProfileRepository import com.ruleup.onboarding.domain.logging.SignupTimer import com.ruleup.onboarding.presentation.common.AuthFailureUi +import com.ruleup.profile.domain.entity.NicknameCheck +import com.ruleup.profile.domain.repository.ProfileRepository +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import java.time.Clock @@ -59,6 +64,114 @@ class OnboardingViewModelTest { ) } + @Test + fun `유효한 닉네임 입력이 멈춘 지 250ms 뒤 서버 확인을 시작한다`() = + runTest { + val checked = mutableListOf() + val profiles = + profileRepository { + checked += it + availableNickname() + } + val viewModel = viewModel(session = SignupSession(), profiles = profiles) + + viewModel.onIntent(OnboardingIntent.SetNickName("도전왕")) + advanceTimeBy(249) + runCurrent() + assertTrue(checked.isEmpty()) + advanceTimeBy(1) + runCurrent() + + assertEquals(listOf("도전왕"), checked) + assertEquals(true, viewModel.uiState.value.nicknameAvailable) + } + + @Test + fun `확인한 닉네임을 지웠다가 다시 입력하면 다시 사용할 수 있다`() = + runTest { + val checked = mutableListOf() + val viewModel = + viewModel( + session = SignupSession(), + profiles = + profileRepository { + checked += it + availableNickname() + }, + ) + viewModel.onIntent(OnboardingIntent.SetNickName("도전왕")) + advanceTimeBy(600) + runCurrent() + viewModel.onIntent(OnboardingIntent.SetNickName("")) + viewModel.onIntent(OnboardingIntent.SetNickName("도전왕")) + advanceTimeBy(600) + runCurrent() + + assertEquals(listOf("도전왕", "도전왕"), checked) + assertEquals(true, viewModel.uiState.value.nicknameAvailable) + } + + @Test + fun `닉네임 확인이 실패해도 다시 입력하면 서버 확인 후 진행할 수 있다`() = + runTest { + var attempts = 0 + val viewModel = + viewModel( + session = SignupSession(), + profiles = + profileRepository { + attempts++ + if (attempts == 1) throw java.io.IOException("offline") + availableNickname() + }, + ) + + viewModel.onIntent(OnboardingIntent.SetNickName("도전왕")) + advanceTimeBy(250) + runCurrent() + assertEquals(null, viewModel.uiState.value.nicknameAvailable) + assertTrue( + viewModel.uiState.value.nicknameMessage + ?.contains("확인하지 못했어요") == true, + ) + + viewModel.onIntent(OnboardingIntent.SetNickName("")) + viewModel.onIntent(OnboardingIntent.SetNickName("도전왕")) + advanceTimeBy(250) + runCurrent() + assertEquals(2, attempts) + assertEquals(true, viewModel.uiState.value.nicknameAvailable) + } + + @Test + fun `이전 닉네임 확인이 오래 걸려도 새 입력을 확인한다`() = + runTest { + val checked = mutableListOf() + val first = CompletableDeferred() + val profiles = + profileRepository { name -> + checked += name + if (name == "도전왕") first.await() else availableNickname() + } + val viewModel = viewModel(session = SignupSession(), profiles = profiles) + viewModel.onIntent(OnboardingIntent.SetNickName("도전왕")) + advanceTimeBy(600) + runCurrent() + viewModel.onIntent(OnboardingIntent.SetNickName("새로운왕")) + advanceTimeBy(600) + runCurrent() + + assertEquals(listOf("도전왕", "새로운왕"), checked) + assertEquals(true, viewModel.uiState.value.nicknameAvailable) + } + + private fun profileRepository(check: suspend (String) -> NicknameCheck): ProfileRepository = + object : ProfileRepository by FakeProfileRepository() { + override suspend fun checkNickname(nickname: String): NicknameCheck = check(nickname) + } + + private fun availableNickname() = NicknameCheck(valid = true, available = true, reason = null) + @Test fun `가입 토큰이 만료됐으면 안내를 띄운 뒤 로그인부터 다시 하게 한다`() = runTest { @@ -138,9 +251,9 @@ class OnboardingViewModelTest { auth: FakeAuthRepository = FakeAuthRepository(), session: SignupSession = startedSession(), nav: RecordingNavigationHelper = RecordingNavigationHelper(), - ): OnboardingViewModel { - val profiles = FakeProfileRepository() - return OnboardingViewModel( + profiles: ProfileRepository = FakeProfileRepository(), + ): OnboardingViewModel = + OnboardingViewModel( signupUseCase = SignupUseCase(auth, FakeDeviceIdentityRepository(), profiles, FakeTokenRepository(), testObservability()), validateBirthDateUseCase = @@ -153,5 +266,4 @@ class OnboardingViewModelTest { navigationHelper = nav, pendingDeepLink = PendingDeepLink(), ) - } }