mirror of
https://github.com/openMF/mobile-wallet.git
synced 2026-02-06 11:07:02 +00:00
test: Add Unit Test for :feature:auth module (#1871)
Co-authored-by: Hekmatullah <hekmatullah.aminullah2@gmail.com>
This commit is contained in:
parent
cf3862447d
commit
72c988bb08
@ -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 {
|
||||
|
||||
@ -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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom [KSerializer] for serializing and deserializing an `ImmutableList<String>` 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<String>`, then converts to or from an `ImmutableList`.
|
||||
*
|
||||
* ### Example Usage:
|
||||
* ```
|
||||
* @Serializable
|
||||
* data class UserPreferences(
|
||||
* @Serializable(with = ImmutableListSerializer::class)
|
||||
* val favoriteTags: ImmutableList<String>
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* ### 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<ImmutableList<String>> {
|
||||
override val descriptor = ListSerializer(String.serializer()).descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: ImmutableList<String>) {
|
||||
ListSerializer(String.serializer()).serialize(encoder, value.toList())
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): ImmutableList<String> {
|
||||
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"`.
|
||||
*
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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<StringProvider> { DefaultStringProvider() }
|
||||
}
|
||||
|
||||
val DispatchersModule = module {
|
||||
includes(ioDispatcherModule)
|
||||
|
||||
@ -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
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -73,4 +73,8 @@
|
||||
<string name="feature_auth_error_pincode_required">Please enter your pin code.</string>
|
||||
<string name="feature_auth_error_country_required">Please enter your country.</string>
|
||||
<string name="feature_auth_error_state_required">Please enter your state.</string>
|
||||
|
||||
<string name="feature_auth_error_field_already_exists">%1$s already exists.</string>
|
||||
<string name="feature_auth_error_check_uniqueness_failed">Unable to check if %1$s is unique. Please try again later.</string>
|
||||
<string name="feature_auth_registration_successful">Registration successful.</string>
|
||||
</resources>
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<SignUpState, SignUpEvent, SignUpAction>(
|
||||
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<String>("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<String> = persistentListOf(),
|
||||
val countriesWithStates: Map<String, List<String>> = 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 {
|
||||
|
||||
@ -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<LoginState.DialogState.Error>(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<LoginEvent.NavigateToSignup>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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<StringProvider> {
|
||||
everySuspend { get(any(), anyVarargs<Any>()) } 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()
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
|
||||
|
||||
@ -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 (*)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user