Skip to content

Commit ba29fe1

Browse files
committed
Add unit tests for FindByViewModel.
1 parent f8d7c27 commit ba29fe1

File tree

3 files changed

+286
-9
lines changed

3 files changed

+286
-9
lines changed

app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,13 @@ class FindByActivity : PassphraseRequiredActivity() {
134134
onNavigationClick = { finishAfterTransition() },
135135
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_start_24)
136136
) {
137-
val context = LocalContext.current
138-
139137
Content(
140138
paddingValues = it,
141139
state = state,
142140
onUserEntryChanged = viewModel::onUserEntryChanged,
143141
onNextClick = {
144142
lifecycleScope.launch {
145-
when (val result = viewModel.onNextClicked(context)) {
143+
when (val result = viewModel.onNextClicked()) {
146144
is FindByResult.Success -> {
147145
setResult(RESULT_OK, Intent().putExtra(RECIPIENT_ID, result.recipientId))
148146
finishAfterTransition()
@@ -195,7 +193,7 @@ class FindByActivity : PassphraseRequiredActivity() {
195193
val cleansed = state.userEntry.removePrefix(state.selectedCountry.countryCode.toString())
196194
E164Util.formatAsE164WithCountryCodeForDisplay(state.selectedCountry.countryCode.toString(), cleansed)
197195
}
198-
stringResource(id = R.string.FindByActivity__s_is_not_a_valid_phone_number, state.userEntry)
196+
stringResource(id = R.string.FindByActivity__s_is_not_a_valid_phone_number, formattedNumber)
199197
}
200198

201199
Dialogs.SimpleAlertDialog(

app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

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

8-
import android.content.Context
98
import androidx.annotation.WorkerThread
109
import androidx.compose.runtime.State
1110
import androidx.compose.runtime.mutableStateOf
@@ -43,13 +42,13 @@ class FindByViewModel(
4342
internalState.value = state.value.copy(selectedCountry = country)
4443
}
4544

46-
suspend fun onNextClicked(context: Context): FindByResult {
45+
suspend fun onNextClicked(): FindByResult {
4746
internalState.value = state.value.copy(isLookupInProgress = true)
4847
val findByResult = viewModelScope.async(context = Dispatchers.IO) {
4948
if (state.value.mode == FindByMode.USERNAME) {
5049
performUsernameLookup()
5150
} else {
52-
performPhoneLookup(context)
51+
performPhoneLookup()
5352
}
5453
}.await()
5554

@@ -66,14 +65,14 @@ class FindByViewModel(
6665
}
6766

6867
return when (val result = UsernameRepository.fetchAciForUsername(usernameString = username.removePrefix("@"))) {
69-
UsernameRepository.UsernameAciFetchResult.NetworkError -> FindByResult.NotFound()
68+
UsernameRepository.UsernameAciFetchResult.NetworkError -> FindByResult.NetworkError
7069
UsernameRepository.UsernameAciFetchResult.NotFound -> FindByResult.NotFound()
7170
is UsernameRepository.UsernameAciFetchResult.Success -> FindByResult.Success(Recipient.externalUsername(result.aci, username).id)
7271
}
7372
}
7473

7574
@WorkerThread
76-
private fun performPhoneLookup(context: Context): FindByResult {
75+
private fun performPhoneLookup(): FindByResult {
7776
val stateSnapshot = state.value
7877
val countryCode = stateSnapshot.selectedCountry.countryCode
7978
val nationalNumber = stateSnapshot.userEntry.removePrefix(countryCode.toString())
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/*
2+
* Copyright 2025 Signal Messenger, LLC
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
package org.thoughtcrime.securesms.recipients.ui.findby
7+
8+
import io.mockk.every
9+
import io.mockk.mockk
10+
import io.mockk.mockkObject
11+
import io.mockk.mockkStatic
12+
import io.mockk.unmockkObject
13+
import io.mockk.unmockkStatic
14+
import kotlinx.coroutines.test.runTest
15+
import org.junit.After
16+
import org.junit.Assert.assertEquals
17+
import org.junit.Assert.assertTrue
18+
import org.junit.Before
19+
import org.junit.Test
20+
import org.thoughtcrime.securesms.dependencies.AppDependencies
21+
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
22+
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
23+
import org.thoughtcrime.securesms.recipients.Recipient
24+
import org.thoughtcrime.securesms.recipients.RecipientId
25+
import org.thoughtcrime.securesms.recipients.RecipientRepository
26+
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
27+
import org.thoughtcrime.securesms.registration.ui.countrycode.CountryUtils
28+
import org.whispersystems.signalservice.api.push.ServiceId
29+
import java.util.Optional
30+
31+
class FindByViewModelTest {
32+
33+
private val liveRecipientCache = mockk<LiveRecipientCache>(relaxed = true)
34+
private lateinit var viewModel: FindByViewModel
35+
36+
@Before
37+
fun setup() {
38+
mockkStatic(AppDependencies::class)
39+
every { AppDependencies.recipientCache } returns liveRecipientCache
40+
every { Recipient.self() } returns mockk {
41+
every { e164 } returns Optional.of("+15551234")
42+
}
43+
}
44+
45+
@After
46+
fun tearDown() {
47+
unmockkStatic(AppDependencies::class)
48+
}
49+
50+
@Test
51+
fun `Given phone number mode, when I change user entry, then I expect digits only`() {
52+
viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)
53+
54+
viewModel.onUserEntryChanged("123abc456")
55+
val result = viewModel.state.value.userEntry
56+
57+
assertEquals("123456", result)
58+
}
59+
60+
@Test
61+
fun `Given username mode, when I change user entry, then I expect unaltered value`() {
62+
viewModel = FindByViewModel(FindByMode.USERNAME)
63+
64+
viewModel.onUserEntryChanged("username123")
65+
val result = viewModel.state.value.userEntry
66+
67+
assertEquals("username123", result)
68+
}
69+
70+
@Test
71+
fun `Given a selected country, when I update it, then I expect the state to reflect it`() {
72+
viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)
73+
val country = Country(emoji = "", name = "United States", countryCode = 1, regionCode = "US")
74+
75+
viewModel.onCountrySelected(country)
76+
val result = viewModel.state.value.selectedCountry
77+
78+
assertEquals(country, result)
79+
}
80+
81+
@Test
82+
fun `Given invalid username, when I click next, then I expect InvalidEntry`() = runTest {
83+
viewModel = FindByViewModel(FindByMode.USERNAME)
84+
85+
viewModel.onUserEntryChanged("invalid username")
86+
val result = viewModel.onNextClicked()
87+
88+
assertTrue(result is FindByResult.InvalidEntry)
89+
}
90+
91+
@Test
92+
fun `Given empty phone number, when I click next, then I expect InvalidEntry`() = runTest {
93+
viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)
94+
95+
viewModel.onUserEntryChanged("")
96+
mockkStatic(RecipientRepository::class).apply {
97+
every { RecipientRepository.lookupNewE164(any()) } returns RecipientRepository.LookupResult.InvalidEntry
98+
}
99+
100+
val result = viewModel.onNextClicked()
101+
102+
assertTrue(result is FindByResult.InvalidEntry)
103+
104+
unmockkObject(RecipientRepository)
105+
}
106+
107+
@Test
108+
fun `Given valid phone lookup, when I click next, then I expect Success`() = runTest {
109+
val recipientId = RecipientId.from(123L)
110+
111+
mockkStatic(RecipientRepository::class).apply {
112+
every { RecipientRepository.lookupNewE164("+15551234") } returns
113+
RecipientRepository.LookupResult.Success(recipientId)
114+
}
115+
116+
viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)
117+
val country = Country(emoji = "🇺🇸", name = "United States", countryCode = 1, regionCode = "US")
118+
119+
viewModel.onCountrySelected(country)
120+
viewModel.onUserEntryChanged("5551234")
121+
122+
val result = viewModel.onNextClicked()
123+
124+
assertTrue(result is FindByResult.Success)
125+
assertEquals((result as FindByResult.Success).recipientId, recipientId)
126+
127+
unmockkObject(RecipientRepository)
128+
}
129+
130+
@Test
131+
fun `Given unknown phone lookup, when I click next, then I expect NotFound`() = runTest {
132+
val recipientId = RecipientId.from(123L)
133+
134+
mockkStatic(RecipientRepository::class).apply {
135+
every { RecipientRepository.lookupNewE164(any()) } returns RecipientRepository.LookupResult.NotFound(recipientId)
136+
}
137+
138+
viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)
139+
viewModel.onUserEntryChanged("0000000000")
140+
141+
val result = viewModel.onNextClicked()
142+
143+
assertTrue(result is FindByResult.NotFound)
144+
assertEquals((result as FindByResult.NotFound).recipientId, recipientId)
145+
146+
unmockkObject(RecipientRepository)
147+
}
148+
149+
@Test
150+
fun `Given matching country name, when I filter countries, then I expect matched countries`() {
151+
val countries = listOf(
152+
Country(emoji = "🇺🇸", name = "United States", countryCode = 1, regionCode = "US"),
153+
Country(emoji = "🇨🇦", name = "Canada", countryCode = 1, regionCode = "CA"),
154+
Country(emoji = "🇬🇧", name = "United Kingdom", countryCode = 44, regionCode = "GB")
155+
)
156+
157+
mockkObject(CountryUtils).apply {
158+
every { CountryUtils.getCountries() } returns countries
159+
}
160+
161+
viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)
162+
163+
viewModel.filterCountries("United")
164+
val result = viewModel.state.value.filteredCountries
165+
166+
assertEquals(2, result.size)
167+
assertTrue(result.any { it.name == "United States" })
168+
assertTrue(result.any { it.name == "United Kingdom" })
169+
170+
unmockkObject(CountryUtils)
171+
}
172+
173+
@Test
174+
fun `Given matching country code, when I filter countries, then I expect matched countries`() {
175+
val countries = listOf(
176+
Country(emoji = "🇺🇸", name = "United States", countryCode = 1, regionCode = "US"),
177+
Country(emoji = "🇨🇦", name = "Canada", countryCode = 1, regionCode = "CA"),
178+
Country(emoji = "🇬🇧", name = "United Kingdom", countryCode = 44, regionCode = "GB")
179+
)
180+
181+
mockkObject(CountryUtils).apply {
182+
every { CountryUtils.getCountries() } returns countries
183+
}
184+
185+
viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)
186+
187+
viewModel.filterCountries("1")
188+
val result = viewModel.state.value.filteredCountries
189+
190+
assertEquals(2, result.size)
191+
assertTrue(result.any { it.name == "United States" })
192+
assertTrue(result.any { it.name == "Canada" })
193+
194+
unmockkObject(CountryUtils)
195+
}
196+
197+
@Test
198+
fun `Given empty country filter, when I filter countries, then I expect empty countries list`() {
199+
val countries = listOf(
200+
Country(emoji = "🇺🇸", name = "United States", countryCode = 1, regionCode = "US"),
201+
Country(emoji = "🇨🇦", name = "Canada", countryCode = 1, regionCode = "CA"),
202+
Country(emoji = "🇬🇧", name = "United Kingdom", countryCode = 44, regionCode = "GB")
203+
)
204+
205+
mockkObject(CountryUtils).apply {
206+
every { CountryUtils.getCountries() } returns countries
207+
}
208+
209+
viewModel = FindByViewModel(FindByMode.PHONE_NUMBER)
210+
211+
viewModel.filterCountries("")
212+
val result = viewModel.state.value.filteredCountries
213+
214+
assertEquals(0, result.size)
215+
216+
unmockkObject(CountryUtils)
217+
}
218+
219+
@Test
220+
fun `Given username not found, when I click next, then I expect NotFound`() = runTest {
221+
mockkStatic(UsernameRepository::class)
222+
every { UsernameRepository.fetchAciForUsername("john") } returns UsernameRepository.UsernameAciFetchResult.NotFound
223+
224+
viewModel = FindByViewModel(FindByMode.USERNAME)
225+
viewModel.onUserEntryChanged("@john")
226+
227+
val result = viewModel.onNextClicked()
228+
229+
assertTrue(result is FindByResult.NotFound)
230+
231+
unmockkObject(UsernameRepository)
232+
}
233+
234+
@Test
235+
fun `Given username fetch network error, when I click next, then I expect NetworkError`() = runTest {
236+
mockkStatic(UsernameRepository::class)
237+
every { UsernameRepository.fetchAciForUsername("jane") } returns UsernameRepository.UsernameAciFetchResult.NetworkError
238+
239+
viewModel = FindByViewModel(FindByMode.USERNAME)
240+
viewModel.onUserEntryChanged("@jane")
241+
242+
val result = viewModel.onNextClicked()
243+
244+
assertTrue(result is FindByResult.NetworkError)
245+
246+
unmockkObject(UsernameRepository)
247+
}
248+
249+
@Test
250+
fun `Given valid username, when I click next, then I expect Success`() = runTest {
251+
val aci: ServiceId.ACI = mockk(relaxed = true)
252+
val username = "@doe"
253+
val recipientId = RecipientId.from(456L)
254+
255+
mockkStatic(UsernameRepository::class).apply {
256+
every {
257+
UsernameRepository.fetchAciForUsername("doe") // stripped @
258+
} returns UsernameRepository.UsernameAciFetchResult.Success(aci)
259+
}
260+
261+
mockkObject(Recipient)
262+
val mockRecipient = mockk<Recipient>()
263+
every { mockRecipient.id } returns recipientId
264+
every { Recipient.externalUsername(aci, username) } returns mockRecipient
265+
266+
viewModel = FindByViewModel(FindByMode.USERNAME)
267+
viewModel.onUserEntryChanged(username)
268+
269+
val result = viewModel.onNextClicked()
270+
271+
assertTrue(result is FindByResult.Success)
272+
assertEquals(recipientId, (result as FindByResult.Success).recipientId)
273+
274+
unmockkStatic(UsernameRepository::class)
275+
unmockkObject(Recipient)
276+
}
277+
278+
279+
}
280+

0 commit comments

Comments
 (0)