Skip to content

Commit 275e61f

Browse files
committed
chore(test): add unit test infrastructure and OpenCode controller tests
Add reusable test dependencies (MockK, Turbine, coroutines-test) as a version catalog bundle and enable isReturnDefaultValues globally in the convention plugin. Add controller tests for CurrencyController, AccountController, TokenController, TransactionController, and MessagingController using fake repository implementations.
1 parent 4c93c61 commit 275e61f

8 files changed

Lines changed: 1233 additions & 0 deletions

File tree

build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
2727
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2828
}
2929

30+
testOptions {
31+
unitTests.isReturnDefaultValues = true
32+
}
33+
3034
compileOptions {
3135
sourceCompatibility = JavaVersion.VERSION_21
3236
targetCompatibility = JavaVersion.VERSION_21

gradle/libs.versions.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ androidx-test-runner = "1.7.0"
6868
junit = "4.13.2"
6969
androidx-junit = "1.3.0"
7070
espresso = "3.7.0"
71+
mockk = "1.13.16"
72+
turbine = "1.2.0"
7173
robolectric = "4.14.1"
7274
mixpanel = "8.3.0"
7375

@@ -267,6 +269,9 @@ junit = { module = "junit:junit", version.ref = "junit" }
267269
espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
268270
espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espresso" }
269271
espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espresso" }
272+
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
273+
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
274+
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
270275
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
271276

272277
# Screenshot testing
@@ -285,6 +290,7 @@ compose = ["compose-ui", "compose-foundation", "compose-material", "compose-mate
285290
hilt = ["hilt-android", "javax-inject"]
286291
hilt-compiler = ["hilt-android-compiler", "hilt-compiler"]
287292
testing = ["junit", "androidx-junit", "espresso-core"]
293+
unit-testing = ["kotlinx-coroutines-test", "mockk", "turbine"]
288294
voyager = ["voyager-navigator", "voyager-hilt", "voyager-bottomsheet", "voyager-tabs", "voyager-transitions"]
289295
grpc = ["grpc-okhttp", "grpc-kotlin", "grpc-protobuf-lite", "grpc-stub"]
290296

services/opencode/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ android {
2222
buildFeatures {
2323
buildConfig = true
2424
}
25+
2526
}
2627

2728
dependencies {
@@ -92,4 +93,5 @@ dependencies {
9293
implementation(libs.event.bus)
9394

9495
testImplementation(kotlin("test"))
96+
testImplementation(libs.bundles.unit.testing)
9597
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package com.getcode.opencode.controllers
2+
3+
import com.getcode.ed25519.Ed25519
4+
import com.getcode.opencode.model.accounts.AccountCluster
5+
import com.getcode.opencode.model.accounts.AccountFilter
6+
import com.getcode.opencode.model.accounts.AccountInfo
7+
import com.getcode.opencode.model.accounts.AccountResponse
8+
import com.getcode.opencode.model.accounts.AccountType
9+
import com.getcode.opencode.model.core.ID
10+
import com.getcode.opencode.model.core.errors.GetAccountsError
11+
import com.getcode.opencode.model.transactions.ExchangeData
12+
import com.getcode.opencode.repositories.AccountRepository
13+
import com.getcode.solana.keys.Mint
14+
import com.getcode.solana.keys.Key32
15+
import com.getcode.solana.keys.PublicKey
16+
import com.getcode.utils.network.NetworkObserverStub
17+
import io.mockk.mockk
18+
import kotlinx.coroutines.CoroutineScope
19+
import kotlinx.coroutines.ExperimentalCoroutinesApi
20+
import kotlinx.coroutines.test.runTest
21+
import org.junit.Test
22+
import kotlin.test.assertEquals
23+
import kotlin.test.assertFalse
24+
import kotlin.test.assertIs
25+
import kotlin.test.assertTrue
26+
27+
@OptIn(ExperimentalCoroutinesApi::class)
28+
class AccountControllerTest {
29+
30+
private val repository = FakeAccountRepository()
31+
private val networkObserver = NetworkObserverStub(isConnected = false)
32+
private val controller = AccountController(repository, networkObserver)
33+
34+
// region hasAccountFor
35+
36+
@Test
37+
fun `hasAccountFor returns false when no accounts exist`() {
38+
assertFalse(controller.hasAccountFor(Mint.usdf))
39+
}
40+
41+
// endregion
42+
43+
// region createUserAccount
44+
45+
@Test
46+
fun `createUserAccount delegates to repository`() = runTest {
47+
val id: ID = listOf(1, 2, 3)
48+
repository.createUserAccountResult = Result.success(id)
49+
50+
val cluster = mockk<AccountCluster>(relaxed = true)
51+
val result = controller.createUserAccount(cluster, Mint.usdf)
52+
53+
assertTrue(result.isSuccess)
54+
assertEquals(id, result.getOrThrow())
55+
}
56+
57+
@Test
58+
fun `createUserAccount propagates failure`() = runTest {
59+
val error = RuntimeException("creation failed")
60+
repository.createUserAccountResult = Result.failure(error)
61+
62+
val cluster = mockk<AccountCluster>(relaxed = true)
63+
val result = controller.createUserAccount(cluster, Mint.usdf)
64+
65+
assertTrue(result.isFailure)
66+
assertEquals(error, result.exceptionOrNull())
67+
}
68+
69+
// endregion
70+
71+
// region getAccounts
72+
73+
@Test
74+
fun `getAccounts returns accounts from repository`() = runTest {
75+
val accountInfo = stubAccountInfo(Mint.usdf)
76+
val response = AccountResponse(
77+
accounts = mapOf(accountInfo.address to accountInfo)
78+
)
79+
repository.getAccountsResult = Result.success(response)
80+
81+
val cluster = mockk<AccountCluster>(relaxed = true)
82+
val result = controller.getAccounts(cluster, cluster)
83+
84+
assertTrue(result.isSuccess)
85+
assertEquals(1, result.getOrThrow().accounts.size)
86+
}
87+
88+
@Test
89+
fun `getAccounts propagates repository failure`() = runTest {
90+
val error = GetAccountsError.NotFound()
91+
repository.getAccountsResult = Result.failure(error)
92+
93+
val cluster = mockk<AccountCluster>(relaxed = true)
94+
val result = controller.getAccounts(cluster, cluster)
95+
96+
assertTrue(result.isFailure)
97+
assertIs<GetAccountsError.NotFound>(result.exceptionOrNull())
98+
}
99+
100+
// endregion
101+
102+
// region getAccount
103+
104+
@Test
105+
fun `getAccount returns single account from repository`() = runTest {
106+
val accountInfo = stubAccountInfo(Mint.usdf)
107+
repository.getAccountResult = Result.success(accountInfo)
108+
109+
val cluster = mockk<AccountCluster>(relaxed = true)
110+
val filter = AccountFilter.AccountType(AccountType.Primary)
111+
val result = controller.getAccount(cluster, cluster, filter)
112+
113+
assertTrue(result.isSuccess)
114+
assertEquals(accountInfo.mint, result.getOrThrow().mint)
115+
}
116+
117+
@Test
118+
fun `getAccount propagates repository failure`() = runTest {
119+
val error = GetAccountsError.NotFound()
120+
repository.getAccountResult = Result.failure(error)
121+
122+
val cluster = mockk<AccountCluster>(relaxed = true)
123+
val filter = AccountFilter.AccountType(AccountType.Primary)
124+
val result = controller.getAccount(cluster, cluster, filter)
125+
126+
assertTrue(result.isFailure)
127+
assertIs<GetAccountsError.NotFound>(result.exceptionOrNull())
128+
}
129+
130+
// endregion
131+
132+
// region helpers
133+
134+
private fun stubAccountInfo(mint: Mint): AccountInfo {
135+
return AccountInfo(
136+
address = Key32.mock,
137+
owner = null,
138+
authority = null,
139+
accountType = AccountType.Primary,
140+
index = 0,
141+
balanceSource = AccountInfo.BalanceSource.Cache,
142+
balance = 1000L,
143+
managementState = AccountInfo.ManagementState.Locked,
144+
blockchainState = AccountInfo.BlockchainState.Exists,
145+
claimState = AccountInfo.ClaimState.Unknown,
146+
originalExchangeData = mockk(relaxed = true),
147+
mint = mint,
148+
createdAt = null,
149+
isGiftCardIssuer = false,
150+
usdCostBasis = 0.0,
151+
)
152+
}
153+
154+
// endregion
155+
}
156+
157+
/**
158+
* Fake [AccountRepository]. MockK cannot return [kotlin.Result] from mocked interfaces.
159+
*/
160+
private class FakeAccountRepository : AccountRepository {
161+
var isValidAccountResult: Result<Boolean> = Result.success(true)
162+
var createUserAccountResult: Result<ID> = Result.success(emptyList())
163+
var getAccountsResult: Result<AccountResponse> = Result.success(AccountResponse(emptyMap()))
164+
var getAccountResult: Result<AccountInfo> = Result.failure(RuntimeException("not configured"))
165+
166+
override suspend fun isValidAccount(owner: Ed25519.KeyPair): Result<Boolean> =
167+
isValidAccountResult
168+
169+
override suspend fun createUserAccount(
170+
scope: CoroutineScope,
171+
ownerForMint: AccountCluster,
172+
mint: Mint,
173+
): Result<ID> = createUserAccountResult
174+
175+
override suspend fun getAccounts(
176+
accountOwner: Ed25519.KeyPair,
177+
requestingOwner: Ed25519.KeyPair,
178+
filter: AccountFilter?,
179+
): Result<AccountResponse> = getAccountsResult
180+
181+
override suspend fun getAccount(
182+
accountOwner: Ed25519.KeyPair,
183+
requestingOwner: Ed25519.KeyPair,
184+
filter: AccountFilter,
185+
): Result<AccountInfo> = getAccountResult
186+
}
187+

0 commit comments

Comments
 (0)