diff --git a/feature/kyc/build.gradle.kts b/feature/kyc/build.gradle.kts index ef03eb259..b15ecfce3 100644 --- a/feature/kyc/build.gradle.kts +++ b/feature/kyc/build.gradle.kts @@ -17,6 +17,10 @@ android { kotlin { sourceSets { + commonTest.dependencies { + implementation(libs.turbine) + } + commonMain.dependencies { implementation(compose.ui) implementation(compose.foundation) diff --git a/feature/kyc/src/commonMain/kotlin/org/mifospay/feature/kyc/KYCLevel1Screen.kt b/feature/kyc/src/commonMain/kotlin/org/mifospay/feature/kyc/KYCLevel1Screen.kt index bed4f4131..1c759d060 100644 --- a/feature/kyc/src/commonMain/kotlin/org/mifospay/feature/kyc/KYCLevel1Screen.kt +++ b/feature/kyc/src/commonMain/kotlin/org/mifospay/feature/kyc/KYCLevel1Screen.kt @@ -193,7 +193,9 @@ private fun KYCLevel1ScreenContent( TextButton( onClick = { showDialog = false - onAction(KycLevel1Action.DobChanged(dateState.selectedDateMillis!!)) + dateState.selectedDateMillis?.let { millis -> + onAction(KycLevel1Action.DobChanged(millis)) + } }, enabled = confirmEnabled.value, ) { diff --git a/feature/kyc/src/commonTest/kotlin/org/mifospay/feature/kyc/KYCLevel1ViewModelTest.kt b/feature/kyc/src/commonTest/kotlin/org/mifospay/feature/kyc/KYCLevel1ViewModelTest.kt new file mode 100644 index 000000000..ca1c35d33 --- /dev/null +++ b/feature/kyc/src/commonTest/kotlin/org/mifospay/feature/kyc/KYCLevel1ViewModelTest.kt @@ -0,0 +1,195 @@ +/* + * Copyright 2024 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.kyc + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +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 kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Unit tests for [KYCLevel1ViewModel]. + * + * Covers the DobChanged action path — specifically verifying the ViewModel correctly + * processes a Long timestamp into a formatted date string. This guards against + * regressions to the null-safety fix on the UI side (selectedDateMillis!! → ?.let). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class KYCLevel1ViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var fakeKycRepository: FakeKycLevelRepository + private lateinit var fakeUserPreferencesRepository: FakeUserPreferencesRepository + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + fakeKycRepository = FakeKycLevelRepository() + fakeUserPreferencesRepository = FakeUserPreferencesRepository(clientId = 42L) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): KYCLevel1ViewModel { + return KYCLevel1ViewModel( + kycLevelRepository = fakeKycRepository, + repository = fakeUserPreferencesRepository, + savedStateHandle = SavedStateHandle(), + ) + } + + // region DobChanged action + + @Test + fun givenValidTimestamp_whenDobChanged_thenDobInputIsUpdated() = runTest { + val viewModel = createViewModel() + advanceUntilIdle() + + // January 15, 2000 00:00:00 UTC in millis + val timestampMillis = 947894400000L + + viewModel.trySendAction(KycLevel1Action.DobChanged(timestampMillis)) + advanceUntilIdle() + + val state = viewModel.stateFlow.value + assertTrue( + state.dobInput.isNotEmpty(), + "dobInput should be non-empty after DobChanged", + ) + // DateHelper formats as "dd-MM-yyyy" — verify it's a recognisable date string + assertTrue( + state.dobInput.contains("-"), + "dobInput should be in dd-MM-yyyy format, got: ${state.dobInput}", + ) + } + + @Test + fun givenInitialState_thenDobInputIsEmpty() = runTest { + val viewModel = createViewModel() + advanceUntilIdle() + + assertEquals("", viewModel.stateFlow.value.dobInput) + } + + @Test + fun givenDobChanged_whenSubmitClickedWithOtherFieldsEmpty_thenFirstNameErrorShown() = runTest { + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.trySendAction(KycLevel1Action.DobChanged(947894400000L)) + advanceUntilIdle() + + viewModel.trySendAction(KycLevel1Action.SubmitClicked) + advanceUntilIdle() + + val dialogState = viewModel.stateFlow.value.dialogState + assertIs(dialogState) + assertEquals("First name is required", dialogState.message) + } + + @Test + fun givenAllFieldsMissingDob_whenSubmitClicked_thenDobErrorShown() = runTest { + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.trySendAction(KycLevel1Action.FirstNameChanged("John")) + viewModel.trySendAction(KycLevel1Action.LastNameChanged("Doe")) + viewModel.trySendAction(KycLevel1Action.MobileNoChanged("1234567890")) + viewModel.trySendAction(KycLevel1Action.AddressLine1Changed("123 Main St")) + viewModel.trySendAction(KycLevel1Action.AddressLine2Changed("Apt 1")) + // intentionally skip DobChanged + advanceUntilIdle() + + viewModel.trySendAction(KycLevel1Action.SubmitClicked) + advanceUntilIdle() + + val dialogState = viewModel.stateFlow.value.dialogState + assertIs(dialogState) + assertEquals("Date of birth is required", dialogState.message) + } + + // endregion + + // region Existing KYC data loading + + @Test + fun givenExistingKycData_whenViewModelCreated_thenStateIsPopulated() = runTest { + fakeKycRepository.setLevel1Details( + org.mifospay.core.model.kyc.KYCLevel1Details( + firstName = "Alice", + lastName = "Smith", + addressLine1 = "456 Oak Ave", + addressLine2 = "Suite 2", + mobileNo = "9876543210", + dob = "15-01-2000", + currentLevel = "KYC_LEVEL_1", + ), + ) + + val viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.stateFlow.value + assertEquals("Alice", state.firstNameInput) + assertEquals("Smith", state.lastNameInput) + assertEquals("15-01-2000", state.dobInput) + assertTrue(state.doesExist) + } + + @Test + fun givenNoExistingKycData_whenViewModelCreated_thenStateIsEmpty() = runTest { + fakeKycRepository.setLevel1Details(null) + + val viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.stateFlow.value + assertEquals("", state.firstNameInput) + assertEquals("", state.dobInput) + assertTrue(!state.doesExist) + } + + // endregion + + // region DismissDialog + + @Test + fun givenErrorDialogShown_whenDismissDialog_thenDialogStateIsNull() = runTest { + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.trySendAction(KycLevel1Action.SubmitClicked) + advanceUntilIdle() + + assertNotNull(viewModel.stateFlow.value.dialogState) + + viewModel.trySendAction(KycLevel1Action.DismissDialog) + advanceUntilIdle() + + assertEquals(null, viewModel.stateFlow.value.dialogState) + } + + // endregion +} diff --git a/feature/kyc/src/commonTest/kotlin/org/mifospay/feature/kyc/TestFakes.kt b/feature/kyc/src/commonTest/kotlin/org/mifospay/feature/kyc/TestFakes.kt new file mode 100644 index 000000000..b4a7b3b67 --- /dev/null +++ b/feature/kyc/src/commonTest/kotlin/org/mifospay/feature/kyc/TestFakes.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2024 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.kyc + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import org.mifospay.core.common.DataState +import org.mifospay.core.data.repository.KycLevelRepository +import org.mifospay.core.datastore.UserPreferencesRepository +import org.mifospay.core.model.account.DefaultAccount +import org.mifospay.core.model.client.Client +import org.mifospay.core.model.client.UpdatedClient +import org.mifospay.core.model.instance.InterbankServer +import org.mifospay.core.model.instance.ServerInstance +import org.mifospay.core.model.kyc.KYCLevel1Details +import org.mifospay.core.model.user.UserInfo + +internal class FakeKycLevelRepository : KycLevelRepository { + private var level1Details: KYCLevel1Details? = null + private var shouldReturnError = false + + fun setLevel1Details(details: KYCLevel1Details?) { + level1Details = details + } + + fun setShouldReturnError(error: Boolean) { + shouldReturnError = error + } + + override fun fetchKYCLevel1Details(clientId: Long): Flow> { + return if (shouldReturnError) { + flowOf(DataState.Error(Throwable("Network error"))) + } else { + flowOf(DataState.Success(level1Details)) + } + } + + override suspend fun addKYCLevel1Details( + clientId: Long, + kycLevel1Details: KYCLevel1Details, + ): DataState = DataState.Success("KYC Level 1 details added successfully") + + override suspend fun updateKYCLevel1Details( + clientId: Long, + kycLevel1Details: KYCLevel1Details, + ): DataState = DataState.Success("KYC Level 1 details updated successfully") +} + +internal class FakeUserPreferencesRepository( + clientId: Long = 1L, +) : UserPreferencesRepository { + override val clientId: StateFlow = MutableStateFlow(clientId) + + override val userInfo: Flow = flowOf( + UserInfo( + username = "", + userId = 0L, + base64EncodedAuthenticationKey = "", + authenticated = false, + officeId = 0, + officeName = "", + roles = emptyList(), + permissions = emptyList(), + clients = emptyList(), + shouldRenewPassword = false, + isTwoFactorAuthenticationRequired = false, + ), + ) + override val token: StateFlow = MutableStateFlow(null) + override val client: StateFlow = MutableStateFlow(null) + override val authToken: String? = null + override val defaultAccount: StateFlow = MutableStateFlow(null) + override val defaultAccountId: StateFlow = MutableStateFlow(null) + override val selectedInstance: StateFlow = MutableStateFlow(null) + override val selectedInterbankInstance: StateFlow = MutableStateFlow(null) + override val accountExternalIds: StateFlow> = MutableStateFlow(emptyMap()) + + override suspend fun updateToken(token: String): DataState = DataState.Success(Unit) + override suspend fun updateUserInfo(user: UserInfo): DataState = DataState.Success(Unit) + override suspend fun updateClientInfo(client: Client): DataState = DataState.Success(Unit) + override suspend fun updateClientProfile(client: UpdatedClient): DataState = + DataState.Success(Unit) + + override suspend fun updateDefaultAccount(account: DefaultAccount): DataState = + DataState.Success(Unit) + + override suspend fun updateSelectedInstance(instance: ServerInstance): DataState = + DataState.Success(Unit) + + override suspend fun updateSelectedInterbankInstance(instance: InterbankServer): DataState = + DataState.Success(Unit) + + override suspend fun updateAccountExternalIds( + accountExternalIds: Map, + ): DataState = DataState.Success(Unit) + + override fun getAccountExternalId(accountId: Long): String? = null + override suspend fun logOut() {} +}