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/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/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) + } + } 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") + } +} 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/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..81d30ce --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/OnboardingViewModel.kt @@ -0,0 +1,161 @@ +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 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 +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 { + 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(isSubmitting = false, step = OnboardingStep.COMPLETE) } + postSideEffect(OnboardingSideEffect.PermissionRequest) + }.onFailure { e -> + reduce { state.copy(isSubmitting = false) } + postSideEffect(OnboardingSideEffect.Failure(e)) + } + } + + 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/model/OnboardingSideEffect.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingSideEffect.kt new file mode 100644 index 0000000..44e98f3 --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingSideEffect.kt @@ -0,0 +1,15 @@ +package com.kikidan.onboarding.model + +sealed interface OnboardingSideEffect { + data object NavigateToHome : OnboardingSideEffect + + data object Exit : OnboardingSideEffect + + data object InvalidInput : 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 new file mode 100644 index 0000000..d424650 --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingState.kt @@ -0,0 +1,79 @@ +package com.kikidan.onboarding.model + +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, +} + +@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 isSubmitting: Boolean = false, +) { + 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 + } + + OnboardingStep.COMPLETE -> { + false + } + } +} + +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/model/OnboardingStep.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt new file mode 100644 index 0000000..ae02fde --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/OnboardingStep.kt @@ -0,0 +1,29 @@ +package com.kikidan.onboarding.model + +/** + * 가입 플로우의 단계. 선언 순서가 곧 진행 순서다. + * + * @param progress 프로그레스바 채움 비율(Figma 실측, 트랙 316dp 기준). 첫 스텝(TERMS)은 진행률 대신 + * 라벨 텍스트를 보여주므로 null이다. + */ +enum class OnboardingStep( + val progress: Float?, +) { + TERMS(null), + NAME(1f / 3f), + BIRTH_INFO(2f / 3f), + EXTRA_QUESTION(1f), + + COMPLETE(null), + ; + + val previous: OnboardingStep? + get() = + when (this) { + COMPLETE -> null + else -> entries.getOrNull(ordinal - 1) + } + + val next: OnboardingStep? + get() = entries.getOrNull(ordinal + 1) +} diff --git a/feature/onboarding/src/main/java/com/kikidan/onboarding/model/TermsAgreementUiModel.kt b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/TermsAgreementUiModel.kt new file mode 100644 index 0000000..edb0355 --- /dev/null +++ b/feature/onboarding/src/main/java/com/kikidan/onboarding/model/TermsAgreementUiModel.kt @@ -0,0 +1,21 @@ +package com.kikidan.onboarding.model + +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..a81229d --- /dev/null +++ b/feature/onboarding/src/test/java/com/kikidan/onboarding/OnboardingViewModelTest.kt @@ -0,0 +1,283 @@ +package com.kikidan.onboarding + +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 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 +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.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.assertEquals +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(isSubmitting = true) } + expectState { copy(isSubmitting = false, step = OnboardingStep.COMPLETE) } + expectSideEffect(OnboardingSideEffect.PermissionRequest) + } + } + + @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)) + } + } +} 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") +} 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")