Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions feature/kyc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ android {

kotlin {
sourceSets {
commonTest.dependencies {
implementation(libs.turbine)
}

commonMain.dependencies {
implementation(compose.ui)
implementation(compose.foundation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<KycLevel1State.DialogState.Error>(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<KycLevel1State.DialogState.Error>(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
}
Original file line number Diff line number Diff line change
@@ -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<DataState<KYCLevel1Details?>> {
return if (shouldReturnError) {
flowOf(DataState.Error(Throwable("Network error")))
} else {
flowOf(DataState.Success(level1Details))
}
}

override suspend fun addKYCLevel1Details(
clientId: Long,
kycLevel1Details: KYCLevel1Details,
): DataState<String> = DataState.Success("KYC Level 1 details added successfully")

override suspend fun updateKYCLevel1Details(
clientId: Long,
kycLevel1Details: KYCLevel1Details,
): DataState<String> = DataState.Success("KYC Level 1 details updated successfully")
}

internal class FakeUserPreferencesRepository(
clientId: Long = 1L,
) : UserPreferencesRepository {
override val clientId: StateFlow<Long?> = MutableStateFlow(clientId)

override val userInfo: Flow<UserInfo> = 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<String?> = MutableStateFlow(null)
override val client: StateFlow<Client?> = MutableStateFlow(null)
override val authToken: String? = null
override val defaultAccount: StateFlow<DefaultAccount?> = MutableStateFlow(null)
override val defaultAccountId: StateFlow<Long?> = MutableStateFlow(null)
override val selectedInstance: StateFlow<ServerInstance?> = MutableStateFlow(null)
override val selectedInterbankInstance: StateFlow<InterbankServer?> = MutableStateFlow(null)
override val accountExternalIds: StateFlow<Map<Long, String>> = MutableStateFlow(emptyMap())

override suspend fun updateToken(token: String): DataState<Unit> = DataState.Success(Unit)
override suspend fun updateUserInfo(user: UserInfo): DataState<Unit> = DataState.Success(Unit)
override suspend fun updateClientInfo(client: Client): DataState<Unit> = DataState.Success(Unit)
override suspend fun updateClientProfile(client: UpdatedClient): DataState<Unit> =
DataState.Success(Unit)

override suspend fun updateDefaultAccount(account: DefaultAccount): DataState<Unit> =
DataState.Success(Unit)

override suspend fun updateSelectedInstance(instance: ServerInstance): DataState<Unit> =
DataState.Success(Unit)

override suspend fun updateSelectedInterbankInstance(instance: InterbankServer): DataState<Unit> =
DataState.Success(Unit)

override suspend fun updateAccountExternalIds(
accountExternalIds: Map<Long, String>,
): DataState<Unit> = DataState.Success(Unit)

override fun getAccountExternalId(accountId: Long): String? = null
override suspend fun logOut() {}
}