Skip to content

Commit c367725

Browse files
committed
chore: add test coverage for NumberInputHelper, PhoneUtils, and OnRampController
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 81cdb2d commit c367725

6 files changed

Lines changed: 562 additions & 0 deletions

File tree

apps/flipcash/shared/onramp/coinbase/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ android {
1010
}
1111

1212
dependencies {
13+
testImplementation(kotlin("test"))
14+
testImplementation(libs.bundles.unit.testing)
15+
testImplementation(libs.robolectric)
16+
1317
implementation(libs.androidx.localbroadcastmanager)
1418
implementation(libs.kotlinx.serialization.json)
1519

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package com.flipcash.app.onramp
2+
3+
import com.coinbase.onramp.api.CoinbaseApi
4+
import com.coinbase.onramp.data.OnRampApiConfig
5+
import com.flipcash.services.models.UserProfile
6+
import com.flipcash.services.user.UserManager
7+
import com.getcode.opencode.exchange.Exchange
8+
import com.getcode.opencode.model.accounts.AccountCluster
9+
import com.getcode.opencode.model.financial.CurrencyCode
10+
import com.getcode.opencode.model.financial.Fiat
11+
import com.getcode.solana.keys.PublicKey
12+
import io.mockk.every
13+
import io.mockk.mockk
14+
import kotlinx.coroutines.ExperimentalCoroutinesApi
15+
import kotlinx.coroutines.test.runTest
16+
import org.junit.Before
17+
import org.junit.Test
18+
import org.junit.runner.RunWith
19+
import org.robolectric.RobolectricTestRunner
20+
import org.robolectric.annotation.Config
21+
import kotlin.test.assertIs
22+
import kotlin.test.assertTrue
23+
24+
@OptIn(ExperimentalCoroutinesApi::class)
25+
@RunWith(RobolectricTestRunner::class)
26+
@Config(manifest = Config.NONE)
27+
class OnRampControllerTest {
28+
29+
private val jwtProvider = mockk<OnRampJwtProvider>(relaxed = true)
30+
private val api = mockk<CoinbaseApi>(relaxed = true)
31+
private val userManager = mockk<UserManager>(relaxed = true)
32+
private val exchange = mockk<Exchange>(relaxed = true)
33+
34+
private val onRampApiEndpoint = OnRampApiConfig(
35+
scheme = "https",
36+
host = "api.example.com",
37+
path = "onramp/v1/buy",
38+
method = "POST",
39+
useSandbox = false,
40+
)
41+
42+
private lateinit var controller: OnRampController
43+
44+
@Before
45+
fun setUp() {
46+
controller = OnRampController(
47+
jwtProvider = jwtProvider,
48+
onRampApiEndpoint = onRampApiEndpoint,
49+
api = api,
50+
userManager = userManager,
51+
exchange = exchange,
52+
)
53+
}
54+
55+
private fun stubAccountId(present: Boolean = true) {
56+
if (present) {
57+
every { userManager.accountId } returns listOf(1, 2, 3, 4).map { it.toByte() }
58+
} else {
59+
every { userManager.accountId } returns null
60+
}
61+
}
62+
63+
private fun stubDepositAddress(present: Boolean = true) {
64+
if (present) {
65+
val address = PublicKey(ByteArray(32).toList())
66+
val cluster = mockk<AccountCluster>(relaxed = true) {
67+
every { usdfDepositAddress } returns address
68+
}
69+
every { userManager.accountCluster } returns cluster
70+
} else {
71+
every { userManager.accountCluster } returns null
72+
}
73+
}
74+
75+
private fun stubProfile(email: String? = "test@test.com", phone: String? = "+11234567890") {
76+
val profile = UserProfile(
77+
displayName = "Test",
78+
socialAccounts = emptyList(),
79+
verifiedEmailAddress = email,
80+
verifiedPhoneNumber = phone,
81+
)
82+
every { userManager.profile } returns profile
83+
}
84+
85+
private fun stubValidUser() {
86+
stubAccountId()
87+
stubDepositAddress()
88+
stubProfile()
89+
}
90+
91+
// region placeOrderInclusiveOfFees validation
92+
93+
@Test
94+
fun `placeOrderInclusiveOfFees fails when accountId is null`() = runTest {
95+
stubAccountId(present = false)
96+
stubDepositAddress()
97+
stubProfile()
98+
99+
val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD))
100+
assertTrue(result.isFailure)
101+
assertTrue(result.exceptionOrNull()?.message?.contains("User ID") == true)
102+
}
103+
104+
@Test
105+
fun `placeOrderInclusiveOfFees fails when deposit address is null`() = runTest {
106+
stubAccountId()
107+
stubDepositAddress(present = false)
108+
stubProfile()
109+
110+
val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD))
111+
assertTrue(result.isFailure)
112+
assertTrue(result.exceptionOrNull()?.message?.contains("Deposit address") == true)
113+
}
114+
115+
@Test
116+
fun `placeOrderInclusiveOfFees fails when email is null`() = runTest {
117+
stubAccountId()
118+
stubDepositAddress()
119+
stubProfile(email = null, phone = "+11234567890")
120+
121+
val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD))
122+
assertTrue(result.isFailure)
123+
assertIs<OnRampAuthError.VerificationRequired>(result.exceptionOrNull())
124+
assertTrue((result.exceptionOrNull() as OnRampAuthError.VerificationRequired).email)
125+
}
126+
127+
@Test
128+
fun `placeOrderInclusiveOfFees fails when phone is null`() = runTest {
129+
stubAccountId()
130+
stubDepositAddress()
131+
stubProfile(email = "test@test.com", phone = null)
132+
133+
val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD))
134+
assertTrue(result.isFailure)
135+
assertIs<OnRampAuthError.VerificationRequired>(result.exceptionOrNull())
136+
assertTrue((result.exceptionOrNull() as OnRampAuthError.VerificationRequired).phone)
137+
}
138+
139+
@Test
140+
fun `placeOrderInclusiveOfFees returns VerificationRequired with correct flags`() = runTest {
141+
stubAccountId()
142+
stubDepositAddress()
143+
stubProfile(email = null, phone = null)
144+
145+
val result = controller.placeOrderInclusiveOfFees(Fiat(10, CurrencyCode.USD))
146+
assertTrue(result.isFailure)
147+
val error = result.exceptionOrNull()
148+
assertIs<OnRampAuthError.VerificationRequired>(error)
149+
assertTrue(error.phone)
150+
assertTrue(error.email)
151+
}
152+
153+
// endregion
154+
155+
// region placeOrderExclusiveOfFees validation
156+
157+
@Test
158+
fun `placeOrderExclusiveOfFees fails when accountId is null`() = runTest {
159+
stubAccountId(present = false)
160+
stubDepositAddress()
161+
stubProfile()
162+
163+
val result = controller.placeOrderExclusiveOfFees(Fiat(10, CurrencyCode.USD))
164+
assertTrue(result.isFailure)
165+
assertTrue(result.exceptionOrNull()?.message?.contains("User ID") == true)
166+
}
167+
168+
@Test
169+
fun `placeOrderExclusiveOfFees fails when exchange rate missing for non-USD`() = runTest {
170+
stubValidUser()
171+
every { exchange.rateToUsd(CurrencyCode.EUR) } returns null
172+
173+
val result = controller.placeOrderExclusiveOfFees(Fiat(10, CurrencyCode.EUR))
174+
assertTrue(result.isFailure)
175+
assertTrue(result.exceptionOrNull()?.message?.contains("Exchange rate") == true)
176+
}
177+
178+
@Test
179+
fun `placeOrderExclusiveOfFees fails when email and phone both null`() = runTest {
180+
stubAccountId()
181+
stubDepositAddress()
182+
stubProfile(email = null, phone = null)
183+
184+
val result = controller.placeOrderExclusiveOfFees(Fiat(10, CurrencyCode.USD))
185+
assertTrue(result.isFailure)
186+
val error = result.exceptionOrNull()
187+
assertIs<OnRampAuthError.VerificationRequired>(error)
188+
assertTrue(error.phone)
189+
assertTrue(error.email)
190+
}
191+
192+
// endregion
193+
}

apps/flipcash/shared/phone/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,8 @@ dependencies {
1010
api(libs.lib.phone.number.port)
1111

1212
api(libs.rinku.compose)
13+
14+
testImplementation(kotlin("test"))
15+
testImplementation(libs.bundles.unit.testing)
16+
testImplementation(libs.robolectric)
1317
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package com.flipcash.app.phone
2+
3+
import com.getcode.opencode.exchange.Exchange
4+
import io.michaelrocks.libphonenumber.android.PhoneNumberUtil
5+
import io.mockk.every
6+
import io.mockk.mockk
7+
import io.mockk.mockkStatic
8+
import io.mockk.unmockkStatic
9+
import org.junit.After
10+
import org.junit.Before
11+
import org.junit.Test
12+
import org.junit.runner.RunWith
13+
import org.robolectric.RobolectricTestRunner
14+
import org.robolectric.RuntimeEnvironment
15+
import org.robolectric.annotation.Config
16+
import kotlin.test.assertEquals
17+
import kotlin.test.assertFalse
18+
import kotlin.test.assertTrue
19+
20+
@RunWith(RobolectricTestRunner::class)
21+
@Config(manifest = Config.NONE)
22+
class PhoneUtilsTest {
23+
24+
private lateinit var phoneUtils: PhoneUtils
25+
private val mockPhoneNumberUtil = mockk<PhoneNumberUtil>(relaxed = true)
26+
27+
@Before
28+
fun setUp() {
29+
mockkStatic(PhoneNumberUtil::class)
30+
every { PhoneNumberUtil.createInstance(any<android.content.Context>()) } returns mockPhoneNumberUtil
31+
every { mockPhoneNumberUtil.supportedRegions } returns setOf("US", "GB", "CA", "DE")
32+
every { mockPhoneNumberUtil.getCountryCodeForRegion("US") } returns 1
33+
every { mockPhoneNumberUtil.getCountryCodeForRegion("GB") } returns 44
34+
every { mockPhoneNumberUtil.getCountryCodeForRegion("CA") } returns 1
35+
every { mockPhoneNumberUtil.getCountryCodeForRegion("DE") } returns 49
36+
37+
val context = RuntimeEnvironment.getApplication()
38+
val exchange = mockk<Exchange>(relaxed = true) {
39+
every { getFlag(any()) } returns 0
40+
}
41+
phoneUtils = PhoneUtils(context, exchange)
42+
}
43+
44+
@After
45+
fun tearDown() {
46+
unmockkStatic(PhoneNumberUtil::class)
47+
}
48+
49+
// region toFlagEmoji
50+
51+
@Test
52+
fun `toFlagEmoji converts US to flag emoji`() {
53+
val result = phoneUtils.toFlagEmoji("US")
54+
val expected = "\uD83C\uDDFA\uD83C\uDDF8"
55+
assertEquals(expected, result)
56+
}
57+
58+
@Test
59+
fun `toFlagEmoji converts GB to flag emoji`() {
60+
val result = phoneUtils.toFlagEmoji("GB")
61+
val expected = "\uD83C\uDDEC\uD83C\uDDE7"
62+
assertEquals(expected, result)
63+
}
64+
65+
@Test
66+
fun `toFlagEmoji returns input for non-2-letter string`() {
67+
assertEquals("USA", phoneUtils.toFlagEmoji("USA"))
68+
assertEquals("U", phoneUtils.toFlagEmoji("U"))
69+
}
70+
71+
@Test
72+
fun `toFlagEmoji returns input for numeric string`() {
73+
assertEquals("12", phoneUtils.toFlagEmoji("12"))
74+
}
75+
76+
// endregion
77+
78+
// region isPhoneNumberValid
79+
80+
@Test
81+
fun `isPhoneNumberValid accepts valid US number`() {
82+
val mockNumber = mockk<io.michaelrocks.libphonenumber.android.Phonenumber.PhoneNumber>(relaxed = true)
83+
every { mockPhoneNumberUtil.parse("+12025551234", "US") } returns mockNumber
84+
every { mockPhoneNumberUtil.isValidNumber(mockNumber) } returns true
85+
every { mockPhoneNumberUtil.getNumberType(mockNumber) } returns PhoneNumberUtil.PhoneNumberType.FIXED_LINE_OR_MOBILE
86+
87+
assertTrue(phoneUtils.isPhoneNumberValid("+12025551234", "US"))
88+
}
89+
90+
@Test
91+
fun `isPhoneNumberValid accepts valid UK number`() {
92+
val mockNumber = mockk<io.michaelrocks.libphonenumber.android.Phonenumber.PhoneNumber>(relaxed = true)
93+
every { mockPhoneNumberUtil.parse("+447911123456", "GB") } returns mockNumber
94+
every { mockPhoneNumberUtil.isValidNumber(mockNumber) } returns true
95+
every { mockPhoneNumberUtil.getNumberType(mockNumber) } returns PhoneNumberUtil.PhoneNumberType.MOBILE
96+
97+
assertTrue(phoneUtils.isPhoneNumberValid("+447911123456", "GB"))
98+
}
99+
100+
@Test
101+
fun `isPhoneNumberValid rejects invalid number`() {
102+
val mockNumber = mockk<io.michaelrocks.libphonenumber.android.Phonenumber.PhoneNumber>(relaxed = true)
103+
every { mockPhoneNumberUtil.parse("12345", "US") } returns mockNumber
104+
every { mockPhoneNumberUtil.isValidNumber(mockNumber) } returns false
105+
106+
assertFalse(phoneUtils.isPhoneNumberValid("12345", "US"))
107+
}
108+
109+
@Test
110+
fun `isPhoneNumberValid rejects empty string`() {
111+
every { mockPhoneNumberUtil.parse("", "US") } throws io.michaelrocks.libphonenumber.android.NumberParseException(
112+
io.michaelrocks.libphonenumber.android.NumberParseException.ErrorType.NOT_A_NUMBER,
113+
"empty"
114+
)
115+
116+
assertFalse(phoneUtils.isPhoneNumberValid("", "US"))
117+
}
118+
119+
// endregion
120+
121+
// region getCountryCode
122+
123+
@Test
124+
fun `getCountryCode detects US from prefix 1`() {
125+
assertEquals("US", phoneUtils.getCountryCode("12025551234"))
126+
}
127+
128+
@Test
129+
fun `getCountryCode detects UK from prefix 44`() {
130+
assertEquals("GB", phoneUtils.getCountryCode("447911123456"))
131+
}
132+
133+
@Test
134+
fun `getCountryCode returns default for unknown prefix`() {
135+
val result = phoneUtils.getCountryCode("99999999999")
136+
assertTrue(result.isNotEmpty())
137+
}
138+
139+
// endregion
140+
141+
// region formatNumber
142+
143+
@Test
144+
fun `formatNumber adds plus prefix`() {
145+
val result = phoneUtils.formatNumber("12025551234", "US", plus = true)
146+
assertTrue(result.startsWith("+"))
147+
}
148+
149+
@Test
150+
fun `formatNumber without plus prefix`() {
151+
// PhoneNumberUtils.formatNumber returns null for unrecognized numbers in Robolectric,
152+
// so the raw number is returned
153+
val result = phoneUtils.formatNumber("12025551234", "US", plus = false)
154+
assertFalse(result.startsWith("+"))
155+
}
156+
157+
// endregion
158+
}

ui/components/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ android {
1212
}
1313

1414
dependencies {
15+
testImplementation(kotlin("test"))
1516
implementation(project(":libs:datetime"))
1617
implementation(project(":libs:encryption:ed25519"))
1718
implementation(project(":libs:encryption:utils"))

0 commit comments

Comments
 (0)