test: Add Unit Test for :feature:auth module (#1871)

Co-authored-by: Hekmatullah <hekmatullah.aminullah2@gmail.com>
This commit is contained in:
Shreyash Borde 2025-07-16 10:08:35 +05:30 committed by GitHub
parent cf3862447d
commit 72c988bb08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1608 additions and 59 deletions

View File

@ -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 {

View File

@ -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"`.
*

View File

@ -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 Androids `Resources.getSystem()` or Composes `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 Multiplatforms [getString].
*
* This class is meant to be used in runtime environments where resource resolution is supported.
* It should not be used in unit testsuse 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)
}
}

View File

@ -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)

View File

@ -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

View File

@ -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")
}
}

View File

@ -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,

View File

@ -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)
}
}
}

View File

@ -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>

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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" }

View File

@ -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 (*)

View File

@ -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'

View File

@ -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 {