From 72c988bb0845e1b4c21e30f4e034856828aa75ef Mon Sep 17 00:00:00 2001 From: Shreyash Borde <116138842+Shreyash16b@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:08:35 +0530 Subject: [PATCH] test: Add Unit Test for `:feature:auth` module (#1871) Co-authored-by: Hekmatullah --- core/common/build.gradle.kts | 1 + .../core/common/DateAsStringSerializer.kt | 39 + .../mifospay/core/common/StringProvider.kt | 68 ++ .../core/common/di/DispatchersModule.kt | 6 + .../core/common/utils/OpenForMokkery.kt | 37 + core/domain/build.gradle.kts | 11 + .../org/mifospay/core/domain/LoginUseCase.kt | 2 + feature/auth/build.gradle.kts | 16 + .../composeResources/values/strings.xml | 4 + .../MobileVerificationViewModel.kt | 2 +- .../feature/auth/signup/SignupScreen.kt | 22 +- .../feature/auth/signup/SignupViewModel.kt | 205 ++++- .../feature/auth/login/LoginViewModelTest.kt | 235 +++++ .../MobileVerificationViewModelTest.kt | 169 ++++ .../auth/signup/SignUpViewModelTest.kt | 815 ++++++++++++++++++ gradle/libs.versions.toml | 24 +- .../prodReleaseRuntimeClasspath.tree.txt | 7 +- mifospay-android/prodRelease-badging.txt | 2 +- .../org/mifospay/shared/di/KoinModules.kt | 2 + 19 files changed, 1608 insertions(+), 59 deletions(-) create mode 100644 core/common/src/commonMain/kotlin/org/mifospay/core/common/StringProvider.kt create mode 100644 core/common/src/commonMain/kotlin/org/mifospay/core/common/utils/OpenForMokkery.kt create mode 100644 feature/auth/src/commonTest/kotlin/org/mifospay/feature/auth/login/LoginViewModelTest.kt create mode 100644 feature/auth/src/commonTest/kotlin/org/mifospay/feature/auth/mobileVerify/MobileVerificationViewModelTest.kt create mode 100644 feature/auth/src/commonTest/kotlin/org/mifospay/feature/auth/signup/SignUpViewModelTest.kt diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index f7eee4f5..b065c262 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -47,6 +47,7 @@ kotlin { implementation(libs.jb.composeRuntime) implementation(libs.jb.lifecycleViewmodelSavedState) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.collections.immutable) } androidMain.dependencies { diff --git a/core/common/src/commonMain/kotlin/org/mifospay/core/common/DateAsStringSerializer.kt b/core/common/src/commonMain/kotlin/org/mifospay/core/common/DateAsStringSerializer.kt index cb20f1c6..71f97e8c 100644 --- a/core/common/src/commonMain/kotlin/org/mifospay/core/common/DateAsStringSerializer.kt +++ b/core/common/src/commonMain/kotlin/org/mifospay/core/common/DateAsStringSerializer.kt @@ -9,7 +9,11 @@ */ package org.mifospay.core.common +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor @@ -65,6 +69,41 @@ object DateAsStringSerializer : KSerializer { } } +/** + * A custom [KSerializer] for serializing and deserializing an `ImmutableList` using kotlinx.serialization. + * + * This serializer bridges the gap between Kotlin's `ImmutableList` from the [kotlinx.collections.immutable] package + * and standard JSON array representations. Internally, it uses a [ListSerializer] to handle the + * (de)serialization as a regular `List`, then converts to or from an `ImmutableList`. + * + * ### Example Usage: + * ``` + * @Serializable + * data class UserPreferences( + * @Serializable(with = ImmutableListSerializer::class) + * val favoriteTags: ImmutableList + * ) + * ``` + * + * ### Supported Format: + * - JSON Array: `["tag1", "tag2", "tag3"]` + * + * ### Notes: + * - During serialization, the `ImmutableList` is converted to a regular list before encoding. + * - During deserialization, the resulting list is converted to an `ImmutableList` using `.toPersistentList()`. + */ +object ImmutableListSerializer : KSerializer> { + override val descriptor = ListSerializer(String.serializer()).descriptor + + override fun serialize(encoder: Encoder, value: ImmutableList) { + ListSerializer(String.serializer()).serialize(encoder, value.toList()) + } + + override fun deserialize(decoder: Decoder): ImmutableList { + return ListSerializer(String.serializer()).deserialize(decoder).toPersistentList() + } +} + /** * Formats a date represented as a list of integers into a string of the format `"YYYY-MM-DD"`. * diff --git a/core/common/src/commonMain/kotlin/org/mifospay/core/common/StringProvider.kt b/core/common/src/commonMain/kotlin/org/mifospay/core/common/StringProvider.kt new file mode 100644 index 00000000..5132148b --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/mifospay/core/common/StringProvider.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.common + +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString + +/** + * Provides an abstraction layer for accessing localized string resources. + * + * This interface is designed to decouple business logic (e.g., ViewModels) from platform-specific + * implementations such as Android’s `Resources.getSystem()` or Compose’s `getString()` API. + * + * ## Why this abstraction exists: + * Using `getString(StringResource)` directly in a ViewModel or business logic can break unit tests, + * especially when running on the JVM, where Android platform APIs are not available or mocked. + * + * To avoid exceptions like: + * ``` + * Method getSystem in android.content.res.Resources not mocked. + * ``` + * the [StringProvider] allows injecting a test-safe mock/fake implementation during testing, + * while still using the actual `getString()` logic in production via [DefaultStringProvider]. + * + * ## Example (in ViewModel or shared logic): + * ``` + * val message = stringProvider.get(Res.string.feature_auth_error_email_required) + * ``` + */ +interface StringProvider { + + /** + * Resolves the given [StringResource] into a localized string. + * + * @param resource The string resource to retrieve. + * @param formatArgs Optional arguments for formatting. + * @return The final localized and formatted string. + */ + suspend fun get(resource: StringResource, vararg formatArgs: Any = emptyArray()): String +} + +/** + * Default implementation of [StringProvider] that uses Compose Multiplatform’s [getString]. + * + * This class is meant to be used in runtime environments where resource resolution is supported. + * It should not be used in unit tests—use a mock or fake instead to avoid runtime exceptions + * from accessing platform APIs like `Resources.getSystem`. + */ +class DefaultStringProvider : StringProvider { + + /** + * Retrieves and formats a localized string from a [StringResource]. + * + * @param resource The resource identifier to resolve. + * @param formatArgs Optional format arguments. + * @return The resolved localized string. + */ + override suspend fun get(resource: StringResource, vararg formatArgs: Any): String { + return getString(resource, *formatArgs) + } +} diff --git a/core/common/src/commonMain/kotlin/org/mifospay/core/common/di/DispatchersModule.kt b/core/common/src/commonMain/kotlin/org/mifospay/core/common/di/DispatchersModule.kt index 4bf17cc0..69c62b66 100644 --- a/core/common/src/commonMain/kotlin/org/mifospay/core/common/di/DispatchersModule.kt +++ b/core/common/src/commonMain/kotlin/org/mifospay/core/common/di/DispatchersModule.kt @@ -16,7 +16,13 @@ import kotlinx.coroutines.SupervisorJob import org.koin.core.module.Module import org.koin.core.qualifier.named import org.koin.dsl.module +import org.mifospay.core.common.DefaultStringProvider import org.mifospay.core.common.MifosDispatchers +import org.mifospay.core.common.StringProvider + +val stringProviderModule = module { + single { DefaultStringProvider() } +} val DispatchersModule = module { includes(ioDispatcherModule) diff --git a/core/common/src/commonMain/kotlin/org/mifospay/core/common/utils/OpenForMokkery.kt b/core/common/src/commonMain/kotlin/org/mifospay/core/common/utils/OpenForMokkery.kt new file mode 100644 index 00000000..ed10b0eb --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/mifospay/core/common/utils/OpenForMokkery.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.common.utils + +/** + * Annotation to mark a class as open for mocking during tests using Mokkery. + * + * When used with the Kotlin AllOpen plugin, any class annotated with this will be treated as `open` + * during test compilation, allowing frameworks like Mokkery to create test doubles. + * + * ### Usage Example: + * ``` + * @OpenForMokkery + * class SomeService { + * fun doWork() = ... + * } + * ``` + * + * In your build.gradle.kts: + * ``` + * plugins { + * kotlin("plugin.allopen") + * } + * + * allOpen { + * annotation("your.package.OpenForMokkery") + * } + * ``` + */ +annotation class OpenForMokkery diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 6ceb3c24..42a7872d 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -9,6 +9,7 @@ */ plugins { alias(libs.plugins.mifospay.kmp.library) + alias(libs.plugins.kotlin.allopen) } android { @@ -23,4 +24,14 @@ kotlin { implementation(projects.core.model) } } +} + +// Only open classes annotated with @OpenForMokkery during test tasks +fun isTestingTask(name: String) = name.contains("test", ignoreCase = true) +val isTesting = gradle.startParameter.taskNames.any(::isTestingTask) + +if (isTesting) { + allOpen { + annotation("org.mifospay.core.common.utils.OpenForMokkery") + } } \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/org/mifospay/core/domain/LoginUseCase.kt b/core/domain/src/commonMain/kotlin/org/mifospay/core/domain/LoginUseCase.kt index c6544221..a56cc30a 100644 --- a/core/domain/src/commonMain/kotlin/org/mifospay/core/domain/LoginUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/mifospay/core/domain/LoginUseCase.kt @@ -12,11 +12,13 @@ package org.mifospay.core.domain import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import org.mifospay.core.common.DataState +import org.mifospay.core.common.utils.OpenForMokkery import org.mifospay.core.data.repository.AuthenticationRepository import org.mifospay.core.data.repository.ClientRepository import org.mifospay.core.datastore.UserPreferencesRepository import org.mifospay.core.model.user.UserInfo +@OpenForMokkery class LoginUseCase( private val repository: AuthenticationRepository, private val clientRepository: ClientRepository, diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 37a02166..4d4078fb 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -1,3 +1,14 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +import org.jetbrains.compose.ExperimentalComposeLibrary + /* * Copyright 2024 Mifos Initiative * @@ -9,6 +20,7 @@ */ plugins { alias(libs.plugins.mifospay.cmp.feature) + alias(libs.plugins.mokkery) } android { @@ -41,5 +53,9 @@ kotlin { implementation(libs.play.services.auth) } + + commonTest.dependencies { + implementation(libs.turbine) + } } } \ No newline at end of file diff --git a/feature/auth/src/commonMain/composeResources/values/strings.xml b/feature/auth/src/commonMain/composeResources/values/strings.xml index de609cdf..149ce425 100644 --- a/feature/auth/src/commonMain/composeResources/values/strings.xml +++ b/feature/auth/src/commonMain/composeResources/values/strings.xml @@ -73,4 +73,8 @@ Please enter your pin code. Please enter your country. Please enter your state. + + %1$s already exists. + Unable to check if %1$s is unique. Please try again later. + Registration successful. \ No newline at end of file diff --git a/feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/mobileVerify/MobileVerificationViewModel.kt b/feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/mobileVerify/MobileVerificationViewModel.kt index 40538f9c..eaeac978 100644 --- a/feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/mobileVerify/MobileVerificationViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/mobileVerify/MobileVerificationViewModel.kt @@ -144,7 +144,7 @@ class MobileVerificationViewModel( viewModelScope.launch { // TODO:: Call repository request an otp to this phone no. mutableStateFlow.update { - MobileVerificationState.VerifyOtpState(phoneNo) + MobileVerificationState.VerifyOtpState(phoneNo = phoneNo) } trySendAction(MobileVerificationAction.DismissDialog) } diff --git a/feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/signup/SignupScreen.kt b/feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/signup/SignupScreen.kt index bdce63ec..bc810676 100644 --- a/feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/signup/SignupScreen.kt +++ b/feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/signup/SignupScreen.kt @@ -60,8 +60,6 @@ import mobile_wallet.feature.auth.generated.resources.feature_auth_state import mobile_wallet.feature.auth.generated.resources.feature_auth_username import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel -import org.mifospay.core.common.dialogManager.DialogManager -import org.mifospay.core.common.dialogManager.DialogMessage import org.mifospay.core.designsystem.component.BasicDialogState import org.mifospay.core.designsystem.component.LoadingDialogState import org.mifospay.core.designsystem.component.MifosBasicDialog @@ -76,6 +74,7 @@ import org.mifospay.core.ui.DropdownBoxItem import org.mifospay.core.ui.ExposedDropdownBox import org.mifospay.core.ui.MifosPasswordField import org.mifospay.core.ui.utils.EventsEffect +import org.mifospay.feature.auth.signup.SignUpState.DialogState @Composable internal fun SignupScreen( @@ -88,7 +87,6 @@ internal fun SignupScreen( val snackbarHostState = remember { SnackbarHostState() } val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val dialogMessage by DialogManager.dialogMessage.collectAsStateWithLifecycle() EventsEffect(viewModel) { event -> when (event) { @@ -103,7 +101,7 @@ internal fun SignupScreen( } SignUpDialogs( - dialogMessage = dialogMessage, + dialogState = state.dialogState, onDismissRequest = remember(viewModel) { { viewModel.trySendAction(SignUpAction.ErrorDialogDismiss) } }, @@ -395,24 +393,24 @@ private fun SignupScreenContent( @Composable private fun SignUpDialogs( - dialogMessage: DialogMessage, + dialogState: DialogState?, onDismissRequest: () -> Unit, ) { - when (dialogMessage) { - is DialogMessage.Loading -> MifosLoadingDialog( + when (dialogState) { + DialogState.Loading -> MifosLoadingDialog( visibilityState = LoadingDialogState.Shown, ) - is DialogMessage.StringMessage -> MifosBasicDialog( - visibilityState = BasicDialogState.Shown(dialogMessage.message), + is DialogState.Error.StringMessage -> MifosBasicDialog( + visibilityState = BasicDialogState.Shown(dialogState.message), onDismissRequest = onDismissRequest, ) - is DialogMessage.ResourceMessage -> MifosBasicDialog( - visibilityState = BasicDialogState.Shown(stringResource(dialogMessage.message)), + is DialogState.Error.ResourceMessage -> MifosBasicDialog( + visibilityState = BasicDialogState.Shown(stringResource(dialogState.message)), onDismissRequest = onDismissRequest, ) - is DialogMessage.None -> Unit + else -> Unit } } diff --git a/feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/signup/SignupViewModel.kt b/feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/signup/SignupViewModel.kt index e0735bdc..d93a8c91 100644 --- a/feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/signup/SignupViewModel.kt +++ b/feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/signup/SignupViewModel.kt @@ -27,10 +27,12 @@ import kotlinx.serialization.Transient import mobile_wallet.feature.auth.generated.resources.Res import mobile_wallet.feature.auth.generated.resources.feature_auth_error_address_line1_required import mobile_wallet.feature.auth.generated.resources.feature_auth_error_address_line2_required +import mobile_wallet.feature.auth.generated.resources.feature_auth_error_check_uniqueness_failed import mobile_wallet.feature.auth.generated.resources.feature_auth_error_confirm_password_required import mobile_wallet.feature.auth.generated.resources.feature_auth_error_country_required import mobile_wallet.feature.auth.generated.resources.feature_auth_error_email_invalid import mobile_wallet.feature.auth.generated.resources.feature_auth_error_email_required +import mobile_wallet.feature.auth.generated.resources.feature_auth_error_field_already_exists import mobile_wallet.feature.auth.generated.resources.feature_auth_error_first_name_required import mobile_wallet.feature.auth.generated.resources.feature_auth_error_last_name_required import mobile_wallet.feature.auth.generated.resources.feature_auth_error_mobile_invalid @@ -41,9 +43,11 @@ import mobile_wallet.feature.auth.generated.resources.feature_auth_error_pincode import mobile_wallet.feature.auth.generated.resources.feature_auth_error_select_savings_account import mobile_wallet.feature.auth.generated.resources.feature_auth_error_state_required import mobile_wallet.feature.auth.generated.resources.feature_auth_error_username_required +import mobile_wallet.feature.auth.generated.resources.feature_auth_registration_successful +import org.jetbrains.compose.resources.StringResource import org.mifospay.core.common.DataState -import org.mifospay.core.common.dialogManager.DialogManager -import org.mifospay.core.common.dialogManager.DialogMessage.Companion.toDialogMessage +import org.mifospay.core.common.ImmutableListSerializer +import org.mifospay.core.common.StringProvider import org.mifospay.core.common.getSerialized import org.mifospay.core.common.setSerialized import org.mifospay.core.common.utils.formatAsBulletPoints @@ -62,12 +66,14 @@ import org.mifospay.core.ui.utils.PasswordChecker import org.mifospay.core.ui.utils.PasswordStrength import org.mifospay.core.ui.utils.PasswordStrengthResult import org.mifospay.feature.auth.signup.SignUpAction.Internal.ReceivePasswordStrengthResult +import org.mifospay.feature.auth.signup.SignUpState.DialogState class SignupViewModel( private val userRepository: UserRepository, private val searchRepository: SearchRepository, private val clientRepository: ClientRepository, private val assetRepository: AssetRepository, + private val stringProvider: StringProvider, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle.getSerialized(KEY_STATE) ?: SignUpState(), @@ -80,8 +86,7 @@ class SignupViewModel( private var passwordStrengthJob: Job = Job().apply { complete() } init { - stateFlow - .onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } + stateFlow.onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } .launchIn(viewModelScope) savedStateHandle.get("mobileNumber")?.let { @@ -196,7 +201,9 @@ class SignupViewModel( } is SignUpAction.ErrorDialogDismiss -> { - DialogManager.dismissDialog() + mutableStateFlow.update { + it.copy(dialogState = null) + } } is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action) @@ -255,88 +262,160 @@ class SignupViewModel( private fun handleSignUpResult(action: SignUpAction.Internal.ReceiveRegisterResult) { when (val result = action.registerResult) { is DataState.Success -> { - DialogManager.dismissDialog() + mutableStateFlow.update { + it.copy(dialogState = null) + } sendEvent(SignUpEvent.NavigateToLogin(result.data)) } is DataState.Error -> { - DialogManager.showMessage(result.exception.toDialogMessage()) + mutableStateFlow.update { + it.copy(dialogState = DialogState.Error.StringMessage(result.exception.toString())) + } } DataState.Loading -> { - DialogManager.showLoading() + mutableStateFlow.update { it.copy(dialogState = DialogState.Loading) } } } } private fun handleSubmitClick() = when { state.savingsProductId == 0 -> { - DialogManager.showMessage(Res.string.feature_auth_error_select_savings_account) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_select_savings_account), + ) + } } state.firstNameInput.isEmpty() -> { - DialogManager.showMessage(Res.string.feature_auth_error_first_name_required) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_first_name_required), + ) + } } state.lastNameInput.isEmpty() -> { - DialogManager.showMessage(Res.string.feature_auth_error_last_name_required) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_last_name_required), + ) + } } state.userNameInput.isEmpty() -> { - DialogManager.showMessage(Res.string.feature_auth_error_username_required) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_username_required), + ) + } } state.emailInput.isEmpty() -> { - DialogManager.showMessage(Res.string.feature_auth_error_email_required) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_email_required), + ) + } } !state.emailInput.isValidEmail() -> { - DialogManager.showMessage(Res.string.feature_auth_error_email_invalid) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_email_invalid), + ) + } } state.mobileNumberInput.isEmpty() -> { - DialogManager.showMessage(Res.string.feature_auth_error_mobile_required) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_mobile_required), + ) + } } state.mobileNumberInput.length < 10 -> { - DialogManager.showMessage(Res.string.feature_auth_error_mobile_invalid) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_mobile_invalid), + ) + } } state.passwordInput.isEmpty() -> { - DialogManager.showMessage(Res.string.feature_auth_error_password_required) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_password_required), + ) + } } state.passwordFeedback.isNotEmpty() -> { val bulletListPasswordFeedback = formatAsBulletPoints(state.passwordFeedback) - DialogManager.showMessage(bulletListPasswordFeedback) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.StringMessage(bulletListPasswordFeedback), + ) + } } state.confirmPasswordInput.isEmpty() -> { - DialogManager.showMessage(Res.string.feature_auth_error_confirm_password_required) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_confirm_password_required), + ) + } } state.passwordInput != state.confirmPasswordInput -> { - DialogManager.showMessage(Res.string.feature_auth_error_passwords_mismatch) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_passwords_mismatch), + ) + } } state.addressLine1Input.isEmpty() -> { - DialogManager.showMessage(Res.string.feature_auth_error_address_line1_required) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_address_line1_required), + ) + } } state.addressLine2Input.isEmpty() -> { - DialogManager.showMessage(Res.string.feature_auth_error_address_line2_required) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_address_line2_required), + ) + } } state.pinCodeInput.isEmpty() -> { - DialogManager.showMessage(Res.string.feature_auth_error_pincode_required) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_pincode_required), + ) + } } state.countryInput.isEmpty() -> { - DialogManager.showMessage(Res.string.feature_auth_error_country_required) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_country_required), + ) + } } state.stateInput.isEmpty() -> { - DialogManager.showMessage(Res.string.feature_auth_error_state_required) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.ResourceMessage(Res.string.feature_auth_error_state_required), + ) + } } else -> initiateSignUp() @@ -346,7 +425,9 @@ class SignupViewModel( Enhancement: Move the following code in to a Use Case */ private fun initiateSignUp() { - DialogManager.showLoading() + mutableStateFlow.update { + it.copy(dialogState = DialogState.Loading) + } val fieldsToCheck = mapOf( "Username" to state.userNameInput, @@ -368,16 +449,31 @@ class SignupViewModel( when (result) { is DataState.Loading -> {} is DataState.Success -> { - if (result.data.isNotEmpty()) "$label already exists." else null + if (result.data.isNotEmpty()) { + stringProvider.get( + resource = Res.string.feature_auth_error_field_already_exists, + label, + ) + } else { + null + } } - is DataState.Error -> - "Unable to check if $label is unique. Please try again later." + is DataState.Error -> stringProvider.get( + Res.string.feature_auth_error_check_uniqueness_failed, + label, + ) } } if (errorMessages.isNotEmpty()) { - DialogManager.showMessage(errorMessages.joinToString("\n")) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.StringMessage( + errorMessages.joinToString("\n"), + ), + ) + } } else { val newUser = NewUser( state.userNameInput, @@ -395,7 +491,13 @@ class SignupViewModel( viewModelScope.launch { when (val result = userRepository.createUser(newUser)) { is DataState.Error -> { - DialogManager.showMessage(result.exception.toDialogMessage()) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.StringMessage( + result.exception.message.toString(), + ), + ) + } } is DataState.Success -> { @@ -428,7 +530,11 @@ class SignupViewModel( is DataState.Error -> { deleteUser(userId) val message = result.exception.message.toString() - DialogManager.showMessage(message) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.StringMessage(message), + ) + } } is DataState.Success -> { @@ -446,13 +552,21 @@ class SignupViewModel( is DataState.Error -> { deleteUser(userId) deleteClient(clientId) - DialogManager.showMessage(result.exception.toDialogMessage()) + mutableStateFlow.update { + it.copy( + dialogState = DialogState.Error.StringMessage( + result.exception.toString(), + ), + ) + } } is DataState.Success -> { - DialogManager.dismissDialog() + mutableStateFlow.update { + it.copy(dialogState = null) + } - sendEvent(SignUpEvent.ShowToast("Registration successful.")) + sendEvent(SignUpEvent.ShowToast(stringProvider.get(Res.string.feature_auth_registration_successful))) sendAction( SignUpAction.Internal.ReceiveRegisterResult( DataState.Success(state.userNameInput), @@ -480,10 +594,9 @@ class SignupViewModel( private fun loadCountriesFromJson() { viewModelScope.launch { when (val countriesWithStatesResult = assetRepository.getCountriesWithStates()) { - is DataState.Success -> - mutableStateFlow.update { - it.copy(countriesWithStates = countriesWithStatesResult.data) - } + is DataState.Success -> mutableStateFlow.update { + it.copy(countriesWithStates = countriesWithStatesResult.data) + } is DataState.Error -> Logger.d("Failed to load countries.json: ${countriesWithStatesResult.exception.message}") is DataState.Loading -> {} @@ -508,13 +621,23 @@ data class SignUpState( val stateInput: String = "", val countryInput: String = "", val businessNameInput: String = "", + @Transient val dialogState: DialogState? = null, val passwordStrengthState: PasswordStrengthState = PasswordStrengthState.NONE, + @Serializable(with = ImmutableListSerializer::class) val passwordFeedback: ImmutableList = persistentListOf(), val countriesWithStates: Map> = emptyMap(), ) { @Transient - val statesForSelectedCountry = - countriesWithStates[countryInput] + val statesForSelectedCountry = countriesWithStates[countryInput] + + sealed interface DialogState { + sealed interface Error : DialogState { + data class StringMessage(val message: String) : Error + data class ResourceMessage(val message: StringResource) : Error + } + + data object Loading : DialogState + } } sealed interface SignUpEvent { diff --git a/feature/auth/src/commonTest/kotlin/org/mifospay/feature/auth/login/LoginViewModelTest.kt b/feature/auth/src/commonTest/kotlin/org/mifospay/feature/auth/login/LoginViewModelTest.kt new file mode 100644 index 00000000..7e6ba56f --- /dev/null +++ b/feature/auth/src/commonTest/kotlin/org/mifospay/feature/auth/login/LoginViewModelTest.kt @@ -0,0 +1,235 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.mock +import dev.mokkery.verifySuspend +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.mifospay.core.common.DataState +import org.mifospay.core.domain.LoginUseCase +import org.mifospay.core.model.user.UserInfo +import org.mifospay.feature.auth.login.LoginAction +import org.mifospay.feature.auth.login.LoginEvent +import org.mifospay.feature.auth.login.LoginState +import org.mifospay.feature.auth.login.LoginViewModel +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class LoginViewModelTest { + private val testDispatcher: CoroutineDispatcher = StandardTestDispatcher() + + private val loginUseCase: LoginUseCase = mock() + + private lateinit var viewModel: LoginViewModel + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + + viewModel = LoginViewModel( + loginUseCase = loginUseCase, + savedStateHandle = SavedStateHandle(), + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Verifies initial state values are set correctly on ViewModel init. + */ + @Test + fun givenInitialState_whenViewModelInitialized_thenValidInitialConditions() { + assertEquals("", viewModel.stateFlow.value.username) + assertEquals("", viewModel.stateFlow.value.password) + assertFalse(viewModel.stateFlow.value.isPasswordVisible) + assertEquals(null, viewModel.stateFlow.value.dialogState) + } + + /** + * Tests that the username input updates the ViewModel state correctly. + */ + @Test + fun givenUsernameInput_whenUsernameChanged_thenStateUpdated() = runTest { + viewModel.trySendAction(LoginAction.UsernameChanged("alice")) + advanceUntilIdle() + assertEquals("alice", viewModel.stateFlow.value.username) + } + + /** + * Tests that the password input updates the ViewModel state correctly. + */ + @Test + fun givenPasswordInput_whenPasswordChanged_thenStateUpdated() = runTest { + viewModel.trySendAction(LoginAction.PasswordChanged("secret")) + advanceUntilIdle() + assertEquals("secret", viewModel.stateFlow.value.password) + } + + /** + * Tests the toggle password visibility logic. + */ + @Test + fun whenTogglePasswordVisibility_thenVisibilityStateUpdated() = runTest { + assertFalse(viewModel.stateFlow.value.isPasswordVisible) + + viewModel.trySendAction(LoginAction.TogglePasswordVisibility) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.isPasswordVisible) + + viewModel.trySendAction(LoginAction.TogglePasswordVisibility) + advanceUntilIdle() + assertFalse(viewModel.stateFlow.value.isPasswordVisible) + } + + /** + * Tests that correct credentials result in a success state with no error dialog. + * + * Uses [everySuspend] to mock suspend call and [verifySuspend] to verify usage. + */ + @Test + fun givenCorrectCredentials_whenLoginClicked_thenNoErrorShown() = runTest { + /* + * Mocks the LoginUseCase to return a successful user info when invoked. + */ + everySuspend { + loginUseCase.invoke("Mifos", "MifosPassword") + } returns DataState.Success( + UserInfo( + userId = 1, + username = "abc", + base64EncodedAuthenticationKey = "fake-auth-key", + authenticated = true, + officeId = 1, + officeName = "Main Office", + roles = emptyList(), + permissions = emptyList(), + clients = listOf(1), + shouldRenewPassword = false, + isTwoFactorAuthenticationRequired = false, + ), + ) + + viewModel.trySendAction(LoginAction.UsernameChanged("Mifos")) + viewModel.trySendAction(LoginAction.PasswordChanged("MifosPassword")) + viewModel.trySendAction(LoginAction.LoginClicked) + + advanceUntilIdle() + + /* + * Verifies that loginUseCase was invoked with the correct credentials. + */ + verifySuspend { + loginUseCase.invoke("Mifos", "MifosPassword") + } + + assertNull(viewModel.stateFlow.value.dialogState) + } + + /** + * Tests that incorrect credentials show an error dialog with appropriate message. + */ + @Test + fun givenIncorrectCredentials_whenLoginClicked_thenErrorDialogShown() = runTest { + /* + * Mocks the LoginUseCase to return DataState.Error for wrong credentials. + */ + everySuspend { + loginUseCase.invoke("Hekmat", "MyPassword") + } returns DataState.Error(Exception("Invalid Credentials")) + + viewModel.trySendAction(LoginAction.UsernameChanged("Hekmat")) + viewModel.trySendAction(LoginAction.PasswordChanged("MyPassword")) + viewModel.trySendAction(LoginAction.LoginClicked) + + advanceUntilIdle() + + /* + * Verifies that loginUseCase was invoked with the provided wrong credentials. + */ + verifySuspend { + loginUseCase.invoke("Hekmat", "MyPassword") + } + + val dialog = viewModel.stateFlow.value.dialogState + assertIs(dialog) + assertEquals("Invalid Credentials", dialog.message) + } + + /** + * Tests that signup click triggers navigation to signup screen. + */ + @Test + fun whenSignupClicked_thenNavigateToSignupEventEmitted() = runTest { + viewModel.eventFlow.test { + viewModel.trySendAction(LoginAction.SignupClicked) + assertIs(awaitItem()) + } + } + + /** + * Tests that successful login triggers navigation to passcode screen. + */ + @Test + fun givenCorrectCredentials_whenLoginSucceeds_thenNavigateToPasscodeScreenEmitted() = runTest { + everySuspend { + loginUseCase.invoke("validUser", "validPass") + } returns DataState.Success( + UserInfo( + userId = 1, + username = "validUser", + base64EncodedAuthenticationKey = "auth", + authenticated = true, + officeId = 1, + officeName = "HQ", + roles = emptyList(), + permissions = emptyList(), + clients = listOf(1), + shouldRenewPassword = false, + isTwoFactorAuthenticationRequired = false, + ), + ) + + viewModel.trySendAction(LoginAction.UsernameChanged("validUser")) + viewModel.trySendAction(LoginAction.PasswordChanged("validPass")) + viewModel.trySendAction(LoginAction.LoginClicked) + advanceUntilIdle() + + /* + * Verifies that loginUseCase was invoked with the correct credentials. + */ + verifySuspend { + loginUseCase.invoke("validUser", "validPass") + } + + viewModel.eventFlow.test { + // Expect NavigateToPasscodeScreen after successful login + assertTrue(awaitItem() is LoginEvent.NavigateToPasscodeScreen) + } + } +} diff --git a/feature/auth/src/commonTest/kotlin/org/mifospay/feature/auth/mobileVerify/MobileVerificationViewModelTest.kt b/feature/auth/src/commonTest/kotlin/org/mifospay/feature/auth/mobileVerify/MobileVerificationViewModelTest.kt new file mode 100644 index 00000000..5cf961fe --- /dev/null +++ b/feature/auth/src/commonTest/kotlin/org/mifospay/feature/auth/mobileVerify/MobileVerificationViewModelTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.auth.mobileVerify + +import androidx.lifecycle.SavedStateHandle +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verifySuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.mifospay.core.common.DataState +import org.mifospay.core.data.repository.SearchRepository +import org.mifospay.core.model.search.SearchResult +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class MobileVerificationViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val searchRepository: SearchRepository = mock() + private lateinit var viewModel: MobileVerificationViewModel + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + viewModel = MobileVerificationViewModel( + searchRepository = searchRepository, + savedStateHandle = SavedStateHandle(), + ) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Tests that the ViewModel updates the state when the user enters a new phone number. + */ + @Test + fun givenPhoneNumber_whenPhoneNumberChanged_thenStateIsUpdated() = runTest { + val newPhone = "9876543210" + viewModel.trySendAction(MobileVerificationAction.PhoneNoChanged(newPhone)) + advanceUntilIdle() + val state = viewModel.stateFlow.value + assertEquals(newPhone, (state as MobileVerificationState.VerifyPhoneState).phoneNo) + } + + /** + * Tests that a valid phone number not found in the system triggers a transition to OTP entry. + */ + @Test + fun givenValidPhoneNumberNotInSystem_whenVerifyPhoneClicked_thenTransitionToOtpState() = + runTest { + val validPhoneNumber = "9876543210" + + /* + * Mocks searchResources to return an empty list to indicate the number doesn't exist. + */ + everySuspend { + searchRepository.searchResources( + validPhoneNumber, + any(), + any(), + ) + } returns DataState.Success(data = emptyList()) + + viewModel.trySendAction(MobileVerificationAction.PhoneNoChanged(validPhoneNumber)) + viewModel.trySendAction(MobileVerificationAction.VerifyPhoneBtnClicked) + + advanceUntilIdle() + + /* + * Verifies that searchResources was called with the correct phone number. + */ + verifySuspend { + searchRepository.searchResources(validPhoneNumber, any(), any()) + } + + assertEquals( + expected = validPhoneNumber, + actual = (viewModel.stateFlow.value as MobileVerificationState.VerifyOtpState).phoneNo, + ) + } + + /** + * Tests that entering an invalid phone number results in an error dialog. + */ + @Test + fun givenInvalidPhoneNumber_whenVerifyPhoneClicked_thenErrorDialogShown() = + runTest { + val invalidPhoneNo = "123" + + viewModel.trySendAction(MobileVerificationAction.PhoneNoChanged(invalidPhoneNo)) + viewModel.trySendAction(MobileVerificationAction.VerifyPhoneBtnClicked) + + advanceUntilIdle() + + val dialogState = + (viewModel.stateFlow.value as MobileVerificationState.VerifyPhoneState).dialogState + assertTrue(dialogState is MobileVerificationState.DialogState.Error) + } + + /** + * Tests that entering a phone number that already exists shows an error dialog. + */ + @Test + fun givenExistingPhoneNumber_whenVerifyPhoneClicked_thenErrorDialogShown() = + runTest { + val existingPhoneNumber = "1234567890" + + /* + * Mocks searchResources to return a result indicating the phone number is already taken. + */ + everySuspend { + searchRepository.searchResources( + existingPhoneNumber, + any(), + any(), + ) + } returns DataState.Success( + data = listOf( + SearchResult( + entityId = 1, + entityAccountNo = "123", + entityName = "SameUserName", + entityType = "savings", + parentId = 1, + parentName = "smith", + ), + ), + ) + + viewModel.trySendAction(MobileVerificationAction.PhoneNoChanged(existingPhoneNumber)) + viewModel.trySendAction(MobileVerificationAction.VerifyPhoneBtnClicked) + + advanceUntilIdle() + + /* + * Verifies that searchResources was called with the expected parameters. + * Ensures the ViewModel triggered the repository call as expected. + */ + verifySuspend { + searchRepository.searchResources(existingPhoneNumber, any(), any()) + } + + val dialogState = + (viewModel.stateFlow.value as MobileVerificationState.VerifyPhoneState).dialogState + assertTrue(dialogState is MobileVerificationState.DialogState.Error) + } +} diff --git a/feature/auth/src/commonTest/kotlin/org/mifospay/feature/auth/signup/SignUpViewModelTest.kt b/feature/auth/src/commonTest/kotlin/org/mifospay/feature/auth/signup/SignUpViewModelTest.kt new file mode 100644 index 00000000..7f8c7a0c --- /dev/null +++ b/feature/auth/src/commonTest/kotlin/org/mifospay/feature/auth/signup/SignUpViewModelTest.kt @@ -0,0 +1,815 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.auth.signup + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.matcher.eq +import dev.mokkery.matcher.logical.or +import dev.mokkery.matcher.varargs.anyVarargs +import dev.mokkery.mock +import dev.mokkery.verifySuspend +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.mifospay.core.common.DataState +import org.mifospay.core.common.StringProvider +import org.mifospay.core.data.repository.AssetRepository +import org.mifospay.core.data.repository.ClientRepository +import org.mifospay.core.data.repository.SearchRepository +import org.mifospay.core.data.repository.UserRepository +import org.mifospay.core.model.search.SearchResult +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SignUpViewModelTest { + + private val testDispatcher: CoroutineDispatcher = StandardTestDispatcher() + + private val mockUserRepository: UserRepository = mock() + private val mockSearchRepository: SearchRepository = mock() + private val mockClientRepository: ClientRepository = mock() + private val mockAssetRepository: AssetRepository = mock() + private val mockStringProvider: StringProvider = mock { + everySuspend { get(any(), anyVarargs()) } returns "Mock String" + } + + private lateinit var viewModel: SignupViewModel + + private val fakeSearchResult = SearchResult( + entityId = 1, + entityAccountNo = "123", + entityName = "SameUserName", + entityType = "savings", + parentId = 1, + parentName = "smith", + ) + + @OptIn(ExperimentalCoroutinesApi::class) + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + + everySuspend { mockAssetRepository.getCountriesWithStates() } returns DataState.Success( + mapOf( + "Afghanistan" to listOf( + "Badakhshān", "Baghlān", "Balkh", "Bādghīs", "Bāmyān", + ), + "Albania" to listOf( + "Berat", "Dibër", "Durrës", "Elbasan", "Fier", + ), + ), + ) + + viewModel = SignupViewModel( + userRepository = mockUserRepository, + searchRepository = mockSearchRepository, + clientRepository = mockClientRepository, + assetRepository = mockAssetRepository, + stringProvider = mockStringProvider, + savedStateHandle = SavedStateHandle(), + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Ensures the first name is updated in the state when user inputs it. + */ + @Test + fun givenFirstName_whenInputChanged_thenFirstNameIsUpdated() = runTest { + val firstName = "John" + viewModel.trySendAction(SignUpAction.FirstNameInputChange(firstName)) + advanceUntilIdle() + assertEquals(firstName, viewModel.stateFlow.value.firstNameInput) + } + + /** + * Ensures the last name is updated in the state when user inputs it. + */ + @Test + fun givenLastName_whenInputChanged_thenLastNameIsUpdated() = runTest { + val lastName = "Doe" + viewModel.trySendAction(SignUpAction.LastNameInputChange(lastName)) + advanceUntilIdle() + assertEquals(lastName, viewModel.stateFlow.value.lastNameInput) + } + + /** + * Ensures the username is updated in the state. + */ + @Test + fun givenUsername_whenInputChanged_thenUsernameIsUpdated() = runTest { + val username = "john_doe" + viewModel.trySendAction(SignUpAction.UserNameInputChange(username)) + advanceUntilIdle() + assertEquals(username, viewModel.stateFlow.value.userNameInput) + } + + /** + * Verifies the email input updates the corresponding state. + */ + @Test + fun givenEmail_whenInputChanged_thenEmailIsUpdated() = runTest { + val email = "john@example.com" + viewModel.trySendAction(SignUpAction.EmailInputChange(email)) + advanceUntilIdle() + assertEquals(email, viewModel.stateFlow.value.emailInput) + } + + /** + * Confirms the mobile number field updates properly in the state. + */ + @Test + fun givenMobileNumber_whenInputChanged_thenMobileNumberIsUpdated() = runTest { + val mobile = "9876543210" + viewModel.trySendAction(SignUpAction.MobileNumberInputChange(mobile)) + advanceUntilIdle() + assertEquals(mobile, viewModel.stateFlow.value.mobileNumberInput) + } + + /** + * Ensures password field gets updated on input. + */ + @Test + fun givenPassword_whenInputChanged_thenPasswordIsUpdated() = runTest { + val password = "Test@1234" + viewModel.trySendAction(SignUpAction.PasswordInputChange(password)) + advanceUntilIdle() + assertEquals(password, viewModel.stateFlow.value.passwordInput) + } + + /** + * Ensures confirm password field gets updated on input. + */ + @Test + fun givenConfirmPassword_whenInputChanged_thenConfirmPasswordIsUpdated() = + runTest { + val confirmPassword = "Test@1234" + viewModel.trySendAction(SignUpAction.ConfirmPasswordInputChange(confirmPassword)) + advanceUntilIdle() + assertEquals(confirmPassword, viewModel.stateFlow.value.confirmPasswordInput) + } + + /** + * Verifies Address Line 1 updates correctly. + */ + @Test + fun givenAddressLine1_whenInputChanged_thenAddressLine1IsUpdated() = runTest { + val address1 = "123 Main Street" + viewModel.trySendAction(SignUpAction.AddressLine1InputChange(address1)) + advanceUntilIdle() + assertEquals(address1, viewModel.stateFlow.value.addressLine1Input) + } + + /** + * Verifies Address Line 2 updates correctly. + */ + @Test + fun givenAddressLine2_whenInputChanged_thenAddressLine2IsUpdated() = runTest { + val address2 = "Suite 456" + viewModel.trySendAction(SignUpAction.AddressLine2InputChange(address2)) + advanceUntilIdle() + assertEquals(address2, viewModel.stateFlow.value.addressLine2Input) + } + + /** + * Checks the pincode input updates its field correctly. + */ + @Test + fun givenPinCode_whenInputChanged_thenPinCodeIsUpdated() = runTest { + val pincode = "56000" + viewModel.trySendAction(SignUpAction.PinCodeInputChange(pincode)) + advanceUntilIdle() + assertEquals(pincode, viewModel.stateFlow.value.pinCodeInput) + } + + /** + * Ensures country change updates the country field and resets the state field. + */ + @Test + fun givenCountry_whenInputChanged_thenCountryIsUpdatedAndStateReset() = runTest { + val country = "USA" + viewModel.trySendAction(SignUpAction.CountryInputChange(country)) + advanceUntilIdle() + assertEquals(country, viewModel.stateFlow.value.countryInput) + assertEquals("", viewModel.stateFlow.value.stateInput) + } + + /** + * Ensures the selected state updates correctly in the ViewModel state. + */ + @Test + fun givenState_whenInputChanged_thenStateIsUpdated() = runTest { + val state = "KA" + viewModel.trySendAction(SignUpAction.StateInputChange(state)) + advanceUntilIdle() + assertEquals(state, viewModel.stateFlow.value.stateInput) + } + + /** + * Verifies the correct list of states is shown for the selected country. + * + * Includes verification of call to [mockAssetRepository.getCountriesWithStates] + */ + @Test + fun givenCountrySelected_whenFetchingStates_thenReturnExpectedStates() = runTest { + viewModel.trySendAction(SignUpAction.CountryInputChange("Afghanistan")) + advanceUntilIdle() + + // Verifies that getCountriesWithStates() was called exactly once during the process + verifySuspend { mockAssetRepository.getCountriesWithStates() } + + assertEquals( + listOf("Badakhshān", "Baghlān", "Balkh", "Bādghīs", "Bāmyān"), + viewModel.stateFlow.value.statesForSelectedCountry, + ) + } + + /** + * Tests a successful end-to-end sign-up flow. + * + * This includes: + * - Verifying that the user and client are created. + * - Verifying that the client is assigned to the user. + * - Ensuring no error dialog is shown at the end. + */ + @Test + fun givenValidInputs_whenSubmitClicked_thenUserAndClientAreCreatedSuccessfully() = runTest { + /* Mock the SearchRepository to simulate that both the entered username and mobile number are available. + * This means the backend did not find any existing user or client with the provided credentials, + * so it returns an empty list, indicating no conflicts. + * This sets up the scenario for a successful registration flow. + */ + everySuspend { + mockSearchRepository.searchResources( + "john_doe", + any(), + any(), + ) + } returns DataState.Success(emptyList()) + everySuspend { + mockSearchRepository.searchResources( + "9876543210", + any(), + any(), + ) + } returns DataState.Success(emptyList()) + + // Mock user creation to return a user ID + everySuspend { mockUserRepository.createUser(any()) } returns DataState.Success(123) + + // Mock client creation to return a client ID + everySuspend { mockClientRepository.createClient(any()) } returns DataState.Success(456) + + // Mock assigning client to user to return success + everySuspend { + mockUserRepository.assignClientToUser( + any(), + any(), + ) + } returns DataState.Success(Unit) + + enterAllFields() + + viewModel.trySendAction(SignUpAction.SubmitClick) + + advanceUntilIdle() + + // Verify that all critical suspend calls were made as expected + verifySuspend { + mockSearchRepository.searchResources("john_doe", any(), any()) + mockSearchRepository.searchResources("9876543210", any(), any()) + mockUserRepository.createUser(any()) + mockClientRepository.createClient(any()) + mockUserRepository.assignClientToUser(123, 456) + } + + assertNull(viewModel.stateFlow.value.dialogState) + } + + /** + * Tests a complete and successful signup process: + * + * - Verifies that both username and mobile number pass uniqueness checks. + * - Ensures user and client are created successfully. + * - Confirms the client is assigned to the user. + * - Validates the emitted events: + * - A toast message confirming signup success. + * - A navigation event redirecting the user to the login screen. + */ + @Test + fun givenValidInputs_whenSignUpSucceeds_thenShowToastAndNavigateToLogin() = runTest { + /* Mock the SearchRepository to simulate that both the entered username and mobile number are available. + * This means the backend did not find any existing user or client with the provided credentials, + * so it returns an empty list, indicating no conflicts. + * This sets up the scenario for a successful registration flow. + */ + everySuspend { + mockSearchRepository.searchResources( + or(eq("john_doe"), eq("9876543210")), + any(), + any(), + ) + } returns DataState.Success(emptyList()) + + // Mock user creation + everySuspend { mockUserRepository.createUser(any()) } returns DataState.Success(101) + + // Mock client creation + everySuspend { mockClientRepository.createClient(any()) } returns DataState.Success(202) + + // Mock client-user assignment + everySuspend { mockUserRepository.assignClientToUser(101, 202) } returns DataState.Success(Unit) + + enterAllFields() + + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + + // Verify that all critical suspend calls were made as expected + verifySuspend { + mockSearchRepository.searchResources("john_doe", any(), any()) + mockSearchRepository.searchResources("9876543210", any(), any()) + mockUserRepository.createUser(any()) + mockClientRepository.createClient(any()) + mockUserRepository.assignClientToUser(any(), any()) + } + + viewModel.eventFlow.test { + // Assert: Show toast + val toastEvent = awaitItem() + assertTrue(toastEvent is SignUpEvent.ShowToast) + assertEquals("Mock String", toastEvent.message) + + // Assert: Navigate to login + val navigationEvent = awaitItem() + assertTrue(navigationEvent is SignUpEvent.NavigateToLogin) + assertEquals("john_doe", navigationEvent.username) + } + } + + /** + * Tests that when the user clicks the close button during signup, + * the ViewModel emits a [SignUpEvent.NavigateBack] event to trigger back navigation. + */ + @Test + fun whenCloseClicked_thenNavigateBackEventEmitted() = runTest { + viewModel.eventFlow.test { + // Act: simulate clicking the close/back button + viewModel.trySendAction(SignUpAction.CloseClick) + + // Assert: the viewModel emits a NavigateBack event + val event = awaitItem() + assertTrue(event is SignUpEvent.NavigateBack) + } + } + + /** + * Tests that if the entered username already exists, the ViewModel shows an error dialog. + * + * This ensures the UI properly handles the case where the user tries to register with + * a username that is already taken. + */ + @Test + fun givenExistingUsername_whenSubmitClicked_thenErrorDialogShown() = runTest { + /* Mocks searchResources to return a result indicating the username or mobile already exists. + * This simulates the backend identifying a duplicate username during the signup process. + */ + everySuspend { + mockSearchRepository.searchResources( + or(eq("SameUserName"), eq("9876543210")), any(), any(), + ) + } returns DataState.Success( + listOf( + fakeSearchResult, + ), + ) + + enterAllFields() + + viewModel.trySendAction(SignUpAction.UserNameInputChange("SameUserName")) + + viewModel.trySendAction(SignUpAction.SubmitClick) + + advanceUntilIdle() + + /* + * Verifies that search was performed for both the username and the mobile number. + * Confirms the repository method was invoked with the expected parameters. + */ + verifySuspend { + mockSearchRepository.searchResources("SameUserName", any(), any()) + mockSearchRepository.searchResources( + query = "9876543210", + resources = any(), + exactMatch = any(), + ) + } + + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that if the entered mobile number already exists, the ViewModel shows an error dialog. + * + * This ensures the UI prevents duplicate registrations using an already registered mobile number. + */ + @Test + fun givenExistingMobileNumber_whenSubmitClicked_thenErrorDialogShown() = runTest { + /* + * Mocks searchResources to return a result indicating the username or mobile already exists. + * Specifically simulates a conflict on the mobile number while keeping the username valid. + */ + everySuspend { + mockSearchRepository.searchResources( + query = or(eq("john_doe"), eq("1234567890")), + resources = any(), + exactMatch = any(), + ) + } returns DataState.Success( + listOf( + fakeSearchResult, + ), + ) + + enterAllFields() + + viewModel.trySendAction(SignUpAction.MobileNumberInputChange("1234567890")) + + viewModel.trySendAction(SignUpAction.SubmitClick) + + advanceUntilIdle() + + /* + * Verifies that the repository was queried with both username and mobile number + * to check for existing users before submission. + * */ + verifySuspend { + mockSearchRepository.searchResources( + query = "john_doe", + resources = any(), + exactMatch = any(), + ) + mockSearchRepository.searchResources( + query = "1234567890", + resources = any(), + exactMatch = any(), + ) + } + + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that if both the username and mobile number already exist, the ViewModel shows a combined error dialog. + * + * This validates the system's ability to detect and handle multiple field conflicts in one go. + */ + @Test + fun givenExistingUsernameAndMobileNumber_whenSubmitClicked_thenErrorDialogShown() = + runTest { + /* Mocks searchResources to return a result showing both username and mobile are taken. + * This covers the scenario where a new user attempts to register with fully duplicated credentials. + */ + everySuspend { + mockSearchRepository.searchResources( + query = or(eq("SameUserName"), eq("1234567890")), + any(), + any(), + ) + } returns DataState.Success( + listOf( + fakeSearchResult, + ), + ) + + enterAllFields() + + viewModel.trySendAction(SignUpAction.UserNameInputChange("SameUserName")) + viewModel.trySendAction(SignUpAction.MobileNumberInputChange("1234567890")) + + viewModel.trySendAction(SignUpAction.SubmitClick) + + advanceUntilIdle() + + // Verifies that both conflicting queries (username and mobile number) were made to the repository. + verifySuspend { + mockSearchRepository.searchResources("SameUserName", any(), any()) + mockSearchRepository.searchResources( + query = "1234567890", + resources = any(), + exactMatch = any(), + ) + } + + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that when the first name is missing and the form is submitted, + * the ViewModel responds with a validation error dialog. + */ + @Test + fun givenMissingFirstName_whenSubmitClicked_thenErrorDialogShown() = runTest { + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that a missing last name triggers a required field error dialog. + */ + @Test + fun givenMissingLastName_whenSubmitClicked_thenErrorDialogShown() = runTest { + enterFirstName() + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that a missing username triggers a required field error dialog. + */ + @Test + fun givenMissingUsername_whenSubmitClicked_thenErrorDialogShown() = runTest { + enterLastName() + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that a missing email triggers a required field error dialog. + */ + @Test + fun givenMissingEmail_whenSubmitClicked_thenErrorDialogShown() = runTest { + enterUserName() + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that an invalid email format leads to a validation error. + */ + @Test + fun givenInvalidEmailFormat_whenSubmitClicked_thenErrorDialogShown() = runTest { + enterUserName() + viewModel.trySendAction(SignUpAction.EmailInputChange("not-an-email")) + + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that an empty mobile number field shows a required field error. + */ + @Test + fun givenMissingMobileNumber_whenSubmitClicked_thenErrorDialogShown() = + runTest(testDispatcher) { + enterEmail() + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that entering a short (invalid) mobile number triggers an error. + */ + @Test + fun givenShortMobileNumber_whenSubmitClicked_thenErrorDialogShown() = runTest { + enterEmail() + viewModel.trySendAction(SignUpAction.MobileNumberInputChange("12345")) + + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that a missing password results in a required field error dialog. + */ + @Test + fun givenMissingPassword_whenSubmitClicked_thenErrorDialogShown() = runTest { + enterMobileNumber() + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that if confirm password is not entered, an error dialog is shown. + */ + @Test + fun givenMissingConfirmPassword_whenSubmitClicked_thenErrorDialogShown() = + runTest { + enterPassword() + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that a mismatch between password and confirm password triggers an error. + */ + @Test + fun givenPasswordMismatch_whenSubmitClicked_thenErrorDialogShown() = runTest { + enterPassword() + viewModel.trySendAction(SignUpAction.ConfirmPasswordInputChange("DifferentPassword")) + + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that missing Address Line 1 results in a validation error dialog. + */ + @Test + fun givenMissingAddressLine1_whenSubmitClicked_thenErrorDialogShown() = + runTest { + enterConfirmPassword() + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that missing Address Line 2 triggers a required field error. + */ + @Test + fun givenMissingAddressLine2_whenSubmitClicked_thenErrorDialogShown() = + runTest { + enterAddressLine1() + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that missing pincode results in a validation error message. + */ + @Test + fun givenMissingPinCode_whenSubmitClicked_thenErrorDialogShown() = runTest { + enterAddressLine2() + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that not selecting a country during sign-up shows a required field error. + */ + @Test + fun givenMissingCountry_whenSubmitClicked_thenErrorDialogShown() = runTest { + enterPinCode() + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Tests that not selecting a state results in an error dialog. + */ + @Test + fun givenMissingState_whenSubmitClicked_thenErrorDialogShown() = runTest { + enterCountry() + viewModel.trySendAction(SignUpAction.SubmitClick) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.dialogState is SignUpState.DialogState.Error) + } + + /** + * Fills in savings account number, business name, and first name. + * Acts as the initial field setup for form input sequences. + */ + private fun enterFirstName() { + viewModel.trySendAction(SignUpAction.SavingsAccountNoInputChange(1)) + viewModel.trySendAction(SignUpAction.BusinessNameInputChange("Acme Corp")) + viewModel.trySendAction(SignUpAction.FirstNameInputChange("John")) + } + + /** + * Fills in the last name after first name has been entered. + */ + private fun enterLastName() { + enterFirstName() + viewModel.trySendAction(SignUpAction.LastNameInputChange("Doe")) + } + + /** + * Fills in the username after last name has been entered. + */ + private fun enterUserName() { + enterLastName() + viewModel.trySendAction(SignUpAction.UserNameInputChange("john_doe")) + } + + /** + * Fills in the email address after username has been entered. + */ + private fun enterEmail() { + enterUserName() + viewModel.trySendAction(SignUpAction.EmailInputChange("john@example.com")) + } + + /** + * Fills in the mobile number after email has been entered. + */ + private fun enterMobileNumber() { + enterEmail() + viewModel.trySendAction(SignUpAction.MobileNumberInputChange("9876543210")) + } + + /** + * Fills in the password after mobile number has been entered. + */ + private fun enterPassword() { + enterMobileNumber() + viewModel.trySendAction(SignUpAction.PasswordInputChange("Strongkey@123")) + } + + /** + * Fills in the confirm password field after the password has been entered. + */ + private fun enterConfirmPassword() { + enterPassword() + viewModel.trySendAction(SignUpAction.ConfirmPasswordInputChange("Strongkey@123")) + } + + /** + * Fills in address line 1 after confirm password has been entered. + */ + private fun enterAddressLine1() { + enterConfirmPassword() + viewModel.trySendAction(SignUpAction.AddressLine1InputChange("123 Main St")) + } + + /** + * Fills in address line 2 after address line 1 has been entered. + */ + private fun enterAddressLine2() { + enterAddressLine1() + viewModel.trySendAction(SignUpAction.AddressLine2InputChange("Apt 456")) + } + + /** + * Fills in the pin code after address line 2 has been entered. + */ + private fun enterPinCode() { + enterAddressLine2() + viewModel.trySendAction(SignUpAction.PinCodeInputChange("560001")) + } + + /** + * Fills in the country after pin code has been entered. + */ + private fun enterCountry() { + enterPinCode() + viewModel.trySendAction(SignUpAction.CountryInputChange("IN")) + } + + /** + * Fills in the state after country has been entered. + */ + private fun enterState() { + enterCountry() + viewModel.trySendAction(SignUpAction.StateInputChange("KA")) + } + + /** + * Convenience method to fill in all required fields in the correct order. + * This is used for tests that need the form to be fully populated. + */ + private fun enterAllFields() { + enterState() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a90ee5b6..2d0b9e18 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,9 +37,15 @@ mlkit="17.3.0" # Testing Dependencies espresso-core = "3.6.1" junitVersion = "4.13.2" +kotestVersion = "5.9.1" +mokkeryVersion = "2.7.2" truth = "1.4.4" +turbineVersion = "1.2.1" roborazzi = "1.26.0" zxingVersion = "3.5.3" +coreKtx = "1.6.1" +composeTest = "1.6.8" +mokkery = "2.8.0" # Utility Dependencies dependencyGuard = "0.5.0" @@ -206,10 +212,13 @@ lint-api = { group = "com.android.tools.lint", name = "lint-api", version.ref = lint-checks = { group = "com.android.tools.lint", name = "lint-checks", version.ref = "androidTools" } lint-tests = { group = "com.android.tools.lint", name = "lint-tests", version.ref = "androidTools" } +mokkery = {group = "dev.mokkery", name="mokkery-gradle", version.ref="mokkeryVersion"} + protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbineVersion" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxingVersion" } @@ -231,6 +240,11 @@ jb-savedstate = { module = "org.jetbrains.androidx.savedstate:savedstate", versi jb-composeNavigation = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "composeNavigation" } jb-navigation = { module = "org.jetbrains.androidx.navigation:navigation-common", version.ref = "composeNavigation" } +kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotestVersion" } +kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotestVersion" } +kotest-framework-datatest = { module = "io.kotest:kotest-framework-datatest", version.ref = "kotestVersion" } +kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotestVersion" } + koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } koin-androidx-navigation = { group = "io.insert-koin", name = "koin-androidx-navigation", version.ref = "koin" } @@ -313,6 +327,10 @@ moko-permission-compose = { group = "dev.icerock.moko", name = "permissions-comp window-size = { group = "dev.chrisbanes.material3", name = "material3-window-size-class-multiplatform", version.ref = "windowsSizeClass" } +compose-ui-test-junit4-android = { module = "androidx.compose.ui:ui-test-junit4-android", version.ref = "composeTest" } +compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "composeTest" } +core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "coreKtx" } + [bundles] androidx-compose-ui-test = [ "androidx-compose-ui-test", @@ -320,11 +338,16 @@ androidx-compose-ui-test = [ ] [plugins] + +mokkery-plugin = {id="dev.mokkery", version.ref="mokkeryVersion"} # Android & Kotlin Plugins android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-allopen = { id = "org.jetbrains.kotlin.plugin.allopen", version.ref = "kotlin" } +mokkery = { id = "dev.mokkery", version.ref = "mokkery" } + mifospay-android-application = { id = "mifospay.android.application", version = "unspecified" } mifospay-android-application-compose = { id = "mifospay.android.application.compose", version = "unspecified" } @@ -365,4 +388,3 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } spotless = { id = "com.diffplug.spotless", version.ref = "spotlessVersion" } version-catalog-linter = { id = "io.github.pemistahl.version-catalog-linter", version.ref = "versionCatalogLinterVersion" } - diff --git a/mifospay-android/dependencies/prodReleaseRuntimeClasspath.tree.txt b/mifospay-android/dependencies/prodReleaseRuntimeClasspath.tree.txt index fa985af4..c400c82d 100644 --- a/mifospay-android/dependencies/prodReleaseRuntimeClasspath.tree.txt +++ b/mifospay-android/dependencies/prodReleaseRuntimeClasspath.tree.txt @@ -1095,6 +1095,9 @@ | | | | +--- org.jetbrains.kotlinx:kotlinx-serialization-bom:1.8.0 (*) | | | | +--- org.jetbrains.kotlin:kotlin-stdlib:2.1.0 -> 2.1.21 (*) | | | | \--- org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.0 (*) +| | | +--- org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8 +| | | | \--- org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.3.8 +| | | | \--- org.jetbrains.kotlin:kotlin-stdlib:1.9.21 -> 2.1.21 (*) | | | \--- org.jetbrains.kotlin:kotlin-parcelize-runtime:2.1.20 | | | +--- org.jetbrains.kotlin:kotlin-stdlib:2.1.20 -> 2.1.21 (*) | | | \--- org.jetbrains.kotlin:kotlin-android-extensions-runtime:2.1.20 @@ -1616,9 +1619,7 @@ | +--- org.jetbrains.androidx.savedstate:savedstate:1.2.2 (*) | +--- org.jetbrains.androidx.core:core-bundle:1.0.1 (*) | +--- org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha10 (*) -| +--- org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8 -| | \--- org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.3.8 -| | \--- org.jetbrains.kotlin:kotlin-stdlib:1.9.21 -> 2.1.21 (*) +| +--- org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8 (*) | +--- org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3 -> 1.8.0 (*) | +--- project :core:domain | | +--- org.jetbrains.kotlin:kotlin-stdlib:2.1.20 -> 2.1.21 (*) diff --git a/mifospay-android/prodRelease-badging.txt b/mifospay-android/prodRelease-badging.txt index fe1fbf30..35982aab 100644 --- a/mifospay-android/prodRelease-badging.txt +++ b/mifospay-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.7.1-beta.0.5' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.7.2-beta.0.10' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' sdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt b/mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt index 07802953..b87dc378 100644 --- a/mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt +++ b/mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt @@ -16,6 +16,7 @@ import org.koin.dsl.koinApplication import org.koin.dsl.module import org.mifos.library.passcode.di.PasscodeModule import org.mifospay.core.common.di.DispatchersModule +import org.mifospay.core.common.di.stringProviderModule import org.mifospay.core.data.di.RepositoryModule import org.mifospay.core.datastore.di.PreferencesModule import org.mifospay.core.domain.di.DomainModule @@ -46,6 +47,7 @@ import org.mifospay.shared.MifosPayViewModel object KoinModules { private val commonModules = module { + includes(stringProviderModule) includes(DispatchersModule) } private val dataModules = module {