lib: expand test coverage
Some checks failed
Test / Validate Gradle wrapper (push) Has been cancelled
Test / Run tests (push) Has been cancelled

This commit is contained in:
Oscar Mira 2025-04-19 22:03:09 +02:00
parent 46637358db
commit 6f0e214d87
No known key found for this signature in database
GPG key ID: B371B98C5DC32237
12 changed files with 378 additions and 8 deletions

View file

@ -15,4 +15,5 @@ androidx-test-runner = { module = "androidx.test:runner", version.ref = "android
androidx-test-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-truth" }
junit = { module = "junit:junit", version.ref = "junit" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }

View file

@ -116,5 +116,5 @@ dependencies {
androidTestImplementation(testLibs.androidx.test.truth)
androidTestImplementation(testLibs.androidx.test.rules)
androidTestImplementation(testLibs.androidx.test.runner)
androidTestImplementation(testLibs.mockk)
androidTestImplementation(testLibs.mockk.android)
}

View file

@ -0,0 +1,29 @@
package im.molly.monero
import com.google.common.truth.FailureMetadata
import com.google.common.truth.Subject
import com.google.common.truth.Truth.assertAbout
import kotlinx.coroutines.flow.first
class MoneroWalletSubject private constructor(
metadata: FailureMetadata,
private val actual: MoneroWallet,
) : Subject(metadata, actual) {
companion object {
fun assertThat(wallet: MoneroWallet): MoneroWalletSubject {
return assertAbout(factory).that(wallet)
}
private val factory = Factory { metadata, actual: MoneroWallet ->
MoneroWalletSubject(metadata, actual)
}
}
suspend fun matchesStateOf(expected: MoneroWallet) {
with(actual) {
check("publicAddress").that(publicAddress).isEqualTo(expected.publicAddress)
check("ledger").that(ledger().first()).isEqualTo(expected.ledger().first())
}
}
}

View file

@ -8,7 +8,7 @@ import kotlin.random.Random
class SecretKeyParcelableTest {
@Test
fun testParcel() {
fun secretKeyIsParcelable() {
val secret = Random.nextBytes(32)
val originalKey = SecretKey(secret)

View file

@ -0,0 +1,112 @@
package im.molly.monero.e2etest
import android.content.Context
import android.content.Intent
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ServiceTestRule
import com.google.common.truth.Truth.assertThat
import im.molly.monero.InMemoryWalletDataStore
import im.molly.monero.MoneroNetwork
import im.molly.monero.MoneroWallet
import im.molly.monero.MoneroWalletSubject
import im.molly.monero.RestorePoint
import im.molly.monero.SecretKey
import im.molly.monero.WalletProvider
import im.molly.monero.internal.IWalletService
import im.molly.monero.internal.WalletServiceClient
import im.molly.monero.mnemonics.MnemonicCode
import im.molly.monero.mnemonics.MoneroMnemonic
import im.molly.monero.service.BaseWalletService
import im.molly.monero.service.InProcessWalletService
import im.molly.monero.service.SandboxedWalletService
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalStdlibApi::class)
@LargeTest
//@RunWith(Parameterized::class)
abstract class MoneroWalletTest(private val serviceClass: Class<out BaseWalletService>) {
@get:Rule
val walletServiceRule = ServiceTestRule()
private lateinit var walletProvider: WalletProvider
private val context: Context by lazy { InstrumentationRegistry.getInstrumentation().context }
private fun bindService(): IWalletService {
val binder = walletServiceRule.bindService(Intent(context, serviceClass))
return IWalletService.Stub.asInterface(binder)
}
private fun unbindService() {
walletServiceRule.unbindService()
}
@Before
fun setUp() {
val walletService = bindService()
walletProvider = WalletServiceClient.withBoundService(context, walletService)
}
@After
fun tearDown() {
walletProvider.disconnect()
unbindService()
}
@Test
fun restoredWalletHasExpectedAddress() = runTest {
val key = SecretKey("148d78d2aba7dbca5cd8f6abcfb0b3c009ffbdbea1ff373d50ed94d78286640e".hexToByteArray())
val wallet = walletProvider.restoreWallet(
network = MoneroNetwork.Mainnet,
secretSpendKey = key,
restorePoint = RestorePoint.Genesis,
)
assertThat(wallet.publicAddress.address)
.isEqualTo("42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm")
}
@Test
fun testSaveToMultipleStores() = runTest {
val defaultStore = InMemoryWalletDataStore()
val wallet = walletProvider.createNewWallet(MoneroNetwork.Mainnet, defaultStore)
wallet.save()
val newStore = InMemoryWalletDataStore()
wallet.save(newStore)
assertThat(defaultStore.toByteArray()).isEqualTo(newStore.toByteArray())
}
@Test
fun testCreateAccountAndSave() = runTest {
val wallet = walletProvider.createNewWallet(MoneroNetwork.Mainnet)
val newAccount = wallet.createAccount()
withReopenedWallet(wallet) { original, reopened ->
MoneroWalletSubject.assertThat(reopened).matchesStateOf(original)
}
}
private suspend fun withReopenedWallet(
wallet: MoneroWallet,
action: suspend (original: MoneroWallet, reopened: MoneroWallet) -> Unit,
) {
walletProvider.openWallet(
network = wallet.network,
dataStore = InMemoryWalletDataStore().apply {
wallet.save(targetStore = this)
},
).use { reopened ->
action(wallet, reopened)
}
}
}
class MoneroWalletInProcessTest : MoneroWalletTest(InProcessWalletService::class.java)
class MoneroWalletSandboxedTest : MoneroWalletTest(SandboxedWalletService::class.java)

View file

@ -0,0 +1,116 @@
package im.molly.monero.internal
import android.os.ParcelFileDescriptor
import com.google.common.truth.Truth.assertThat
import im.molly.monero.WalletDataStore
import io.mockk.clearAllMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.IOException
class DataStoreAdapterTest {
@get:Rule
val mockkRule = MockKRule(this)
private val dataStore = mockk<WalletDataStore>(relaxed = true)
private val testDispatcher: TestDispatcher = StandardTestDispatcher()
private val testIOException = IOException("Test IO Exception")
private lateinit var adapter: DataStoreAdapter
@Before
fun setUp() {
adapter = DataStoreAdapter(dataStore, ioDispatcher = testDispatcher)
}
@Test
fun overwriteIsPassedToDataStore() = runTest(testDispatcher) {
coEvery { dataStore.save(any(), any()) } returns Unit
listOf(true, false).forEach {
adapter.saveWithFd(overwrite = it) {}
coVerify(exactly = 1) { dataStore.save(any(), overwrite = it) }
}
}
@Test
fun propagatesIOExceptionWhenLoadFails() = runTest(testDispatcher) {
coEvery { dataStore.load() } throws testIOException
val exception = runCatching {
adapter.loadWithFd {}
}.exceptionOrNull()
assertThat(exception).isEqualTo(testIOException)
}
@Test
fun propagatesIOExceptionWhenSaveFails() = runTest(testDispatcher) {
coEvery { dataStore.save(any(), any()) } throws testIOException
val exception = runCatching {
adapter.saveWithFd(overwrite = true) {}
}.exceptionOrNull()
assertThat(exception).isEqualTo(testIOException)
}
@Test
fun pipeIsAlwaysClosedAfterLoad() = runTest(testDispatcher) {
val (readFd, writeFd) = mockPipe()
coEvery { dataStore.load() } returns ByteArrayInputStream(byteArrayOf(1, 2, 3))
adapter.loadWithFd({})
coVerify { readFd.close() }
coVerify { writeFd.close() }
clearAllMocks(answers = false)
runCatching {
adapter.loadWithFd({ throw RuntimeException() })
}
coVerify { readFd.close() }
coVerify { writeFd.close() }
}
@Test
fun pipeIsAlwaysClosedAfterSave() = runTest(testDispatcher) {
val (readFd, writeFd) = mockPipe()
coEvery { dataStore.save(any(), any()) } returns Unit
adapter.saveWithFd(overwrite = true, {})
coVerify { readFd.close() }
coVerify { writeFd.close() }
clearAllMocks(answers = false)
runCatching {
adapter.saveWithFd(overwrite = true, { throw RuntimeException() })
}
coVerify { readFd.close() }
coVerify { writeFd.close() }
}
private fun mockPipe(): Pair<ParcelFileDescriptor, ParcelFileDescriptor> {
val readFd = mockk<ParcelFileDescriptor>(relaxed = true)
val writeFd = mockk<ParcelFileDescriptor>(relaxed = true)
mockkStatic(ParcelFileDescriptor::class)
coEvery { ParcelFileDescriptor.createPipe() } returns arrayOf(readFd, writeFd)
return readFd to writeFd
}
}

View file

@ -55,7 +55,7 @@ class NativeWalletTest {
}
@Test
fun atGenesisBalanceIsZero() = runTest {
fun balanceIsZeroAtGenesis() = runTest {
with(
NativeWallet.localSyncWallet(
networkId = MoneroNetwork.Mainnet.id,

View file

@ -20,7 +20,7 @@ class MoneroMnemonicTest {
)
@Test
fun testKnownMnemonics() {
fun knownMnemonics() {
testCases.forEach {
validateMnemonicGeneration(it)
validateEntropyRecovery(it)
@ -28,22 +28,22 @@ class MoneroMnemonicTest {
}
@Test(expected = IllegalArgumentException::class)
fun testEmptyEntropy() {
fun emptyEntropy() {
MoneroMnemonic.generateMnemonic(ByteArray(0))
}
@Test(expected = IllegalArgumentException::class)
fun testInvalidEntropy() {
fun invalidEntropy() {
MoneroMnemonic.generateMnemonic(ByteArray(2))
}
@Test(expected = IllegalArgumentException::class)
fun testEmptyWords() {
fun emptyWords() {
MoneroMnemonic.recoverEntropy("")
}
@Test(expected = IllegalArgumentException::class)
fun testInvalidLanguage() {
fun invalidLanguage() {
MoneroMnemonic.generateMnemonic(ByteArray(32), Locale("ZZ"))
}

View file

@ -0,0 +1,26 @@
package im.molly.monero.service
import android.content.Context
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
class WalletServiceSandboxingTest {
private val context: Context by lazy { InstrumentationRegistry.getInstrumentation().context }
@Test
fun inProcessWalletServiceIsNotIsolated() = runTest {
InProcessWalletService.connect(context).use { walletProvider ->
assertThat(walletProvider.isServiceIsolated()).isFalse()
}
}
@Test
fun sandboxedWalletServiceIsIsolated() = runTest {
SandboxedWalletService.connect(context).use { walletProvider ->
assertThat(walletProvider.isServiceIsolated()).isTrue()
}
}
}

View file

@ -23,6 +23,8 @@ interface WalletProvider : Closeable {
client: MoneroNodeClient? = null,
): MoneroWallet
fun isServiceIsolated(): Boolean
fun disconnect()
override fun close() {

View file

@ -146,6 +146,8 @@ internal class WalletServiceClient(
}
}
override fun isServiceIsolated(): Boolean = service.isRemote()
override fun disconnect() {
context.unbindService(serviceConnection ?: return)
}

View file

@ -0,0 +1,82 @@
package im.molly.monero
import com.google.common.truth.Truth.assertThat
import im.molly.monero.util.decodeBase58
import io.mockk.every
import io.mockk.mockkStatic
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class PublicAddressParsingTest(
private val expectedNetwork: MoneroNetwork,
private val isSubAddress: Boolean,
private val address: String,
) {
companion object {
@JvmStatic
@Parameterized.Parameters
fun data(): List<Array<Any>> = listOf(
arrayOf(MoneroNetwork.Mainnet, false, "42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm"),
arrayOf(MoneroNetwork.Mainnet, false, "44Kbx4sJ7JDRDV5aAhLJzQCjDz2ViLRduE3ijDZu3osWKBjMGkV1XPk4pfDUMqt1Aiezvephdqm6YD19GKFD9ZcXVUTp6BW"),
arrayOf(MoneroNetwork.Testnet, false, "9ujeXrjzf7bfeK3KZdCqnYaMwZVFuXemPU8Ubw335rj2FN1CdMiWNyFV3ksEfMFvRp9L9qum5UxkP5rN9aLcPxbH1au4WAB"),
arrayOf(MoneroNetwork.Stagenet, false, "53teqCAESLxeJ1REzGMAat1ZeHvuajvDiXqboEocPaDRRmqWoVPzy46GLo866qRFjbNhfkNckyhST3WEvBviDwpUDd7DSzB"),
arrayOf(MoneroNetwork.Mainnet, true, "8AsN91rznfkBGTY8psSNkJBg9SZgxxGGRUhGwRptBhgr5XSQ1XzmA9m8QAnoxydecSh5aLJXdrgXwTDMMZ1AuXsN1EX5Mtm"),
arrayOf(MoneroNetwork.Mainnet, true, "86kKnBKFqzCLxtK1Jmx2BkNBDBSMDEVaRYMMyVbeURYDWs8uNGDZURKCA5yRcyMxHzPcmCf1q2fSdhQVcaKsFrtGRsdGfNk"),
arrayOf(MoneroNetwork.Testnet, true,"BdKg9udkvckC5T58a8Nmtb6BNsgRAxs7uA2D49sWNNX5HPW5Us6Wxu8QMXrnSx3xPBQQ2iu9kwEcRGAoiz6EPmcZKbF62GS"),
arrayOf(MoneroNetwork.Testnet, true, "BcFvPa3fT4gVt5QyRDe5Vv7VtUFao9ci8NFEy3r254KF7R1N2cNB5FYhGvrHbMStv4D6VDzZ5xtxeKV8vgEPMnDcNFuwZb9"),
arrayOf(MoneroNetwork.Stagenet, true, "73LhUiix4DVFMcKhsPRG51QmCsv8dYYbL6GcQoLwEEFvPvkVvc7BhebfA4pnEFF9Lq66hwvLqBvpHjTcqvpJMHmmNjPPBqa"),
arrayOf(MoneroNetwork.Stagenet, true, "7A1Hr63MfgUa8pkWxueD5xBqhQczkusYiCMYMnJGcGmuQxa7aDBxN1G7iCuLCNB3VPeb2TW7U9FdxB27xKkWKfJ8VhUZthF"),
)
}
@Test
fun `parse determines correct type and network`() {
val parsed = PublicAddress.parse(address)
assertThat(parsed.address).isEqualTo(address)
assertThat(parsed.toString()).isEqualTo(address)
assertThat(parsed.network).isEqualTo(expectedNetwork)
assertThat(parsed.isSubAddress()).isEqualTo(isSubAddress)
when (parsed) {
is SubAddress -> assertThat(isSubAddress).isTrue()
is StandardAddress -> assertThat(isSubAddress).isFalse()
else -> error("Unexpected address type: ${parsed::class}")
}
}
}
class PublicAddressExceptionTest {
@Test
fun `throws InvalidAddress on unknown prefix`() {
mockkStatic("im.molly.monero.util.Base58Kt")
val unknownPrefix = "unknownprefix"
every { unknownPrefix.decodeBase58() } returns byteArrayOf(99, 1, 2, 3, 4, 5)
val thrown = Assert.assertThrows(InvalidAddress::class.java) {
PublicAddress.parse(unknownPrefix)
}
assertThat(thrown.message).contains("Unrecognized address prefix")
}
@Test
fun `throws InvalidAddress when address is too short`() {
val thrown = Assert.assertThrows(InvalidAddress::class.java) {
PublicAddress.parse("111")
}
assertThat(thrown.message).contains("Address too short")
}
@Test
fun `throws InvalidAddress on decoding error`() {
val thrown = Assert.assertThrows(InvalidAddress::class.java) {
PublicAddress.parse("zz")
}
assertThat(thrown.message).contains("Base58 decoding error")
}
}