From ca6312b8b69220f9cf8dc717e948c4f8eea97dfc Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sun, 2 Aug 2026 16:23:09 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20onboarding=20=ED=94=BC=EC=B2=98=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신규 온보딩 기능을 위한 :feature:onboarding 모듈을 생성하고 app 모듈에 의존성을 추가한다. settings.gradle.kts에 모듈을 등록하여 전체 프로젝트 빌드 구성에 포함한다. --- app/build.gradle.kts | 1 + feature/onboarding/.gitignore | 1 + feature/onboarding/build.gradle.kts | 7 +++++++ feature/onboarding/consumer-rules.pro | 0 feature/onboarding/proguard-rules.pro | 21 +++++++++++++++++++ .../onboarding/src/main/AndroidManifest.xml | 2 ++ settings.gradle.kts | 1 + 7 files changed, 33 insertions(+) create mode 100644 feature/onboarding/.gitignore create mode 100644 feature/onboarding/build.gradle.kts create mode 100644 feature/onboarding/consumer-rules.pro create mode 100644 feature/onboarding/proguard-rules.pro create mode 100644 feature/onboarding/src/main/AndroidManifest.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80b14eb..a0e72f2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,6 +64,7 @@ dependencies { implementation(projects.core.dataLocal) implementation(projects.core.navigation) implementation(projects.core.designsystem) + implementation(projects.feature.onboarding) implementation(projects.feature.auth) implementation(libs.androidx.navigation3.runtime) diff --git a/feature/onboarding/.gitignore b/feature/onboarding/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/feature/onboarding/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts new file mode 100644 index 0000000..a0abcd6 --- /dev/null +++ b/feature/onboarding/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.todakun.feature) +} + +android { + namespace = "com.kikidan.onboarding" +} diff --git a/feature/onboarding/consumer-rules.pro b/feature/onboarding/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature/onboarding/proguard-rules.pro b/feature/onboarding/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature/onboarding/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/onboarding/src/main/AndroidManifest.xml b/feature/onboarding/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/onboarding/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 6408b97..b6c12b1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,5 +35,6 @@ include(":core:data") include(":core:data-local") include(":core:data-remote") include(":core:navigation") +include(":feature:onboarding") include(":core:designsystem") include(":feature:auth") From f98e76bc1097360e66c124f920ed96756b6b8d9f Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sun, 2 Aug 2026 16:25:49 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20UseCase=20=EB=B0=8F=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회원가입 성공 시 발급된 토큰을 TokenRepository에 저장하도록 SignUpUseCase를 구현한다. OnboardingTerm enum으로 서비스 이용약관 및 마케팅 수신 동의 항목의 필수 여부를 정의한다. 도메인 레이어의 비즈니스 로직 검증을 위해 junit 및 coroutines-test 의존성을 추가한다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01299PBN5qzgPtdnPH2e25rs --- core/domain/build.gradle.kts | 2 + .../domain/model/onboarding/OnboardingTerm.kt | 10 +++++ .../domain/model/onboarding/UserName.kt | 37 +++++++++++++++++++ .../domain/repository/AuthRepository.kt | 7 ++++ .../kikidan/domain/usecase/SignUpUseCase.kt | 25 +++++++++++++ 5 files changed, 81 insertions(+) create mode 100644 core/domain/src/main/java/com/kikidan/domain/model/onboarding/OnboardingTerm.kt create mode 100644 core/domain/src/main/java/com/kikidan/domain/model/onboarding/UserName.kt create mode 100644 core/domain/src/main/java/com/kikidan/domain/usecase/SignUpUseCase.kt diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 9d613a1..1bedec9 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -16,4 +16,6 @@ dependencies { implementation(libs.javax.inject) implementation(libs.kotlinx.serialization.json) api(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/core/domain/src/main/java/com/kikidan/domain/model/onboarding/OnboardingTerm.kt b/core/domain/src/main/java/com/kikidan/domain/model/onboarding/OnboardingTerm.kt new file mode 100644 index 0000000..08546b8 --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/model/onboarding/OnboardingTerm.kt @@ -0,0 +1,10 @@ +package com.kikidan.domain.model.onboarding + +enum class OnboardingTerm( + val required: Boolean, +) { + SERVICE(required = true), + PRIVACY(required = true), + AI_DATA_TRANSFER(required = true), + MARKETING(required = false), +} diff --git a/core/domain/src/main/java/com/kikidan/domain/model/onboarding/UserName.kt b/core/domain/src/main/java/com/kikidan/domain/model/onboarding/UserName.kt new file mode 100644 index 0000000..758d906 --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/model/onboarding/UserName.kt @@ -0,0 +1,37 @@ +package com.kikidan.domain.model.onboarding + +/** + * 이름 입력 검증 결과. 실패 사유별로 화면이 다른 안내 문구를 고를 수 있도록 타입으로 구분한다. + */ +sealed interface UserName { + data class Valid( + val name: String, + ) : UserName + + sealed interface Invalid : UserName { + data object Empty : Invalid + + data class TooLong( + val input: String, + ) : Invalid + + data class ContainsSpecialCharacter( + val input: String, + ) : Invalid + } + + companion object { + const val MAX_LENGTH = 10 + private val ALLOWED_PATTERN = Regex("^[가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z0-9 ]+$") + + fun from(raw: String): UserName { + val trimmed = raw.trim() + return when { + trimmed.isEmpty() -> Invalid.Empty + trimmed.length > MAX_LENGTH -> Invalid.TooLong(raw) + !ALLOWED_PATTERN.matches(trimmed) -> Invalid.ContainsSpecialCharacter(raw) + else -> Valid(trimmed) + } + } + } +} diff --git a/core/domain/src/main/java/com/kikidan/domain/repository/AuthRepository.kt b/core/domain/src/main/java/com/kikidan/domain/repository/AuthRepository.kt index 6fccf6c..9a7a3f4 100644 --- a/core/domain/src/main/java/com/kikidan/domain/repository/AuthRepository.kt +++ b/core/domain/src/main/java/com/kikidan/domain/repository/AuthRepository.kt @@ -3,9 +3,16 @@ package com.kikidan.domain.repository import com.kikidan.domain.model.auth.AuthToken import com.kikidan.domain.model.auth.LoginResult import com.kikidan.domain.model.auth.OAuthCredential +import com.kikidan.domain.model.auth.OnboardingToken +import com.kikidan.domain.model.auth.SignupSubmission interface AuthRepository { suspend fun login(credential: OAuthCredential): Result + suspend fun signup( + signupSubmission: SignupSubmission, + onboardingToken: OnboardingToken, + ): Result + suspend fun refresh(refreshToken: String): Result } diff --git a/core/domain/src/main/java/com/kikidan/domain/usecase/SignUpUseCase.kt b/core/domain/src/main/java/com/kikidan/domain/usecase/SignUpUseCase.kt new file mode 100644 index 0000000..16c37cb --- /dev/null +++ b/core/domain/src/main/java/com/kikidan/domain/usecase/SignUpUseCase.kt @@ -0,0 +1,25 @@ +package com.kikidan.domain.usecase + +import com.kikidan.domain.model.auth.AuthToken +import com.kikidan.domain.model.auth.OnboardingToken +import com.kikidan.domain.model.auth.SignupSubmission +import com.kikidan.domain.repository.AuthRepository +import com.kikidan.domain.repository.TokenRepository +import javax.inject.Inject + +class SignUpUseCase + @Inject + constructor( + private val authRepository: AuthRepository, + private val tokenRepository: TokenRepository, + ) { + suspend operator fun invoke( + signupSubmission: SignupSubmission, + onboardingToken: OnboardingToken, + ): Result { + val authToken = + authRepository.signup(signupSubmission, onboardingToken).getOrElse { return Result.failure(it) } + tokenRepository.saveToken(authToken).getOrElse { return Result.failure(it) } + return Result.success(authToken) + } + } From 507fb4244142cc545e3bb812d64b8c8f78a5f65e Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sun, 2 Aug 2026 16:26:56 +0900 Subject: [PATCH 3/7] =?UTF-8?q?test:=20UserName=20=EB=B0=8F=20SignUpUseCas?= =?UTF-8?q?e=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=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 UserName의 유효성 검사 로직과 SignUpUseCase의 토큰 저장 흐름을 검증하는 테스트를 추가한다. - UserName: 한글·영문·숫자 허용, 특수문자 및 이모지 제한, 최대 길이 및 앞뒤 공백 제거 로직 검증 - SignUpUseCase: 회원가입 성공 시 토큰 저장 여부와 성공/실패 시의 결과 전파 로직 검증 --- .../domain/model/onboarding/UserNameTest.kt | 58 ++++++++ .../domain/usecase/SignUpUseCaseTest.kt | 126 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 core/domain/src/test/java/com/kikidan/domain/model/onboarding/UserNameTest.kt create mode 100644 core/domain/src/test/java/com/kikidan/domain/usecase/SignUpUseCaseTest.kt diff --git a/core/domain/src/test/java/com/kikidan/domain/model/onboarding/UserNameTest.kt b/core/domain/src/test/java/com/kikidan/domain/model/onboarding/UserNameTest.kt new file mode 100644 index 0000000..512fb90 --- /dev/null +++ b/core/domain/src/test/java/com/kikidan/domain/model/onboarding/UserNameTest.kt @@ -0,0 +1,58 @@ +package com.kikidan.domain.model.onboarding + +import org.junit.Assert.assertEquals +import org.junit.Test + +class UserNameTest { + @Test + fun `한글 이름은 통과한다`() { + assertEquals(UserName.Valid("토닥이"), UserName.from("토닥이")) + } + + @Test + fun `영문과 숫자, 공백이 섞여도 통과한다`() { + assertEquals(UserName.Valid("Todak un 2"), UserName.from("Todak un 2")) + } + + @Test + fun `빈 문자열은 Empty다`() { + assertEquals(UserName.Invalid.Empty, UserName.from("")) + } + + @Test + fun `공백만 있으면 Empty다`() { + assertEquals(UserName.Invalid.Empty, UserName.from(" ")) + } + + @Test + fun `특수문자가 포함되면 ContainsSpecialCharacter다`() { + assertEquals(UserName.Invalid.ContainsSpecialCharacter("토닥이##"), UserName.from("토닥이##")) + } + + @Test + fun `이모지도 특수문자로 걸러진다`() { + assertEquals(UserName.Invalid.ContainsSpecialCharacter("토닥이🙂"), UserName.from("토닥이🙂")) + } + + @Test + fun `최대 길이까지는 통과한다`() { + val name = "가".repeat(UserName.MAX_LENGTH) + + assertEquals(UserName.Valid(name), UserName.from(name)) + } + + @Test + fun `최대 길이를 넘으면 TooLong이다`() { + val name = "가".repeat(UserName.MAX_LENGTH + 1) + + assertEquals(UserName.Invalid.TooLong(name), UserName.from(name)) + } + + @Test + fun `앞뒤 공백은 길이 계산에서 제외된다`() { + val trimmed = "가".repeat(UserName.MAX_LENGTH) + val name = " $trimmed " + + assertEquals(UserName.Valid(trimmed), UserName.from(name)) + } +} diff --git a/core/domain/src/test/java/com/kikidan/domain/usecase/SignUpUseCaseTest.kt b/core/domain/src/test/java/com/kikidan/domain/usecase/SignUpUseCaseTest.kt new file mode 100644 index 0000000..92a3324 --- /dev/null +++ b/core/domain/src/test/java/com/kikidan/domain/usecase/SignUpUseCaseTest.kt @@ -0,0 +1,126 @@ +package com.kikidan.domain.usecase + +import com.kikidan.domain.model.auth.AuthToken +import com.kikidan.domain.model.auth.Job +import com.kikidan.domain.model.auth.LoginResult +import com.kikidan.domain.model.auth.OAuthCredential +import com.kikidan.domain.model.auth.OnboardingToken +import com.kikidan.domain.model.auth.RelationshipStatus +import com.kikidan.domain.model.auth.SignupSubmission +import com.kikidan.domain.model.user.Birth +import com.kikidan.domain.model.user.BirthTime +import com.kikidan.domain.model.user.DateType +import com.kikidan.domain.model.user.Gender +import com.kikidan.domain.repository.AuthRepository +import com.kikidan.domain.repository.TokenRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.time.LocalDate + +class SignUpUseCaseTest { + private lateinit var authRepository: FakeAuthRepository + private lateinit var tokenRepository: FakeTokenRepository + private lateinit var sut: SignUpUseCase + + private val onboardingToken = OnboardingToken("onboarding-token") + private val user = + SignupSubmission( + name = "토닥이", + job = Job.STUDENT, + relationshipStatus = RelationshipStatus.SOLO, + gender = Gender.FEMALE, + birth = Birth(DateType.SOLAR, LocalDate.of(1999, 2, 13), BirthTime.JA), + ) + + @Before + fun setUp() { + authRepository = FakeAuthRepository() + tokenRepository = FakeTokenRepository() + sut = SignUpUseCase(authRepository, tokenRepository) + } + + @Test + fun `signup이_성공하면_토큰을_저장하고_AuthToken을_반환한다`() = + runTest { + // given + val expected = AuthToken("access", "refresh") + authRepository.signupResult = Result.success(expected) + + // when + val result = sut(user, onboardingToken) + + // then + assertEquals(expected, result.getOrNull()) + assertEquals(expected, tokenRepository.savedToken) + } + + @Test + fun `signup이_실패하면_토큰을_저장하지_않고_실패를_그대로_반환한다`() = + runTest { + // given + val error = IOException("network error") + authRepository.signupResult = Result.failure(error) + + // when + val result = sut(user, onboardingToken) + + // then + assertTrue(result.isFailure) + assertEquals(error, result.exceptionOrNull()) + assertFalse(tokenRepository.saveCalled) + } + + @Test + fun `토큰_저장이_실패하면_실패를_반환한다`() = + runTest { + // given + authRepository.signupResult = Result.success(AuthToken("access", "refresh")) + val saveError = IOException("disk error") + tokenRepository.saveResult = Result.failure(saveError) + + // when + val result = sut(user, onboardingToken) + + // then + assertTrue(result.isFailure) + assertEquals(saveError, result.exceptionOrNull()) + } + + private class FakeAuthRepository : AuthRepository { + var signupResult: Result = Result.success(AuthToken("access", "refresh")) + + override suspend fun login(credential: OAuthCredential): Result = error("not used") + + override suspend fun signup( + signupSubmission: SignupSubmission, + onboardingToken: OnboardingToken, + ): Result = signupResult + + override suspend fun refresh(refreshToken: String): Result = error("not used") + } + + private class FakeTokenRepository : TokenRepository { + var saveResult: Result = Result.success(Unit) + var saveCalled: Boolean = false + var savedToken: AuthToken? = null + + override fun observeLoginState(): Flow> = flowOf() + + override suspend fun getToken(): Result = error("not used") + + override suspend fun saveToken(token: AuthToken): Result { + saveCalled = true + savedToken = token + return saveResult + } + + override suspend fun clearToken(): Result = error("not used") + } +} From 333caca41aac667ded5cac36c52c5c0d464f5c9f Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sun, 2 Aug 2026 16:30:32 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=B0=8F=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20Repository=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthRepository에 signup API를 연동하고 runCatchingCancellable을 적용하여 코루틴 취소가 적절히 전파되도록 구현한다. 온보딩 모듈에는 Orbit MVI를 도입하여 단계별 입력 상태를 관리한다. - OnboardingStep: 약관 동의, 이름, 생년월일, 추가 질문 단계 정의 및 진행률 계산 - TermsAgreementUiModel: 필수 약관 동의 여부 검증 및 전체 선택 로직 처리 - OnboardingViewModel: 각 단계별 유효성 검증(canProceed)과 최종 가입 요청 및 화면 전환 처리 - 테스트 코드를 통해 스텝별 유효성 검사, 뒤로가기 동작, 그리고 가입 완료 로직을 검증한다. --- .../data/repository/AuthRepositoryImpl.kt | 10 + .../data/fake/FakeRemoteAuthDataSource.kt | 7 +- .../data/repository/AuthRepositoryImplTest.kt | 55 ++++ .../onboarding/OnboardingSideEffect.kt | 11 + .../com/kikidan/onboarding/OnboardingState.kt | 75 ++++++ .../com/kikidan/onboarding/OnboardingStep.kt | 23 ++ .../kikidan/onboarding/OnboardingViewModel.kt | 152 +++++++++++ .../onboarding/TermsAgreementUiModel.kt | 21 ++ .../onboarding/OnboardingViewModelTest.kt | 253 ++++++++++++++++++ 9 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingSideEffect.kt create mode 100644 feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingState.kt create mode 100644 feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingStep.kt create mode 100644 feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt create mode 100644 feature/onboarding/src/main/java/com/kikidan/onboarding/TermsAgreementUiModel.kt create mode 100644 feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt diff --git a/core/data/src/main/java/com/kikidan/data/repository/AuthRepositoryImpl.kt b/core/data/src/main/java/com/kikidan/data/repository/AuthRepositoryImpl.kt index eaccb24..44f7bb9 100644 --- a/core/data/src/main/java/com/kikidan/data/repository/AuthRepositoryImpl.kt +++ b/core/data/src/main/java/com/kikidan/data/repository/AuthRepositoryImpl.kt @@ -4,6 +4,8 @@ import com.kikidan.data.datasource.RemoteAuthDataSource import com.kikidan.domain.model.auth.AuthToken import com.kikidan.domain.model.auth.LoginResult import com.kikidan.domain.model.auth.OAuthCredential +import com.kikidan.domain.model.auth.OnboardingToken +import com.kikidan.domain.model.auth.SignupSubmission import com.kikidan.domain.repository.AuthRepository import com.kikidan.domain.util.runCatchingCancellable import javax.inject.Inject @@ -22,4 +24,12 @@ class AuthRepositoryImpl runCatchingCancellable { remoteAuthDataSource.postRefresh(refreshToken) } + + override suspend fun signup( + signupSubmission: SignupSubmission, + onboardingToken: OnboardingToken, + ): Result = + runCatchingCancellable { + remoteAuthDataSource.postSignup(signupSubmission, onboardingToken) + } } diff --git a/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteAuthDataSource.kt b/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteAuthDataSource.kt index 04f302f..cd101c7 100644 --- a/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteAuthDataSource.kt +++ b/core/data/src/test/java/com/kikidan/data/fake/FakeRemoteAuthDataSource.kt @@ -12,6 +12,8 @@ class FakeRemoteAuthDataSource : RemoteAuthDataSource { var loginResult: LoginResult = LoginResult(AuthToken("access", "refresh"), OnboardingToken("onboarding"), newMember = false) var throwOnLogin: Throwable? = null + var signupResult: AuthToken = AuthToken("signup-access", "signup-refresh") + var throwOnSignup: Throwable? = null var refreshResult: AuthToken = AuthToken("new-access", "new-refresh") var throwOnRefresh: Throwable? = null @@ -24,7 +26,10 @@ class FakeRemoteAuthDataSource : RemoteAuthDataSource { override suspend fun postSignup( signupSubmission: SignupSubmission, onboardingToken: OnboardingToken, - ): AuthToken = error("not used") + ): AuthToken { + throwOnSignup?.let { throw it } + return signupResult + } override suspend fun postLogout() = error("not used") diff --git a/core/data/src/test/java/com/kikidan/data/repository/AuthRepositoryImplTest.kt b/core/data/src/test/java/com/kikidan/data/repository/AuthRepositoryImplTest.kt index 12fbd47..d7f44cf 100644 --- a/core/data/src/test/java/com/kikidan/data/repository/AuthRepositoryImplTest.kt +++ b/core/data/src/test/java/com/kikidan/data/repository/AuthRepositoryImplTest.kt @@ -2,11 +2,18 @@ package com.kikidan.data.repository import com.kikidan.data.fake.FakeRemoteAuthDataSource import com.kikidan.domain.model.auth.AuthToken +import com.kikidan.domain.model.auth.Job import com.kikidan.domain.model.auth.LoginResult import com.kikidan.domain.model.auth.OAuthCredential import com.kikidan.domain.model.auth.OAuthProviderType import com.kikidan.domain.model.auth.OAuthToken import com.kikidan.domain.model.auth.OnboardingToken +import com.kikidan.domain.model.auth.RelationshipStatus +import com.kikidan.domain.model.auth.SignupSubmission +import com.kikidan.domain.model.user.Birth +import com.kikidan.domain.model.user.BirthTime +import com.kikidan.domain.model.user.DateType +import com.kikidan.domain.model.user.Gender import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -14,12 +21,22 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.io.IOException +import java.time.LocalDate class AuthRepositoryImplTest { private lateinit var fakeRemoteAuthDataSource: FakeRemoteAuthDataSource private lateinit var sut: AuthRepositoryImpl private val credential = OAuthCredential(OAuthProviderType.KAKAO, OAuthToken("token")) + private val user = + SignupSubmission( + name = "토닥이", + job = Job.STUDENT, + relationshipStatus = RelationshipStatus.SOLO, + gender = Gender.FEMALE, + birth = Birth(DateType.SOLAR, LocalDate.of(1999, 2, 13), BirthTime.JA), + ) + private val onboardingToken = OnboardingToken("onboarding-1") @Before fun setUp() { @@ -92,4 +109,42 @@ class AuthRepositoryImplTest { assertTrue(result.isFailure) assertTrue(result.exceptionOrNull() is IOException) } + + @Test + fun `signup이_성공하면_DataSource의_AuthToken이_그대로_Result_success로_반환된다`() = + runTest { + // given + val expected = AuthToken("a-2", "r-2") + fakeRemoteAuthDataSource.signupResult = expected + + // when + val result = sut.signup(user, onboardingToken) + + // then + assertEquals(expected, result.getOrNull()) + } + + @Test + fun `DataSource의_signup이_예외를_throw하면_Result_failure로_반환되고_예외가_누수되지_않는다`() = + runTest { + // given + fakeRemoteAuthDataSource.throwOnSignup = IOException("network error") + + // when + val result = sut.signup(user, onboardingToken) + + // then + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IOException) + } + + @Test(expected = CancellationException::class) + fun `DataSource의_signup이_CancellationException을_throw하면_Result로_감싸지지_않고_그대로_전파된다`() = + runTest { + // given + fakeRemoteAuthDataSource.throwOnSignup = CancellationException("cancelled") + + // when + sut.signup(user, onboardingToken) + } } diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingSideEffect.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingSideEffect.kt new file mode 100644 index 0000000..08d4b33 --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingSideEffect.kt @@ -0,0 +1,11 @@ +package com.kikidan.onboarding + +sealed interface OnboardingSideEffect { + data object NavigateToHome : OnboardingSideEffect + + data object Exit : OnboardingSideEffect + + data object InvalidInput : OnboardingSideEffect + + data object Failure : OnboardingSideEffect +} diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingState.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingState.kt new file mode 100644 index 0000000..540169a --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingState.kt @@ -0,0 +1,75 @@ +package com.kikidan.onboarding + +import androidx.compose.runtime.Immutable +import com.kikidan.domain.model.auth.Job +import com.kikidan.domain.model.auth.RelationshipStatus +import com.kikidan.domain.model.auth.SignupSubmission +import com.kikidan.domain.model.onboarding.UserName +import com.kikidan.domain.model.user.Birth +import com.kikidan.domain.model.user.BirthTime +import com.kikidan.domain.model.user.DateType +import com.kikidan.domain.model.user.Gender +import java.time.LocalDate + +enum class OnboardingSheet { + BIRTH_DATE, + BIRTH_TIME, +} + +enum class OnboardingDialog { + EXIT_CONFIRM, + SIGN_UP_COMPLETE, +} + +@Immutable +data class OnboardingState( + val step: OnboardingStep = OnboardingStep.TERMS, + val termsAgreement: TermsAgreementUiModel = TermsAgreementUiModel(), + val username: UserName = UserName.Invalid.Empty, + val gender: Gender? = null, + val calendarType: DateType? = null, + val birthDate: LocalDate? = null, + val birthTime: BirthTime? = null, + val lifeStage: Job? = null, + val relationshipStatus: RelationshipStatus? = null, + val sheet: OnboardingSheet? = null, + val dialog: OnboardingDialog? = null, +) { + val canProceed: Boolean + get() = + when (step) { + OnboardingStep.TERMS -> { + termsAgreement.allRequiredSelected + } + + OnboardingStep.NAME -> { + username is UserName.Valid + } + + OnboardingStep.BIRTH_INFO -> { + gender != null && + calendarType != null && + birthDate != null && + birthTime != null + } + + OnboardingStep.EXTRA_QUESTION -> { + lifeStage != null && relationshipStatus != null + } + } +} + +fun OnboardingState.toDomain(): SignupSubmission? { + return SignupSubmission( + name = (username as? UserName.Valid)?.name ?: return null, + job = lifeStage ?: return null, + relationshipStatus = relationshipStatus ?: return null, + gender = gender ?: return null, + birth = + Birth( + dateType = calendarType ?: return null, + date = birthDate ?: return null, + time = birthTime ?: return null, + ), + ) +} diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingStep.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingStep.kt new file mode 100644 index 0000000..cd6a5e1 --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingStep.kt @@ -0,0 +1,23 @@ +package com.kikidan.onboarding + +/** + * 가입 플로우의 단계. 선언 순서가 곧 진행 순서다. + * + * @param progress 프로그레스바 채움 비율(Figma 실측, 트랙 316dp 기준). 첫 스텝(TERMS)은 진행률 대신 + * 라벨 텍스트를 보여주므로 null이다. + */ +enum class OnboardingStep( + val progress: Float?, +) { + TERMS(null), + NAME(1f / 3f), + BIRTH_INFO(2f / 3f), + EXTRA_QUESTION(1f), + ; + + val previous: OnboardingStep? + get() = entries.getOrNull(ordinal - 1) + + val next: OnboardingStep? + get() = entries.getOrNull(ordinal + 1) +} diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..add5d8c --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt @@ -0,0 +1,152 @@ +package com.kikidan.onboarding + +import androidx.lifecycle.ViewModel +import com.kikidan.domain.model.auth.Job +import com.kikidan.domain.model.auth.OnboardingToken +import com.kikidan.domain.model.auth.RelationshipStatus +import com.kikidan.domain.model.onboarding.OnboardingTerm +import com.kikidan.domain.model.onboarding.UserName +import com.kikidan.domain.model.user.BirthTime +import com.kikidan.domain.model.user.DateType +import com.kikidan.domain.model.user.Gender +import com.kikidan.domain.usecase.SignUpUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +class OnboardingViewModel + @Inject + constructor( + private val signUpUseCase: SignUpUseCase, + ) : ViewModel(), + ContainerHost { + override val container = container(OnboardingState()) + + fun onNextClick() = + intent { + if (!state.canProceed) return@intent + val next = state.step.next + next?.let { reduce { state.copy(step = next) } } + } + + fun onBackClick() = + intent { + when (state.step) { + OnboardingStep.TERMS -> reduce { state.copy(dialog = OnboardingDialog.EXIT_CONFIRM) } + else -> state.step.previous?.let { previous -> reduce { state.copy(step = previous) } } + } + } + + fun moveToStep(step: OnboardingStep) = + intent { + reduce { state.copy(step = step) } + } + + fun onDialogDismiss() = + intent { + reduce { state.copy(dialog = null) } + } + + fun onExitConfirmed() = + intent { + reduce { OnboardingState() } + postSideEffect(OnboardingSideEffect.Exit) + } + + fun onCompleteConfirmed(onboardingToken: OnboardingToken) = + intent { + val signupSubmission = state.toDomain() + if (signupSubmission == null) { + postSideEffect(OnboardingSideEffect.InvalidInput) + return@intent + } + signUpUseCase( + signupSubmission = signupSubmission, + onboardingToken = onboardingToken, + ).onSuccess { + reduce { state.copy(dialog = OnboardingDialog.SIGN_UP_COMPLETE) } + postSideEffect(OnboardingSideEffect.NavigateToHome) + }.onFailure { e -> + postSideEffect(OnboardingSideEffect.Failure) + } + } + + fun onTermChange(term: OnboardingTerm) = + intent { + val updated = state.termsAgreement.toggle(term) + reduce { state.copy(termsAgreement = updated) } + } + + fun onAllTermsChange(agreed: Boolean) = + intent { + reduce { state.copy(termsAgreement = state.termsAgreement.withAll(agreed)) } + } + + fun onNameChange(name: String) = + intent { + reduce { state.copy(username = UserName.from(name)) } + } + + fun onGenderSelect(gender: Gender) = + intent { + reduce { state.copy(gender = gender) } + } + + fun onCalendarTypeSelect(calendarType: DateType) = + intent { + reduce { state.copy(calendarType = calendarType) } + } + + fun onSheetOpen(sheet: OnboardingSheet) = + intent { + reduce { state.copy(sheet = sheet) } + } + + fun onSheetDismiss() = + intent { + reduce { state.copy(sheet = null) } + } + + fun onBirthDateChange(date: LocalDate) = + intent { + reduce { state.copy(birthDate = date) } + } + + fun onBirthDateClear() = + intent { + reduce { state.copy(birthDate = null) } + } + + fun onBirthTimeChange(birthTime: BirthTime) = + intent { + reduce { state.copy(birthTime = birthTime) } + } + + fun onBirthTimeClear() = + intent { + reduce { state.copy(birthTime = null) } + } + + fun onBirthTimeUnknownChange(unknown: Boolean) = + intent { + reduce { + state.copy( + birthTime = if (unknown) BirthTime.UNKNOWN else null, + sheet = if (unknown && state.sheet == OnboardingSheet.BIRTH_TIME) null else state.sheet, + ) + } + } + + fun onLifeStageSelect(lifeStage: Job) = + intent { + reduce { state.copy(lifeStage = lifeStage) } + } + + fun onRelationshipStatusSelect(relationshipStatus: RelationshipStatus) = + intent { + reduce { state.copy(relationshipStatus = relationshipStatus) } + } + } diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/TermsAgreementUiModel.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/TermsAgreementUiModel.kt new file mode 100644 index 0000000..c10a1d9 --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/TermsAgreementUiModel.kt @@ -0,0 +1,21 @@ +package com.kikidan.onboarding + +import com.kikidan.domain.model.onboarding.OnboardingTerm + +data class TermsAgreementUiModel( + val selected: Set = emptySet(), +) { + val allRequiredSelected: Boolean + get() = OnboardingTerm.entries.none { it.required && it !in selected } + + val allSelected: Boolean + get() = selected.size == OnboardingTerm.entries.size + + fun toggle(term: OnboardingTerm): TermsAgreementUiModel = + copy(selected = if (isAgreed(term)) selected - term else selected + term) + + fun withAll(value: Boolean): TermsAgreementUiModel = + copy(selected = if (value) OnboardingTerm.entries.toSet() else emptySet()) + + fun isAgreed(onboardingTerm: OnboardingTerm) = onboardingTerm in selected +} diff --git a/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt b/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt new file mode 100644 index 0000000..e0b379a --- /dev/null +++ b/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt @@ -0,0 +1,253 @@ +package com.kikidan.onboarding + +import com.kikidan.domain.model.auth.AuthToken +import com.kikidan.domain.model.auth.Job +import com.kikidan.domain.model.auth.LoginResult +import com.kikidan.domain.model.auth.OAuthCredential +import com.kikidan.domain.model.auth.OnboardingToken +import com.kikidan.domain.model.auth.RelationshipStatus +import com.kikidan.domain.model.auth.SignupSubmission +import com.kikidan.domain.model.onboarding.OnboardingTerm +import com.kikidan.domain.model.onboarding.UserName +import com.kikidan.domain.model.user.BirthTime +import com.kikidan.domain.model.user.DateType +import com.kikidan.domain.model.user.Gender +import com.kikidan.domain.repository.AuthRepository +import com.kikidan.domain.repository.TokenRepository +import com.kikidan.domain.usecase.SignUpUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.orbitmvi.orbit.test.test +import java.time.LocalDate + +@OptIn(ExperimentalCoroutinesApi::class) +class OnboardingViewModelTest { + private lateinit var authRepository: FakeAuthRepository + private lateinit var tokenRepository: FakeTokenRepository + + private val onboardingToken = OnboardingToken("onboarding-token") + + private fun viewModel() = OnboardingViewModel(SignUpUseCase(authRepository, tokenRepository)) + + @Before + fun setUp() { + // viewModelScope가 Dispatchers.Main을 요구하므로 테스트 디스패처로 바꿔둔다. + Dispatchers.setMain(StandardTestDispatcher()) + authRepository = FakeAuthRepository() + tokenRepository = FakeTokenRepository() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `필수 약관을 모두 동의해야 다음으로 넘어갈 수 있다`() = + runTest { + val initial = OnboardingState(step = OnboardingStep.TERMS) + viewModel().test(this, initialState = initial) { + containerHost.onTermChange(OnboardingTerm.SERVICE) + expectState { copy(termsAgreement = termsAgreement.toggle(OnboardingTerm.SERVICE)) } + + containerHost.onNextClick() + expectNoItems() + + containerHost.onTermChange(OnboardingTerm.PRIVACY) + expectState { copy(termsAgreement = termsAgreement.toggle(OnboardingTerm.PRIVACY)) } + containerHost.onTermChange(OnboardingTerm.AI_DATA_TRANSFER) + expectState { copy(termsAgreement = termsAgreement.toggle(OnboardingTerm.AI_DATA_TRANSFER)) } + + containerHost.onNextClick() + expectState { copy(step = OnboardingStep.NAME) } + } + } + + @Test + fun `선택 약관은 다음 단계 진행에 영향을 주지 않는다`() = + runTest { + val terms = TermsAgreementUiModel(setOf(OnboardingTerm.SERVICE, OnboardingTerm.PRIVACY, OnboardingTerm.AI_DATA_TRANSFER)) + + assertTrue(OnboardingState(step = OnboardingStep.TERMS, termsAgreement = terms).canProceed) + assertFalse( + OnboardingState( + step = OnboardingStep.TERMS, + termsAgreement = terms.toggle(OnboardingTerm.PRIVACY), + ).canProceed, + ) + } + + @Test + fun `전체 동의를 켜면 모든 항목이 켜지고 끄면 모두 꺼진다`() = + runTest { + val initial = OnboardingState(step = OnboardingStep.TERMS) + viewModel().test(this, initialState = initial) { + containerHost.onAllTermsChange(true) + expectState { + copy(termsAgreement = TermsAgreementUiModel(OnboardingTerm.entries.toSet())) + } + + containerHost.onAllTermsChange(false) + expectState { copy(termsAgreement = TermsAgreementUiModel()) } + } + } + + @Test + fun `항목 하나를 해제하면 전체 동의 상태가 풀린다`() = + runTest { + val initial = OnboardingState(step = OnboardingStep.TERMS) + viewModel().test(this, initialState = initial) { + containerHost.onAllTermsChange(true) + skipItems(1) + + containerHost.onTermChange(OnboardingTerm.MARKETING) + val state = awaitState() + + assertFalse(state.termsAgreement.allSelected) + assertTrue(state.termsAgreement.allRequiredSelected) + } + } + + @Test + fun `특수문자가 든 이름은 에러로 표시되고 다음으로 넘어갈 수 없다`() = + runTest { + val initial = OnboardingState(step = OnboardingStep.NAME) + viewModel().test(this, initialState = initial) { + containerHost.onNameChange("토닥이##") + expectState { + copy(username = UserName.Invalid.ContainsSpecialCharacter("토닥이##")) + } + + containerHost.onNextClick() + expectNoItems() + } + } + + @Test + fun `유효한 이름을 넣으면 다음 스텝으로 넘어간다`() = + runTest { + val initial = OnboardingState(step = OnboardingStep.NAME) + viewModel().test(this, initialState = initial) { + containerHost.onNameChange("토닥이") + expectState { copy(username = UserName.Valid("토닥이")) } + + containerHost.onNextClick() + expectState { copy(step = OnboardingStep.BIRTH_INFO) } + } + } + + @Test + fun `시간 모름을 켜면 고른 시각이 지워지고 열린 시트가 닫힌다`() = + runTest { + val initial = + OnboardingState( + step = OnboardingStep.BIRTH_INFO, + birthTime = BirthTime.JA, + sheet = OnboardingSheet.BIRTH_TIME, + ) + viewModel().test(this, initialState = initial) { + containerHost.onBirthTimeUnknownChange(true) + + expectState { + copy(birthTime = BirthTime.UNKNOWN, sheet = null) + } + } + } + + @Test + fun `시간 모름을 켜면 시각을 고르지 않아도 다음으로 넘어갈 수 있다`() { + val base = + OnboardingState( + step = OnboardingStep.BIRTH_INFO, + gender = Gender.FEMALE, + calendarType = DateType.SOLAR, + birthDate = LocalDate.of(1999, 2, 13), + ) + + assertFalse(base.canProceed) + assertTrue(base.copy(birthTime = BirthTime.UNKNOWN).canProceed) + assertTrue(base.copy(birthTime = BirthTime.JA).canProceed) + } + + @Test + fun `약관 동의 스텝에서 뒤로가면 이탈 확인 다이얼로그가 뜬다`() = + runTest { + val initial = OnboardingState(step = OnboardingStep.TERMS) + viewModel().test(this, initialState = initial) { + containerHost.onBackClick() + expectState { copy(dialog = OnboardingDialog.EXIT_CONFIRM) } + + containerHost.onExitConfirmed() + expectState { OnboardingState() } + expectSideEffect(OnboardingSideEffect.Exit) + } + } + + @Test + fun `중간 스텝에서 뒤로가면 이전 스텝으로 돌아간다`() = + runTest { + val initial = OnboardingState(step = OnboardingStep.BIRTH_INFO) + viewModel().test(this, initialState = initial) { + containerHost.onBackClick() + + expectState { copy(step = OnboardingStep.NAME) } + } + } + + @Test + fun `마지막 스텝에서 다음을 누르면 완료 다이얼로그를 거쳐 홈으로 이동한다`() = + runTest { + val initial = + OnboardingState( + step = OnboardingStep.EXTRA_QUESTION, + username = UserName.Valid("토닥이"), + gender = Gender.FEMALE, + calendarType = DateType.SOLAR, + birthDate = LocalDate.of(1999, 2, 13), + birthTime = BirthTime.JA, + lifeStage = Job.STUDENT, + relationshipStatus = RelationshipStatus.SOLO, + ) + viewModel().test(this, initialState = initial) { + containerHost.onCompleteConfirmed(onboardingToken) + expectState { copy(dialog = OnboardingDialog.SIGN_UP_COMPLETE) } + expectSideEffect(OnboardingSideEffect.NavigateToHome) + } + } + + private class FakeAuthRepository : AuthRepository { + var signupResult: Result = Result.success(AuthToken("access", "refresh")) + + override suspend fun login(credential: OAuthCredential): Result = error("not used") + + override suspend fun signup( + signupSubmission: SignupSubmission, + onboardingToken: OnboardingToken, + ): Result = signupResult + + override suspend fun refresh(refreshToken: String): Result = error("not used") + } + + private class FakeTokenRepository : TokenRepository { + var saveResult: Result = Result.success(Unit) + + override fun observeLoginState(): Flow> = flowOf() + + override suspend fun getToken(): Result = error("not used") + + override suspend fun saveToken(token: AuthToken): Result = saveResult + + override suspend fun clearToken(): Result = error("not used") + } +} From d64107bc8b02c5c7f715e792ec50e09ef65a023d Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sun, 2 Aug 2026 17:05:58 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20U?= =?UTF-8?q?I=20=EB=AA=A8=EB=8D=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnboardingState, OnboardingStep, OnboardingSideEffect 및 TermsAgreementUiModel 등 UI 상태와 관련된 클래스들을 `com.kikidan.onboarding.model` 패키지로 이동하여 모듈 내부 구조를 정리한다. 클래스 위치 변경에 따라 OnboardingViewModel과 관련 테스트 코드의 import 경로를 수정한다. Co-Authored-By: Claude 3.5 Sonnet --- .../com/kikidan/onboarding/OnboardingViewModel.kt | 6 ++++++ .../{ => model}/OnboardingSideEffect.kt | 2 +- .../onboarding/{ => model}/OnboardingState.kt | 2 +- .../onboarding/{ => model}/OnboardingStep.kt | 2 +- .../{ => model}/TermsAgreementUiModel.kt | 2 +- .../kikidan/onboarding/OnboardingViewModelTest.kt | 15 ++++++++++++++- 6 files changed, 24 insertions(+), 5 deletions(-) rename feature/onboarding/src/main/java/com/kikidan/onboarding/{ => model}/OnboardingSideEffect.kt (86%) rename feature/onboarding/src/main/java/com/kikidan/onboarding/{ => model}/OnboardingState.kt (98%) rename feature/onboarding/src/main/java/com/kikidan/onboarding/{ => model}/OnboardingStep.kt (93%) rename feature/onboarding/src/main/java/com/kikidan/onboarding/{ => model}/TermsAgreementUiModel.kt (95%) diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt index add5d8c..70ea040 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt @@ -10,6 +10,12 @@ import com.kikidan.domain.model.user.BirthTime import com.kikidan.domain.model.user.DateType import com.kikidan.domain.model.user.Gender import com.kikidan.domain.usecase.SignUpUseCase +import com.kikidan.onboarding.model.OnboardingDialog +import com.kikidan.onboarding.model.OnboardingSheet +import com.kikidan.onboarding.model.OnboardingSideEffect +import com.kikidan.onboarding.model.OnboardingState +import com.kikidan.onboarding.model.OnboardingStep +import com.kikidan.onboarding.model.toDomain import dagger.hilt.android.lifecycle.HiltViewModel import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingSideEffect.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingSideEffect.kt similarity index 86% rename from feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingSideEffect.kt rename to feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingSideEffect.kt index 08d4b33..6f08eaa 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingSideEffect.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingSideEffect.kt @@ -1,4 +1,4 @@ -package com.kikidan.onboarding +package com.kikidan.onboarding.model sealed interface OnboardingSideEffect { data object NavigateToHome : OnboardingSideEffect diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingState.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt similarity index 98% rename from feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingState.kt rename to feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt index 540169a..4abf9e9 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingState.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt @@ -1,4 +1,4 @@ -package com.kikidan.onboarding +package com.kikidan.onboarding.model import androidx.compose.runtime.Immutable import com.kikidan.domain.model.auth.Job diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingStep.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt similarity index 93% rename from feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingStep.kt rename to feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt index cd6a5e1..2c00659 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingStep.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt @@ -1,4 +1,4 @@ -package com.kikidan.onboarding +package com.kikidan.onboarding.model /** * 가입 플로우의 단계. 선언 순서가 곧 진행 순서다. diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/TermsAgreementUiModel.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/TermsAgreementUiModel.kt similarity index 95% rename from feature/onboarding/src/main/java/com/kikidan/onboarding/TermsAgreementUiModel.kt rename to feature/onboarding/src/main/java/com/kikidan/onboarding/model/TermsAgreementUiModel.kt index c10a1d9..edb0355 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/TermsAgreementUiModel.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/TermsAgreementUiModel.kt @@ -1,4 +1,4 @@ -package com.kikidan.onboarding +package com.kikidan.onboarding.model import com.kikidan.domain.model.onboarding.OnboardingTerm diff --git a/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt b/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt index e0b379a..58fca87 100644 --- a/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt +++ b/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt @@ -15,6 +15,12 @@ import com.kikidan.domain.model.user.Gender import com.kikidan.domain.repository.AuthRepository import com.kikidan.domain.repository.TokenRepository import com.kikidan.domain.usecase.SignUpUseCase +import com.kikidan.onboarding.model.OnboardingDialog +import com.kikidan.onboarding.model.OnboardingSheet +import com.kikidan.onboarding.model.OnboardingSideEffect +import com.kikidan.onboarding.model.OnboardingState +import com.kikidan.onboarding.model.OnboardingStep +import com.kikidan.onboarding.model.TermsAgreementUiModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -77,7 +83,14 @@ class OnboardingViewModelTest { @Test fun `선택 약관은 다음 단계 진행에 영향을 주지 않는다`() = runTest { - val terms = TermsAgreementUiModel(setOf(OnboardingTerm.SERVICE, OnboardingTerm.PRIVACY, OnboardingTerm.AI_DATA_TRANSFER)) + val terms = + TermsAgreementUiModel( + setOf( + OnboardingTerm.SERVICE, + OnboardingTerm.PRIVACY, + OnboardingTerm.AI_DATA_TRANSFER, + ), + ) assertTrue(OnboardingState(step = OnboardingStep.TERMS, termsAgreement = terms).canProceed) assertFalse( From 6c4a5b0f32a6da1b671e4367db2a27dafc99ea94 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 4 Aug 2026 01:02:40 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=A0=9C=EC=B6=9C=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=9A=94=EC=B2=AD=20=ED=9D=90=EB=A6=84=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회원가입 요청 시 `isSubmitting` 상태를 추가하여 중복 요청을 방지한다. 성공 시 즉시 홈으로 이동하는 대신 `PermissionRequest` 사이드 이펙트를 먼저 발생시키며, `Failure` 사이드 이펙트에 `Throwable`을 포함하여 실패 원인을 전달하도록 수정한다. 최종 완료 확인(`onSignUpCompleteConfirmed`) 시점에 다이얼로그를 닫고 홈 화면으로 이동한다. --- .../kikidan/onboarding/OnboardingViewModel.kt | 15 ++++- .../onboarding/model/OnboardingSideEffect.kt | 6 +- .../onboarding/model/OnboardingState.kt | 1 + .../onboarding/OnboardingViewModelTest.kt | 60 ++++++++++++++++++- 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt index 70ea040..d655e76 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt @@ -64,22 +64,31 @@ class OnboardingViewModel fun onCompleteConfirmed(onboardingToken: OnboardingToken) = intent { + if (state.isSubmitting) return@intent val signupSubmission = state.toDomain() if (signupSubmission == null) { postSideEffect(OnboardingSideEffect.InvalidInput) return@intent } + reduce { state.copy(isSubmitting = true) } signUpUseCase( signupSubmission = signupSubmission, onboardingToken = onboardingToken, ).onSuccess { - reduce { state.copy(dialog = OnboardingDialog.SIGN_UP_COMPLETE) } - postSideEffect(OnboardingSideEffect.NavigateToHome) + reduce { state.copy(isSubmitting = false) } + postSideEffect(OnboardingSideEffect.PermissionRequest) }.onFailure { e -> - postSideEffect(OnboardingSideEffect.Failure) + reduce { state.copy(isSubmitting = false) } + postSideEffect(OnboardingSideEffect.Failure(e)) } } + fun onSignUpCompleteConfirmed() = + intent { + reduce { state.copy(dialog = null) } + postSideEffect(OnboardingSideEffect.NavigateToHome) + } + fun onTermChange(term: OnboardingTerm) = intent { val updated = state.termsAgreement.toggle(term) diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingSideEffect.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingSideEffect.kt index 6f08eaa..44e98f3 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingSideEffect.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingSideEffect.kt @@ -7,5 +7,9 @@ sealed interface OnboardingSideEffect { data object InvalidInput : OnboardingSideEffect - data object Failure : OnboardingSideEffect + data object PermissionRequest : OnboardingSideEffect + + data class Failure( + val throwable: Throwable, + ) : OnboardingSideEffect } diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt index 4abf9e9..8a8a425 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt @@ -34,6 +34,7 @@ data class OnboardingState( val relationshipStatus: RelationshipStatus? = null, val sheet: OnboardingSheet? = null, val dialog: OnboardingDialog? = null, + val isSubmitting: Boolean = false, ) { val canProceed: Boolean get() = diff --git a/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt b/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt index 58fca87..bd85e79 100644 --- a/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt +++ b/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -234,20 +235,75 @@ class OnboardingViewModelTest { ) viewModel().test(this, initialState = initial) { containerHost.onCompleteConfirmed(onboardingToken) - expectState { copy(dialog = OnboardingDialog.SIGN_UP_COMPLETE) } + expectState { copy(isSubmitting = true) } + expectState { copy(isSubmitting = false, dialog = OnboardingDialog.SIGN_UP_COMPLETE) } + expectSideEffect(OnboardingSideEffect.PermissionRequest) + + containerHost.onSignUpCompleteConfirmed() + expectState { copy(dialog = null) } expectSideEffect(OnboardingSideEffect.NavigateToHome) } } + @Test + fun `제출 중에는 완료 요청이 다시 전송되지 않는다`() = + runTest { + val initial = + OnboardingState( + step = OnboardingStep.EXTRA_QUESTION, + username = UserName.Valid("토닥이"), + gender = Gender.FEMALE, + calendarType = DateType.SOLAR, + birthDate = LocalDate.of(1999, 2, 13), + birthTime = BirthTime.JA, + lifeStage = Job.STUDENT, + relationshipStatus = RelationshipStatus.SOLO, + isSubmitting = true, + ) + viewModel().test(this, initialState = initial) { + containerHost.onCompleteConfirmed(onboardingToken) + expectNoItems() + } + assertEquals(0, authRepository.signupCallCount) + } + + @Test + fun `회원가입이 실패하면 원인과 함께 실패 사이드이펙트가 전달된다`() = + runTest { + val error = IllegalStateException("네트워크 오류") + authRepository.signupResult = Result.failure(error) + val initial = + OnboardingState( + step = OnboardingStep.EXTRA_QUESTION, + username = UserName.Valid("토닥이"), + gender = Gender.FEMALE, + calendarType = DateType.SOLAR, + birthDate = LocalDate.of(1999, 2, 13), + birthTime = BirthTime.JA, + lifeStage = Job.STUDENT, + relationshipStatus = RelationshipStatus.SOLO, + ) + viewModel().test(this, initialState = initial) { + containerHost.onCompleteConfirmed(onboardingToken) + expectState { copy(isSubmitting = true) } + expectState { copy(isSubmitting = false) } + expectSideEffect(OnboardingSideEffect.Failure(error)) + } + } + private class FakeAuthRepository : AuthRepository { var signupResult: Result = Result.success(AuthToken("access", "refresh")) + var signupCallCount = 0 override suspend fun login(credential: OAuthCredential): Result = error("not used") override suspend fun signup( signupSubmission: SignupSubmission, onboardingToken: OnboardingToken, - ): Result = signupResult + ): Result { + signupCallCount++ + return signupResult + } override suspend fun refresh(refreshToken: String): Result = error("not used") } From 5ff8152b4d3dd28d6f81f322cf99abf5e55bb37c Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Tue, 4 Aug 2026 01:23:12 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=20Step=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회원가입 성공 시 완료 다이얼로그를 띄우는 대신 `OnboardingStep.COMPLETE` 단계로 진입하도록 변경한다. 완료 단계는 진행바 비율을 갖지 않으며, 이전 단계로의 이동을 허용하지 않는다. 테스트 코드 가독성을 위해 `FakeAuthRepository`와 `FakeTokenRepository`를 별도 파일로 분리한다. --- .../kikidan/onboarding/OnboardingViewModel.kt | 8 +--- .../onboarding/model/OnboardingState.kt | 5 +- .../onboarding/model/OnboardingStep.kt | 8 +++- .../onboarding/OnboardingViewModelTest.kt | 47 ++----------------- .../onboarding/fake/FakeAuthRepository.kt | 25 ++++++++++ .../onboarding/fake/FakeTokenRepository.kt | 18 +++++++ 6 files changed, 59 insertions(+), 52 deletions(-) create mode 100644 feature/onboarding/src/test/java/com/kikidan/onboarding/fake/FakeAuthRepository.kt create mode 100644 feature/onboarding/src/test/java/com/kikidan/onboarding/fake/FakeTokenRepository.kt diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt index d655e76..81d30ce 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt @@ -75,7 +75,7 @@ class OnboardingViewModel signupSubmission = signupSubmission, onboardingToken = onboardingToken, ).onSuccess { - reduce { state.copy(isSubmitting = false) } + reduce { state.copy(isSubmitting = false, step = OnboardingStep.COMPLETE) } postSideEffect(OnboardingSideEffect.PermissionRequest) }.onFailure { e -> reduce { state.copy(isSubmitting = false) } @@ -83,12 +83,6 @@ class OnboardingViewModel } } - fun onSignUpCompleteConfirmed() = - intent { - reduce { state.copy(dialog = null) } - postSideEffect(OnboardingSideEffect.NavigateToHome) - } - fun onTermChange(term: OnboardingTerm) = intent { val updated = state.termsAgreement.toggle(term) diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt index 8a8a425..d424650 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt @@ -18,7 +18,6 @@ enum class OnboardingSheet { enum class OnboardingDialog { EXIT_CONFIRM, - SIGN_UP_COMPLETE, } @Immutable @@ -57,6 +56,10 @@ data class OnboardingState( OnboardingStep.EXTRA_QUESTION -> { lifeStage != null && relationshipStatus != null } + + OnboardingStep.COMPLETE -> { + false + } } } diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt index 2c00659..ae02fde 100644 --- a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt @@ -13,10 +13,16 @@ enum class OnboardingStep( NAME(1f / 3f), BIRTH_INFO(2f / 3f), EXTRA_QUESTION(1f), + + COMPLETE(null), ; val previous: OnboardingStep? - get() = entries.getOrNull(ordinal - 1) + get() = + when (this) { + COMPLETE -> null + else -> entries.getOrNull(ordinal - 1) + } val next: OnboardingStep? get() = entries.getOrNull(ordinal + 1) diff --git a/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt b/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt index bd85e79..a81229d 100644 --- a/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt +++ b/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt @@ -1,20 +1,16 @@ package com.kikidan.onboarding -import com.kikidan.domain.model.auth.AuthToken import com.kikidan.domain.model.auth.Job -import com.kikidan.domain.model.auth.LoginResult -import com.kikidan.domain.model.auth.OAuthCredential import com.kikidan.domain.model.auth.OnboardingToken import com.kikidan.domain.model.auth.RelationshipStatus -import com.kikidan.domain.model.auth.SignupSubmission import com.kikidan.domain.model.onboarding.OnboardingTerm import com.kikidan.domain.model.onboarding.UserName import com.kikidan.domain.model.user.BirthTime import com.kikidan.domain.model.user.DateType import com.kikidan.domain.model.user.Gender -import com.kikidan.domain.repository.AuthRepository -import com.kikidan.domain.repository.TokenRepository import com.kikidan.domain.usecase.SignUpUseCase +import com.kikidan.onboarding.fake.FakeAuthRepository +import com.kikidan.onboarding.fake.FakeTokenRepository import com.kikidan.onboarding.model.OnboardingDialog import com.kikidan.onboarding.model.OnboardingSheet import com.kikidan.onboarding.model.OnboardingSideEffect @@ -23,8 +19,6 @@ import com.kikidan.onboarding.model.OnboardingStep import com.kikidan.onboarding.model.TermsAgreementUiModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -220,7 +214,7 @@ class OnboardingViewModelTest { } @Test - fun `마지막 스텝에서 다음을 누르면 완료 다이얼로그를 거쳐 홈으로 이동한다`() = + fun `마지막 스텝에서 다음을 누르면 완료 단계로 넘어간다`() = runTest { val initial = OnboardingState( @@ -236,12 +230,8 @@ class OnboardingViewModelTest { viewModel().test(this, initialState = initial) { containerHost.onCompleteConfirmed(onboardingToken) expectState { copy(isSubmitting = true) } - expectState { copy(isSubmitting = false, dialog = OnboardingDialog.SIGN_UP_COMPLETE) } + expectState { copy(isSubmitting = false, step = OnboardingStep.COMPLETE) } expectSideEffect(OnboardingSideEffect.PermissionRequest) - - containerHost.onSignUpCompleteConfirmed() - expectState { copy(dialog = null) } - expectSideEffect(OnboardingSideEffect.NavigateToHome) } } @@ -290,33 +280,4 @@ class OnboardingViewModelTest { expectSideEffect(OnboardingSideEffect.Failure(error)) } } - - private class FakeAuthRepository : AuthRepository { - var signupResult: Result = Result.success(AuthToken("access", "refresh")) - var signupCallCount = 0 - - override suspend fun login(credential: OAuthCredential): Result = error("not used") - - override suspend fun signup( - signupSubmission: SignupSubmission, - onboardingToken: OnboardingToken, - ): Result { - signupCallCount++ - return signupResult - } - - override suspend fun refresh(refreshToken: String): Result = error("not used") - } - - private class FakeTokenRepository : TokenRepository { - var saveResult: Result = Result.success(Unit) - - override fun observeLoginState(): Flow> = flowOf() - - override suspend fun getToken(): Result = error("not used") - - override suspend fun saveToken(token: AuthToken): Result = saveResult - - override suspend fun clearToken(): Result = error("not used") - } } diff --git a/feature/onboarding/src/test/java/com/kikidan/onboarding/fake/FakeAuthRepository.kt b/feature/onboarding/src/test/java/com/kikidan/onboarding/fake/FakeAuthRepository.kt new file mode 100644 index 0000000..f28affa --- /dev/null +++ b/feature/onboarding/src/test/java/com/kikidan/onboarding/fake/FakeAuthRepository.kt @@ -0,0 +1,25 @@ +package com.kikidan.onboarding.fake + +import com.kikidan.domain.model.auth.AuthToken +import com.kikidan.domain.model.auth.LoginResult +import com.kikidan.domain.model.auth.OAuthCredential +import com.kikidan.domain.model.auth.OnboardingToken +import com.kikidan.domain.model.auth.SignupSubmission +import com.kikidan.domain.repository.AuthRepository + +class FakeAuthRepository : AuthRepository { + var signupResult: Result = Result.success(AuthToken("access", "refresh")) + var signupCallCount = 0 + + override suspend fun login(credential: OAuthCredential): Result = error("not used") + + override suspend fun signup( + signupSubmission: SignupSubmission, + onboardingToken: OnboardingToken, + ): Result { + signupCallCount++ + return signupResult + } + + override suspend fun refresh(refreshToken: String): Result = error("not used") +} diff --git a/feature/onboarding/src/test/java/com/kikidan/onboarding/fake/FakeTokenRepository.kt b/feature/onboarding/src/test/java/com/kikidan/onboarding/fake/FakeTokenRepository.kt new file mode 100644 index 0000000..2a67f34 --- /dev/null +++ b/feature/onboarding/src/test/java/com/kikidan/onboarding/fake/FakeTokenRepository.kt @@ -0,0 +1,18 @@ +package com.kikidan.onboarding.fake + +import com.kikidan.domain.model.auth.AuthToken +import com.kikidan.domain.repository.TokenRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class FakeTokenRepository : TokenRepository { + var saveResult: Result = Result.success(Unit) + + override fun observeLoginState(): Flow> = flowOf() + + override suspend fun getToken(): Result = error("not used") + + override suspend fun saveToken(token: AuthToken): Result = saveResult + + override suspend fun clearToken(): Result = error("not used") +}