Skip to content

Add unit tests for FindByViewModel. #14145

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,13 @@ class FindByActivity : PassphraseRequiredActivity() {
onNavigationClick = { finishAfterTransition() },
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_start_24)
) {
val context = LocalContext.current

Content(
paddingValues = it,
state = state,
onUserEntryChanged = viewModel::onUserEntryChanged,
onNextClick = {
lifecycleScope.launch {
when (val result = viewModel.onNextClicked(context)) {
when (val result = viewModel.onNextClicked()) {
is FindByResult.Success -> {
setResult(RESULT_OK, Intent().putExtra(RECIPIENT_ID, result.recipientId))
finishAfterTransition()
Expand Down Expand Up @@ -191,10 +189,6 @@ class FindByActivity : PassphraseRequiredActivity() {
val body = if (state.mode == FindByMode.USERNAME) {
stringResource(id = R.string.FindByActivity__s_is_not_a_valid_username, state.userEntry)
} else {
val formattedNumber = remember(state.userEntry) {
val cleansed = state.userEntry.removePrefix(state.selectedCountry.countryCode.toString())
E164Util.formatAsE164WithCountryCodeForDisplay(state.selectedCountry.countryCode.toString(), cleansed)
}
stringResource(id = R.string.FindByActivity__s_is_not_a_valid_phone_number, state.userEntry)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to keep this as the user provided input instead of the formatted input so things match what the user sees/entered.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we can also remove the formattedNumber.

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

package org.thoughtcrime.securesms.recipients.ui.findby

import android.content.Context
import androidx.annotation.WorkerThread
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
Expand Down Expand Up @@ -43,13 +42,13 @@ class FindByViewModel(
internalState.value = state.value.copy(selectedCountry = country)
}

suspend fun onNextClicked(context: Context): FindByResult {
suspend fun onNextClicked(): FindByResult {
internalState.value = state.value.copy(isLookupInProgress = true)
val findByResult = viewModelScope.async(context = Dispatchers.IO) {
if (state.value.mode == FindByMode.USERNAME) {
performUsernameLookup()
} else {
performPhoneLookup(context)
performPhoneLookup()
}
}.await()

Expand All @@ -66,14 +65,14 @@ class FindByViewModel(
}

return when (val result = UsernameRepository.fetchAciForUsername(usernameString = username.removePrefix("@"))) {
UsernameRepository.UsernameAciFetchResult.NetworkError -> FindByResult.NotFound()
UsernameRepository.UsernameAciFetchResult.NetworkError -> FindByResult.NetworkError
UsernameRepository.UsernameAciFetchResult.NotFound -> FindByResult.NotFound()
is UsernameRepository.UsernameAciFetchResult.Success -> FindByResult.Success(Recipient.externalUsername(result.aci, username).id)
}
}

@WorkerThread
private fun performPhoneLookup(context: Context): FindByResult {
private fun performPhoneLookup(): FindByResult {
val stateSnapshot = state.value
val countryCode = stateSnapshot.selectedCountry.countryCode
val nationalNumber = stateSnapshot.userEntry.removePrefix(countryCode.toString())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/

package org.thoughtcrime.securesms.recipients.ui.findby

import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientRepository
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
import org.thoughtcrime.securesms.registration.ui.countrycode.CountryUtils
import org.whispersystems.signalservice.api.push.ServiceId
import java.util.Optional

class FindByViewModelTest {

private val liveRecipientCache = mockk<LiveRecipientCache>(relaxed = true)
private lateinit var viewModel: FindByViewModel

@Before
fun setup() {
mockkStatic(AppDependencies::class)
every { AppDependencies.recipientCache } returns liveRecipientCache
every { Recipient.self() } returns mockk {
every { e164 } returns Optional.of("+15551234")
}
}

@After
fun tearDown() {
unmockkStatic(AppDependencies::class)
}

@Test
fun `Given phone number mode, when I change user entry, then I expect digits only`() {
viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)

viewModel.onUserEntryChanged("123abc456")
val result = viewModel.state.value.userEntry

assertEquals("123456", result)
}

@Test
fun `Given username mode, when I change user entry, then I expect unaltered value`() {
viewModel = FindByViewModel(FindByMode.USERNAME)

viewModel.onUserEntryChanged("username123")
val result = viewModel.state.value.userEntry

assertEquals("username123", result)
}

@Test
fun `Given a selected country, when I update it, then I expect the state to reflect it`() {
viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)
val country = Country(emoji = "", name = "United States", countryCode = 1, regionCode = "US")

viewModel.onCountrySelected(country)
val result = viewModel.state.value.selectedCountry

assertEquals(country, result)
}

@Test
fun `Given invalid username, when I click next, then I expect InvalidEntry`() = runTest {
viewModel = FindByViewModel(FindByMode.USERNAME)

viewModel.onUserEntryChanged("invalid username")
val result = viewModel.onNextClicked()

assertTrue(result is FindByResult.InvalidEntry)
}

@Test
fun `Given empty phone number, when I click next, then I expect InvalidEntry`() = runTest {
viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)

viewModel.onUserEntryChanged("")
mockkStatic(RecipientRepository::class).apply {
every { RecipientRepository.lookupNewE164(any()) } returns RecipientRepository.LookupResult.InvalidEntry
}

val result = viewModel.onNextClicked()

assertTrue(result is FindByResult.InvalidEntry)

unmockkObject(RecipientRepository)
}

@Test
fun `Given valid phone lookup, when I click next, then I expect Success`() = runTest {
val recipientId = RecipientId.from(123L)

mockkStatic(RecipientRepository::class).apply {
every { RecipientRepository.lookupNewE164("+15551234") } returns
RecipientRepository.LookupResult.Success(recipientId)
}

viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)
val country = Country(emoji = "🇺🇸", name = "United States", countryCode = 1, regionCode = "US")

viewModel.onCountrySelected(country)
viewModel.onUserEntryChanged("5551234")

val result = viewModel.onNextClicked()

assertTrue(result is FindByResult.Success)
assertEquals((result as FindByResult.Success).recipientId, recipientId)

unmockkObject(RecipientRepository)
}

@Test
fun `Given unknown phone lookup, when I click next, then I expect NotFound`() = runTest {
val recipientId = RecipientId.from(123L)

mockkStatic(RecipientRepository::class).apply {
every { RecipientRepository.lookupNewE164(any()) } returns RecipientRepository.LookupResult.NotFound(recipientId)
}

viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)
viewModel.onUserEntryChanged("0000000000")

val result = viewModel.onNextClicked()

assertTrue(result is FindByResult.NotFound)
assertEquals((result as FindByResult.NotFound).recipientId, recipientId)

unmockkObject(RecipientRepository)
}

@Test
fun `Given matching country name, when I filter countries, then I expect matched countries`() {
val countries = listOf(
Country(emoji = "🇺🇸", name = "United States", countryCode = 1, regionCode = "US"),
Country(emoji = "🇨🇦", name = "Canada", countryCode = 1, regionCode = "CA"),
Country(emoji = "🇬🇧", name = "United Kingdom", countryCode = 44, regionCode = "GB")
)

mockkObject(CountryUtils).apply {
every { CountryUtils.getCountries() } returns countries
}

viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)

viewModel.filterCountries("United")
val result = viewModel.state.value.filteredCountries

assertEquals(2, result.size)
assertTrue(result.any { it.name == "United States" })
assertTrue(result.any { it.name == "United Kingdom" })

unmockkObject(CountryUtils)
}

@Test
fun `Given matching country code, when I filter countries, then I expect matched countries`() {
val countries = listOf(
Country(emoji = "🇺🇸", name = "United States", countryCode = 1, regionCode = "US"),
Country(emoji = "🇨🇦", name = "Canada", countryCode = 1, regionCode = "CA"),
Country(emoji = "🇬🇧", name = "United Kingdom", countryCode = 44, regionCode = "GB")
)

mockkObject(CountryUtils).apply {
every { CountryUtils.getCountries() } returns countries
}

viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)

viewModel.filterCountries("1")
val result = viewModel.state.value.filteredCountries

assertEquals(2, result.size)
assertTrue(result.any { it.name == "United States" })
assertTrue(result.any { it.name == "Canada" })

unmockkObject(CountryUtils)
}

@Test
fun `Given empty country filter, when I filter countries, then I expect empty countries list`() {
val countries = listOf(
Country(emoji = "🇺🇸", name = "United States", countryCode = 1, regionCode = "US"),
Country(emoji = "🇨🇦", name = "Canada", countryCode = 1, regionCode = "CA"),
Country(emoji = "🇬🇧", name = "United Kingdom", countryCode = 44, regionCode = "GB")
)

mockkObject(CountryUtils).apply {
every { CountryUtils.getCountries() } returns countries
}

viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)

viewModel.filterCountries("")
val result = viewModel.state.value.filteredCountries

assertEquals(0, result.size)

unmockkObject(CountryUtils)
}

@Test
fun `Given username not found, when I click next, then I expect NotFound`() = runTest {
mockkStatic(UsernameRepository::class)
every { UsernameRepository.fetchAciForUsername("john") } returns UsernameRepository.UsernameAciFetchResult.NotFound

viewModel = FindByViewModel(FindByMode.USERNAME)
viewModel.onUserEntryChanged("@john")

val result = viewModel.onNextClicked()

assertTrue(result is FindByResult.NotFound)

unmockkObject(UsernameRepository)
}

@Test
fun `Given username fetch network error, when I click next, then I expect NetworkError`() = runTest {
mockkStatic(UsernameRepository::class)
every { UsernameRepository.fetchAciForUsername("jane") } returns UsernameRepository.UsernameAciFetchResult.NetworkError

viewModel = FindByViewModel(FindByMode.USERNAME)
viewModel.onUserEntryChanged("@jane")

val result = viewModel.onNextClicked()

assertTrue(result is FindByResult.NetworkError)

unmockkObject(UsernameRepository)
}

@Test
fun `Given valid username, when I click next, then I expect Success`() = runTest {
val aci: ServiceId.ACI = mockk(relaxed = true)
val username = "@doe"
val recipientId = RecipientId.from(456L)

mockkStatic(UsernameRepository::class).apply {
every {
UsernameRepository.fetchAciForUsername("doe") // stripped @
} returns UsernameRepository.UsernameAciFetchResult.Success(aci)
}

mockkObject(Recipient)
val mockRecipient = mockk<Recipient>()
every { mockRecipient.id } returns recipientId
every { Recipient.externalUsername(aci, username) } returns mockRecipient

viewModel = FindByViewModel(FindByMode.USERNAME)
viewModel.onUserEntryChanged(username)

val result = viewModel.onNextClicked()

assertTrue(result is FindByResult.Success)
assertEquals(recipientId, (result as FindByResult.Success).recipientId)

unmockkStatic(UsernameRepository::class)
unmockkObject(Recipient)
}
}