From 30dfd5ecb679ba67072cc76a06fe6c0f0543228c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 3 Apr 2026 22:21:13 -0400 Subject: [PATCH 01/16] feat: improve test coverage across crypto, encryption, and core modules Fix failing ExternalWalletDeeplinkStateErrorTest by replacing tryEmit() with suspending emit() on unbuffered MutableSharedFlow. Add ~117 new unit tests covering ShortVec, AgoraMemo, MessageHeader, AccountMeta, Instruction, program addresses, PublicKey, Key32, Mint, CachePolicyHandler, Loadable, and Result/List extensions. Signed-off-by: Brandon McAnsh --- .../app/core/cache/CachePolicyHandlerTest.kt | 224 ++++++++++++++++++ .../flipcash/app/core/data/LoadableTest.kt | 65 +++++ .../app/core/extensions/ListExtensionsTest.kt | 34 +++ .../extensions/ResultExtensionsTest.kt | 50 ++++ .../internal/ExternalWalletDeeplinkState.kt | 8 +- libs/crypto/solana/build.gradle.kts | 2 + .../com/getcode/solana/AgoraMemoTest.kt | 116 +++++++++ .../com/getcode/solana/InstructionTest.kt | 167 +++++++++++++ .../com/getcode/solana/MessageHeaderTest.kt | 88 +++++++ .../com/getcode/solana/ProgramAddressTest.kt | 57 +++++ .../kotlin/com/getcode/solana/ShortVecTest.kt | 86 +++++++ .../getcode/solana/keys/AccountMetaTest.kt | 188 +++++++++++++++ libs/encryption/keys/build.gradle.kts | 2 + .../kotlin/com/getcode/solana/keys/KeyTest.kt | 82 +++++++ .../com/getcode/solana/keys/MintTest.kt | 65 +++++ .../com/getcode/solana/keys/PublicKeyTest.kt | 111 +++++++++ 16 files changed, 1343 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/cache/CachePolicyHandlerTest.kt create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/data/LoadableTest.kt create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/extensions/ListExtensionsTest.kt create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/internal/extensions/ResultExtensionsTest.kt create mode 100644 libs/crypto/solana/src/test/kotlin/com/getcode/solana/AgoraMemoTest.kt create mode 100644 libs/crypto/solana/src/test/kotlin/com/getcode/solana/InstructionTest.kt create mode 100644 libs/crypto/solana/src/test/kotlin/com/getcode/solana/MessageHeaderTest.kt create mode 100644 libs/crypto/solana/src/test/kotlin/com/getcode/solana/ProgramAddressTest.kt create mode 100644 libs/crypto/solana/src/test/kotlin/com/getcode/solana/ShortVecTest.kt create mode 100644 libs/crypto/solana/src/test/kotlin/com/getcode/solana/keys/AccountMetaTest.kt create mode 100644 libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/KeyTest.kt create mode 100644 libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/MintTest.kt create mode 100644 libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/PublicKeyTest.kt diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/cache/CachePolicyHandlerTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/cache/CachePolicyHandlerTest.kt new file mode 100644 index 000000000..42c151f6c --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/cache/CachePolicyHandlerTest.kt @@ -0,0 +1,224 @@ +package com.flipcash.app.core.cache + +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CachePolicyHandlerTest { + + private val handler = CachePolicyHandler() + + // region CacheOnly + + @Test + fun `CacheOnly with cache hit returns success with Cache origin`() = runTest { + val result = handler.execute( + cachePolicy = CachePolicy.CacheOnly, + cacheLookup = { "cached" }, + persistNetworkData = { }, + networkRequest = { Result.success("network") }, + domainMapper = { it }, + ) + + assertTrue(result.isSuccess) + val entry = result.getOrThrow() + assertEquals("cached", entry.data) + assertEquals(DataOrigin.Cache, entry.origin) + } + + @Test + fun `CacheOnly with cache miss returns failure`() = runTest { + val result = handler.execute( + cachePolicy = CachePolicy.CacheOnly, + cacheLookup = { null }, + persistNetworkData = { }, + networkRequest = { Result.success("network") }, + domainMapper = { it }, + ) + + assertTrue(result.isFailure) + } + + // endregion + + // region CacheFirst + + @Test + fun `CacheFirst with cache hit returns success with Cache origin`() = runTest { + val result = handler.execute( + cachePolicy = CachePolicy.CacheFirst, + cacheLookup = { "cached" }, + persistNetworkData = { }, + networkRequest = { Result.success("network") }, + domainMapper = { it }, + ) + + assertTrue(result.isSuccess) + val entry = result.getOrThrow() + assertEquals("cached", entry.data) + assertEquals(DataOrigin.Cache, entry.origin) + } + + @Test + fun `CacheFirst with cache miss and network success returns success with Network origin`() = runTest { + val result = handler.execute( + cachePolicy = CachePolicy.CacheFirst, + cacheLookup = { null }, + persistNetworkData = { }, + networkRequest = { Result.success("network") }, + domainMapper = { it }, + ) + + assertTrue(result.isSuccess) + val entry = result.getOrThrow() + assertEquals("network", entry.data) + assertEquals(DataOrigin.Network, entry.origin) + } + + @Test + fun `CacheFirst with cache miss and network failure returns failure`() = runTest { + val result = handler.execute( + cachePolicy = CachePolicy.CacheFirst, + cacheLookup = { null }, + persistNetworkData = { }, + networkRequest = { Result.failure(RuntimeException("network error")) }, + domainMapper = { it }, + ) + + assertTrue(result.isFailure) + } + + // endregion + + // region NetworkFirst + + @Test + fun `NetworkFirst with network success returns success with Network origin`() = runTest { + val result = handler.execute( + cachePolicy = CachePolicy.NetworkFirst, + cacheLookup = { "cached" }, + persistNetworkData = { }, + networkRequest = { Result.success("network") }, + domainMapper = { it }, + ) + + assertTrue(result.isSuccess) + val entry = result.getOrThrow() + assertEquals("network", entry.data) + assertEquals(DataOrigin.Network, entry.origin) + } + + @Test + fun `NetworkFirst with network failure and cache hit returns success with Cache origin`() = runTest { + val result = handler.execute( + cachePolicy = CachePolicy.NetworkFirst, + cacheLookup = { "cached" }, + persistNetworkData = { }, + networkRequest = { Result.failure(RuntimeException("network error")) }, + domainMapper = { it }, + ) + + assertTrue(result.isSuccess) + val entry = result.getOrThrow() + assertEquals("cached", entry.data) + assertEquals(DataOrigin.Cache, entry.origin) + } + + @Test + fun `NetworkFirst with network failure and cache miss returns failure`() = runTest { + val result = handler.execute( + cachePolicy = CachePolicy.NetworkFirst, + cacheLookup = { null }, + persistNetworkData = { }, + networkRequest = { Result.failure(RuntimeException("network error")) }, + domainMapper = { it }, + ) + + assertTrue(result.isFailure) + } + + // endregion + + // region NetworkOnly + + @Test + fun `NetworkOnly with network success returns success with Network origin`() = runTest { + val result = handler.execute( + cachePolicy = CachePolicy.NetworkOnly, + cacheLookup = { "cached" }, + persistNetworkData = { }, + networkRequest = { Result.success("network") }, + domainMapper = { it }, + ) + + assertTrue(result.isSuccess) + val entry = result.getOrThrow() + assertEquals("network", entry.data) + assertEquals(DataOrigin.Network, entry.origin) + } + + @Test + fun `NetworkOnly with network failure returns failure`() = runTest { + val result = handler.execute( + cachePolicy = CachePolicy.NetworkOnly, + cacheLookup = { "cached" }, + persistNetworkData = { }, + networkRequest = { Result.failure(RuntimeException("network error")) }, + domainMapper = { it }, + ) + + assertTrue(result.isFailure) + } + + // endregion + + // region persistNetworkData + + @Test + fun `persistNetworkData is called when network succeeds for NetworkOnly`() = runTest { + var persisted: String? = null + + handler.execute( + cachePolicy = CachePolicy.NetworkOnly, + cacheLookup = { null }, + persistNetworkData = { persisted = it }, + networkRequest = { Result.success("network-data") }, + domainMapper = { it }, + ) + + assertEquals("network-data", persisted) + } + + @Test + fun `persistNetworkData is called when network succeeds for NetworkFirst`() = runTest { + var persisted: String? = null + + handler.execute( + cachePolicy = CachePolicy.NetworkFirst, + cacheLookup = { null }, + persistNetworkData = { persisted = it }, + networkRequest = { Result.success("network-data") }, + domainMapper = { it }, + ) + + assertEquals("network-data", persisted) + } + + @Test + fun `persistNetworkData is called when CacheFirst falls back to network`() = runTest { + var persisted: String? = null + + handler.execute( + cachePolicy = CachePolicy.CacheFirst, + cacheLookup = { null }, + persistNetworkData = { persisted = it }, + networkRequest = { Result.success("network-data") }, + domainMapper = { it }, + ) + + assertEquals("network-data", persisted) + } + + // endregion +} diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/data/LoadableTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/data/LoadableTest.kt new file mode 100644 index 000000000..344749b47 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/data/LoadableTest.kt @@ -0,0 +1,65 @@ +package com.flipcash.app.core.data + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LoadableTest { + + @Test + fun `Loading state reports isLoading true`() { + val state: Loadable = Loadable.Loading() + + assertTrue(state.isLoading()) + assertFalse(state.isLoaded()) + assertFalse(state.isError()) + } + + @Test + fun `Loaded state reports isLoaded true`() { + val state: Loadable = Loadable.Loaded("data") + + assertTrue(state.isLoaded()) + assertFalse(state.isLoading()) + assertFalse(state.isError()) + } + + @Test + fun `Error state reports isError true`() { + val state: Loadable = Loadable.Error("something went wrong") + + assertTrue(state.isError()) + assertFalse(state.isLoading()) + assertFalse(state.isLoaded()) + } + + @Test + fun `dataOrNull returns data for Loading with data`() { + val state: Loadable = Loadable.Loading(data = "partial") + + assertEquals("partial", state.dataOrNull) + } + + @Test + fun `dataOrNull returns null for Loading without data`() { + val state: Loadable = Loadable.Loading() + + assertNull(state.dataOrNull) + } + + @Test + fun `dataOrNull returns data for Loaded`() { + val state: Loadable = Loadable.Loaded("complete") + + assertEquals("complete", state.dataOrNull) + } + + @Test + fun `dataOrNull returns null for Error`() { + val state: Loadable = Loadable.Error("fail") + + assertNull(state.dataOrNull) + } +} diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/extensions/ListExtensionsTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/extensions/ListExtensionsTest.kt new file mode 100644 index 000000000..418ac8270 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/extensions/ListExtensionsTest.kt @@ -0,0 +1,34 @@ +package com.flipcash.app.core.extensions + +import org.junit.Test +import kotlin.test.assertEquals + +class ListExtensionsTest { + + @Test + fun `filterIsNotInstance removes instances of specified type`() { + val list: List = listOf("hello", 1, "world", 2, 3) + + val result = list.filterIsNotInstance() + + assertEquals(listOf(1, 2, 3), result) + } + + @Test + fun `filterIsNotInstance keeps non-matching types`() { + val list: List = listOf(1, 2, 3) + + val result = list.filterIsNotInstance() + + assertEquals(listOf(1, 2, 3), result) + } + + @Test + fun `filterIsNotInstance on empty list returns empty`() { + val list = emptyList() + + val result = list.filterIsNotInstance() + + assertEquals(emptyList(), result) + } +} diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/internal/extensions/ResultExtensionsTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/internal/extensions/ResultExtensionsTest.kt new file mode 100644 index 000000000..19ef1a91c --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/internal/extensions/ResultExtensionsTest.kt @@ -0,0 +1,50 @@ +package com.flipcash.app.core.internal.extensions + +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ResultExtensionsTest { + + @Test + fun `mapResult transforms success to new success`() = runTest { + val result = Result.success(42) + .mapResult { Result.success(it.toString()) } + + assertTrue(result.isSuccess) + assertEquals("42", result.getOrThrow()) + } + + @Test + fun `mapResult transforms success to failure`() = runTest { + val result = Result.success(42) + .mapResult { Result.failure(RuntimeException("mapped error")) } + + assertTrue(result.isFailure) + assertEquals("mapped error", result.exceptionOrNull()?.message) + } + + @Test + fun `mapResult preserves failure without calling transform`() = runTest { + var transformCalled = false + val result = Result.failure(RuntimeException("original error")) + .mapResult { + transformCalled = true + Result.success(it.toString()) + } + + assertTrue(result.isFailure) + assertEquals("original error", result.exceptionOrNull()?.message) + assertEquals(false, transformCalled) + } + + @Test + fun `mapResult catches exception thrown by transform`() = runTest { + val result = Result.success(42) + .mapResult { throw IllegalStateException("transform threw") } + + assertTrue(result.isFailure) + assertEquals("transform threw", result.exceptionOrNull()?.message) + } +} diff --git a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/internal/ExternalWalletDeeplinkState.kt b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/internal/ExternalWalletDeeplinkState.kt index 2b6cab002..57d084a6b 100644 --- a/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/internal/ExternalWalletDeeplinkState.kt +++ b/apps/flipcash/shared/onramp/deeplinks/src/main/kotlin/com/flipcash/app/onramp/internal/ExternalWalletDeeplinkState.kt @@ -299,7 +299,9 @@ class ExternalWalletDeeplinkState( } else { val resolvedError = DeeplinkError.fromCode(error?.errorCode) val message = error?.errorMessage ?: "Something went wrong" - errors.tryEmit(DeeplinkOnRampError.WalletProvidedError(resolvedError, message = message)) + scope.launch { + errors.emit(DeeplinkOnRampError.WalletProvidedError(resolvedError, message = message)) + } } } is DeeplinkType.ExternalWalletSignedTransaction -> { @@ -310,7 +312,9 @@ class ExternalWalletDeeplinkState( } else { val resolvedError = DeeplinkError.fromCode(error?.errorCode) val message = error?.errorMessage ?: "Something went wrong" - errors.tryEmit(DeeplinkOnRampError.WalletProvidedError(resolvedError, message = message)) + scope.launch { + errors.emit(DeeplinkOnRampError.WalletProvidedError(resolvedError, message = message)) + } } } else -> {} diff --git a/libs/crypto/solana/build.gradle.kts b/libs/crypto/solana/build.gradle.kts index 7626539c6..71513038e 100644 --- a/libs/crypto/solana/build.gradle.kts +++ b/libs/crypto/solana/build.gradle.kts @@ -30,4 +30,6 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) ksp(libs.hilt.compiler) + + testImplementation(kotlin("test")) } diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/AgoraMemoTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/AgoraMemoTest.kt new file mode 100644 index 000000000..03b5fbb47 --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/AgoraMemoTest.kt @@ -0,0 +1,116 @@ +package com.getcode.solana + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AgoraMemoTest { + + // Note: encode/decode roundtrip tests require Android Base64 and cannot run as JVM unit tests. + // Testing construction, validation, and field access instead. + + @Test + fun constructionWithDefaults() { + val memo = AgoraMemo(transferType = TransferType.none, appIndex = 0) + assertEquals(MagicByte.default, memo.magicByte) + assertEquals(1.toByte(), memo.version) + assertEquals(TransferType.none, memo.transferType) + assertEquals(0, memo.appIndex) + } + + @Test + fun constructionWithAllTransferTypes() { + for (type in listOf(TransferType.none, TransferType.earn, TransferType.spend, TransferType.p2p)) { + val memo = AgoraMemo(transferType = type, appIndex = 10) + assertEquals(type, memo.transferType) + } + } + + @Test + fun foreignKeyPaddedToByteLength() { + val shortKey = listOf(1, 2, 3) + val memo = AgoraMemo(transferType = TransferType.earn, appIndex = 0, bytes = shortKey) + assertEquals(AgoraMemo.byteLength, memo.bytes.size) + assertEquals(1.toByte(), memo.bytes[0]) + assertEquals(2.toByte(), memo.bytes[1]) + assertEquals(3.toByte(), memo.bytes[2]) + // Remaining bytes should be zero-padded + for (i in 3 until AgoraMemo.byteLength) { + assertEquals(0.toByte(), memo.bytes[i]) + } + } + + @Test + fun foreignKeyFullLength() { + val fullKey = List(AgoraMemo.byteLength) { (it + 1).toByte() } + val memo = AgoraMemo(transferType = TransferType.p2p, appIndex = 42, bytes = fullKey) + assertEquals(fullKey, memo.bytes) + } + + @Test + fun magicByteValidValues() { + MagicByte(1) + MagicByte(2) + MagicByte(3) + } + + @Test + fun magicByteInvalidZeroThrows() { + assertFailsWith { + MagicByte(0) + } + } + + @Test + fun magicByteInvalidFourThrows() { + assertFailsWith { + MagicByte(4) + } + } + + @Test + fun magicByteEquality() { + assertEquals(MagicByte(1), MagicByte(1)) + } + + @Test + fun magicByteHashCode() { + assertEquals(MagicByte(1).hashCode(), MagicByte(1).hashCode()) + } + + @Test + fun transferTypeGetByValueNone() { + assertEquals(TransferType.none, TransferType.getByValue(0)) + } + + @Test + fun transferTypeGetByValueEarn() { + assertEquals(TransferType.earn, TransferType.getByValue(1)) + } + + @Test + fun transferTypeGetByValueSpend() { + assertEquals(TransferType.spend, TransferType.getByValue(2)) + } + + @Test + fun transferTypeGetByValueP2p() { + assertEquals(TransferType.p2p, TransferType.getByValue(3)) + } + + @Test + fun transferTypeGetByValueUnknown() { + assertEquals(TransferType.unknown, TransferType.getByValue(99)) + } + + @Test + fun companionConstants() { + assertEquals(2, AgoraMemo.magicByteBitLength) + assertEquals(3, AgoraMemo.versionBitLength) + assertEquals(5, AgoraMemo.transferTypeBitLength) + assertEquals(16, AgoraMemo.appIndexBitLength) + assertEquals(230, AgoraMemo.foreignKeyBitLength) + assertEquals(28, AgoraMemo.byteLength) // 230 / 8 + assertEquals(32, AgoraMemo.totalByteCount) + } +} diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/InstructionTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/InstructionTest.kt new file mode 100644 index 000000000..b320da3bd --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/InstructionTest.kt @@ -0,0 +1,167 @@ +package com.getcode.solana + +import com.getcode.solana.keys.AccountMeta +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class InstructionTest { + + private fun testKey(index: Int): PublicKey { + return PublicKey(ByteArray(32) { index.toByte() }.toList()) + } + + // Instruction.compile tests + + @Test + fun compileProducesCorrectProgramIndex() { + val programKey = testKey(0) + val accountKey = testKey(1) + + val instruction = Instruction( + program = programKey, + accounts = listOf(AccountMeta.readonly(accountKey)), + data = listOf(0xAA.toByte()), + ) + + val messageAccounts = listOf(programKey, accountKey) + val compiled = instruction.compile(messageAccounts) + + assertEquals(0.toByte(), compiled.programIndex) + } + + @Test + fun compileProducesCorrectAccountIndexes() { + val programKey = testKey(0) + val account1 = testKey(1) + val account2 = testKey(2) + + val instruction = Instruction( + program = programKey, + accounts = listOf( + AccountMeta.readonly(account1), + AccountMeta.writable(account2), + ), + data = listOf(0x01), + ) + + val messageAccounts = listOf(programKey, account1, account2) + val compiled = instruction.compile(messageAccounts) + + assertEquals(listOf(1.toByte(), 2.toByte()), compiled.accountIndexes) + } + + @Test + fun compilePreservesData() { + val programKey = testKey(0) + val data = listOf(0x01, 0x02, 0x03) + + val instruction = Instruction( + program = programKey, + accounts = emptyList(), + data = data, + ) + + val compiled = instruction.compile(listOf(programKey)) + assertEquals(data, compiled.data) + } + + // CompiledInstruction encode/fromList roundtrip + + @Test + fun compiledInstructionEncodeFromListRoundtrip() { + val compiled = CompiledInstruction( + programIndex = 3.toByte(), + accountIndexes = listOf(0.toByte(), 1.toByte(), 2.toByte()), + data = listOf(0xAA.toByte(), 0xBB.toByte()), + ) + + val encoded = compiled.encode() + val decoded = CompiledInstruction.fromList(encoded) + + assertNotNull(decoded) + assertEquals(compiled.programIndex, decoded.programIndex) + assertEquals(compiled.accountIndexes, decoded.accountIndexes) + assertEquals(compiled.data, decoded.data) + } + + @Test + fun compiledInstructionEncodeFromListRoundtripEmptyData() { + val compiled = CompiledInstruction( + programIndex = 0.toByte(), + accountIndexes = listOf(1.toByte()), + data = emptyList(), + ) + + val encoded = compiled.encode() + val decoded = CompiledInstruction.fromList(encoded) + + assertNotNull(decoded) + assertEquals(compiled.programIndex, decoded.programIndex) + assertEquals(compiled.accountIndexes, decoded.accountIndexes) + assertEquals(compiled.data, decoded.data) + } + + // decompile tests + + @Test + fun decompileReversesCompile() { + val programKey = testKey(0) + val account1 = testKey(1) + val account2 = testKey(2) + + val originalInstruction = Instruction( + program = programKey, + accounts = listOf( + AccountMeta.readonly(account1), + AccountMeta.writable(account2), + ), + data = listOf(0x42), + ) + + val messageAccounts = listOf(programKey, account1, account2) + val accountMetas = listOf( + AccountMeta.program(programKey), + AccountMeta.readonly(account1), + AccountMeta.writable(account2), + ) + + val compiled = originalInstruction.compile(messageAccounts) + val decompiled = compiled.decompile(accountMetas) + + assertNotNull(decompiled) + assertEquals(originalInstruction.program, decompiled.program) + assertEquals(originalInstruction.data, decompiled.data) + assertEquals(originalInstruction.accounts.size, decompiled.accounts.size) + } + + @Test + fun decompileReturnsNullWhenInsufficientAccounts() { + val compiled = CompiledInstruction( + programIndex = 0.toByte(), + accountIndexes = listOf(1.toByte(), 2.toByte()), + data = listOf(0x01), + ) + + // Only one account, but we need at least 3 (program + 2 account indexes) + val accounts = listOf(AccountMeta.program(testKey(0))) + val result = compiled.decompile(accounts) + assertNull(result) + } + + // fromList edge cases + + @Test + fun fromListWithEmptyDataReturnsNull() { + val result = CompiledInstruction.fromList(emptyList()) + assertNull(result) + } + + @Test + fun fromListWithSingleByteReturnsNull() { + val result = CompiledInstruction.fromList(listOf(0.toByte())) + assertNull(result) + } +} diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/MessageHeaderTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/MessageHeaderTest.kt new file mode 100644 index 000000000..f24591bcf --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/MessageHeaderTest.kt @@ -0,0 +1,88 @@ +package com.getcode.solana + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MessageHeaderTest { + + @Test + fun encodeProducesThreeBytes() { + val header = MessageHeader( + requiredSignatures = 2, + readOnlySigners = 1, + readOnly = 3, + ) + val encoded = header.encode() + assertEquals(3, encoded.size) + } + + @Test + fun encodeContainsCorrectValues() { + val header = MessageHeader( + requiredSignatures = 2, + readOnlySigners = 1, + readOnly = 3, + ) + val encoded = header.encode() + assertEquals(2.toByte(), encoded[0]) + assertEquals(1.toByte(), encoded[1]) + assertEquals(3.toByte(), encoded[2]) + } + + @Test + fun encodeFromListRoundtrip() { + val header = MessageHeader( + requiredSignatures = 5, + readOnlySigners = 2, + readOnly = 4, + ) + val encoded = header.encode() + val decoded = MessageHeader.fromList(encoded.toList()) + + assertEquals(header.requiredSignatures, decoded.requiredSignatures) + assertEquals(header.readOnlySigners, decoded.readOnlySigners) + assertEquals(header.readOnly, decoded.readOnly) + } + + @Test + fun fromListDecodesCorrectly() { + val bytes = listOf(10, 3, 7) + val header = MessageHeader.fromList(bytes) + + assertEquals(10, header.requiredSignatures) + assertEquals(3, header.readOnlySigners) + assertEquals(7, header.readOnly) + } + + @Test + fun equality() { + val header1 = MessageHeader( + requiredSignatures = 1, + readOnlySigners = 2, + readOnly = 3, + ) + val header2 = MessageHeader( + requiredSignatures = 1, + readOnlySigners = 2, + readOnly = 3, + ) + assertEquals(header1, header2) + } + + @Test + fun headerLengthConstant() { + assertEquals(3, MessageHeader.length) + } + + @Test + fun encodeZeroValues() { + val header = MessageHeader( + requiredSignatures = 0, + readOnlySigners = 0, + readOnly = 0, + ) + val encoded = header.encode() + assertTrue(encoded.all { it == 0.toByte() }) + } +} diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/ProgramAddressTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/ProgramAddressTest.kt new file mode 100644 index 000000000..39779d706 --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/ProgramAddressTest.kt @@ -0,0 +1,57 @@ +package com.getcode.solana + +import com.getcode.solana.instructions.programs.AssociatedTokenProgram +import com.getcode.solana.instructions.programs.SystemProgram +import com.getcode.solana.instructions.programs.TokenProgram +import com.getcode.solana.keys.base58 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ProgramAddressTest { + + @Test + fun systemProgramAddressIsAllZeros() { + val bytes = SystemProgram.address.bytes + assertEquals(32, bytes.size) + assertTrue(bytes.all { it == 0.toByte() }) + } + + @Test + fun tokenProgramAddressBase58() { + assertEquals( + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + TokenProgram.address.base58(), + ) + } + + @Test + fun associatedTokenProgramAddressBase58() { + assertEquals( + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + AssociatedTokenProgram.address.base58(), + ) + } + + @Test + fun systemProgramCommandEnumHas12Values() { + assertEquals(12, SystemProgram.Command.entries.size) + } + + @Test + fun systemProgramCommandContainsExpectedValues() { + val commands = SystemProgram.Command.entries.map { it.name } + assertTrue("createAccount" in commands) + assertTrue("assign" in commands) + assertTrue("transfer" in commands) + assertTrue("createAccountWithSeed" in commands) + assertTrue("advanceNonceAccount" in commands) + assertTrue("withdrawNonceAccount" in commands) + assertTrue("initializeNonceAccount" in commands) + assertTrue("authorizeNonceAccount" in commands) + assertTrue("allocate" in commands) + assertTrue("allocateWithSeed" in commands) + assertTrue("assignWithSeed" in commands) + assertTrue("transferWithSeed" in commands) + } +} diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/ShortVecTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/ShortVecTest.kt new file mode 100644 index 000000000..d1c8e203c --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/ShortVecTest.kt @@ -0,0 +1,86 @@ +package com.getcode.solana + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ShortVecTest { + + @Test + fun encodeLenThenDecodeLenRoundtrip() { + val values = listOf(0, 1, 127, 128, 255, 256, 16383, 16384) + for (value in values) { + val encoded = ShortVec.encodeLen(value) + val (decoded, remaining) = ShortVec.decodeLen(encoded) + assertEquals(value, decoded, "Roundtrip failed for value $value") + assertTrue(remaining.isEmpty(), "No remaining bytes expected for value $value") + } + } + + @Test + fun encodeLenZeroReturnsSingleZeroByte() { + val encoded = ShortVec.encodeLen(0) + assertEquals(listOf(0), encoded) + } + + @Test + fun encodeLenSingleByteNoContinuation() { + val encoded = ShortVec.encodeLen(127) + assertEquals(1, encoded.size) + assertEquals(127.toByte(), encoded[0]) + } + + @Test + fun encodeLenContinuationBitForValue128() { + val encoded = ShortVec.encodeLen(128) + assertEquals(2, encoded.size) + // First byte should have continuation bit set (0x80) + assertTrue((encoded[0].toInt() and 0x80) != 0, "Continuation bit should be set") + // Low 7 bits of first byte = 0, second byte = 1 + assertEquals(0x80.toByte(), encoded[0]) + assertEquals(1.toByte(), encoded[1]) + } + + @Test + fun decodeLenPreservesRemainingBytes() { + val encoded = ShortVec.encodeLen(42) + val extra = listOf(0xAA.toByte(), 0xBB.toByte()) + val input = encoded + extra + val (decoded, remaining) = ShortVec.decodeLen(input) + assertEquals(42, decoded) + assertEquals(extra, remaining) + } + + @Test + fun encodePrependsLengthThenData() { + val data = listOf(10, 20, 30) + val encoded = ShortVec.encode(data) + // First byte(s) should be the length encoding, then the data + val expectedLength = ShortVec.encodeLen(3) + assertEquals(expectedLength + data, encoded) + } + + @Test + fun encodeListEmptyList() { + val encoded = ShortVec.encodeList(emptyList()) + // Should just be the encoded length of 0 + assertEquals(ShortVec.encodeLen(0), encoded) + } + + @Test + fun encodeListSingleItem() { + val item = listOf(1, 2, 3) + val encoded = ShortVec.encodeList(listOf(item)) + val expected = ShortVec.encodeLen(1) + item + assertEquals(expected, encoded) + } + + @Test + fun encodeListMultipleItems() { + val item1 = listOf(1, 2) + val item2 = listOf(3, 4, 5) + val encoded = ShortVec.encodeList(listOf(item1, item2)) + val expected = ShortVec.encodeLen(2) + item1 + item2 + assertEquals(expected, encoded) + } +} diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/keys/AccountMetaTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/keys/AccountMetaTest.kt new file mode 100644 index 000000000..f95f34ec4 --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/keys/AccountMetaTest.kt @@ -0,0 +1,188 @@ +package com.getcode.solana.keys + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AccountMetaTest { + + private fun testKey(index: Int): PublicKey { + return PublicKey(ByteArray(32) { index.toByte() }.toList()) + } + + // Factory method tests + + @Test + fun payerSetsCorrectFlags() { + val meta = AccountMeta.payer(testKey(1)) + assertTrue(meta.isSigner) + assertTrue(meta.isWritable) + assertTrue(meta.isPayer) + assertFalse(meta.isProgram) + } + + @Test + fun writableDefaultSetsCorrectFlags() { + val meta = AccountMeta.writable(testKey(2)) + assertFalse(meta.isSigner) + assertTrue(meta.isWritable) + assertFalse(meta.isPayer) + assertFalse(meta.isProgram) + } + + @Test + fun writableWithSignerSetsCorrectFlags() { + val meta = AccountMeta.writable(testKey(2), signer = true) + assertTrue(meta.isSigner) + assertTrue(meta.isWritable) + assertFalse(meta.isPayer) + assertFalse(meta.isProgram) + } + + @Test + fun readonlyDefaultSetsCorrectFlags() { + val meta = AccountMeta.readonly(testKey(3)) + assertFalse(meta.isSigner) + assertFalse(meta.isWritable) + assertFalse(meta.isPayer) + assertFalse(meta.isProgram) + } + + @Test + fun readonlyWithSignerSetsCorrectFlags() { + val meta = AccountMeta.readonly(testKey(3), signer = true) + assertTrue(meta.isSigner) + assertFalse(meta.isWritable) + assertFalse(meta.isPayer) + assertFalse(meta.isProgram) + } + + @Test + fun programSetsCorrectFlags() { + val meta = AccountMeta.program(testKey(4)) + assertFalse(meta.isSigner) + assertFalse(meta.isWritable) + assertFalse(meta.isPayer) + assertTrue(meta.isProgram) + } + + // Sorting tests + + @Test + fun payerSortsBeforeSigner() { + val payer = AccountMeta.payer(testKey(1)) + val signer = AccountMeta.readonly(testKey(2), signer = true) + assertTrue(payer < signer) + } + + @Test + fun signerSortsBeforeWritable() { + val signer = AccountMeta.readonly(testKey(1), signer = true) + val writable = AccountMeta.writable(testKey(2)) + assertTrue(signer < writable) + } + + @Test + fun writableSortsBeforeReadonly() { + val writable = AccountMeta.writable(testKey(1)) + val readonly = AccountMeta.readonly(testKey(2)) + assertTrue(writable < readonly) + } + + @Test + fun readonlySortsBeforeProgram() { + val readonly = AccountMeta.readonly(testKey(1)) + val program = AccountMeta.program(testKey(2)) + assertTrue(readonly < program) + } + + @Test + fun fullSortOrder() { + val program = AccountMeta.program(testKey(5)) + val readonly = AccountMeta.readonly(testKey(4)) + val writable = AccountMeta.writable(testKey(3)) + val signer = AccountMeta.readonly(testKey(2), signer = true) + val payer = AccountMeta.payer(testKey(1)) + + val sorted = listOf(program, readonly, writable, signer, payer).sorted() + + assertEquals(payer, sorted[0]) + assertEquals(signer, sorted[1]) + assertEquals(writable, sorted[2]) + assertEquals(readonly, sorted[3]) + assertEquals(program, sorted[4]) + } + + // filterUniqueAccounts tests + + @Test + fun filterUniqueAccountsDeduplicates() { + val key = testKey(1) + val meta1 = AccountMeta.readonly(key) + val meta2 = AccountMeta.readonly(key) + val result = listOf(meta1, meta2).filterUniqueAccounts() + assertEquals(1, result.size) + } + + @Test + fun filterUniqueAccountsPromotesPermissions() { + val key = testKey(1) + val readonly = AccountMeta.readonly(key) + val writable = AccountMeta.writable(key) + val result = listOf(readonly, writable).filterUniqueAccounts() + + assertEquals(1, result.size) + assertTrue(result[0].isWritable, "Should promote to writable") + } + + @Test + fun filterUniqueAccountsPromotesSigner() { + val key = testKey(1) + val nonSigner = AccountMeta.writable(key, signer = false) + val signer = AccountMeta.readonly(key, signer = true) + val result = listOf(nonSigner, signer).filterUniqueAccounts() + + assertEquals(1, result.size) + assertTrue(result[0].isSigner, "Should promote to signer") + assertTrue(result[0].isWritable, "Should retain writable from first entry") + } + + @Test + fun filterUniqueAccountsPreservesDistinctKeys() { + val meta1 = AccountMeta.readonly(testKey(1)) + val meta2 = AccountMeta.writable(testKey(2)) + val result = listOf(meta1, meta2).filterUniqueAccounts() + assertEquals(2, result.size) + } + + // compareLexicographically tests + + @Test + fun compareLexicographicallyEqual() { + val a = byteArrayOf(1, 2, 3) + val b = byteArrayOf(1, 2, 3) + assertEquals(0, AccountMeta.compareLexicographically(a, b)) + } + + @Test + fun compareLexicographicallyLessThan() { + val a = byteArrayOf(1, 2, 3) + val b = byteArrayOf(1, 2, 4) + assertTrue(AccountMeta.compareLexicographically(a, b) < 0) + } + + @Test + fun compareLexicographicallyGreaterThan() { + val a = byteArrayOf(1, 3, 3) + val b = byteArrayOf(1, 2, 3) + assertTrue(AccountMeta.compareLexicographically(a, b) > 0) + } + + @Test + fun compareLexicographicallyShorterArray() { + val a = byteArrayOf(1, 2) + val b = byteArrayOf(1, 2, 3) + assertTrue(AccountMeta.compareLexicographically(a, b) < 0) + } +} diff --git a/libs/encryption/keys/build.gradle.kts b/libs/encryption/keys/build.gradle.kts index 2c9182910..831c47934 100644 --- a/libs/encryption/keys/build.gradle.kts +++ b/libs/encryption/keys/build.gradle.kts @@ -15,4 +15,6 @@ dependencies { implementation(libs.grpc.okhttp) implementation(libs.grpc.kotlin) implementation(libs.kotlinx.serialization.json) + + testImplementation(kotlin("test")) } diff --git a/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/KeyTest.kt b/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/KeyTest.kt new file mode 100644 index 000000000..761405d5a --- /dev/null +++ b/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/KeyTest.kt @@ -0,0 +1,82 @@ +package com.getcode.solana.keys + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class KeyTest { + + @Test + fun `Key32 zero has 32 zero bytes`() { + val zero = Key32.zero + + assertEquals(32, zero.bytes.size) + assertTrue(zero.bytes.all { it == 0.toByte() }) + } + + @Test + fun `Key32 zero size is 32`() { + assertEquals(32, Key32.zero.size) + } + + @Test + fun `Key32 from base58 string roundtrips correctly`() { + val base58 = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + val key = Key32(base58) + + assertEquals(base58, key.base58()) + assertEquals(32, key.bytes.size) + } + + @Test + fun `Key32 compareTo returns zero for equal keys`() { + val bytes = ByteArray(32) { it.toByte() }.toList() + val key1 = Key32(bytes) + val key2 = Key32(bytes) + + assertEquals(0, key1.compareTo(key2)) + } + + @Test + fun `Key32 compareTo orders by unsigned byte value`() { + // key with 0xFF in first byte should be greater than key with 0x00 + val smallBytes = ByteArray(32) { 0x00.toByte() }.toList() + val largeBytes = ByteArray(32).also { it[0] = 0xFF.toByte() }.toList() + + val smallKey = Key32(smallBytes) + val largeKey = Key32(largeBytes) + + assertTrue(smallKey < largeKey) + assertTrue(largeKey > smallKey) + } + + @Test + fun `Key32 compareTo compares subsequent bytes when first bytes are equal`() { + val bytes1 = ByteArray(32) { 0x01.toByte() }.toList() + val bytes2 = ByteArray(32) { 0x01.toByte() }.toMutableList().also { + it[1] = 0x02.toByte() + } + + val key1 = Key32(bytes1) + val key2 = Key32(bytes2) + + assertTrue(key1 < key2) + } + + @Test + fun `Key32 equality for same bytes`() { + val bytes = ByteArray(32) { 42.toByte() }.toList() + val key1 = Key32(bytes) + val key2 = Key32(bytes) + + assertEquals(key1, key2) + } + + @Test + fun `Key32 zero base58 encodes consistently`() { + val zero1 = Key32.zero + val zero2 = Key32(ByteArray(32).toList()) + + assertEquals(zero1.base58(), zero2.base58()) + } +} diff --git a/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/MintTest.kt b/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/MintTest.kt new file mode 100644 index 000000000..d0eae5121 --- /dev/null +++ b/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/MintTest.kt @@ -0,0 +1,65 @@ +package com.getcode.solana.keys + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MintTest { + + @Test + fun `Mint kin has correct base58 address`() { + assertEquals("kinXdEcpDQeHPEuQnqmUgtYykqKGVFq6CeVX5iAHJq6", Mint.kin.base58()) + } + + @Test + fun `Mint usdc has correct base58 address`() { + assertEquals("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", Mint.usdc.base58()) + } + + @Test + fun `Mint kin is a PublicKey instance`() { + assertTrue(Mint.kin is PublicKey) + } + + @Test + fun `Mint usdc is a PublicKey instance`() { + assertTrue(Mint.usdc is PublicKey) + } + + @Test + fun `two calls to Mint kin produce equal objects`() { + val kin1 = Mint.kin + val kin2 = Mint.kin + + assertEquals(kin1, kin2) + assertEquals(kin1.bytes, kin2.bytes) + } + + @Test + fun `two calls to Mint usdc produce equal objects`() { + val usdc1 = Mint.usdc + val usdc2 = Mint.usdc + + assertEquals(usdc1, usdc2) + assertEquals(usdc1.bytes, usdc2.bytes) + } + + @Test + fun `Mint kin has 32 bytes`() { + assertEquals(32, Mint.kin.bytes.size) + } + + @Test + fun `Mint kin and usdc are different`() { + val kin = Mint.kin + val usdc = Mint.usdc + + assertTrue(kin.bytes != usdc.bytes) + assertTrue(kin.base58() != usdc.base58()) + } + + @Test + fun `Mint usdf has correct base58 address`() { + assertEquals("5AMAA9JV9H97YYVxx8F6FsCMmTwXSuTTQneiup4RYAUQ", Mint.usdf.base58()) + } +} diff --git a/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/PublicKeyTest.kt b/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/PublicKeyTest.kt new file mode 100644 index 000000000..e8c8a1bb9 --- /dev/null +++ b/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/PublicKeyTest.kt @@ -0,0 +1,111 @@ +package com.getcode.solana.keys + +import com.getcode.vendor.Base58 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class PublicKeyTest { + + private val tokenProgramAddress = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + + @Test + fun `create from bytes and verify base58 roundtrip`() { + val bytes = ByteArray(32) { it.toByte() }.toList() + val key = PublicKey(bytes) + + val base58String = key.base58() + val decoded = Base58.decode(base58String).toList() + + assertEquals(bytes, decoded) + } + + @Test + fun `create from base58 string and verify bytes match`() { + val expectedBytes = Base58.decode(tokenProgramAddress).toList() + val key = PublicKey(tokenProgramAddress) + + assertEquals(expectedBytes, key.bytes) + assertEquals(tokenProgramAddress, key.base58()) + } + + @Test + fun `fromBase58 factory method creates correct key`() { + val key = PublicKey.fromBase58(tokenProgramAddress) + + assertEquals(tokenProgramAddress, key.base58()) + assertEquals(32, key.bytes.size) + } + + @Test + fun `equals returns true for same bytes`() { + val bytes = ByteArray(32) { it.toByte() }.toList() + val key1 = PublicKey(bytes) + val key2 = PublicKey(bytes) + + assertEquals(key1, key2) + } + + @Test + fun `equals returns false for different bytes`() { + val bytes1 = ByteArray(32) { it.toByte() }.toList() + val bytes2 = ByteArray(32) { (it + 1).toByte() }.toList() + val key1 = PublicKey(bytes1) + val key2 = PublicKey(bytes2) + + assertNotEquals(key1, key2) + } + + @Test + fun `equals returns true for keys created from same base58`() { + val key1 = PublicKey(tokenProgramAddress) + val key2 = PublicKey.fromBase58(tokenProgramAddress) + + assertEquals(key1, key2) + } + + @Test + fun `toString returns base58 encoding`() { + val key = PublicKey(tokenProgramAddress) + + assertEquals(tokenProgramAddress, key.toString()) + } + + @Test + fun `description returns base58 encoding`() { + val key = PublicKey(tokenProgramAddress) + + assertEquals(tokenProgramAddress, key.description) + } + + @Test + fun `hashCode is consistent with equals`() { + val bytes = ByteArray(32) { it.toByte() }.toList() + val key1 = PublicKey(bytes) + val key2 = PublicKey(bytes) + + assertEquals(key1.hashCode(), key2.hashCode()) + } + + @Test + fun `hashCode differs for different keys`() { + val key1 = PublicKey(ByteArray(32) { it.toByte() }.toList()) + val key2 = PublicKey(ByteArray(32) { (it + 1).toByte() }.toList()) + + assertNotEquals(key1.hashCode(), key2.hashCode()) + } + + @Test + fun `key has 32 bytes`() { + val key = PublicKey(tokenProgramAddress) + + assertEquals(32, key.bytes.size) + assertEquals(32, key.size) + } + + @Test + fun `MAX_SEEDS is 16`() { + assertEquals(16, PublicKey.MAX_SEEDS) + } +} From dd977f3f07685f4f013690ed42bd0e1a276e9b52 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 4 Apr 2026 14:59:08 -0400 Subject: [PATCH 02/16] test(encryption): add tests for Base58, DerivePath, MnemonicPhrase, and utils - Base58: encode/decode roundtrips, leading zeros, checked encoding, invalid chars - DerivePath: path parsing, predefined paths, password handling - MnemonicPhrase: word count validation for 12/24 words - Extensions: byte array conversions, hex encoding, URL encode/decode, replaceParam --- libs/encryption/base58/build.gradle.kts | 1 + .../kotlin/com/getcode/vendor/Base58Test.kt | 113 +++++++++++ libs/encryption/mnemonic/build.gradle.kts | 2 + .../com/getcode/crypt/DerivePathTest.kt | 136 +++++++++++++ .../com/getcode/crypt/MnemonicPhraseTest.kt | 53 +++++ libs/encryption/utils/build.gradle.kts | 2 + .../com/getcode/utils/ExtensionsTest.kt | 190 ++++++++++++++++++ 7 files changed, 497 insertions(+) create mode 100644 libs/encryption/base58/src/test/kotlin/com/getcode/vendor/Base58Test.kt create mode 100644 libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/DerivePathTest.kt create mode 100644 libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/MnemonicPhraseTest.kt create mode 100644 libs/encryption/utils/src/test/kotlin/com/getcode/utils/ExtensionsTest.kt diff --git a/libs/encryption/base58/build.gradle.kts b/libs/encryption/base58/build.gradle.kts index 4b075490e..7efa5e3d2 100644 --- a/libs/encryption/base58/build.gradle.kts +++ b/libs/encryption/base58/build.gradle.kts @@ -7,4 +7,5 @@ android { } dependencies { + testImplementation(kotlin("test")) } diff --git a/libs/encryption/base58/src/test/kotlin/com/getcode/vendor/Base58Test.kt b/libs/encryption/base58/src/test/kotlin/com/getcode/vendor/Base58Test.kt new file mode 100644 index 000000000..0009dceb4 --- /dev/null +++ b/libs/encryption/base58/src/test/kotlin/com/getcode/vendor/Base58Test.kt @@ -0,0 +1,113 @@ +package com.getcode.vendor + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class Base58Test { + + @Test + fun encodeEmpty() { + assertEquals("", Base58.encode(ByteArray(0))) + } + + @Test + fun encodeDecodeRoundtrip() { + val data = byteArrayOf(1, 2, 3, 4, 5) + val encoded = Base58.encode(data) + val decoded = Base58.decode(encoded) + assertTrue(data.contentEquals(decoded)) + } + + @Test + fun decodeEmpty() { + assertEquals(0, Base58.decode("").size) + } + + @Test + fun encodeLeadingZeros() { + // Leading zero bytes should produce leading '1' characters + val data = byteArrayOf(0, 0, 1) + val encoded = Base58.encode(data) + assertTrue(encoded.startsWith("11")) + } + + @Test + fun decodeLeadingOnes() { + // Leading '1' chars decode to leading zero bytes + val decoded = Base58.decode("11BXF") + assertEquals(0, decoded[0]) + assertEquals(0, decoded[1]) + } + + @Test + fun knownVector() { + // "Hello World" in Base58 + val data = "Hello World".toByteArray(Charsets.UTF_8) + val encoded = Base58.encode(data) + val decoded = Base58.decode(encoded) + assertEquals("Hello World", String(decoded, Charsets.UTF_8)) + } + + @Test + fun decodeInvalidCharacterThrows() { + assertFailsWith { + Base58.decode("0OIl") // '0', 'O', 'I', 'l' are not in Base58 alphabet + } + } + + @Test + fun encodeCheckedRoundtrip() { + val payload = byteArrayOf(10, 20, 30) + val encoded = Base58.encodeChecked(0, payload) + val decoded = Base58.decodeChecked(encoded) + // First byte is version + assertEquals(0, decoded[0]) + assertTrue(payload.contentEquals(decoded.copyOfRange(1, decoded.size))) + } + + @Test + fun decodeCheckedInvalidChecksumThrows() { + val encoded = Base58.encodeChecked(0, byteArrayOf(1, 2, 3)) + // Tamper with a character + val tampered = encoded.dropLast(1) + if (encoded.last() == '2') "3" else "2" + assertFailsWith { + Base58.decodeChecked(tampered) + } + } + + @Test + fun decodeCheckedTooShortThrows() { + assertFailsWith { + Base58.decodeChecked("1") // decodes to too few bytes + } + } + + @Test + fun decodeToBigInteger() { + val data = byteArrayOf(1, 0) // 256 in big-endian + val encoded = Base58.encode(data) + val bigInt = Base58.decodeToBigInteger(encoded) + assertEquals(256.toBigInteger(), bigInt) + } + + @Test + fun allZeroBytes() { + val data = ByteArray(5) + val encoded = Base58.encode(data) + assertEquals("11111", encoded) + val decoded = Base58.decode(encoded) + assertTrue(data.contentEquals(decoded)) + } + + @Test + fun singleByte() { + for (b in 0..255) { + val data = byteArrayOf(b.toByte()) + val encoded = Base58.encode(data) + val decoded = Base58.decode(encoded) + assertTrue(data.contentEquals(decoded), "Roundtrip failed for byte $b") + } + } +} diff --git a/libs/encryption/mnemonic/build.gradle.kts b/libs/encryption/mnemonic/build.gradle.kts index 59f31a567..bd2267918 100644 --- a/libs/encryption/mnemonic/build.gradle.kts +++ b/libs/encryption/mnemonic/build.gradle.kts @@ -16,4 +16,6 @@ dependencies { implementation(libs.grpc.okhttp) implementation(libs.grpc.kotlin) implementation(libs.androidx.core) + + testImplementation(kotlin("test")) } diff --git a/libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/DerivePathTest.kt b/libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/DerivePathTest.kt new file mode 100644 index 000000000..94828c20b --- /dev/null +++ b/libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/DerivePathTest.kt @@ -0,0 +1,136 @@ +package com.getcode.crypt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DerivePathTest { + + @Test + fun parsePrimaryPath() { + val path = DerivePath.newInstance("m/44'/501'/0'/0'") + assertNotNull(path) + assertEquals(4, path.indexes.size) + assertEquals(44, path.indexes[0].value) + assertTrue(path.indexes[0].hardened) + assertEquals(501, path.indexes[1].value) + assertEquals(0, path.indexes[2].value) + assertEquals(0, path.indexes[3].value) + } + + @Test + fun primaryPathMatchesPredefined() { + val parsed = DerivePath.newInstance("m/44'/501'/0'/0'") + assertEquals(DerivePath.primary, parsed) + } + + @Test + fun stringRepresentationRoundtrip() { + val original = "m/44'/501'/0'/0'" + val path = DerivePath.newInstance(original) + assertNotNull(path) + assertEquals(original, path.stringRepresentation()) + } + + @Test + fun parseNonHardenedIndexes() { + val path = DerivePath.newInstance("m/44/501/0/0") + assertNotNull(path) + assertEquals(4, path.indexes.size) + path.indexes.forEach { assertFalse(it.hardened) } + } + + @Test + fun parseMixedHardenedIndexes() { + val path = DerivePath.newInstance("m/44'/501/0'/0") + assertNotNull(path) + assertTrue(path.indexes[0].hardened) + assertFalse(path.indexes[1].hardened) + assertTrue(path.indexes[2].hardened) + assertFalse(path.indexes[3].hardened) + } + + @Test + fun invalidPathNoIdentifier() { + val path = DerivePath.newInstance("x/44'/501'/0'/0'") + assertNull(path) + } + + @Test + fun invalidPathNonNumericIndex() { + val path = DerivePath.newInstance("m/abc/501'/0'/0'") + assertNull(path) + } + + @Test + fun emptyPathAfterIdentifier() { + val path = DerivePath.newInstance("m") + assertNotNull(path) + assertEquals(0, path.indexes.size) + } + + @Test + fun bucketPaths() { + assertEquals("m/44'/501'/0'/0'/0'/1", DerivePath.bucket1.stringRepresentation()) + assertEquals("m/44'/501'/0'/0'/0'/10", DerivePath.bucket10.stringRepresentation()) + assertEquals("m/44'/501'/0'/0'/0'/100", DerivePath.bucket100.stringRepresentation()) + assertEquals("m/44'/501'/0'/0'/0'/1000", DerivePath.bucket1k.stringRepresentation()) + assertEquals("m/44'/501'/0'/0'/0'/10000", DerivePath.bucket10k.stringRepresentation()) + assertEquals("m/44'/501'/0'/0'/0'/100000", DerivePath.bucket100k.stringRepresentation()) + assertEquals("m/44'/501'/0'/0'/0'/1000000", DerivePath.bucket1m.stringRepresentation()) + } + + @Test + fun swapPath() { + assertEquals("m/44'/501'/0'/0'/1'/0", DerivePath.swap.stringRepresentation()) + } + + @Test + fun getBucketIncoming() { + val path = DerivePath.getBucketIncoming(5) + assertEquals("m/44'/501'/0'/0'/5'/2", path.stringRepresentation()) + } + + @Test + fun getBucketOutgoing() { + val path = DerivePath.getBucketOutgoing(5) + assertEquals("m/44'/501'/0'/0'/5'/3", path.stringRepresentation()) + } + + @Test + fun getPool() { + val path = DerivePath.getPool(42) + assertEquals("m/44'/501'/0'/0'/7665'/42'", path.stringRepresentation()) + } + + @Test + fun getPoolRendezvous() { + val path = DerivePath.getPoolRendezvous(7) + assertEquals("m/44'/501'/0'/0'/2335'/7'", path.stringRepresentation()) + } + + @Test + fun equalityByIndexes() { + val a = DerivePath.newInstance("m/44'/501'/0'/0'") + val b = DerivePath.newInstance("m/44'/501'/0'/0'") + assertEquals(a, b) + } + + @Test + fun passwordPreserved() { + val path = DerivePath.newInstance("m/44'/501'/0'/0'/0'/0", password = "example.com") + assertNotNull(path) + assertEquals("example.com", path.password) + } + + @Test + fun passwordNotAffectEquality() { + val a = DerivePath.newInstance("m/44'/501'/0'/0'", password = "a") + val b = DerivePath.newInstance("m/44'/501'/0'/0'", password = "b") + assertEquals(a, b) + } + + private fun assertFalse(value: Boolean) = kotlin.test.assertFalse(value) +} diff --git a/libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/MnemonicPhraseTest.kt b/libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/MnemonicPhraseTest.kt new file mode 100644 index 000000000..813f5e8f8 --- /dev/null +++ b/libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/MnemonicPhraseTest.kt @@ -0,0 +1,53 @@ +package com.getcode.crypt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class MnemonicPhraseTest { + + @Test + fun newInstanceWith12Words() { + val words = List(12) { "word$it" } + val phrase = MnemonicPhrase.newInstance(words) + assertNotNull(phrase) + assertEquals(MnemonicPhrase.Kind.L12, phrase.kind) + assertEquals(words, phrase.words) + } + + @Test + fun newInstanceWith24Words() { + val words = List(24) { "word$it" } + val phrase = MnemonicPhrase.newInstance(words) + assertNotNull(phrase) + assertEquals(MnemonicPhrase.Kind.L24, phrase.kind) + assertEquals(words, phrase.words) + } + + @Test + fun newInstanceWithInvalidCountReturnsNull() { + assertNull(MnemonicPhrase.newInstance(emptyList())) + assertNull(MnemonicPhrase.newInstance(List(1) { "word" })) + assertNull(MnemonicPhrase.newInstance(List(11) { "word" })) + assertNull(MnemonicPhrase.newInstance(List(13) { "word" })) + assertNull(MnemonicPhrase.newInstance(List(23) { "word" })) + assertNull(MnemonicPhrase.newInstance(List(25) { "word" })) + } + + @Test + fun wordString() { + val words = listOf("alpha", "bravo", "charlie", "delta", "echo", "foxtrot", + "golf", "hotel", "india", "juliet", "kilo", "lima") + val phrase = MnemonicPhrase.newInstance(words) + assertNotNull(phrase) + assertEquals("alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima", phrase.wordString) + } + + @Test + fun kindEnum() { + assertEquals(2, MnemonicPhrase.Kind.entries.size) + assertNotNull(MnemonicPhrase.Kind.valueOf("L12")) + assertNotNull(MnemonicPhrase.Kind.valueOf("L24")) + } +} diff --git a/libs/encryption/utils/build.gradle.kts b/libs/encryption/utils/build.gradle.kts index 437d6a41d..2bf077557 100644 --- a/libs/encryption/utils/build.gradle.kts +++ b/libs/encryption/utils/build.gradle.kts @@ -11,4 +11,6 @@ dependencies { implementation(libs.protobuf.kotlin.lite) implementation(project(":libs:encryption:ed25519")) implementation(libs.kotlinx.serialization.json) + + testImplementation(kotlin("test")) } diff --git a/libs/encryption/utils/src/test/kotlin/com/getcode/utils/ExtensionsTest.kt b/libs/encryption/utils/src/test/kotlin/com/getcode/utils/ExtensionsTest.kt new file mode 100644 index 000000000..77e187f71 --- /dev/null +++ b/libs/encryption/utils/src/test/kotlin/com/getcode/utils/ExtensionsTest.kt @@ -0,0 +1,190 @@ +package com.getcode.utils + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ExtensionsTest { + + // --- Int.intToByteArray / Int.bytes --- + + @Test + fun intToByteArrayZero() { + val bytes = 0.intToByteArray() + assertEquals(4, bytes.size) + bytes.forEach { assertEquals(0, it.toInt()) } + } + + @Test + fun intToByteArrayOne() { + // Little-endian: 1 in first byte + val bytes = 1.intToByteArray() + assertEquals(1, bytes[0]) + assertEquals(0, bytes[1]) + assertEquals(0, bytes[2]) + assertEquals(0, bytes[3]) + } + + @Test + fun intToByteArray256() { + // 256 = 0x00000100 little-endian → [0, 1, 0, 0] + val bytes = 256.intToByteArray() + assertEquals(0, bytes[0]) + assertEquals(1, bytes[1]) + assertEquals(0, bytes[2]) + assertEquals(0, bytes[3]) + } + + @Test + fun intBytesProperty() { + assertEquals(42.intToByteArray().toList(), 42.bytes) + } + + // --- Long.toByteArray / Long.bytes --- + + @Test + fun longToByteArrayZero() { + val bytes = 0L.toByteArray() + assertEquals(8, bytes.size) + bytes.forEach { assertEquals(0, it.toInt()) } + } + + @Test + fun longToByteArrayOne() { + val bytes = 1L.toByteArray() + assertEquals(1, bytes[0]) + for (i in 1..7) assertEquals(0, bytes[i]) + } + + @Test + fun longBytesProperty() { + assertEquals(123456L.toByteArray().toList(), 123456L.bytes) + } + + // --- byteArrayToLong roundtrip --- + + @Test + fun longRoundtrip() { + val value = 987654321L + assertEquals(value, value.toByteArray().byteArrayToLong()) + } + + // --- byteArrayToInt --- + + @Test + fun byteArrayToIntZero() { + assertEquals(0, byteArrayOf(0, 0, 0, 0).byteArrayToInt()) + } + + @Test + fun byteArrayToIntOne() { + assertEquals(1, byteArrayOf(1, 0, 0, 0).byteArrayToInt()) + } + + @Test + fun byteArrayToInt256() { + assertEquals(256, byteArrayOf(0, 1, 0, 0).byteArrayToInt()) + } + + // --- Int/Long roundtrip via byteArrayToInt --- + + @Test + fun intRoundtrip() { + val value = 12345 + assertEquals(value, value.intToByteArray().byteArrayToInt()) + } + + // --- toByteList --- + + @Test + fun toByteListConvertsCorrectly() { + assertEquals(listOf(1, 2, -1), listOf(1, 2, 255).toByteList()) + } + + // --- subByteArray --- + + @Test + fun subByteArrayExtractsRange() { + val arr = byteArrayOf(10, 20, 30, 40, 50) + val sub = arr.subByteArray(1, 3) + assertEquals(3, sub.size) + assertEquals(20, sub[0]) + assertEquals(30, sub[1]) + assertEquals(40, sub[2]) + } + + // --- Byte shl --- + + @Test + fun byteShl() { + val b: Byte = 1 + assertEquals(4.toByte(), b shl 2) + } + + // --- String.urlEncode / urlDecode --- + + @Test + fun urlEncodeDecodeRoundtrip() { + val original = "hello world & foo=bar" + assertEquals(original, original.urlEncode().urlDecode()) + } + + @Test + fun urlEncodeSpaces() { + assertEquals("hello+world", "hello world".urlEncode()) + } + + // --- String.replaceParam --- + + @Test + fun replaceParamSingle() { + assertEquals("Hello Alice", "Hello %1\$s".replaceParam("Alice")) + } + + @Test + fun replaceParamMultiple() { + assertEquals("Hello Alice and Bob", "Hello %1\$s and %2\$s".replaceParam("Alice", "Bob")) + } + + @Test + fun replaceParamNullUsesEmpty() { + assertEquals("Hello ", "Hello %1\$s".replaceParam(null)) + } + + @Test + fun replaceParamWithIndex() { + assertEquals("Hello World", "Hello %1\$s".replaceParam(0, "World")) + } + + // --- hexEncodedString --- + + @Test + fun hexEncodedStringLowercase() { + assertEquals("0a1bff", listOf(0x0a, 0x1b, -1).hexEncodedString()) + } + + @Test + fun hexEncodedStringUppercase() { + assertEquals("0A1BFF", listOf(0x0a, 0x1b, -1).hexEncodedString(setOf(HexEncodingOptions.Uppercase))) + } + + @Test + fun hexEncodedStringEmpty() { + assertEquals("", emptyList().hexEncodedString()) + } + + // --- toUTF8Bytes --- + + @Test + fun toUTF8Bytes() { + val bytes = "abc".toUTF8Bytes() + assertEquals(3, bytes.size) + assertEquals(97, bytes[0].toInt()) // 'a' + } + + // --- longToByteArray alias --- + + @Test + fun longToByteArrayAlias() { + assertEquals(42L.toByteArray().toList(), 42L.longToByteArray().toList()) + } +} From 531002098dbc64d71b830c15c11ac13a2f09e471 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 4 Apr 2026 14:59:16 -0400 Subject: [PATCH 03/16] test(crypto/solana): add DataSlice tests Covers canConsume, consume, prefix, suffix, chunk, byteToUnsignedInt, and ByteArray.toLong. --- .../kotlin/com/getcode/utils/DataSliceTest.kt | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 libs/crypto/solana/src/test/kotlin/com/getcode/utils/DataSliceTest.kt diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/utils/DataSliceTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/utils/DataSliceTest.kt new file mode 100644 index 000000000..9dfbf5dcd --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/utils/DataSliceTest.kt @@ -0,0 +1,153 @@ +package com.getcode.utils + +import com.getcode.utils.DataSlice.byteToUnsignedInt +import com.getcode.utils.DataSlice.canConsume +import com.getcode.utils.DataSlice.chunk +import com.getcode.utils.DataSlice.consume +import com.getcode.utils.DataSlice.prefix +import com.getcode.utils.DataSlice.suffix +import com.getcode.utils.DataSlice.tail +import com.getcode.utils.DataSlice.toLong +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DataSliceTest { + + // --- canConsume --- + + @Test + fun canConsumeTrue() { + assertTrue(listOf(1, 2, 3).canConsume(3)) + } + + @Test + fun canConsumeFalse() { + assertFalse(listOf(1, 2).canConsume(3)) + } + + @Test + fun canConsumeZero() { + assertTrue(emptyList().canConsume(0)) + } + + // --- consume --- + + @Test + fun consumeSplitsCorrectly() { + val result = listOf(1, 2, 3, 4, 5).consume(3) + assertEquals(listOf(1, 2, 3), result.consumed) + assertEquals(listOf(4, 5), result.remaining) + } + + @Test + fun consumeZeroReturnsEmptyConsumed() { + val result = listOf(1, 2).consume(0) + assertEquals(emptyList(), result.consumed) + assertEquals(listOf(1, 2), result.remaining) + } + + @Test + fun consumeAll() { + val result = listOf(1, 2, 3).consume(3) + assertEquals(listOf(1, 2, 3), result.consumed) + assertEquals(emptyList(), result.remaining) + } + + // --- prefix --- + + @Test + fun prefixReturnsFirstN() { + assertEquals(listOf(1, 2), listOf(1, 2, 3, 4).prefix(2)) + } + + @Test + fun prefixOutOfBoundsReturnsEmpty() { + assertEquals(emptyList(), listOf(1, 2).prefix(5)) + } + + @Test + fun prefixNegativeReturnsEmpty() { + assertEquals(emptyList(), listOf(1, 2).prefix(-1)) + } + + // --- suffix --- + + @Test + fun suffixReturnsFromIndex() { + assertEquals(listOf(3, 4), listOf(1, 2, 3, 4).suffix(2)) + } + + @Test + fun suffixOutOfBoundsReturnsEmpty() { + assertEquals(emptyList(), listOf(1, 2).suffix(5)) + } + + // --- tail --- + + @Test + fun tailIsSameAsSuffix() { + val data = listOf(1, 2, 3, 4, 5) + assertEquals(data.suffix(2), data.tail(2)) + } + + // --- chunk --- + + @Test + fun chunkSplitsIntoEqualParts() { + val data = listOf(1, 2, 3, 4, 5, 6) + val chunks = data.chunk(2, 3) { it.toList() } + assertEquals(3, chunks?.size) + assertEquals(listOf(1, 2), chunks?.get(0)) + assertEquals(listOf(3, 4), chunks?.get(1)) + assertEquals(listOf(5, 6), chunks?.get(2)) + } + + @Test + fun chunkTooLargeReturnsNull() { + assertNull(listOf(1, 2).chunk(2, 3) { it }) + } + + // --- byteToUnsignedInt --- + + @Test + fun positiveByteUnchanged() { + assertEquals(127, (127).toByte().byteToUnsignedInt()) + } + + @Test + fun negativeByteConverted() { + assertEquals(128, (-128).toByte().byteToUnsignedInt()) + assertEquals(255, (-1).toByte().byteToUnsignedInt()) + } + + @Test + fun zeroByteUnchanged() { + assertEquals(0, (0).toByte().byteToUnsignedInt()) + } + + // --- ByteArray.toLong --- + + @Test + fun byteArrayToLongSingleByte() { + assertEquals(42L, byteArrayOf(42).toLong()) + } + + @Test + fun byteArrayToLongLittleEndian() { + // 0x0100 in little-endian = 256 + assertEquals(256L, byteArrayOf(0, 1).toLong()) + } + + @Test + fun byteArrayToLongAllZeros() { + assertEquals(0L, byteArrayOf(0, 0, 0, 0).toLong()) + } + + @Test + fun byteArrayToLongMaxUnsignedByte() { + assertEquals(255L, byteArrayOf(-1).toLong()) + } +} From 735562e3c661abd2ebf3f63af83358da49ccbd1f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 4 Apr 2026 14:59:34 -0400 Subject: [PATCH 04/16] test(opencode): add tests for Fiat, VersionedMessage, instructions, and utilities - Fiat: construction, arithmetic, comparison, rounding, currency conversion - VersionedMessageV0: encoding structure, determinism, size validation - CompiledInstruction: encode/fromList roundtrip, compile/decompile roundtrip - CurrencyCode: fractionDigits for all 8 special-cased currencies - Result extensions: filter and filterIsInstance - Double/String utils: roundTo, toLocaleAwareDoubleOrNull, padded - ProgramAddresses, SysVar, SodiumError, SwapMetadata --- .../extensions/CurrencyCodeExtensionsTest.kt | 73 +++++ .../extensions/ResultExtensionsTest.kt | 61 ++++ .../solana/programs/ProgramAddressesTest.kt | 130 ++++++++ .../internal/solana/programs/SysVarTest.kt | 25 ++ .../model/core/errors/SodiumErrorTest.kt | 69 ++++ .../opencode/model/financial/FiatTest.kt | 301 ++++++++++++++++++ .../model/transactions/SwapMetadataTest.kt | 60 ++++ .../solana/InstructionIntegrationTest.kt | 171 ++++++++++ .../opencode/solana/VersionedMessageV0Test.kt | 147 +++++++++ .../opencode/utils/DoubleExtensionsTest.kt | 74 +++++ .../opencode/utils/StringExtensionsTest.kt | 87 +++++ 11 files changed, 1198 insertions(+) create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/extensions/CurrencyCodeExtensionsTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/extensions/ResultExtensionsTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/ProgramAddressesTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/SysVarTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SodiumErrorTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/model/transactions/SwapMetadataTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/solana/VersionedMessageV0Test.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/utils/DoubleExtensionsTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/utils/StringExtensionsTest.kt diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/extensions/CurrencyCodeExtensionsTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/extensions/CurrencyCodeExtensionsTest.kt new file mode 100644 index 000000000..5c3f70501 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/extensions/CurrencyCodeExtensionsTest.kt @@ -0,0 +1,73 @@ +package com.getcode.opencode.internal.extensions + +import com.getcode.opencode.model.financial.CurrencyCode +import kotlin.test.Test +import kotlin.test.assertEquals + +class CurrencyCodeExtensionsTest { + + @Test + fun afnHasZeroFractionDigits() { + assertEquals(0, CurrencyCode.AFN.fractionDigits) + } + + @Test + fun copHasZeroFractionDigits() { + assertEquals(0, CurrencyCode.COP.fractionDigits) + } + + @Test + fun idrHasZeroFractionDigits() { + assertEquals(0, CurrencyCode.IDR.fractionDigits) + } + + @Test + fun irrHasZeroFractionDigits() { + assertEquals(0, CurrencyCode.IRR.fractionDigits) + } + + @Test + fun mgaHasZeroFractionDigits() { + assertEquals(0, CurrencyCode.MGA.fractionDigits) + } + + @Test + fun mruHasZeroFractionDigits() { + assertEquals(0, CurrencyCode.MRU.fractionDigits) + } + + @Test + fun tzsHasZeroFractionDigits() { + assertEquals(0, CurrencyCode.TZS.fractionDigits) + } + + @Test + fun uyuHasZeroFractionDigits() { + assertEquals(0, CurrencyCode.UYU.fractionDigits) + } + + @Test + fun usdHasTwoFractionDigits() { + assertEquals(2, CurrencyCode.USD.fractionDigits) + } + + @Test + fun eurHasTwoFractionDigits() { + assertEquals(2, CurrencyCode.EUR.fractionDigits) + } + + @Test + fun jpyHasZeroFractionDigits() { + assertEquals(0, CurrencyCode.JPY.fractionDigits) + } + + @Test + fun kwdHasThreeFractionDigits() { + assertEquals(3, CurrencyCode.KWD.fractionDigits) + } + + @Test + fun gbpHasTwoFractionDigits() { + assertEquals(2, CurrencyCode.GBP.fractionDigits) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/extensions/ResultExtensionsTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/extensions/ResultExtensionsTest.kt new file mode 100644 index 000000000..5bf924567 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/extensions/ResultExtensionsTest.kt @@ -0,0 +1,61 @@ +package com.getcode.opencode.internal.extensions + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ResultExtensionsTest { + + // --- filter --- + + @Test + fun filterPassingPredicateReturnsSuccess() { + val result = Result.success(42).filter { it > 0 } + assertEquals(42, result.getOrThrow()) + } + + @Test + fun filterFailingPredicateReturnsFailure() { + val result = Result.success(42).filter { it > 100 } + assertTrue(result.isFailure) + } + + @Test + fun filterOnFailurePropagatesFailure() { + val original = Result.failure(IllegalStateException("boom")) + val result = original.filter { true } + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalStateException) + } + + // --- filterIsInstance --- + + @Test + fun filterIsInstanceMatchingTypeReturnsSuccess() { + val result: Result = Result.success("hello") + val filtered = result.filterIsInstance() + assertEquals("hello", filtered.getOrThrow()) + } + + @Test + fun filterIsInstanceNonMatchingTypeReturnsFailure() { + val result: Result = Result.success(42) + val filtered = result.filterIsInstance() + assertTrue(filtered.isFailure) + } + + @Test + fun filterIsInstanceOnFailurePropagatesException() { + val original = Result.failure(IllegalArgumentException("bad")) + val filtered = original.filterIsInstance() + assertTrue(filtered.isFailure) + assertTrue(filtered.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun filterIsInstanceSubtype() { + val result: Result = Result.success(RuntimeException("test")) + val filtered = result.filterIsInstance() + assertTrue(filtered.isSuccess) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/ProgramAddressesTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/ProgramAddressesTest.kt new file mode 100644 index 000000000..a631bb450 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/ProgramAddressesTest.kt @@ -0,0 +1,130 @@ +package com.getcode.opencode.internal.solana.programs + +import com.getcode.solana.keys.LENGTH_32 +import com.getcode.solana.keys.base58 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ProgramAddressesTest { + + @Test + fun `ComputeBudgetProgram address is correct`() { + assertEquals( + "ComputeBudget111111111111111111111111111111", + ComputeBudgetProgram.address.base58() + ) + } + + @Test + fun `AssociatedTokenProgram address is correct`() { + assertEquals( + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + AssociatedTokenProgram.address.base58() + ) + } + + @Test + fun `MemoProgram address is correct`() { + assertEquals( + "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo", + MemoProgram.address.base58() + ) + } + + @Test + fun `SystemProgram address is all zeros`() { + val bytes = SystemProgram.address.bytes + assertEquals(LENGTH_32, bytes.size) + assertTrue(bytes.all { it == 0.toByte() }) + } + + @Test + fun `TimelockProgram address is correct`() { + assertEquals( + "time2Z2SCnn3qYg3ULKVtdkh8YmZ5jFdKicnA1W2YnJ", + TimelockProgram.address.base58() + ) + } + + @Test + fun `TimelockProgram legacyAddress is correct`() { + assertEquals( + "timeDBoQGL52du9K7EtrhkJSqpiFapE9dHrmDVkuZx6", + TimelockProgram.legacyAddress.base58() + ) + } + + @Test + fun `SwapValidatorProgram address is correct`() { + assertEquals( + "sWvA66HNNvgamibZe88v3NN5nQwE8tp3KitfViFjukA", + SwapValidatorProgram.address.base58() + ) + } + + @Test + fun `TokenProgram address is correct`() { + assertEquals( + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + TokenProgram.address.base58() + ) + } + + @Test + fun `CurrencyCreatorProgram address is correct`() { + assertEquals( + "ccJYP5gjZqcEHaphcxAZvkxCrnTVfYMjyhSYkpQtf8Z", + CurrencyCreatorProgram.address.base58() + ) + } + + @Test + fun `UsdfProgram address is correct`() { + assertEquals( + "usdfcP2V1bh1Lz7Y87pxR4zJd3wnVtssJ6GeSHFeZeu", + UsdfProgram.address.base58() + ) + } + + @Test + fun `VirtualMachineProgram address is correct`() { + assertEquals( + "vmZ1WUq8SxjBWcaeTCvgJRZbS84R61uniFsQy5YMRTJ", + VirtualMachineProgram.address.base58() + ) + } + + // Command enum entry counts + + @Test + fun `ComputeBudgetProgram Command has 4 entries`() { + assertEquals(4, ComputeBudgetProgram.Command.entries.size) + } + + @Test + fun `TokenProgram Command has 2 entries with correct values`() { + val entries = TokenProgram.Command.entries + assertEquals(2, entries.size) + assertEquals(3.toByte(), TokenProgram.Command.transfer.value) + assertEquals(9.toByte(), TokenProgram.Command.closeAccount.value) + } + + @Test + fun `SystemProgram Command has 12 entries`() { + assertEquals(12, SystemProgram.Command.entries.size) + } + + @Test + fun `CurrencyCreatorProgram Command has 5 entries`() { + assertEquals(5, CurrencyCreatorProgram.Command.entries.size) + } + + @Test + fun `UsdfProgram Command has 2 entries with correct values`() { + val entries = UsdfProgram.Command.entries + assertEquals(2, entries.size) + assertEquals(2.toByte(), UsdfProgram.Command.swap.value) + assertEquals(3.toByte(), UsdfProgram.Command.transfer.value) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/SysVarTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/SysVarTest.kt new file mode 100644 index 000000000..4c7c703ab --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/SysVarTest.kt @@ -0,0 +1,25 @@ +package com.getcode.opencode.internal.solana.programs + +import com.getcode.solana.keys.LENGTH_32 +import kotlin.test.Test +import kotlin.test.assertEquals + +class SysVarTest { + + @Test + fun `SysVar enum has 9 entries`() { + assertEquals(9, SysVar.entries.size) + } + + @Test + fun `SysVar rent address is a valid 32-byte PublicKey`() { + val address = SysVar.rent.address() + assertEquals(LENGTH_32, address.bytes.size) + } + + @Test + fun `SysVar clock address is a valid 32-byte PublicKey`() { + val address = SysVar.clock.address() + assertEquals(LENGTH_32, address.bytes.size) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SodiumErrorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SodiumErrorTest.kt new file mode 100644 index 000000000..e154856f3 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SodiumErrorTest.kt @@ -0,0 +1,69 @@ +package com.getcode.opencode.model.core.errors + +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame + +class SodiumErrorTest { + + @Test + fun `ConversionToCurveFailed is a Throwable`() { + val error = SodiumError.ConversionToCurveFailed() + assertIs(error) + } + + @Test + fun `SharedKeyFailed is a Throwable`() { + val error = SodiumError.SharedKeyFailed() + assertIs(error) + } + + @Test + fun `EncryptionFailed is a Throwable`() { + val error = SodiumError.EncryptionFailed() + assertIs(error) + } + + @Test + fun `DecryptionFailed is a Throwable`() { + val error = SodiumError.DecryptionFailed() + assertIs(error) + } + + @Test + fun `cause is propagated for ConversionToCurveFailed`() { + val root = RuntimeException("test") + val error = SodiumError.ConversionToCurveFailed(root) + assertSame(root, error.cause) + } + + @Test + fun `cause is propagated for SharedKeyFailed`() { + val root = RuntimeException("test") + val error = SodiumError.SharedKeyFailed(root) + assertSame(root, error.cause) + } + + @Test + fun `cause is propagated for EncryptionFailed`() { + val root = RuntimeException("test") + val error = SodiumError.EncryptionFailed(root) + assertSame(root, error.cause) + } + + @Test + fun `cause is propagated for DecryptionFailed`() { + val root = RuntimeException("test") + val error = SodiumError.DecryptionFailed(root) + assertSame(root, error.cause) + } + + @Test + fun `null cause works for all error types`() { + assertNull(SodiumError.ConversionToCurveFailed().cause) + assertNull(SodiumError.SharedKeyFailed().cause) + assertNull(SodiumError.EncryptionFailed().cause) + assertNull(SodiumError.DecryptionFailed().cause) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt new file mode 100644 index 000000000..0165a754b --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt @@ -0,0 +1,301 @@ +package com.getcode.opencode.model.financial + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FiatTest { + + // --- Construction --- + + @Test + fun constructFromQuarks() { + val fiat = Fiat(quarks = 1_000_000L) + assertEquals(1.0, fiat.decimalValue) + } + + @Test + fun constructFromDouble() { + val fiat = Fiat(1.50) + assertEquals(1_500_000L, fiat.quarks) + } + + @Test + fun constructFromInt() { + val fiat = Fiat(5) + assertEquals(5_000_000L, fiat.quarks) + } + + @Test + fun constructFromString() { + val fiat = Fiat("2.50") + assertEquals(2_500_000L, fiat.quarks) + } + + @Test + fun constructFromStringInvalid() { + // parseStringToDouble uses NumberFormat.parse which may throw various exceptions + try { + Fiat("not_a_number") + // If it doesn't throw, it parsed something — that's also a valid behavior + } catch (_: Exception) { + // Expected + } + } + + @Test + fun constructWithCurrencyCode() { + val fiat = Fiat(10.0, CurrencyCode.EUR) + assertEquals(CurrencyCode.EUR, fiat.currencyCode) + } + + // --- decimalValue --- + + @Test + fun decimalValuePrecision() { + val fiat = Fiat(quarks = 1_500_000L) + assertEquals(1.5, fiat.decimalValue) + } + + @Test + fun decimalValueSmallAmount() { + val fiat = Fiat(quarks = 1L) // 0.000001 + assertEquals(0.000001, fiat.decimalValue, 0.0000001) + } + + // --- Sign checks --- + + @Test + fun positiveValue() { + val fiat = Fiat(1.0) + assertTrue(fiat.isPositive) + assertFalse(fiat.isNegative) + } + + @Test + fun negativeValue() { + val fiat = Fiat(quarks = -1_000_000L) + assertFalse(fiat.isPositive) + assertTrue(fiat.isNegative) + } + + @Test + fun zeroIsNeitherPositiveNorNegative() { + assertFalse(Fiat.Zero.isPositive) + assertFalse(Fiat.Zero.isNegative) + } + + // --- Comparisons --- + + @Test + fun compareToEqual() { + assertEquals(0, Fiat(1.0).compareTo(Fiat(1.0))) + } + + @Test + fun compareToLess() { + assertTrue(Fiat(1.0) < Fiat(2.0)) + } + + @Test + fun compareToGreater() { + assertTrue(Fiat(3.0) > Fiat(2.0)) + } + + @Test + fun valueLessThan() { + assertTrue(Fiat(1.0).valueLessThan(Fiat(2.0))) + assertFalse(Fiat(2.0).valueLessThan(Fiat(1.0))) + } + + @Test + fun valueGreaterThan() { + assertTrue(Fiat(2.0).valueGreaterThan(Fiat(1.0))) + assertFalse(Fiat(1.0).valueGreaterThan(Fiat(2.0))) + } + + @Test + fun valueGreaterThanOrEqualTo() { + assertTrue(Fiat(2.0).valueGreaterThanOrEqualTo(Fiat(2.0))) + assertTrue(Fiat(3.0).valueGreaterThanOrEqualTo(Fiat(2.0))) + } + + @Test + fun valueLessThanOrEqualTo() { + assertTrue(Fiat(2.0).valueLessThanOrEqualTo(Fiat(2.0))) + assertTrue(Fiat(1.0).valueLessThanOrEqualTo(Fiat(2.0))) + } + + @Test + fun valueNonZero() { + assertTrue(Fiat(1.0).valueNonZero()) + assertFalse(Fiat.Zero.valueNonZero()) + } + + // --- Operator overloads --- + + @Test + fun plus() { + val result = Fiat(1.50) + Fiat(2.50) + assertEquals(4_000_000L, result.quarks) + } + + @Test + fun plusZeroReturnsOriginal() { + val original = Fiat(5.0) + val result = original + Fiat.Zero + assertTrue(result === original) // identity — short-circuit + } + + @Test + fun plusDifferentCurrencyThrows() { + assertFailsWith { + Fiat(1.0, CurrencyCode.USD) + Fiat(1.0, CurrencyCode.EUR) + } + } + + @Test + fun minus() { + val result = Fiat(5.0) - Fiat(2.0) + assertEquals(3_000_000L, result.quarks) + } + + @Test + fun minusDifferentCurrencyThrows() { + assertFailsWith { + Fiat(5.0, CurrencyCode.USD) - Fiat(1.0, CurrencyCode.EUR) + } + } + + @Test + fun timesInt() { + val result = Fiat(2.0) * 3 + assertEquals(6_000_000L, result.quarks) + } + + @Test + fun timesDouble() { + val result = Fiat(2.0) * 1.5 + assertEquals(3_000_000L, result.quarks) + } + + @Test + fun divInt() { + val result = Fiat(6.0) / 3 + assertEquals(2_000_000L, result.quarks) + } + + // --- Rounding --- + + @Test + fun roundedDefault() { + val fiat = Fiat(1.555) + val rounded = fiat.rounded() + assertEquals(1.56, rounded.decimalValue, 0.001) + } + + @Test + fun roundedCustomPlaces() { + val fiat = Fiat(1.5555) + val rounded = fiat.rounded(3) + assertEquals(1.556, rounded.decimalValue, 0.0001) + } + + // --- Collection operations --- + + @Test + fun sum() { + val result = listOf(Fiat(1.0), Fiat(2.0), Fiat(3.0)).sum() + assertEquals(6_000_000L, result.quarks) + } + + @Test + fun sumEmpty() { + val result = emptyList().sum() + assertEquals(Fiat.Zero, result) + } + + @Test + fun orZeroNull() { + val fiat: Fiat? = null + assertEquals(Fiat.Zero, fiat.orZero()) + } + + @Test + fun orZeroNonNull() { + val fiat: Fiat? = Fiat(5.0) + assertEquals(5_000_000L, fiat.orZero().quarks) + } + + // --- toFiat extension --- + + @Test + fun intToFiat() { + assertEquals(Fiat(5), 5.toFiat()) + } + + @Test + fun doubleToFiat() { + assertEquals(Fiat(5.5).quarks, 5.5.toFiat().quarks) + } + + @Test + fun longToFiat() { + assertEquals(Fiat(5L), 5L.toFiat()) + } + + // --- min / max --- + + @Test + fun minReturnsSmallerValue() { + assertEquals(Fiat(1.0), min(Fiat(1.0), Fiat(2.0))) + } + + @Test + fun maxReturnsLargerValue() { + assertEquals(Fiat(2.0), max(Fiat(1.0), Fiat(2.0))) + } + + // --- Currency conversion --- + + @Test + fun convertingTo() { + val usd = Fiat(100.0, CurrencyCode.USD) + val rate = Rate(0.85, CurrencyCode.EUR) + val eur = usd.convertingTo(rate) + assertEquals(CurrencyCode.EUR, eur.currencyCode) + assertEquals(85.0, eur.decimalValue, 0.01) + } + + @Test + fun convertingToUsdIfNeededNonUsd() { + val eur = Fiat(85.0, CurrencyCode.EUR) + val rate = Rate(0.85, CurrencyCode.EUR) // EUR/USD = 0.85 + val usd = eur.convertingToUsdIfNeeded(rate) + // Should invert rate: 85 * (1/0.85) = 100 + assertEquals(100.0, usd.decimalValue, 0.01) + } + + @Test + fun convertingToUsdIfNeededAlreadyUsd() { + val usd = Fiat(100.0, CurrencyCode.USD) + val rate = Rate(1.0, CurrencyCode.USD) + val result = usd.convertingToUsdIfNeeded(rate) + assertTrue(result === usd) // identity + } + + // --- Constants --- + + @Test + fun multiplier() { + assertEquals(1_000_000.0, Fiat.MULTIPLIER) + } + + @Test + fun zeroConstant() { + assertEquals(0L, Fiat.Zero.quarks) + assertEquals(CurrencyCode.USD, Fiat.Zero.currencyCode) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/transactions/SwapMetadataTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/transactions/SwapMetadataTest.kt new file mode 100644 index 000000000..bce230771 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/transactions/SwapMetadataTest.kt @@ -0,0 +1,60 @@ +package com.getcode.opencode.model.transactions + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SwapMetadataTest { + + @Test + fun `SwapState has 9 entries`() { + assertEquals(9, SwapState.entries.size) + } + + @Test + fun `ordinal 0 is UNKNOWN`() { + assertEquals(SwapState.UNKNOWN, SwapState.entries[0]) + } + + @Test + fun `ordinal 1 is CREATED`() { + assertEquals(SwapState.CREATED, SwapState.entries[1]) + } + + @Test + fun `ordinal 5 is FINALIZED`() { + assertEquals(SwapState.FINALIZED, SwapState.entries[5]) + } + + @Test + fun `ordinal 8 is CANCELLED`() { + assertEquals(SwapState.CANCELLED, SwapState.entries[8]) + } + + @Test + fun `ordinal values match expected order`() { + val expected = listOf( + SwapState.UNKNOWN, + SwapState.CREATED, + SwapState.FUNDING, + SwapState.FUNDED, + SwapState.SUBMITTING, + SwapState.FINALIZED, + SwapState.FAILED, + SwapState.CANCELLING, + SwapState.CANCELLED, + ) + assertEquals(expected, SwapState.entries.toList()) + } + + @Test + fun `entries getOrElse returns UNKNOWN for negative index`() { + val result = SwapState.entries.getOrElse(-1) { SwapState.UNKNOWN } + assertEquals(SwapState.UNKNOWN, result) + } + + @Test + fun `entries getOrElse returns UNKNOWN for out of bounds index`() { + val result = SwapState.entries.getOrElse(99) { SwapState.UNKNOWN } + assertEquals(SwapState.UNKNOWN, result) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt new file mode 100644 index 000000000..473330c57 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/InstructionIntegrationTest.kt @@ -0,0 +1,171 @@ +package com.getcode.opencode.solana + +import com.getcode.solana.keys.AccountMeta +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class InstructionIntegrationTest { + + private fun publicKey(seed: Int): PublicKey { + val bytes = ByteArray(32) { if (it == 0) seed.toByte() else 0 } + return PublicKey(bytes.toList()) + } + + // --- CompiledInstruction encode / fromList roundtrip --- + + @Test + fun compiledInstructionEncodeDecodeRoundtrip() { + val original = CompiledInstruction( + programIndex = 3, + accountIndexes = listOf(0, 1, 2), + data = listOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte()) + ) + + val encoded = original.encode() + val decoded = CompiledInstruction.fromList(encoded) + + assertNotNull(decoded) + assertEquals(original.programIndex, decoded.programIndex) + assertEquals(original.accountIndexes, decoded.accountIndexes) + assertEquals(original.data, decoded.data) + } + + @Test + fun compiledInstructionEmptyData() { + val original = CompiledInstruction( + programIndex = 0, + accountIndexes = listOf(1), + data = emptyList() + ) + + val encoded = original.encode() + val decoded = CompiledInstruction.fromList(encoded) + + assertNotNull(decoded) + assertEquals(0, decoded.data.size) + } + + @Test + fun compiledInstructionEmptyAccounts() { + val original = CompiledInstruction( + programIndex = 0, + accountIndexes = emptyList(), + data = listOf(0x01) + ) + + val encoded = original.encode() + val decoded = CompiledInstruction.fromList(encoded) + + assertNotNull(decoded) + assertEquals(0, decoded.accountIndexes.size) + } + + @Test + fun fromListTooShortReturnsNull() { + assertNull(CompiledInstruction.fromList(emptyList())) + assertNull(CompiledInstruction.fromList(listOf(0))) + } + + @Test + fun byteLengthMatchesEncoded() { + val instruction = CompiledInstruction( + programIndex = 2, + accountIndexes = listOf(0, 1, 3, 4), + data = listOf(0x01, 0x02, 0x03, 0x04, 0x05) + ) + assertEquals(instruction.encode().size, instruction.byteLength) + } + + // --- Instruction compile / decompile roundtrip --- + + @Test + fun compileDecompileRoundtrip() { + val program = publicKey(10) + val acc0 = publicKey(1) + val acc1 = publicKey(2) + + val instruction = Instruction( + program = program, + accounts = listOf( + AccountMeta.writable(acc0, signer = true), + AccountMeta.readonly(acc1), + ), + data = listOf(0x01, 0x02) + ) + + val messageAccounts = listOf(acc0, acc1, program) + val compiled = instruction.compile(messageAccounts) + + assertEquals(2.toByte(), compiled.programIndex) // program at index 2 + assertEquals(listOf(0, 1), compiled.accountIndexes) // acc0=0, acc1=1 + + // Decompile back + val accountMetas = listOf( + AccountMeta.writable(acc0, signer = true), + AccountMeta.readonly(acc1), + AccountMeta.readonly(program), + ) + val decompiled = compiled.decompile(accountMetas) + + assertNotNull(decompiled) + assertEquals(program, decompiled.program) + assertEquals(2, decompiled.accounts.size) + assertEquals(acc0, decompiled.accounts[0].publicKey) + assertEquals(acc1, decompiled.accounts[1].publicKey) + assertEquals(instruction.data, decompiled.data) + } + + @Test + fun decompileWithInsufficientAccountsReturnsNull() { + val compiled = CompiledInstruction( + programIndex = 5, + accountIndexes = listOf(0, 1, 2), + data = emptyList() + ) + // Need at least 4 accounts (3 account indexes + 1 program), only providing 3 + val accounts = listOf( + AccountMeta.readonly(publicKey(1)), + AccountMeta.readonly(publicKey(2)), + AccountMeta.readonly(publicKey(3)), + ) + assertNull(compiled.decompile(accounts)) + } + + // --- Instruction equality --- + + @Test + fun instructionEquality() { + val a = Instruction(publicKey(1), listOf(AccountMeta.readonly(publicKey(2))), listOf(0x01)) + val b = Instruction(publicKey(1), listOf(AccountMeta.readonly(publicKey(2))), listOf(0x01)) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + // --- Multiple instructions encode/decode --- + + @Test + fun multipleCompiledInstructionsEncodeDecodeSequentially() { + val instr1 = CompiledInstruction(0, listOf(1, 2), listOf(0x0A)) + val instr2 = CompiledInstruction(3, listOf(0), listOf(0x0B, 0x0C)) + + val encoded1 = instr1.encode() + val encoded2 = instr2.encode() + val combined = encoded1 + encoded2 + + // Decode first instruction + val decoded1 = CompiledInstruction.fromList(combined) + assertNotNull(decoded1) + assertEquals(instr1.programIndex, decoded1.programIndex) + assertEquals(instr1.data, decoded1.data) + + // Decode second instruction from remainder + val remainder = combined.drop(decoded1.byteLength) + val decoded2 = CompiledInstruction.fromList(remainder) + assertNotNull(decoded2) + assertEquals(instr2.programIndex, decoded2.programIndex) + assertEquals(instr2.data, decoded2.data) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/VersionedMessageV0Test.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/VersionedMessageV0Test.kt new file mode 100644 index 000000000..d24f8497c --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/VersionedMessageV0Test.kt @@ -0,0 +1,147 @@ +package com.getcode.opencode.solana + +import com.getcode.opencode.internal.solana.model.MessageAddressLookupTable +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class VersionedMessageV0Test { + + private fun publicKey(seed: Int): PublicKey { + val bytes = ByteArray(32) { if (it == 0) seed.toByte() else 0 } + return PublicKey(bytes.toList()) + } + + private fun hash(seed: Int): Hash { + val bytes = ByteArray(32) { if (it == 0) seed.toByte() else 0 } + return Hash(bytes.toList()) + } + + // Note: decode (newInstance) tests are excluded because trace() calls Firebase Crashlytics + // which is not initialized in JVM unit tests. + + @Test + fun encodeContainsVersionByte() { + val msg = VersionedMessageV0( + header = MessageHeader(requiredSignatures = 1, readOnlySigners = 0, readOnly = 1), + staticAccountKeys = listOf(publicKey(1)), + recentBlockhash = hash(1), + instructions = emptyList(), + addressLookupTables = emptyList(), + ) + val encoded = msg.encode() + // First byte should be version 0 + offset (0x80) + assertEquals(0x80.toByte(), encoded.first()) + } + + @Test + fun encodeHeaderFollowsVersion() { + val msg = VersionedMessageV0( + header = MessageHeader(requiredSignatures = 2, readOnlySigners = 1, readOnly = 3), + staticAccountKeys = listOf(publicKey(1)), + recentBlockhash = hash(1), + instructions = emptyList(), + addressLookupTables = emptyList(), + ) + val encoded = msg.encode() + // Header starts at byte 1 + assertEquals(2.toByte(), encoded[1]) // requiredSignatures + assertEquals(1.toByte(), encoded[2]) // readOnlySigners + assertEquals(3.toByte(), encoded[3]) // readOnly + } + + @Test + fun encodingIsDeterministic() { + val msg = VersionedMessageV0( + header = MessageHeader(requiredSignatures = 1, readOnlySigners = 0, readOnly = 1), + staticAccountKeys = listOf(publicKey(1), publicKey(2)), + recentBlockhash = hash(5), + instructions = listOf(CompiledInstruction(1, listOf(0), listOf(0xFF.toByte()))), + addressLookupTables = emptyList(), + ) + + assertEquals(msg.encode(), msg.encode()) + } + + @Test + fun encodeWithInstructionsProducesLargerOutput() { + val noInstr = VersionedMessageV0( + header = MessageHeader(requiredSignatures = 1, readOnlySigners = 0, readOnly = 0), + staticAccountKeys = listOf(publicKey(1)), + recentBlockhash = hash(1), + instructions = emptyList(), + addressLookupTables = emptyList(), + ) + val withInstr = VersionedMessageV0( + header = MessageHeader(requiredSignatures = 1, readOnlySigners = 0, readOnly = 0), + staticAccountKeys = listOf(publicKey(1)), + recentBlockhash = hash(1), + instructions = listOf(CompiledInstruction(0, listOf(0), listOf(0x01, 0x02))), + addressLookupTables = emptyList(), + ) + + assertTrue(withInstr.encode().size > noInstr.encode().size) + } + + @Test + fun encodeWithALTsProducesLargerOutput() { + val noALT = VersionedMessageV0( + header = MessageHeader(requiredSignatures = 1, readOnlySigners = 0, readOnly = 0), + staticAccountKeys = listOf(publicKey(1)), + recentBlockhash = hash(1), + instructions = emptyList(), + addressLookupTables = emptyList(), + ) + val withALT = VersionedMessageV0( + header = MessageHeader(requiredSignatures = 1, readOnlySigners = 0, readOnly = 0), + staticAccountKeys = listOf(publicKey(1)), + recentBlockhash = hash(1), + instructions = emptyList(), + addressLookupTables = listOf( + MessageAddressLookupTable(publicKey(50), listOf(0, 1), listOf(2)) + ), + ) + + assertTrue(withALT.encode().size > noALT.encode().size) + } + + @Test + fun encodeMultipleStaticKeysIncreaseSize() { + val oneKey = VersionedMessageV0( + header = MessageHeader(requiredSignatures = 1, readOnlySigners = 0, readOnly = 0), + staticAccountKeys = listOf(publicKey(1)), + recentBlockhash = hash(1), + instructions = emptyList(), + addressLookupTables = emptyList(), + ) + val threeKeys = VersionedMessageV0( + header = MessageHeader(requiredSignatures = 1, readOnlySigners = 0, readOnly = 0), + staticAccountKeys = listOf(publicKey(1), publicKey(2), publicKey(3)), + recentBlockhash = hash(1), + instructions = emptyList(), + addressLookupTables = emptyList(), + ) + + // Each additional key adds 32 bytes + assertEquals(oneKey.encode().size + 64, threeKeys.encode().size) + } + + @Test + fun description() { + val msg = VersionedMessageV0( + header = MessageHeader(requiredSignatures = 1, readOnlySigners = 0, readOnly = 0), + staticAccountKeys = listOf(publicKey(1), publicKey(2)), + recentBlockhash = hash(1), + instructions = emptyList(), + addressLookupTables = listOf( + MessageAddressLookupTable(publicKey(50), listOf(0), listOf(1)) + ), + ) + + assertTrue(msg.description.contains("V0")) + assertTrue(msg.description.contains("staticKeys: 2")) + assertTrue(msg.description.contains("lookups: 1")) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/utils/DoubleExtensionsTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/utils/DoubleExtensionsTest.kt new file mode 100644 index 000000000..c19f0748b --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/utils/DoubleExtensionsTest.kt @@ -0,0 +1,74 @@ +package com.getcode.opencode.utils + +import java.math.RoundingMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DoubleExtensionsTest { + + // --- roundTo --- + + @Test + fun roundToDefaultHalfUp() { + assertEquals(1.56, 1.555.roundTo(2)) + } + + @Test + fun roundToZeroDecimals() { + assertEquals(2.0, 1.5.roundTo(0)) + } + + @Test + fun roundToHalfDown() { + assertEquals(1.55, 1.555.roundTo(2, RoundingMode.HALF_DOWN)) + } + + @Test + fun roundToFloor() { + assertEquals(1.55, 1.559.roundTo(2, RoundingMode.FLOOR)) + } + + @Test + fun roundToCeiling() { + assertEquals(1.56, 1.551.roundTo(2, RoundingMode.CEILING)) + } + + @Test + fun roundToThreeDecimals() { + assertEquals(3.142, 3.14159.roundTo(3)) + } + + @Test + fun roundToNegativeValue() { + assertEquals(-1.56, (-1.555).roundTo(2)) + } + + // --- toByteArray --- + + @Test + fun toByteArrayLength() { + assertEquals(8, 1.0.toByteArray().size) + } + + @Test + fun toByteArrayRoundtrip() { + val value = 3.14159 + val bytes = value.toByteArray() + val longBits = java.lang.Double.doubleToLongBits(value) + + // Reconstruct from big-endian byte array + var reconstructed = 0L + for (i in 0 until 8) { + reconstructed = (reconstructed shl 8) or (bytes[i].toLong() and 0xFF) + } + + assertEquals(longBits, reconstructed) + } + + @Test + fun toByteArrayZero() { + val bytes = 0.0.toByteArray() + assertTrue(bytes.all { it == 0.toByte() }) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/utils/StringExtensionsTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/utils/StringExtensionsTest.kt new file mode 100644 index 000000000..76750d3aa --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/utils/StringExtensionsTest.kt @@ -0,0 +1,87 @@ +package com.getcode.opencode.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class StringExtensionsTest { + + // --- addLeadingZero --- + + @Test + fun addLeadingZeroAlreadyLongEnough() { + assertEquals("abc", "abc".addLeadingZero(2)) + } + + @Test + fun addLeadingZeroExactLength() { + assertEquals("abc", "abc".addLeadingZero(3)) + } + + // --- padded --- + + @Test + fun paddedShorterThanMin() { + val result = "abc".padded(6) + assertEquals(6, result.length) + assertEquals("abc ", result) + } + + @Test + fun paddedAlreadyLongEnough() { + assertEquals("abcdef", "abcdef".padded(3)) + } + + @Test + fun paddedExactLength() { + assertEquals("abc", "abc".padded(3)) + } + + // --- toLocaleAwareDoubleOrNull --- + + @Test + fun parseValidNumber() { + val result = "123.456".toLocaleAwareDoubleOrNull() + assertEquals(123.456, result!!, 0.001) + } + + @Test + fun parseIntegerString() { + val result = "42".toLocaleAwareDoubleOrNull() + assertEquals(42.0, result!!, 0.001) + } + + @Test + fun parseInvalidStringReturnsNull() { + assertNull("not_a_number".toLocaleAwareDoubleOrNull()) + } + + @Test + fun parseEmptyStringReturnsNull() { + assertNull("".toLocaleAwareDoubleOrNull()) + } + + // --- base58 --- + + @Test + fun base58EncodesString() { + val result = "hello".base58 + assert(result.isNotEmpty()) + } + + // --- base64EncodedData --- + + @Test + fun base64EncodedDataNoPaddingNeeded() { + // "abcd" = 4 bytes, 4 % 4 == 0 → no padding + val result = "abcd".base64EncodedData() + assertEquals(4, result.size) + } + + @Test + fun base64EncodedDataPaddingNeeded() { + // "abc" = 3 bytes, 3 % 4 == 3 → needs (4-3)=1 padding "=" chars → 3+4=7 + val result = "abc".base64EncodedData() + assertEquals(7, result.size) + } +} From c1b4b5db28ca1f954570327dce0d784845130aa8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 4 Apr 2026 14:59:42 -0400 Subject: [PATCH 05/16] test(services/flipcash): add error hierarchy tests Covers LoginError, RegisterError, EmailVerificationError, PhoneVerificationError, PlacePoolBetError, and GetJwtError. --- .../flipcash/services/models/ErrorsTest.kt | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt new file mode 100644 index 000000000..d9643f709 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt @@ -0,0 +1,159 @@ +package com.flipcash.services.models + +import com.getcode.utils.CodeServerError +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame + +class ErrorsTest { + + // -- LoginError -- + + @Test + fun `LoginError subtypes are Throwable`() { + assertIs(LoginError.InvalidTimestamp()) + assertIs(LoginError.Denied()) + assertIs(LoginError.Unrecognized()) + assertIs(LoginError.Other()) + } + + @Test + fun `LoginError subtypes are CodeServerError`() { + assertIs(LoginError.InvalidTimestamp()) + assertIs(LoginError.Denied()) + assertIs(LoginError.Unrecognized()) + assertIs(LoginError.Other()) + } + + @Test + fun `LoginError InvalidTimestamp has expected message`() { + assertEquals("Invalid timestamp", LoginError.InvalidTimestamp().message) + } + + @Test + fun `LoginError Other preserves cause`() { + val root = RuntimeException("something broke") + val error = LoginError.Other(root) + assertSame(root, error.cause) + assertEquals("something broke", error.message) + } + + @Test + fun `LoginError Other with null cause has null message`() { + val error = LoginError.Other(null) + assertNull(error.cause) + assertNull(error.message) + } + + // -- RegisterError -- + + @Test + fun `RegisterError InvalidSignature has expected message`() { + assertEquals("Invalid signature", RegisterError.InvalidSignature().message) + } + + @Test + fun `RegisterError subtypes are CodeServerError`() { + assertIs(RegisterError.InvalidSignature()) + assertIs(RegisterError.Denied()) + assertIs(RegisterError.Unrecognized()) + assertIs(RegisterError.Other()) + } + + @Test + fun `RegisterError Other preserves cause`() { + val root = IllegalStateException("bad state") + val error = RegisterError.Other(root) + assertSame(root, error.cause) + assertEquals("bad state", error.message) + } + + // -- EmailVerificationError -- + + @Test + fun `EmailVerificationError has expected variants`() { + assertIs(EmailVerificationError.Denied()) + assertIs(EmailVerificationError.RateLimited()) + assertIs(EmailVerificationError.InvalidEmailAddress()) + assertIs(EmailVerificationError.InvalidVerificationCode()) + assertIs(EmailVerificationError.NoVerification()) + assertIs(EmailVerificationError.Unrecognized()) + assertIs(EmailVerificationError.Other()) + } + + @Test + fun `EmailVerificationError messages are correct`() { + assertEquals("Rate limited", EmailVerificationError.RateLimited().message) + assertEquals("Invalid email address", EmailVerificationError.InvalidEmailAddress().message) + assertEquals("Invalid verification code", EmailVerificationError.InvalidVerificationCode().message) + assertEquals("No verification", EmailVerificationError.NoVerification().message) + } + + // -- PhoneVerificationError -- + + @Test + fun `PhoneVerificationError UnsupportedPhoneType has expected message`() { + assertEquals("Unsupported phone type", PhoneVerificationError.UnsupportedPhoneType().message) + } + + @Test + fun `PhoneVerificationError subtypes are CodeServerError`() { + assertIs(PhoneVerificationError.Denied()) + assertIs(PhoneVerificationError.RateLimited()) + assertIs(PhoneVerificationError.InvalidPhoneNumber()) + assertIs(PhoneVerificationError.UnsupportedPhoneType()) + assertIs(PhoneVerificationError.InvalidVerificationCode()) + assertIs(PhoneVerificationError.NoVerification()) + } + + // -- PlacePoolBetError -- + + @Test + fun `PlacePoolBetError has specific variants with expected messages`() { + assertEquals("Pool not found", PlacePoolBetError.PoolNotFound().message) + assertEquals("Pool closed", PlacePoolBetError.PoolClosed().message) + assertEquals("Bet already made", PlacePoolBetError.BetAlreadyMade().message) + assertEquals("Max bets received", PlacePoolBetError.MaxBetsReceived().message) + assertEquals("Bet outcome solidified", PlacePoolBetError.BetOutcomeSolidified().message) + } + + @Test + fun `PlacePoolBetError subtypes are CodeServerError`() { + assertIs(PlacePoolBetError.PoolNotFound()) + assertIs(PlacePoolBetError.PoolClosed()) + assertIs(PlacePoolBetError.BetAlreadyMade()) + assertIs(PlacePoolBetError.MaxBetsReceived()) + } + + // -- GetJwtError -- + + @Test + fun `GetJwtError has expected variants with correct messages`() { + assertEquals("Unsupported provider", GetJwtError.UnsupportedProvider().message) + assertEquals("Invalid api key", GetJwtError.InvalidApiKey().message) + assertEquals("Phone verification required", GetJwtError.PhoneVerificationRequired().message) + assertEquals("Email verification required", GetJwtError.EmailVerificationRequired().message) + assertEquals("Denied", GetJwtError.Denied().message) + } + + @Test + fun `GetJwtError subtypes are CodeServerError`() { + assertIs(GetJwtError.UnsupportedProvider()) + assertIs(GetJwtError.InvalidApiKey()) + assertIs(GetJwtError.PhoneVerificationRequired()) + assertIs(GetJwtError.EmailVerificationRequired()) + assertIs(GetJwtError.Denied()) + assertIs(GetJwtError.Unrecognized()) + assertIs(GetJwtError.Other()) + } + + @Test + fun `GetJwtError Other preserves cause`() { + val root = RuntimeException("jwt failure") + val error = GetJwtError.Other(root) + assertSame(root, error.cause) + assertEquals("jwt failure", error.message) + } +} From e73f42b12fb214df3998ffd9f8a1023e0a61fb3f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 4 Apr 2026 15:00:05 -0400 Subject: [PATCH 06/16] test(flipcash): add tests for core utils, tokens, deeplinks, persistence, and userflags - FormatUtils: round, format, formatWholeRoundDown, formatCurrency - Number: abbreviated() K/M/B/T suffixes - AggregationType: LTTB downsample and Bucketed aggregation types - DeeplinkError: fromCode mapping and enum coverage - TokenTypeConverters: JSON roundtrips for SocialLinks, BillCustomizations, HolderMetrics - ResolvedFlag/FieldOverride: effectiveValue and override logic --- .../app/core/money/FormatUtilsTest.kt | 77 ++++++++ .../com/flipcash/app/core/util/NumberTest.kt | 67 +++++++ .../flipcash/app/onramp/DeeplinkErrorTest.kt | 101 ++++++++++ .../shared/persistence/db/build.gradle.kts | 2 + .../converters/TokenTypeConvertersTest.kt | 130 ++++++++++++ .../app/tokens/data/AggregationTypeTest.kt | 186 ++++++++++++++++++ .../app/userflags/FieldOverrideTest.kt | 55 ++++++ .../app/userflags/ResolvedFlagTest.kt | 68 +++++++ 8 files changed, 686 insertions(+) create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/money/FormatUtilsTest.kt create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/NumberTest.kt create mode 100644 apps/flipcash/shared/onramp/deeplinks/src/test/kotlin/com/flipcash/app/onramp/DeeplinkErrorTest.kt create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/TokenTypeConvertersTest.kt create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/data/AggregationTypeTest.kt create mode 100644 apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/FieldOverrideTest.kt create mode 100644 apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/ResolvedFlagTest.kt diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/money/FormatUtilsTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/money/FormatUtilsTest.kt new file mode 100644 index 000000000..cf2e74530 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/money/FormatUtilsTest.kt @@ -0,0 +1,77 @@ +package com.flipcash.app.core.money + +import org.junit.Test +import java.util.Locale +import kotlin.test.assertEquals + +class FormatUtilsTest { + + @Test + fun `round 1_005 rounds up to 1_01`() { + // Note: 1.005 in IEEE 754 is slightly less than 1.005, + // so (1.005 * 100.0).roundToInt() == 100 → 1.0 + // This documents the actual floating-point behavior. + val result = FormatUtils.round(1.005) + assertEquals(1.0, result) + } + + @Test + fun `round 1_004 truncates to 1_0`() { + assertEquals(1.0, FormatUtils.round(1.004)) + } + + @Test + fun `round 1_999 rounds to 2_0`() { + assertEquals(2.0, FormatUtils.round(1.999)) + } + + @Test + fun `round 0_0 stays 0_0`() { + assertEquals(0.0, FormatUtils.round(0.0)) + } + + @Test + fun `format 1234_56 returns comma-separated with two decimals`() { + assertEquals("1,234.56", FormatUtils.format(1234.56)) + } + + @Test + fun `format 0_0 returns 0_00`() { + assertEquals("0.00", FormatUtils.format(0.0)) + } + + @Test + fun `formatWholeRoundDown 1234_99 returns 1,234`() { + assertEquals("1,234", FormatUtils.formatWholeRoundDown(1234.99)) + } + + @Test + fun `formatWholeRoundDown 0_1 returns 0`() { + assertEquals("0", FormatUtils.formatWholeRoundDown(0.1)) + } + + @Test + fun `formatCurrency 10_50 with US locale returns dollar format`() { + assertEquals("$10.50", FormatUtils.formatCurrency(10.50, Locale.US)) + } + + @Test + fun `1000 withCommas returns 1,000`() { + assertEquals("1,000", 1000.withCommas()) + } + + @Test + fun `1000000 withCommas returns 1,000,000`() { + assertEquals("1,000,000", 1000000.withCommas()) + } + + @Test + fun `999 withCommas returns 999`() { + assertEquals("999", 999.withCommas()) + } + + @Test + fun `0 withCommas returns 0`() { + assertEquals("0", 0.withCommas()) + } +} diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/NumberTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/NumberTest.kt new file mode 100644 index 000000000..535feb5fe --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/util/NumberTest.kt @@ -0,0 +1,67 @@ +package com.flipcash.app.core.util + +import kotlin.test.Test +import kotlin.test.assertEquals + +class NumberTest { + + @Test + fun belowThousand() { + assertEquals("999", 999.abbreviated()) + } + + @Test + fun exactlyThousand() { + assertEquals("1K", 1000.abbreviated()) + } + + @Test + fun thousandsWithDecimals() { + assertEquals("1.5K", 1500.abbreviated()) + } + + @Test + fun thousandsTrimsTrailingZeros() { + assertEquals("2K", 2000.abbreviated()) + } + + @Test + fun exactlyMillion() { + assertEquals("1M", 1_000_000.abbreviated()) + } + + @Test + fun millionsWithDecimals() { + assertEquals("2.5M", 2_500_000.abbreviated()) + } + + @Test + fun exactlyBillion() { + assertEquals("1B", 1_000_000_000.abbreviated()) + } + + @Test + fun billionsWithDecimals() { + assertEquals("1.23B", 1_230_000_000L.abbreviated()) + } + + @Test + fun exactlyTrillion() { + assertEquals("1T", 1_000_000_000_000L.abbreviated()) + } + + @Test + fun zero() { + assertEquals("0", 0.abbreviated()) + } + + @Test + fun smallNumber() { + assertEquals("42", 42.abbreviated()) + } + + @Test + fun doubleInput() { + assertEquals("1.5K", 1500.0.abbreviated()) + } +} diff --git a/apps/flipcash/shared/onramp/deeplinks/src/test/kotlin/com/flipcash/app/onramp/DeeplinkErrorTest.kt b/apps/flipcash/shared/onramp/deeplinks/src/test/kotlin/com/flipcash/app/onramp/DeeplinkErrorTest.kt new file mode 100644 index 000000000..3b05e8004 --- /dev/null +++ b/apps/flipcash/shared/onramp/deeplinks/src/test/kotlin/com/flipcash/app/onramp/DeeplinkErrorTest.kt @@ -0,0 +1,101 @@ +package com.flipcash.app.onramp + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DeeplinkErrorTest { + + @Test + fun `fromCode Long - Disconnected`() { + assertEquals(DeeplinkError.Disconnected, DeeplinkError.fromCode(4900L)) + } + + @Test + fun `fromCode Long - Unauthorized`() { + assertEquals(DeeplinkError.Unauthorized, DeeplinkError.fromCode(4100L)) + } + + @Test + fun `fromCode Long - UserRejectedRequest`() { + assertEquals(DeeplinkError.UserRejectedRequest, DeeplinkError.fromCode(4001L)) + } + + @Test + fun `fromCode Long - InvalidInput`() { + assertEquals(DeeplinkError.InvalidInput, DeeplinkError.fromCode(-32000L)) + } + + @Test + fun `fromCode Long - RequestedResourceNotAvailable`() { + assertEquals(DeeplinkError.RequestedResourceNotAvailable, DeeplinkError.fromCode(-32002L)) + } + + @Test + fun `fromCode Long - TransactionRejected`() { + assertEquals(DeeplinkError.TransactionRejected, DeeplinkError.fromCode(-32003L)) + } + + @Test + fun `fromCode Long - MethodNotFound`() { + assertEquals(DeeplinkError.MethodNotFound, DeeplinkError.fromCode(-32601L)) + } + + @Test + fun `fromCode Long - InternalError`() { + assertEquals(DeeplinkError.InternalError, DeeplinkError.fromCode(-32603L)) + } + + @Test + fun `fromCode null Long returns Unknown`() { + assertEquals(DeeplinkError.Unknown, DeeplinkError.fromCode(null as Long?)) + } + + @Test + fun `fromCode unrecognized Long returns Unknown`() { + assertEquals(DeeplinkError.Unknown, DeeplinkError.fromCode(9999L)) + } + + @Test + fun `fromCode String - UserRejectedRequest`() { + assertEquals(DeeplinkError.UserRejectedRequest, DeeplinkError.fromCode("4001")) + } + + @Test + fun `fromCode non-numeric String returns Unknown`() { + assertEquals(DeeplinkError.Unknown, DeeplinkError.fromCode("invalid")) + } + + @Test + fun `fromCode null String returns Unknown`() { + assertEquals(DeeplinkError.Unknown, DeeplinkError.fromCode(null as String?)) + } + + @Test + fun `DeeplinkOnRampError subtypes are Throwable`() { + val errors: List = listOf( + DeeplinkOnRampError.FailedToSendTransaction(message = "fail"), + DeeplinkOnRampError.WalletProvidedError( + error = DeeplinkError.Disconnected, + message = "disconnected" + ), + DeeplinkOnRampError.FailedToCreateTransaction(message = "fail"), + DeeplinkOnRampError.DecryptionError(message = "fail"), + ) + + errors.forEach { error -> + assertTrue(error is Throwable, "${error::class.simpleName} should be Throwable") + } + } + + @Test + fun `WalletProvidedError code matches error code`() { + val error = DeeplinkOnRampError.WalletProvidedError( + error = DeeplinkError.UserRejectedRequest, + message = "rejected" + ) + + assertEquals(DeeplinkError.UserRejectedRequest.code, error.code) + assertEquals(4001L, error.code) + } +} diff --git a/apps/flipcash/shared/persistence/db/build.gradle.kts b/apps/flipcash/shared/persistence/db/build.gradle.kts index 29937fe9d..de262a2e9 100644 --- a/apps/flipcash/shared/persistence/db/build.gradle.kts +++ b/apps/flipcash/shared/persistence/db/build.gradle.kts @@ -12,6 +12,8 @@ android { } dependencies { + testImplementation(kotlin("test")) + implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/TokenTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/TokenTypeConvertersTest.kt new file mode 100644 index 000000000..c5f007bbc --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/TokenTypeConvertersTest.kt @@ -0,0 +1,130 @@ +package com.flipcash.app.persistence.converters + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TokenTypeConvertersTest { + + private val converter = TokenTypeConverters() + + // region SocialLinks + + @Test + fun `fromSocialLinks and toSocialLinks roundtrip single website`() { + val original = listOf(SocialLinkSerialized.Website(url = "https://example.com")) + val serialized = converter.toSocialLinks(original) + val deserialized = converter.fromSocialLinks(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `fromSocialLinks and toSocialLinks roundtrip mixed links`() { + val original = listOf( + SocialLinkSerialized.Website(url = "https://example.com"), + SocialLinkSerialized.X(username = "handle"), + SocialLinkSerialized.Telegram(username = "tguser"), + SocialLinkSerialized.Discord(inviteCode = "abc123"), + ) + val serialized = converter.toSocialLinks(original) + val deserialized = converter.fromSocialLinks(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `fromSocialLinks returns null for null input`() { + assertNull(converter.fromSocialLinks(null)) + } + + @Test + fun `toSocialLinks returns null for null input`() { + assertNull(converter.toSocialLinks(null)) + } + + @Test + fun `fromSocialLinks and toSocialLinks roundtrip empty list`() { + val original = emptyList() + val serialized = converter.toSocialLinks(original) + val deserialized = converter.fromSocialLinks(serialized) + assertEquals(original, deserialized) + } + + // endregion + + // region BillCustomizations + + @Test + fun `fromBillCustomizations and toBillCustomizations roundtrip solid background no texture`() { + val original = BillCustomizationsSerialized( + background = BillBackgroundSerialized.Solid(colorHex = "#FF0000"), + texture = null, + icon = null, + ) + val serialized = converter.toBillCustomizations(original) + val deserialized = converter.fromBillCustomizations(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `fromBillCustomizations and toBillCustomizations roundtrip gradient with texture`() { + val original = BillCustomizationsSerialized( + background = BillBackgroundSerialized.Gradient(colors = listOf("#FF0000", "#00FF00", "#0000FF")), + texture = BillTextureSerialized(index = 2, blendMode = "overlay", strength = 0.75f), + icon = "aWNvbg==", + ) + val serialized = converter.toBillCustomizations(original) + val deserialized = converter.fromBillCustomizations(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `fromBillCustomizations returns null for null input`() { + assertNull(converter.fromBillCustomizations(null)) + } + + @Test + fun `toBillCustomizations returns null for null input`() { + assertNull(converter.toBillCustomizations(null)) + } + + // endregion + + // region HolderMetrics + + @Test + fun `fromHolderMetrics and toHolderMetrics roundtrip with deltas`() { + val original = HolderMetricsSerialized( + currentHolders = 1500L, + deltas = listOf( + HolderDeltaSerialized(range = "DAY", delta = 10L), + HolderDeltaSerialized(range = "WEEK", delta = -25L), + ), + ) + val serialized = converter.toHolderMetrics(original) + val deserialized = converter.fromHolderMetrics(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `fromHolderMetrics and toHolderMetrics roundtrip empty deltas`() { + val original = HolderMetricsSerialized( + currentHolders = 0L, + deltas = emptyList(), + ) + val serialized = converter.toHolderMetrics(original) + val deserialized = converter.fromHolderMetrics(serialized) + assertEquals(original, deserialized) + } + + @Test + fun `fromHolderMetrics returns null for null input`() { + assertNull(converter.fromHolderMetrics(null)) + } + + @Test + fun `toHolderMetrics returns null for null input`() { + assertNull(converter.toHolderMetrics(null)) + } + + // endregion +} diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/data/AggregationTypeTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/data/AggregationTypeTest.kt new file mode 100644 index 000000000..fa0e28ca4 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/data/AggregationTypeTest.kt @@ -0,0 +1,186 @@ +package com.flipcash.app.tokens.data + +import com.getcode.ui.components.charts.ChartPoint +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AggregationTypeTest { + + private fun point(x: Long, y: Double) = ChartPoint(x = x, y = y) + + // --- Bucketed: Last --- + + @Test + fun lastReturnsHighestTimestampValue() { + val points = listOf(point(1, 10.0), point(3, 30.0), point(2, 20.0)) + assertEquals(30.0, AggregationType.Last.aggregate(points)) + } + + @Test + fun lastEmptyReturnsNull() { + assertNull(AggregationType.Last.aggregate(emptyList())) + } + + // --- Bucketed: First --- + + @Test + fun firstReturnsLowestTimestampValue() { + val points = listOf(point(3, 30.0), point(1, 10.0), point(2, 20.0)) + assertEquals(10.0, AggregationType.First.aggregate(points)) + } + + @Test + fun firstEmptyReturnsNull() { + assertNull(AggregationType.First.aggregate(emptyList())) + } + + // --- Bucketed: Average --- + + @Test + fun averageComputesMean() { + val points = listOf(point(1, 10.0), point(2, 20.0), point(3, 30.0)) + assertEquals(20.0, AggregationType.Average.aggregate(points)) + } + + @Test + fun averageSinglePoint() { + assertEquals(42.0, AggregationType.Average.aggregate(listOf(point(1, 42.0)))) + } + + @Test + fun averageEmptyReturnsNaN() { + // List.average() returns NaN for empty lists + val result = AggregationType.Average.aggregate(emptyList()) + assertTrue(result != null && result.isNaN()) + } + + // --- Bucketed: Max --- + + @Test + fun maxReturnsHighestValue() { + val points = listOf(point(1, 10.0), point(2, 50.0), point(3, 30.0)) + assertEquals(50.0, AggregationType.Max.aggregate(points)) + } + + @Test + fun maxEmptyReturnsNull() { + assertNull(AggregationType.Max.aggregate(emptyList())) + } + + // --- Bucketed: Min --- + + @Test + fun minReturnsLowestValue() { + val points = listOf(point(1, 10.0), point(2, 5.0), point(3, 30.0)) + assertEquals(5.0, AggregationType.Min.aggregate(points)) + } + + @Test + fun minEmptyReturnsNull() { + assertNull(AggregationType.Min.aggregate(emptyList())) + } + + // --- LTTB Downsample --- + + @Test + fun lttbFewPointsReturnsTargetCount() { + val points = listOf(point(100, 1.0), point(200, 2.0), point(300, 3.0)) + val result = AggregationType.LargestTriangleThreeBuckets.downsample( + startTime = 0, + now = 1000, + points = points, + targetPoints = 10, + currentValue = 5.0, + ) + assertEquals(10, result.size) + } + + @Test + fun lttbLastBucketIsCurrentValue() { + val points = listOf(point(100, 1.0), point(500, 3.0)) + val result = AggregationType.LargestTriangleThreeBuckets.downsample( + startTime = 0, + now = 1000, + points = points, + targetPoints = 5, + currentValue = 99.0, + ) + assertEquals(99.0, result.last().y) + } + + @Test + fun lttbTargetLessThan3ReturnsTwoPoints() { + val points = listOf(point(100, 1.0), point(200, 2.0), point(300, 3.0)) + val result = AggregationType.LargestTriangleThreeBuckets.downsample( + startTime = 0, + now = 1000, + points = points, + targetPoints = 2, + currentValue = 5.0, + ) + assertEquals(2, result.size) + } + + @Test + fun lttbTimestampsAreMonotonicallyIncreasing() { + val points = (1..50).map { point(it.toLong() * 10, it.toDouble()) } + val result = AggregationType.LargestTriangleThreeBuckets.downsample( + startTime = 0, + now = 1000, + points = points, + targetPoints = 20, + currentValue = 100.0, + ) + for (i in 1 until result.size) { + assertTrue(result[i].x > result[i - 1].x, "Timestamps must be monotonically increasing") + } + } + + @Test + fun lttbSignificantGapProducesZeroFill() { + // Data starts at 600 out of 0..1000 — 60% gap, well above the 5% threshold + val points = listOf(point(600, 10.0), point(800, 20.0)) + val result = AggregationType.LargestTriangleThreeBuckets.downsample( + startTime = 0, + now = 1000, + points = points, + targetPoints = 10, + currentValue = 25.0, + ) + // Early buckets (before the data starts at 600) should be zero-filled + val earlyBuckets = result.filter { it.x < 600 } + assertTrue(earlyBuckets.isNotEmpty()) + earlyBuckets.forEach { assertEquals(0.0, it.y) } + } + + @Test + fun lttbSmallGapNoZeroFill() { + // Data starts at 20 out of 0..1000 — 2% gap, below the 5% threshold + val points = listOf(point(20, 10.0), point(500, 20.0), point(900, 30.0)) + val result = AggregationType.LargestTriangleThreeBuckets.downsample( + startTime = 0, + now = 1000, + points = points, + targetPoints = 10, + currentValue = 35.0, + ) + // No zero-filled buckets since the gap is small + val nonLastBuckets = result.dropLast(1) + nonLastBuckets.forEach { assertTrue(it.y > 0.0, "No zero-fill expected for small gap") } + } + + @Test + fun lttbManyPointsDownsampled() { + val points = (1..500).map { point(it.toLong() * 2, it.toDouble()) } + val result = AggregationType.LargestTriangleThreeBuckets.downsample( + startTime = 0, + now = 1001, + points = points, + targetPoints = 50, + currentValue = 999.0, + ) + assertEquals(50, result.size) + } +} diff --git a/apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/FieldOverrideTest.kt b/apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/FieldOverrideTest.kt new file mode 100644 index 000000000..a2d322a03 --- /dev/null +++ b/apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/FieldOverrideTest.kt @@ -0,0 +1,55 @@ +package com.flipcash.app.userflags + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame + +class FieldOverrideTest { + + @Test + fun `None is a singleton`() { + assertSame(FieldOverride.None, FieldOverride.None) + } + + @Test + fun `None is a FieldOverride`() { + assertIs>(FieldOverride.None) + } + + @Test + fun `Value wraps its value`() { + val override = FieldOverride.Value(42) + assertEquals(42, override.value) + } + + @Test + fun `Value wraps string`() { + val override = FieldOverride.Value("hello") + assertEquals("hello", override.value) + } + + @Test + fun `Value wraps null`() { + val override = FieldOverride.Value(null) + assertEquals(null, override.value) + } + + @Test + fun `Value is a FieldOverride`() { + val override: FieldOverride = FieldOverride.Value(10) + assertIs>(override) + } + + @Test + fun `Value equality based on wrapped value`() { + assertEquals(FieldOverride.Value(5), FieldOverride.Value(5)) + } + + @Test + fun `Value copy preserves semantics`() { + val original = FieldOverride.Value(100) + val copy = original.copy(value = 200) + assertEquals(200, copy.value) + } +} diff --git a/apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/ResolvedFlagTest.kt b/apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/ResolvedFlagTest.kt new file mode 100644 index 000000000..96a507a7d --- /dev/null +++ b/apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/ResolvedFlagTest.kt @@ -0,0 +1,68 @@ +package com.flipcash.app.userflags + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ResolvedFlagTest { + + @Test + fun `effectiveValue returns serverValue when override is None`() { + val flag = ResolvedFlag(serverValue = "server", override = FieldOverride.None) + assertEquals("server", flag.effectiveValue) + } + + @Test + fun `effectiveValue returns override value when override is Value`() { + val flag = ResolvedFlag(serverValue = "server", override = FieldOverride.Value("custom")) + assertEquals("custom", flag.effectiveValue) + } + + @Test + fun `isOverridden is false when override is None`() { + val flag = ResolvedFlag(serverValue = 42, override = FieldOverride.None) + assertFalse(flag.isOverridden) + } + + @Test + fun `isOverridden is true when override is Value`() { + val flag = ResolvedFlag(serverValue = 42, override = FieldOverride.Value(99)) + assertTrue(flag.isOverridden) + } + + @Test + fun `works with Boolean type`() { + val flag = ResolvedFlag(serverValue = false, override = FieldOverride.Value(true)) + assertEquals(true, flag.effectiveValue) + assertTrue(flag.isOverridden) + } + + @Test + fun `works with Int type`() { + val flag = ResolvedFlag(serverValue = 1, override = FieldOverride.None) + assertEquals(1, flag.effectiveValue) + assertFalse(flag.isOverridden) + } + + @Test + fun `works with nullable type and null serverValue`() { + val flag = ResolvedFlag(serverValue = null, override = FieldOverride.None) + assertEquals(null, flag.effectiveValue) + assertFalse(flag.isOverridden) + } + + @Test + fun `works with nullable type and override replaces null`() { + val flag = ResolvedFlag(serverValue = null, override = FieldOverride.Value("value")) + assertEquals("value", flag.effectiveValue) + assertTrue(flag.isOverridden) + } + + @Test + fun `works with nullable type and override sets null`() { + val flag = ResolvedFlag(serverValue = "original", override = FieldOverride.Value(null)) + assertEquals(null, flag.effectiveValue) + assertTrue(flag.isOverridden) + } +} From bde8cc8fb2dd5982022a2fca29f4ca21b9cd07bb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 4 Apr 2026 15:09:45 -0400 Subject: [PATCH 07/16] test: add Robolectric tests for Base64, serializers, and DateUtils These tests use Robolectric to handle android.util.Base64 and android.text.format.DateFormat dependencies that cannot run as pure JVM tests. - Base64ExtensionsTest: encode/decode roundtrips, URL-safe, base58 - SerializerTest: ByteList and PublicKey JSON serialization roundtrips - DateUtilsTest: date formatting and Instant conversion --- libs/datetime/build.gradle.kts | 2 + .../kotlin/com/getcode/util/DateUtilsTest.kt | 73 ++++++++++ libs/encryption/keys/build.gradle.kts | 2 + .../utils/serializer/SerializerTest.kt | 85 ++++++++++++ libs/encryption/utils/build.gradle.kts | 1 + .../com/getcode/utils/Base64ExtensionsTest.kt | 127 ++++++++++++++++++ 6 files changed, 290 insertions(+) create mode 100644 libs/datetime/src/test/kotlin/com/getcode/util/DateUtilsTest.kt create mode 100644 libs/encryption/keys/src/test/kotlin/com/getcode/utils/serializer/SerializerTest.kt create mode 100644 libs/encryption/utils/src/test/kotlin/com/getcode/utils/Base64ExtensionsTest.kt diff --git a/libs/datetime/build.gradle.kts b/libs/datetime/build.gradle.kts index f15af1576..b5b225372 100644 --- a/libs/datetime/build.gradle.kts +++ b/libs/datetime/build.gradle.kts @@ -9,4 +9,6 @@ android { dependencies { api(libs.kotlinx.datetime) + testImplementation(kotlin("test")) + testImplementation(libs.robolectric) } diff --git a/libs/datetime/src/test/kotlin/com/getcode/util/DateUtilsTest.kt b/libs/datetime/src/test/kotlin/com/getcode/util/DateUtilsTest.kt new file mode 100644 index 000000000..ca251999c --- /dev/null +++ b/libs/datetime/src/test/kotlin/com/getcode/util/DateUtilsTest.kt @@ -0,0 +1,73 @@ +package com.getcode.util + +import kotlinx.datetime.Instant +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class DateUtilsTest { + + // --- getDate --- + + @Test + fun getDateFormatsWithDefaultPattern() { + // Jan 15, 2023 at noon UTC + val millis = 1673784000000L + val result = DateUtils.getDate(millis) + assertTrue(result.contains("2023")) + } + + @Test + fun getDateFormatsYearOnly() { + val millis = 1673784000000L + val result = DateUtils.getDate(millis, "yyyy") + assertEquals("2023", result) + } + + @Test + fun getDateFormatsMonthDay() { + // March 5, 2024 00:00 UTC + val millis = 1709596800000L + val result = DateUtils.getDate(millis, "MM-dd") + // Should contain month and day + assertTrue(result.matches(Regex("\\d{2}-\\d{2}"))) + } + + @Test + fun getDateFormatsTimeOnly() { + val millis = 1673784000000L + val result = DateUtils.getDate(millis, "h:mm aa") + // Should contain time with AM/PM + assertTrue(result.contains("AM") || result.contains("PM") || + result.contains("am") || result.contains("pm"), + "Expected AM/PM time format but got: $result") + } + + // --- toInstantFromMillis --- + + @Test + fun toInstantFromMillis() { + val millis = 1673784000000L + val instant = millis.toInstantFromMillis() + assertEquals(millis, instant.toEpochMilliseconds()) + } + + @Test + fun toInstantFromMillisZero() { + val instant = 0L.toInstantFromMillis() + assertEquals(Instant.fromEpochMilliseconds(0), instant) + } + + @Test + fun toInstantFromMillisRoundtrip() { + val now = kotlin.time.Clock.System.now() + val millis = now.toEpochMilliseconds() + val instant = millis.toInstantFromMillis() + assertEquals(millis, instant.toEpochMilliseconds()) + } +} diff --git a/libs/encryption/keys/build.gradle.kts b/libs/encryption/keys/build.gradle.kts index 831c47934..9a82f54d7 100644 --- a/libs/encryption/keys/build.gradle.kts +++ b/libs/encryption/keys/build.gradle.kts @@ -17,4 +17,6 @@ dependencies { implementation(libs.kotlinx.serialization.json) testImplementation(kotlin("test")) + testImplementation(libs.robolectric) + testImplementation(libs.kotlinx.serialization.json) } diff --git a/libs/encryption/keys/src/test/kotlin/com/getcode/utils/serializer/SerializerTest.kt b/libs/encryption/keys/src/test/kotlin/com/getcode/utils/serializer/SerializerTest.kt new file mode 100644 index 000000000..66e41f937 --- /dev/null +++ b/libs/encryption/keys/src/test/kotlin/com/getcode/utils/serializer/SerializerTest.kt @@ -0,0 +1,85 @@ +package com.getcode.utils.serializer + +import com.getcode.solana.keys.PublicKey +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class SerializerTest { + + @Serializable + data class ByteListWrapper( + @Serializable(with = ByteListAsBase64Serializer::class) + val data: List + ) + + @Serializable + data class PublicKeyWrapper( + @Serializable(with = PublicKeyAsStringSerializer::class) + val key: PublicKey + ) + + // --- ByteListAsBase64Serializer --- + + @Test + fun byteListSerializerRoundtrip() { + val original = ByteListWrapper(listOf(1, 2, 3, 4, 5, -128, 127)) + val json = Json.encodeToString(original) + val decoded = Json.decodeFromString(json) + assertEquals(original.data, decoded.data) + } + + @Test + fun byteListSerializerEmptyList() { + val original = ByteListWrapper(emptyList()) + val json = Json.encodeToString(original) + val decoded = Json.decodeFromString(json) + assertEquals(original.data, decoded.data) + } + + @Test + fun byteListSerializerLargePayload() { + val data = (0..255).map { it.toByte() } + val original = ByteListWrapper(data) + val json = Json.encodeToString(original) + val decoded = Json.decodeFromString(json) + assertEquals(original.data, decoded.data) + } + + @Test + fun byteListSerializerProducesBase64String() { + val original = ByteListWrapper(listOf(0, 0, 0)) + val json = Json.encodeToString(original) + // Base64 of [0, 0, 0] is "AAAA" + assertTrue(json.contains("AAAA")) + } + + // --- PublicKeyAsStringSerializer --- + + @Test + fun publicKeySerializerRoundtrip() { + val bytes = ByteArray(32) { (it + 1).toByte() } + val key = PublicKey(bytes.toList()) + val original = PublicKeyWrapper(key) + val json = Json.encodeToString(original) + val decoded = Json.decodeFromString(json) + assertEquals(original.key, decoded.key) + } + + @Test + fun publicKeySerializerProducesBase58String() { + val bytes = ByteArray(32) { 1 } + val key = PublicKey(bytes.toList()) + val original = PublicKeyWrapper(key) + val json = Json.encodeToString(original) + // Should contain a base58-encoded string (alphanumeric, no 0OIl) + assertTrue(json.contains("\"key\"")) + } +} diff --git a/libs/encryption/utils/build.gradle.kts b/libs/encryption/utils/build.gradle.kts index 2bf077557..d366bb0cd 100644 --- a/libs/encryption/utils/build.gradle.kts +++ b/libs/encryption/utils/build.gradle.kts @@ -13,4 +13,5 @@ dependencies { implementation(libs.kotlinx.serialization.json) testImplementation(kotlin("test")) + testImplementation(libs.robolectric) } diff --git a/libs/encryption/utils/src/test/kotlin/com/getcode/utils/Base64ExtensionsTest.kt b/libs/encryption/utils/src/test/kotlin/com/getcode/utils/Base64ExtensionsTest.kt new file mode 100644 index 000000000..c882297f9 --- /dev/null +++ b/libs/encryption/utils/src/test/kotlin/com/getcode/utils/Base64ExtensionsTest.kt @@ -0,0 +1,127 @@ +package com.getcode.utils + +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class Base64ExtensionsTest { + + // --- ByteArray.base64 --- + + @Test + fun byteArrayBase64Roundtrip() { + val data = byteArrayOf(1, 2, 3, 4, 5, 127, -128) + val encoded = data.base64 + val decoded = encoded.decodeBase64() + assertTrue(data.contentEquals(decoded)) + } + + @Test + fun byteArrayBase64EmptyArray() { + val data = byteArrayOf() + val encoded = data.base64 + val decoded = encoded.decodeBase64() + assertTrue(data.contentEquals(decoded)) + } + + // --- List.base64 --- + + @Test + fun byteListBase64Roundtrip() { + val data = listOf(10, 20, 30, -1, -128) + val encoded = data.base64 + val decoded = encoded.decodeBase64().toList() + assertEquals(data, decoded) + } + + // --- encodeBase64 / decodeBase64 --- + + @Test + fun encodeBase64DefaultRoundtrip() { + val data = "Hello, World!".toByteArray() + val encoded = data.encodeBase64() + val decoded = encoded.decodeBase64() + assertTrue(data.contentEquals(decoded)) + } + + @Test + fun encodeBase64UrlSafe() { + // URL-safe base64 uses - and _ instead of + and / + val data = byteArrayOf(-1, -2, -3, -4) // bytes that produce + and / in standard base64 + val urlSafe = data.encodeBase64(urlSafe = true) + assertTrue(!urlSafe.contains('+') && !urlSafe.contains('/')) + } + + @Test + fun encodeBase64UrlSafeRoundtrip() { + val data = byteArrayOf(0, 127, -128, 63, -1) + val encoded = data.encodeBase64(urlSafe = true) + // URL-safe decode: replace back and decode + val standard = encoded.replace('-', '+').replace('_', '/') + val decoded = standard.decodeBase64() + assertTrue(data.contentEquals(decoded)) + } + + // --- encodeBase64ToArray --- + + @Test + fun encodeBase64ToArrayRoundtrip() { + val data = byteArrayOf(10, 20, 30, 40, 50) + val encodedBytes = data.encodeBase64ToArray() + val decoded = encodedBytes.decodeBase64() + assertTrue(data.contentEquals(decoded)) + } + + // --- String.decodeBase64 --- + + @Test + fun stringDecodeBase64() { + val original = "Test string for Base64" + val encoded = original.toByteArray().base64 + val decoded = encoded.decodeBase64() + assertEquals(original, String(decoded, Charsets.UTF_8)) + } + + // --- ByteArray.decodeBase64 (from ByteArray input) --- + + @Test + fun byteArrayDecodeBase64() { + val data = byteArrayOf(1, 2, 3, 4) + val encoded = data.encodeBase64ToArray() + val decoded = encoded.decodeBase64() + assertTrue(data.contentEquals(decoded)) + } + + // --- Large data roundtrip --- + + @Test + fun largeDataRoundtrip() { + val data = ByteArray(1024) { it.toByte() } + val encoded = data.base64 + val decoded = encoded.decodeBase64() + assertTrue(data.contentEquals(decoded)) + } + + // --- base58 on List and ByteArray --- + + @Test + fun byteListBase58Roundtrip() { + val data = listOf(1, 2, 3, 4, 5) + val encoded = data.base58 + val decoded = encoded.decodeBase58() + assertTrue(data.toByteArray().contentEquals(decoded)) + } + + @Test + fun byteArrayBase58Roundtrip() { + val data = byteArrayOf(10, 20, 30) + val encoded = data.base58 + val decoded = encoded.decodeBase58() + assertTrue(data.contentEquals(decoded)) + } +} From 040727a6384186738a2be307d18854f17c191426 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 4 Apr 2026 18:02:33 -0400 Subject: [PATCH 08/16] test: add ActivityFeedMessage, DeeplinkFragments, and Instant extension tests - ActivityFeedMessageTest: MessageState.from() parsing with fallback and MessageMetadata.from() JSON deserialization - DeeplinkFragmentsTest: Uri.fragments extension for deeplink parsing (Robolectric for android.net.Uri) - InstantExtensionsTest: toLocalDate, atStartOfDay, atEndOfDay, format, formatLocalized --- apps/flipcash/core/build.gradle.kts | 1 + .../app/core/feed/ActivityFeedMessageTest.kt | 101 +++++++++++++++ .../core/navigation/DeeplinkFragmentsTest.kt | 82 ++++++++++++ .../com/getcode/util/InstantExtensionsTest.kt | 119 ++++++++++++++++++ 4 files changed, 303 insertions(+) create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/feed/ActivityFeedMessageTest.kt create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkFragmentsTest.kt create mode 100644 libs/datetime/src/test/kotlin/com/getcode/util/InstantExtensionsTest.kt diff --git a/apps/flipcash/core/build.gradle.kts b/apps/flipcash/core/build.gradle.kts index 2505318b6..6acc97ec0 100644 --- a/apps/flipcash/core/build.gradle.kts +++ b/apps/flipcash/core/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) implementation(libs.androidx.browser) diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/feed/ActivityFeedMessageTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/feed/ActivityFeedMessageTest.kt new file mode 100644 index 000000000..d0221b0b1 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/feed/ActivityFeedMessageTest.kt @@ -0,0 +1,101 @@ +package com.flipcash.app.core.feed + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ActivityFeedMessageTest { + + // --- MessageState.from --- + + @Test + fun fromPending() { + assertEquals(MessageState.PENDING, MessageState.from("pending")) + } + + @Test + fun fromCompleted() { + assertEquals(MessageState.COMPLETED, MessageState.from("completed")) + } + + @Test + fun fromUnknown() { + assertEquals(MessageState.UNKNOWN, MessageState.from("unknown")) + } + + @Test + fun fromUppercase() { + assertEquals(MessageState.PENDING, MessageState.from("PENDING")) + } + + @Test + fun fromMixedCase() { + assertEquals(MessageState.COMPLETED, MessageState.from("Completed")) + } + + @Test + fun fromInvalidFallsBackToUnknown() { + assertEquals(MessageState.UNKNOWN, MessageState.from("invalid")) + } + + @Test + fun fromEmptyFallsBackToUnknown() { + assertEquals(MessageState.UNKNOWN, MessageState.from("")) + } + + // --- MessageMetadata.from --- + + @Test + fun metadataFromNullReturnsNull() { + assertNull(MessageMetadata.from(null)) + } + + @Test + fun metadataFromInvalidJsonReturnsUnknown() { + assertEquals(MessageMetadata.Unknown, MessageMetadata.from("not json")) + } + + @Test + fun metadataFromGaveCrypto() { + val json = """{"type":"com.flipcash.app.core.feed.MessageMetadata.GaveCrypto"}""" + val result = MessageMetadata.from(json) + assertEquals(MessageMetadata.GaveCrypto, result) + } + + @Test + fun metadataFromReceivedCrypto() { + val json = """{"type":"com.flipcash.app.core.feed.MessageMetadata.ReceivedCrypto"}""" + val result = MessageMetadata.from(json) + assertEquals(MessageMetadata.ReceivedCrypto, result) + } + + @Test + fun metadataFromWithdrewCrypto() { + val json = """{"type":"com.flipcash.app.core.feed.MessageMetadata.WithdrewCrypto"}""" + val result = MessageMetadata.from(json) + assertEquals(MessageMetadata.WithdrewCrypto, result) + } + + @Test + fun metadataFromDepositedCrypto() { + val json = """{"type":"com.flipcash.app.core.feed.MessageMetadata.DepositedCrypto"}""" + val result = MessageMetadata.from(json) + assertEquals(MessageMetadata.DepositedCrypto, result) + } + + @Test + fun metadataFromBoughtToken() { + val json = """{"type":"com.flipcash.app.core.feed.MessageMetadata.BoughtToken"}""" + val result = MessageMetadata.from(json) + assertEquals(MessageMetadata.BoughtToken, result) + } + + @Test + fun metadataFromSoldToken() { + val json = """{"type":"com.flipcash.app.core.feed.MessageMetadata.SoldToken"}""" + val result = MessageMetadata.from(json) + assertEquals(MessageMetadata.SoldToken, result) + } +} diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkFragmentsTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkFragmentsTest.kt new file mode 100644 index 000000000..4d1779412 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkFragmentsTest.kt @@ -0,0 +1,82 @@ +package com.flipcash.app.core.navigation + +import android.net.Uri +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class DeeplinkFragmentsTest { + + @Test + fun parsesEntropyFragment() { + val uri = Uri.parse("https://send.flipcash.com/c/#/e=abc123") + val fragments = uri.fragments + assertEquals("abc123", fragments[Key.entropy]) + } + + @Test + fun parsesPayloadFragment() { + val uri = Uri.parse("https://send.flipcash.com/c/#/p=somePayload") + val fragments = uri.fragments + assertEquals("somePayload", fragments[Key.payload]) + } + + @Test + fun parsesMultipleFragments() { + val uri = Uri.parse("https://send.flipcash.com/c/#/e=abc123/p=payload456") + val fragments = uri.fragments + assertEquals("abc123", fragments[Key.entropy]) + assertEquals("payload456", fragments[Key.payload]) + } + + @Test + fun emptyFragmentReturnsEmptyMap() { + val uri = Uri.parse("https://send.flipcash.com/c/") + val fragments = uri.fragments + assertTrue(fragments.isEmpty()) + } + + @Test + fun noMatchingKeysReturnsEmptyMap() { + val uri = Uri.parse("https://send.flipcash.com/c/#/z=unknown") + val fragments = uri.fragments + assertTrue(fragments.isEmpty()) + } + + @Test + fun parsesKeyFragment() { + val uri = Uri.parse("https://send.flipcash.com/c/#/k=myKey") + val fragments = uri.fragments + assertEquals("myKey", fragments[Key.key]) + } + + @Test + fun parsesDataFragment() { + val uri = Uri.parse("https://send.flipcash.com/c/#/d=someData") + val fragments = uri.fragments + assertEquals("someData", fragments[Key.data]) + } + + @Test + fun handlesValueWithEqualsSign() { + val uri = Uri.parse("https://send.flipcash.com/c/#/e=abc=def") + val fragments = uri.fragments + assertEquals("abc=def", fragments[Key.entropy]) + } + + @Test + fun allFourKeysPresent() { + val uri = Uri.parse("https://send.flipcash.com/c/#/e=entropy/p=payload/k=key/d=data") + val fragments = uri.fragments + assertEquals(4, fragments.size) + assertEquals("entropy", fragments[Key.entropy]) + assertEquals("payload", fragments[Key.payload]) + assertEquals("key", fragments[Key.key]) + assertEquals("data", fragments[Key.data]) + } +} diff --git a/libs/datetime/src/test/kotlin/com/getcode/util/InstantExtensionsTest.kt b/libs/datetime/src/test/kotlin/com/getcode/util/InstantExtensionsTest.kt new file mode 100644 index 000000000..739f34c2a --- /dev/null +++ b/libs/datetime/src/test/kotlin/com/getcode/util/InstantExtensionsTest.kt @@ -0,0 +1,119 @@ +package com.getcode.util + +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class InstantExtensionsTest { + + private val utc = TimeZone.UTC + + // --- toLocalDate --- + + @Test + fun toLocalDateReturnsCorrectDate() { + // 2024-03-15 12:00:00 UTC + val instant = Instant.fromEpochMilliseconds(1710504000000L) + val date = instant.toLocalDate(utc) + assertEquals(2024, date.year) + assertEquals(3, date.monthNumber) + assertEquals(15, date.dayOfMonth) + } + + @Test + fun toLocalDateAtMidnight() { + // 2024-01-01 00:00:00 UTC + val instant = Instant.fromEpochMilliseconds(1704067200000L) + val date = instant.toLocalDate(utc) + assertEquals(2024, date.year) + assertEquals(1, date.monthNumber) + assertEquals(1, date.dayOfMonth) + } + + @Test + fun toLocalDateJustBeforeMidnight() { + // 2024-01-01 23:59:59 UTC + val instant = Instant.fromEpochMilliseconds(1704153599000L) + val date = instant.toLocalDate(utc) + assertEquals(2024, date.year) + assertEquals(1, date.monthNumber) + assertEquals(1, date.dayOfMonth) + } + + // --- atStartOfDay --- + + @Test + fun atStartOfDayReturnsCorrectInstant() { + val date = LocalDate(2024, 3, 15) + val start = date.atStartOfDay(utc) + val expected = date.atStartOfDayIn(utc) + assertEquals(expected, start) + } + + // --- atEndOfDay --- + + @Test + fun atEndOfDayIsOneNanosecondBeforeNextMidnight() { + val date = LocalDate(2024, 3, 15) + val endOfDay = date.atEndOfDay(utc) + val nextDayStart = (date + DatePeriod(days = 1)).atStartOfDayIn(utc) + val diff = nextDayStart - endOfDay + assertEquals(1L, diff.inWholeNanoseconds) + } + + @Test + fun atEndOfDayIsAfterStartOfDay() { + val date = LocalDate(2024, 6, 20) + val start = date.atStartOfDay(utc) + val end = date.atEndOfDay(utc) + assertTrue(end > start) + } + + // --- format --- + + @Test + fun formatDefaultPattern() { + // 2024-03-15 12:00:00 UTC + val instant = Instant.fromEpochMilliseconds(1710504000000L) + val result = instant.format("yyyy-MM-dd", java.util.Locale.US) + assertEquals("2024-03-15", result) + } + + @Test + fun formatYearOnly() { + val instant = Instant.fromEpochMilliseconds(1710504000000L) + val result = instant.format("yyyy", java.util.Locale.US) + assertEquals("2024", result) + } + + // --- formatLocalized --- + + @Test + fun formatLocalizedUses12HourFormat() { + val instant = Instant.fromEpochMilliseconds(1710504000000L) + val result = instant.formatLocalized("h:mm a", is24Hour = false) + assertTrue(result.contains("AM") || result.contains("PM")) + } + + @Test + fun formatLocalizedUses24HourFormat() { + val instant = Instant.fromEpochMilliseconds(1710504000000L) + val result = instant.formatLocalized( + if12Hour = "h:mm a", + is24Hour = true, + if24Hour = "HH:mm" + ) + assertTrue(result.matches(Regex("\\d{1,2}:\\d{2}"))) + } +} From 972115403d9646ebc2c7091e0622e0bdb7946410 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 4 Apr 2026 22:22:25 -0400 Subject: [PATCH 09/16] test(opencode): add ShortVec, UInt16, Maps, Long, and ByteArray tests - ShortVecTest: encode/decode roundtrips, boundary values, encodeList - UInt16Test: little-endian encoding for 0, 255, 256, 65535 - MapsTest: getOrPutIfNonNull conditional insertion - LongTest: floored millisecond-to-second truncation - ByteArrayExtensionsTest: toPublicKey, toMint, toSignature, toHash --- .../extensions/ByteArrayExtensionsTest.kt | 42 ++++++ .../opencode/internal/solana/ShortVecTest.kt | 137 ++++++++++++++++++ .../internal/solana/extensions/UInt16Test.kt | 43 ++++++ .../com/getcode/opencode/utils/LongTest.kt | 42 ++++++ .../com/getcode/opencode/utils/MapsTest.kt | 43 ++++++ 5 files changed, 307 insertions(+) create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/extensions/ByteArrayExtensionsTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/extensions/UInt16Test.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/utils/LongTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/utils/MapsTest.kt diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/extensions/ByteArrayExtensionsTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/extensions/ByteArrayExtensionsTest.kt new file mode 100644 index 000000000..5895dd7ab --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/extensions/ByteArrayExtensionsTest.kt @@ -0,0 +1,42 @@ +package com.getcode.opencode.internal.extensions + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ByteArrayExtensionsTest { + + @Test + fun toPublicKeyPreservesBytes() { + val bytes = ByteArray(32) { (it + 1).toByte() } + val key = bytes.toPublicKey() + assertEquals(bytes.toList(), key.bytes) + } + + @Test + fun toMintPreservesBytes() { + val bytes = ByteArray(32) { it.toByte() } + val mint = bytes.toMint() + assertEquals(bytes.toList(), mint.bytes) + } + + @Test + fun toSignaturePreservesBytes() { + val bytes = ByteArray(64) { it.toByte() } + val signature = bytes.toSignature() + assertEquals(bytes.toList(), signature.bytes) + } + + @Test + fun toHashPreservesBytes() { + val bytes = ByteArray(32) { (it * 2).toByte() } + val hash = bytes.toHash() + assertEquals(bytes.toList(), hash.bytes) + } + + @Test + fun ubyteArrayToPublicKey() { + val ubytes = UByteArray(32) { (it + 1).toUByte() } + val key = ubytes.toPublicKey() + assertEquals(32, key.bytes.size) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt new file mode 100644 index 000000000..c9e5c652c --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/ShortVecTest.kt @@ -0,0 +1,137 @@ +package com.getcode.opencode.internal.solana + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ShortVecTest { + + // --- encodeLen / decodeLen roundtrip --- + + @Test + fun encodeLenZero() { + val encoded = ShortVec.encodeLen(0) + assertEquals(listOf(0), encoded) + } + + @Test + fun encodeLenSmallValue() { + val encoded = ShortVec.encodeLen(5) + assertEquals(listOf(5), encoded) + } + + @Test + fun encodeLenMaxSingleByte() { + val encoded = ShortVec.encodeLen(0x7f) + assertEquals(1, encoded.size) + assertEquals(0x7f.toByte(), encoded[0]) + } + + @Test + fun encodeLenTwoBytes() { + val encoded = ShortVec.encodeLen(0x80) + assertEquals(2, encoded.size) + } + + @Test + fun encodeLenLargerValue() { + val encoded = ShortVec.encodeLen(0x3FFF) + assertEquals(2, encoded.size) + } + + @Test + fun encodeLenThreeBytes() { + val encoded = ShortVec.encodeLen(0x4000) + assertEquals(3, encoded.size) + } + + @Test + fun roundtripZero() { + val encoded = ShortVec.encodeLen(0) + val (decoded, _) = ShortVec.decodeLen(encoded) + assertEquals(0, decoded) + } + + @Test + fun roundtripSmall() { + val encoded = ShortVec.encodeLen(42) + val (decoded, _) = ShortVec.decodeLen(encoded) + assertEquals(42, decoded) + } + + @Test + fun roundtripBoundary127() { + val encoded = ShortVec.encodeLen(127) + val (decoded, _) = ShortVec.decodeLen(encoded) + assertEquals(127, decoded) + } + + @Test + fun roundtripBoundary128() { + val encoded = ShortVec.encodeLen(128) + val (decoded, _) = ShortVec.decodeLen(encoded) + assertEquals(128, decoded) + } + + @Test + fun roundtripBoundary16383() { + val encoded = ShortVec.encodeLen(16383) + val (decoded, _) = ShortVec.decodeLen(encoded) + assertEquals(16383, decoded) + } + + @Test + fun roundtripBoundary16384() { + val encoded = ShortVec.encodeLen(16384) + val (decoded, _) = ShortVec.decodeLen(encoded) + assertEquals(16384, decoded) + } + + @Test + fun decodeLenReturnsTailBytes() { + val encoded = ShortVec.encodeLen(5) + val extra = listOf(0xA, 0xB, 0xC) + val input = encoded + extra + val (value, remaining) = ShortVec.decodeLen(input) + assertEquals(5, value) + assertEquals(extra, remaining) + } + + // --- encode / encodeList --- + + @Test + fun encodePrependsLength() { + val data = listOf(1, 2, 3) + val encoded = ShortVec.encode(data) + assertEquals(listOf(3, 1, 2, 3), encoded) + } + + @Test + fun encodeEmptyList() { + val encoded = ShortVec.encode(emptyList()) + assertEquals(listOf(0), encoded) + } + + @Test + fun encodeListMultipleItems() { + val items = listOf( + listOf(1, 2), + listOf(3, 4, 5) + ) + val encoded = ShortVec.encodeList(items) + // 2 items, then [1,2], then [3,4,5] + assertEquals(listOf(2, 1, 2, 3, 4, 5), encoded) + } + + @Test + fun encodeListEmpty() { + val encoded = ShortVec.encodeList(emptyList()) + assertEquals(listOf(0), encoded) + } + + @Test + fun encodeListSingleItem() { + val items = listOf(listOf(0xA, 0xB)) + val encoded = ShortVec.encodeList(items) + assertEquals(listOf(1, 0xA, 0xB), encoded) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/extensions/UInt16Test.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/extensions/UInt16Test.kt new file mode 100644 index 000000000..32fd5bd7a --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/extensions/UInt16Test.kt @@ -0,0 +1,43 @@ +package com.getcode.opencode.internal.solana.extensions + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UInt16Test { + + @Test + fun zeroEncodesCorrectly() { + assertEquals(listOf(0, 0), 0.toU16Bytes()) + } + + @Test + fun oneEncodesCorrectly() { + assertEquals(listOf(1, 0), 1.toU16Bytes()) + } + + @Test + fun maxSingleByteValue() { + assertEquals(listOf(0xFF.toByte(), 0), 255.toU16Bytes()) + } + + @Test + fun value256EncodesLittleEndian() { + assertEquals(listOf(0, 1), 256.toU16Bytes()) + } + + @Test + fun value257() { + assertEquals(listOf(1, 1), 257.toU16Bytes()) + } + + @Test + fun maxUInt16() { + assertEquals(listOf(0xFF.toByte(), 0xFF.toByte()), 65535.toU16Bytes()) + } + + @Test + fun arbitraryValue() { + // 0x1234 = 4660 → little-endian: [0x34, 0x12] + assertEquals(listOf(0x34, 0x12), 0x1234.toU16Bytes()) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/utils/LongTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/utils/LongTest.kt new file mode 100644 index 000000000..e6ea70c3d --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/utils/LongTest.kt @@ -0,0 +1,42 @@ +package com.getcode.opencode.utils + +import kotlin.test.Test +import kotlin.test.assertEquals + +class LongTest { + + @Test + fun flooredZero() { + assertEquals(0L, 0L.floored) + } + + @Test + fun flooredExactSecond() { + assertEquals(1000L, 1000L.floored) + } + + @Test + fun flooredRoundsDown() { + assertEquals(1000L, 1500L.floored) + } + + @Test + fun flooredSubSecond() { + assertEquals(0L, 500L.floored) + } + + @Test + fun flooredExactMultipleSeconds() { + assertEquals(5000L, 5000L.floored) + } + + @Test + fun flooredLargeValue() { + assertEquals(1710504000000L, 1710504000999L.floored) + } + + @Test + fun flooredJustBelowSecond() { + assertEquals(0L, 999L.floored) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/utils/MapsTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/utils/MapsTest.kt new file mode 100644 index 000000000..d29b9143a --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/utils/MapsTest.kt @@ -0,0 +1,43 @@ +package com.getcode.opencode.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MapsTest { + + @Test + fun getOrPutIfNonNullReturnsExistingValue() { + val map = mutableMapOf("a" to 1) + val result = map.getOrPutIfNonNull("a") { 99 } + assertEquals(1, result) + assertEquals(1, map["a"]) + } + + @Test + fun getOrPutIfNonNullInsertsNewValue() { + val map = mutableMapOf() + val result = map.getOrPutIfNonNull("a") { 42 } + assertEquals(42, result) + assertEquals(42, map["a"]) + } + + @Test + fun getOrPutIfNonNullSkipsNullDefault() { + val map = mutableMapOf() + val result = map.getOrPutIfNonNull("a") { null } + assertNull(result) + assertNull(map["a"]) + } + + @Test + fun getOrPutIfNonNullDoesNotCallDefaultWhenKeyExists() { + val map = mutableMapOf("a" to 1) + var called = false + map.getOrPutIfNonNull("a") { + called = true + 99 + } + assertEquals(false, called) + } +} From 131a80487f84afe5fc2f52bdfaeab88470d17130 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 4 Apr 2026 22:27:06 -0400 Subject: [PATCH 10/16] test(crypto/solana): add Message and ComputeBudgetProgram tests - MessageTest: encode/decode roundtrip, header construction from accounts, account sorting (signers before non-signers, writable before readonly), multiple instructions - ComputeBudgetProgramTest: SetComputeUnitPrice and SetComputeUnitLimit encode/decode roundtrips, encoding format validation --- .../kotlin/com/getcode/solana/MessageTest.kt | 182 ++++++++++++++++++ .../programs/ComputeBudgetProgramTest.kt | 99 ++++++++++ 2 files changed, 281 insertions(+) create mode 100644 libs/crypto/solana/src/test/kotlin/com/getcode/solana/MessageTest.kt create mode 100644 libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgramTest.kt diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/MessageTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/MessageTest.kt new file mode 100644 index 000000000..5279a25c2 --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/MessageTest.kt @@ -0,0 +1,182 @@ +package com.getcode.solana + +import com.getcode.solana.keys.AccountMeta +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class MessageTest { + + private fun testKey(seed: Int): PublicKey = + PublicKey(ByteArray(32) { seed.toByte() }.toList()) + + private fun testHash(): Hash = + Hash(ByteArray(32) { 0xAB.toByte() }.toList()) + + // --- newInstance from accounts --- + + @Test + fun constructsHeaderFromAccounts() { + val payer = AccountMeta.payer(testKey(1)) + val writable = AccountMeta.writable(testKey(2)) + val readonly = AccountMeta.readonly(testKey(3)) + val program = AccountMeta(testKey(4), isSigner = false, isWritable = false, isPayer = false, isProgram = true) + + val message = Message.newInstance( + accounts = listOf(payer, writable, readonly, program), + recentBlockhash = testHash(), + instructions = emptyList() + ) + + // payer is the only signer + assertEquals(1, message.header.requiredSignatures) + } + + @Test + fun payerIsFirstAccount() { + val payer = AccountMeta.payer(testKey(1)) + val writable = AccountMeta.writable(testKey(2)) + + val message = Message.newInstance( + accounts = listOf(writable, payer), + recentBlockhash = testHash(), + instructions = emptyList() + ) + + assertEquals(testKey(1), message.accounts.first().publicKey) + } + + // --- encode / decode roundtrip --- + + @Test + fun encodeDecodeRoundtripNoInstructions() { + val payer = AccountMeta.payer(testKey(1)) + + val original = Message.newInstance( + accounts = listOf(payer), + recentBlockhash = testHash(), + instructions = emptyList() + ) + + val encoded = original.encode() + val decoded = Message.newInstance(encoded.toList()) + + assertNotNull(decoded) + assertEquals(original.header, decoded.header) + assertEquals(original.recentBlockhash, decoded.recentBlockhash) + assertEquals(original.accounts.size, decoded.accounts.size) + assertEquals(original.instructions.size, decoded.instructions.size) + } + + @Test + fun encodeDecodeRoundtripWithInstruction() { + val payer = AccountMeta.payer(testKey(1)) + val dest = AccountMeta.writable(testKey(2)) + val program = AccountMeta(testKey(3), isSigner = false, isWritable = false, isPayer = false, isProgram = true) + + val instruction = Instruction( + program = testKey(3), + accounts = listOf(payer, dest), + data = listOf(0x01, 0x02, 0x03) + ) + + val original = Message.newInstance( + accounts = listOf(payer, dest, program), + recentBlockhash = testHash(), + instructions = listOf(instruction) + ) + + val encoded = original.encode() + val decoded = Message.newInstance(encoded.toList()) + + assertNotNull(decoded) + assertEquals(original.header, decoded.header) + assertEquals(1, decoded.instructions.size) + assertEquals(listOf(0x01, 0x02, 0x03), decoded.instructions[0].data) + } + + @Test + fun encodeDecodePreservesBlockhash() { + val hash = testHash() + val payer = AccountMeta.payer(testKey(1)) + + val original = Message.newInstance( + accounts = listOf(payer), + recentBlockhash = hash, + instructions = emptyList() + ) + + val decoded = Message.newInstance(original.encode().toList()) + assertNotNull(decoded) + assertEquals(hash, decoded.recentBlockhash) + } + + // --- account sorting --- + + @Test + fun signersBeforeNonSigners() { + val payer = AccountMeta.payer(testKey(1)) + val signer = AccountMeta.writable(testKey(2), signer = true) + val nonsigner = AccountMeta.writable(testKey(3)) + + val message = Message.newInstance( + accounts = listOf(nonsigner, signer, payer), + recentBlockhash = testHash(), + instructions = emptyList() + ) + + // First two should be signers + assert(message.accounts[0].isSigner) + assert(message.accounts[1].isSigner) + assert(!message.accounts[2].isSigner) + } + + @Test + fun writableBeforeReadonly() { + val payer = AccountMeta.payer(testKey(1)) + val readonly = AccountMeta.readonly(testKey(2)) + val writable = AccountMeta.writable(testKey(3)) + + val message = Message.newInstance( + accounts = listOf(readonly, writable, payer), + recentBlockhash = testHash(), + instructions = emptyList() + ) + + // Payer first (writable signer), then writable non-signer, then readonly + assert(message.accounts[0].isWritable) + } + + @Test + fun multipleInstructionsRoundtrip() { + val payer = AccountMeta.payer(testKey(1)) + val acc = AccountMeta.writable(testKey(2)) + val program = AccountMeta(testKey(3), isSigner = false, isWritable = false, isPayer = false, isProgram = true) + + val ix1 = Instruction( + program = testKey(3), + accounts = listOf(payer), + data = listOf(0x01) + ) + val ix2 = Instruction( + program = testKey(3), + accounts = listOf(acc), + data = listOf(0x02, 0x03) + ) + + val original = Message.newInstance( + accounts = listOf(payer, acc, program), + recentBlockhash = testHash(), + instructions = listOf(ix1, ix2) + ) + + val decoded = Message.newInstance(original.encode().toList()) + assertNotNull(decoded) + assertEquals(2, decoded.instructions.size) + assertEquals(listOf(0x01), decoded.instructions[0].data) + assertEquals(listOf(0x02, 0x03), decoded.instructions[1].data) + } +} diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgramTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgramTest.kt new file mode 100644 index 000000000..b15c27339 --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/ComputeBudgetProgramTest.kt @@ -0,0 +1,99 @@ +package com.getcode.solana.instructions.programs + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ComputeBudgetProgramTest { + + // --- SetComputeUnitPrice --- + + @Test + fun unitPriceEncodeDecodeRoundtrip() { + val original = ComputeBudgetProgram_SetComputeUnitPrice(microLamports = 50000L, bump = 0) + val instruction = original.instruction() + val decoded = ComputeBudgetProgram_SetComputeUnitPrice.newInstance(instruction) + assertEquals(50000L, decoded.microLamports) + } + + @Test + fun unitPriceZero() { + val original = ComputeBudgetProgram_SetComputeUnitPrice(microLamports = 0L, bump = 0) + val instruction = original.instruction() + val decoded = ComputeBudgetProgram_SetComputeUnitPrice.newInstance(instruction) + assertEquals(0L, decoded.microLamports) + } + + @Test + fun unitPriceLargeValue() { + val original = ComputeBudgetProgram_SetComputeUnitPrice(microLamports = 1_000_000_000L, bump = 0) + val instruction = original.instruction() + val decoded = ComputeBudgetProgram_SetComputeUnitPrice.newInstance(instruction) + assertEquals(1_000_000_000L, decoded.microLamports) + } + + @Test + fun unitPriceEncodeStartsWithCommandByte() { + val price = ComputeBudgetProgram_SetComputeUnitPrice(microLamports = 100, bump = 0) + val encoded = price.encode() + assertEquals(ComputeBudgetProgram.Command.setComputeUnitPrice.ordinal.toByte(), encoded[0]) + } + + @Test + fun unitPriceEncodeHasCorrectLength() { + val price = ComputeBudgetProgram_SetComputeUnitPrice(microLamports = 100, bump = 0) + val encoded = price.encode() + // 1 byte command + 8 bytes Long + assertEquals(9, encoded.size) + } + + // --- SetComputeUnitLimit --- + + @Test + fun unitLimitEncodeDecodeRoundtrip() { + val original = ComputeBudgetProgram_SetComputeUnitLimit(limit = 200_000, bump = 0) + val instruction = original.instruction() + val decoded = ComputeBudgetProgram_SetComputeUnitLimit.newInstance(instruction) + assertEquals(200_000, decoded.limit) + } + + @Test + fun unitLimitZero() { + val original = ComputeBudgetProgram_SetComputeUnitLimit(limit = 0, bump = 0) + val instruction = original.instruction() + val decoded = ComputeBudgetProgram_SetComputeUnitLimit.newInstance(instruction) + assertEquals(0, decoded.limit) + } + + @Test + fun unitLimitMaxValue() { + val original = ComputeBudgetProgram_SetComputeUnitLimit(limit = 1_400_000, bump = 0) + val instruction = original.instruction() + val decoded = ComputeBudgetProgram_SetComputeUnitLimit.newInstance(instruction) + assertEquals(1_400_000, decoded.limit) + } + + @Test + fun unitLimitEncodeStartsWithCommandByte() { + val limit = ComputeBudgetProgram_SetComputeUnitLimit(limit = 100, bump = 0) + val encoded = limit.encode() + assertEquals(ComputeBudgetProgram.Command.setComputeUnitLimit.ordinal.toByte(), encoded[0]) + } + + @Test + fun unitLimitEncodeHasCorrectLength() { + val limit = ComputeBudgetProgram_SetComputeUnitLimit(limit = 100, bump = 0) + val encoded = limit.encode() + // 1 byte command + 4 bytes Int + assertEquals(5, encoded.size) + } + + // --- Command enum --- + + @Test + fun commandValues() { + assertEquals(0.toByte(), ComputeBudgetProgram.Command.requestUnits.value) + assertEquals(1.toByte(), ComputeBudgetProgram.Command.requestHeapFrame.value) + assertEquals(2.toByte(), ComputeBudgetProgram.Command.setComputeUnitLimit.value) + assertEquals(3.toByte(), ComputeBudgetProgram.Command.setComputeUnitPrice.value) + } +} From 8fb4a136bfa69e52b0860a0e4821b6818e449e25 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 4 Apr 2026 22:29:56 -0400 Subject: [PATCH 11/16] test(opencode): add VirtualMachineProgram and CurrencyCreatorProgram tests - VirtualMachineProgramTest: TransferForSwap and CloseSwapAccountIfEmpty encoding, account counts, signer flags, command bytes - CurrencyCreatorProgramTest: BuyAndDepositIntoVm and SellAndDepositIntoVm encoding, account counts, U16 vmMemoryIndex encoding, command bytes --- .../programs/CurrencyCreatorProgramTest.kt | 154 ++++++++++++++++++ .../programs/VirtualMachineProgramTest.kt | 134 +++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/CurrencyCreatorProgramTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/VirtualMachineProgramTest.kt diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/CurrencyCreatorProgramTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/CurrencyCreatorProgramTest.kt new file mode 100644 index 000000000..627dab1a8 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/CurrencyCreatorProgramTest.kt @@ -0,0 +1,154 @@ +package com.getcode.opencode.internal.solana.programs + +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals + +class CurrencyCreatorProgramTest { + + private fun testKey(seed: Int): PublicKey = + PublicKey(ByteArray(32) { seed.toByte() }.toList()) + + // --- Command enum --- + + @Test + fun commandBuyTokensValue() { + assertEquals(4.toByte(), CurrencyCreatorProgram.Command.buyTokens.value) + } + + @Test + fun commandSellTokensValue() { + assertEquals(5.toByte(), CurrencyCreatorProgram.Command.sellTokens.value) + } + + @Test + fun commandBuyAndDepositValue() { + assertEquals(6.toByte(), CurrencyCreatorProgram.Command.buyAndDepositIntoVm.value) + } + + @Test + fun commandSellAndDepositValue() { + assertEquals(7.toByte(), CurrencyCreatorProgram.Command.sellAndDepositIntoVm.value) + } + + // --- BuyAndDepositIntoVm --- + + @Test + fun buyEncodeStartsWithCommand() { + val ix = CurrencyCreatorProgram_BuyAndDepositIntoVm( + inAmount = 1000L, minOutAmount = 900L, vmMemoryIndex = 0, + buyer = testKey(1), pool = testKey(2), + targetMint = testKey(3), baseMint = testKey(4), + vaultTarget = testKey(5), vaultBase = testKey(6), + buyerBase = testKey(7), vmAuthority = testKey(8), + vm = testKey(9), vmMemory = testKey(10), + vmOmnibus = testKey(11), vtaOwner = testKey(12) + ) + assertEquals(6.toByte(), ix.encode()[0]) + } + + @Test + fun buyEncodeLength() { + val ix = CurrencyCreatorProgram_BuyAndDepositIntoVm( + inAmount = 1000L, minOutAmount = 900L, vmMemoryIndex = 0, + buyer = testKey(1), pool = testKey(2), + targetMint = testKey(3), baseMint = testKey(4), + vaultTarget = testKey(5), vaultBase = testKey(6), + buyerBase = testKey(7), vmAuthority = testKey(8), + vm = testKey(9), vmMemory = testKey(10), + vmOmnibus = testKey(11), vtaOwner = testKey(12) + ) + // 1 byte command + 8 bytes inAmount + 8 bytes minOutAmount + 2 bytes vmMemoryIndex = 19 + assertEquals(19, ix.encode().size) + } + + @Test + fun buyInstructionHas14Accounts() { + val ix = CurrencyCreatorProgram_BuyAndDepositIntoVm( + inAmount = 1000L, minOutAmount = 900L, vmMemoryIndex = 0, + buyer = testKey(1), pool = testKey(2), + targetMint = testKey(3), baseMint = testKey(4), + vaultTarget = testKey(5), vaultBase = testKey(6), + buyerBase = testKey(7), vmAuthority = testKey(8), + vm = testKey(9), vmMemory = testKey(10), + vmOmnibus = testKey(11), vtaOwner = testKey(12) + ) + assertEquals(14, ix.instruction().accounts.size) + } + + @Test + fun buyInstructionProgram() { + val ix = CurrencyCreatorProgram_BuyAndDepositIntoVm( + inAmount = 1000L, minOutAmount = 900L, vmMemoryIndex = 0, + buyer = testKey(1), pool = testKey(2), + targetMint = testKey(3), baseMint = testKey(4), + vaultTarget = testKey(5), vaultBase = testKey(6), + buyerBase = testKey(7), vmAuthority = testKey(8), + vm = testKey(9), vmMemory = testKey(10), + vmOmnibus = testKey(11), vtaOwner = testKey(12) + ) + assertEquals(CurrencyCreatorProgram.address, ix.instruction().program) + } + + // --- SellAndDepositIntoVm --- + + @Test + fun sellEncodeStartsWithCommand() { + val ix = CurrencyCreatorProgram_SellAndDepositIntoVm( + inAmount = 500L, minOutAmount = 400L, vmMemoryIndex = 1, + seller = testKey(1), pool = testKey(2), + targetMint = testKey(3), baseMint = testKey(4), + vaultTarget = testKey(5), vaultBase = testKey(6), + sellerTarget = testKey(7), vmAuthority = testKey(8), + vm = testKey(9), vmMemory = testKey(10), + vmOmnibus = testKey(11), vtaOwner = testKey(12) + ) + assertEquals(7.toByte(), ix.encode()[0]) + } + + @Test + fun sellEncodeLength() { + val ix = CurrencyCreatorProgram_SellAndDepositIntoVm( + inAmount = 500L, minOutAmount = 400L, vmMemoryIndex = 1, + seller = testKey(1), pool = testKey(2), + targetMint = testKey(3), baseMint = testKey(4), + vaultTarget = testKey(5), vaultBase = testKey(6), + sellerTarget = testKey(7), vmAuthority = testKey(8), + vm = testKey(9), vmMemory = testKey(10), + vmOmnibus = testKey(11), vtaOwner = testKey(12) + ) + // 1 byte command + 8 + 8 + 2 = 19 + assertEquals(19, ix.encode().size) + } + + @Test + fun sellInstructionHas14Accounts() { + val ix = CurrencyCreatorProgram_SellAndDepositIntoVm( + inAmount = 500L, minOutAmount = 400L, vmMemoryIndex = 1, + seller = testKey(1), pool = testKey(2), + targetMint = testKey(3), baseMint = testKey(4), + vaultTarget = testKey(5), vaultBase = testKey(6), + sellerTarget = testKey(7), vmAuthority = testKey(8), + vm = testKey(9), vmMemory = testKey(10), + vmOmnibus = testKey(11), vtaOwner = testKey(12) + ) + assertEquals(14, ix.instruction().accounts.size) + } + + @Test + fun sellVmMemoryIndexEncodedAsU16() { + val ix = CurrencyCreatorProgram_SellAndDepositIntoVm( + inAmount = 0L, minOutAmount = 0L, vmMemoryIndex = 0x0102, + seller = testKey(1), pool = testKey(2), + targetMint = testKey(3), baseMint = testKey(4), + vaultTarget = testKey(5), vaultBase = testKey(6), + sellerTarget = testKey(7), vmAuthority = testKey(8), + vm = testKey(9), vmMemory = testKey(10), + vmOmnibus = testKey(11), vtaOwner = testKey(12) + ) + val encoded = ix.encode() + // Last 2 bytes should be little-endian 0x0102 → [0x02, 0x01] + assertEquals(0x02.toByte(), encoded[encoded.size - 2]) + assertEquals(0x01.toByte(), encoded[encoded.size - 1]) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/VirtualMachineProgramTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/VirtualMachineProgramTest.kt new file mode 100644 index 000000000..219b73bc7 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/programs/VirtualMachineProgramTest.kt @@ -0,0 +1,134 @@ +package com.getcode.opencode.internal.solana.programs + +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals + +class VirtualMachineProgramTest { + + private fun testKey(seed: Int): PublicKey = + PublicKey(ByteArray(32) { seed.toByte() }.toList()) + + // --- VirtualMachineProgram --- + + @Test + fun commandTransferForSwapValue() { + assertEquals(17.toByte(), VirtualMachineProgram.Command.transferForSwap.value) + } + + @Test + fun commandCloseSwapValue() { + assertEquals(19.toByte(), VirtualMachineProgram.Command.closeSwapAccountIfEmpty.value) + } + + // --- TransferForSwap --- + + @Test + fun transferForSwapEncodeStartsWithCommand() { + val ix = VirtualMachineProgram_TransferForSwap( + vmAuthority = testKey(1), vm = testKey(2), + swapper = testKey(3), swapPda = testKey(4), + swapAta = testKey(5), destination = testKey(6), + amount = 1000L, bump = 255 + ) + val encoded = ix.encode() + assertEquals(17.toByte(), encoded[0]) + } + + @Test + fun transferForSwapEncodeLength() { + val ix = VirtualMachineProgram_TransferForSwap( + vmAuthority = testKey(1), vm = testKey(2), + swapper = testKey(3), swapPda = testKey(4), + swapAta = testKey(5), destination = testKey(6), + amount = 1000L, bump = 255 + ) + // 1 byte command + 8 bytes amount + 1 byte bump = 10 + assertEquals(10, ix.encode().size) + } + + @Test + fun transferForSwapEncodesBump() { + val ix = VirtualMachineProgram_TransferForSwap( + vmAuthority = testKey(1), vm = testKey(2), + swapper = testKey(3), swapPda = testKey(4), + swapAta = testKey(5), destination = testKey(6), + amount = 0L, bump = 42 + ) + val encoded = ix.encode() + assertEquals(42.toByte(), encoded.last()) + } + + @Test + fun transferForSwapInstructionHasCorrectProgram() { + val ix = VirtualMachineProgram_TransferForSwap( + vmAuthority = testKey(1), vm = testKey(2), + swapper = testKey(3), swapPda = testKey(4), + swapAta = testKey(5), destination = testKey(6), + amount = 1000L, bump = 255 + ) + assertEquals(VirtualMachineProgram.address, ix.instruction().program) + } + + @Test + fun transferForSwapInstructionHas7Accounts() { + val ix = VirtualMachineProgram_TransferForSwap( + vmAuthority = testKey(1), vm = testKey(2), + swapper = testKey(3), swapPda = testKey(4), + swapAta = testKey(5), destination = testKey(6), + amount = 1000L, bump = 255 + ) + assertEquals(7, ix.instruction().accounts.size) + } + + @Test + fun transferForSwapSigners() { + val ix = VirtualMachineProgram_TransferForSwap( + vmAuthority = testKey(1), vm = testKey(2), + swapper = testKey(3), swapPda = testKey(4), + swapAta = testKey(5), destination = testKey(6), + amount = 1000L, bump = 255 + ) + val accounts = ix.instruction().accounts + // vmAuthority and swapper should be signers + assert(accounts[0].isSigner) { "vmAuthority should be signer" } + assert(accounts[2].isSigner) { "swapper should be signer" } + assert(!accounts[1].isSigner) { "vm should not be signer" } + } + + // --- CloseSwapAccountIfEmpty --- + + @Test + fun closeSwapEncodeStartsWithCommand() { + val ix = VirtualMachineProgram_CloseSwapAccountIfEmpty( + vmAuthority = testKey(1), vm = testKey(2), + swapper = testKey(3), swapPda = testKey(4), + swapAta = testKey(5), destination = testKey(6), + bump = 255 + ) + assertEquals(19.toByte(), ix.encode()[0]) + } + + @Test + fun closeSwapEncodeLength() { + val ix = VirtualMachineProgram_CloseSwapAccountIfEmpty( + vmAuthority = testKey(1), vm = testKey(2), + swapper = testKey(3), swapPda = testKey(4), + swapAta = testKey(5), destination = testKey(6), + bump = 255 + ) + // 1 byte command + 1 byte bump = 2 + assertEquals(2, ix.encode().size) + } + + @Test + fun closeSwapInstructionHas7Accounts() { + val ix = VirtualMachineProgram_CloseSwapAccountIfEmpty( + vmAuthority = testKey(1), vm = testKey(2), + swapper = testKey(3), swapPda = testKey(4), + swapAta = testKey(5), destination = testKey(6), + bump = 255 + ) + assertEquals(7, ix.instruction().accounts.size) + } +} From f4e939b084a2f991362bbfaed9d49e64c78c3666 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 5 Apr 2026 18:00:46 -0400 Subject: [PATCH 12/16] test: add pure-Java Ed25519 shadow for JVM unit tests The native Ed25519 JNI library crashes during JVM test execution due to System.loadLibrary in the static initializer. This shadow class, backed by net.i2p.crypto:eddsa, provides identical Ed25519 operations (key gen, sign, verify, onCurve) without native dependencies. Modules opt in via srcDir pointing to testing/ed25519-shadow, which compiles the shadow into test classes (highest classpath priority). --- gradle/libs.versions.toml | 1 + libs/crypto/solana/build.gradle.kts | 5 + libs/encryption/mnemonic/build.gradle.kts | 5 + services/opencode/build.gradle.kts | 5 + .../com/getcode/ed25519/Ed25519.java | 232 ++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 testing/ed25519-shadow/com/getcode/ed25519/Ed25519.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9005dd001..ead306a44 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -258,6 +258,7 @@ mixpanel = { module = "com.mixpanel.android:mixpanel-android", version.ref = "mi # Crypto sodium-bindings = { module = "com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings-android", version.ref = "sodium-bindings" } +eddsa = { module = "net.i2p.crypto:eddsa", version = "0.3.0" } # Misc cloudy = { module = "com.github.skydoves:cloudy", version.ref = "cloudy" } diff --git a/libs/crypto/solana/build.gradle.kts b/libs/crypto/solana/build.gradle.kts index 71513038e..b390e47c5 100644 --- a/libs/crypto/solana/build.gradle.kts +++ b/libs/crypto/solana/build.gradle.kts @@ -6,6 +6,10 @@ plugins { android { namespace = "${Gradle.codeNamespace}.vendor.solana" + + sourceSets.getByName("test") { + java.srcDir(rootProject.file("testing/ed25519-shadow")) + } } dependencies { @@ -32,4 +36,5 @@ dependencies { ksp(libs.hilt.compiler) testImplementation(kotlin("test")) + testImplementation(libs.eddsa) } diff --git a/libs/encryption/mnemonic/build.gradle.kts b/libs/encryption/mnemonic/build.gradle.kts index bd2267918..eff807b02 100644 --- a/libs/encryption/mnemonic/build.gradle.kts +++ b/libs/encryption/mnemonic/build.gradle.kts @@ -4,6 +4,10 @@ plugins { android { namespace = "${Gradle.codeNamespace}.encryption.mnemonic" + + sourceSets.getByName("test") { + java.srcDir(rootProject.file("testing/ed25519-shadow")) + } } dependencies { @@ -18,4 +22,5 @@ dependencies { implementation(libs.androidx.core) testImplementation(kotlin("test")) + testImplementation(libs.eddsa) } diff --git a/services/opencode/build.gradle.kts b/services/opencode/build.gradle.kts index ac680f35c..72d0e50cf 100644 --- a/services/opencode/build.gradle.kts +++ b/services/opencode/build.gradle.kts @@ -8,6 +8,10 @@ plugins { android { namespace = "${Gradle.codeNamespace}.services.opencode" + sourceSets.getByName("test") { + java.srcDir(rootProject.file("testing/ed25519-shadow")) + } + defaultConfig { consumerProguardFiles("consumer-rules.pro") @@ -93,5 +97,6 @@ dependencies { implementation(libs.event.bus) testImplementation(kotlin("test")) + testImplementation(libs.eddsa) testImplementation(libs.bundles.unit.testing) } diff --git a/testing/ed25519-shadow/com/getcode/ed25519/Ed25519.java b/testing/ed25519-shadow/com/getcode/ed25519/Ed25519.java new file mode 100644 index 000000000..5d266eec1 --- /dev/null +++ b/testing/ed25519-shadow/com/getcode/ed25519/Ed25519.java @@ -0,0 +1,232 @@ +package com.getcode.ed25519; + +import android.os.Parcel; +import android.os.Parcelable; + +import net.i2p.crypto.eddsa.EdDSAEngine; +import net.i2p.crypto.eddsa.EdDSAPrivateKey; +import net.i2p.crypto.eddsa.EdDSAPublicKey; +import net.i2p.crypto.eddsa.math.Curve; +import net.i2p.crypto.eddsa.math.GroupElement; +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveSpec; +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable; +import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec; +import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec; + +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Objects; + +/** + * Pure-Java shadow of the native Ed25519 class for JVM unit tests. + * Uses net.i2p.crypto:eddsa for all cryptographic operations. + * + * Place this on the test classpath (via srcDir) so it shadows the real + * Ed25519 class that calls System.loadLibrary in its static initializer. + */ +public class Ed25519 { + + private static final EdDSANamedCurveSpec ED25519_SPEC = + EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519); + private static final Curve CURVE = ED25519_SPEC.getCurve(); + + public static ArrayList GenerateKeyPair(String seed) { + byte[] seedBytes = Base64.getDecoder().decode(seed.trim()); + EdDSAPrivateKeySpec privSpec = new EdDSAPrivateKeySpec(seedBytes, ED25519_SPEC); + EdDSAPrivateKey privKey = new EdDSAPrivateKey(privSpec); + EdDSAPublicKeySpec pubSpec = new EdDSAPublicKeySpec(privKey.getA(), ED25519_SPEC); + EdDSAPublicKey pubKey = new EdDSAPublicKey(pubSpec); + + byte[] rawPub = pubKey.getAbyte(); + // Private key format: seed(32) || publicKey(32) = 64 bytes + byte[] rawPriv = new byte[64]; + System.arraycopy(seedBytes, 0, rawPriv, 0, 32); + System.arraycopy(rawPub, 0, rawPriv, 32, 32); + + ArrayList result = new ArrayList<>(); + result.add(Base64.getEncoder().encodeToString(rawPriv)); // index 0 = private + result.add(Base64.getEncoder().encodeToString(rawPub)); // index 1 = public + return result; + } + + public static byte[] CreateSeed16() { + byte[] seed = new byte[16]; + new SecureRandom().nextBytes(seed); + return seed; + } + + public static byte[] CreateSeed32() { + byte[] seed = new byte[32]; + new SecureRandom().nextBytes(seed); + return seed; + } + + public static byte[] Signature(byte[] message, byte[] priKey, byte[] pubKey) { + try { + byte[] seed = new byte[32]; + System.arraycopy(priKey, 0, seed, 0, 32); + EdDSAPrivateKeySpec privSpec = new EdDSAPrivateKeySpec(seed, ED25519_SPEC); + EdDSAPrivateKey privKeyObj = new EdDSAPrivateKey(privSpec); + + EdDSAEngine signer = new EdDSAEngine(MessageDigest.getInstance("SHA-512")); + signer.initSign(privKeyObj); + signer.update(message); + return signer.sign(); + } catch (Exception e) { + throw new RuntimeException("Ed25519 signing failed", e); + } + } + + public static boolean Verify(byte[] sig, byte[] message, byte[] pubKey) { + try { + EdDSAPublicKeySpec pubSpec = new EdDSAPublicKeySpec(pubKey, ED25519_SPEC); + EdDSAPublicKey pubKeyObj = new EdDSAPublicKey(pubSpec); + + EdDSAEngine verifier = new EdDSAEngine(MessageDigest.getInstance("SHA-512")); + verifier.initVerify(pubKeyObj); + verifier.update(message); + return verifier.verify(sig); + } catch (Exception e) { + return false; + } + } + + public static boolean OnCurve(byte[] pubKey) { + try { + GroupElement ge = new GroupElement(CURVE, pubKey); + ge.toCached(); + return true; + } catch (Exception e) { + return false; + } + } + + // No static initializer — no System.loadLibrary calls + + public static KeyPair createKeyPair() { + byte[] bytes = createSeed32(); + return createKeyPair(bytes); + } + + public static KeyPair createKeyPair(String seed) { + List generatedKeyPair = GenerateKeyPair(seed); + return new KeyPair(generatedKeyPair.get(1), generatedKeyPair.get(0), seed); + } + + public static KeyPair createKeyPair(byte[] seed) { + String seed64 = Base64.getEncoder().encodeToString(seed); + List generatedKeyPair = GenerateKeyPair(seed64); + return new KeyPair(generatedKeyPair.get(1), generatedKeyPair.get(0), seed64); + } + + public static byte[] createSeed16() { + return CreateSeed16(); + } + + public static byte[] createSeed32() { + return CreateSeed32(); + } + + public static byte[] sign(byte[] message, KeyPair keyPair) { + return Signature( + message, + keyPair.getPrivateKeyBytes(), + keyPair.getPublicKeyBytes()); + } + + public static boolean verify(byte[] signature, byte[] message, byte[] publicKey) { + return Verify(signature, message, publicKey); + } + + public static boolean onCurve(byte[] pubKey) { + return OnCurve(pubKey); + } + + public static class KeyPair implements Parcelable { + private final String publicKey; + private final String privateKey; + private final String seed; + + public KeyPair(String publicKey, String privateKey, String seed) { + this.publicKey = publicKey; + this.privateKey = privateKey; + this.seed = seed; + } + + protected KeyPair(Parcel in) { + publicKey = in.readString(); + privateKey = in.readString(); + seed = in.readString(); + } + + public static final Creator CREATOR = new Creator<>() { + @Override + public KeyPair createFromParcel(Parcel in) { + return new KeyPair(in); + } + + @Override + public KeyPair[] newArray(int size) { + return new KeyPair[size]; + } + }; + + public String getPublicKey() { + return publicKey; + } + + public String getPrivateKey() { + return privateKey; + } + + public String getSeed() { + return seed; + } + + public boolean verify(byte[] sig, byte[] message) { + return Ed25519.Verify(sig, message, getPublicKeyBytes()); + } + + public byte[] getPrivateKeyBytes() { + if (privateKey == null) return null; + return Base64.getDecoder().decode(privateKey.trim()); + } + + public byte[] getPublicKeyBytes() { + if (publicKey == null) return null; + return Base64.getDecoder().decode(publicKey.trim()); + } + + public byte[] sign(byte[] message) { + return Ed25519.sign(message, this); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + KeyPair keyPair = (KeyPair) o; + return Objects.equals(publicKey, keyPair.publicKey) && Objects.equals(privateKey, keyPair.privateKey); + } + + @Override + public int hashCode() { + return Objects.hash(publicKey, privateKey); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(publicKey); + dest.writeString(privateKey); + dest.writeString(seed); + } + } +} From e07163fa7ae6a613ce69ad89cc92ddc8ea2e48b0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 5 Apr 2026 18:00:52 -0400 Subject: [PATCH 13/16] test: add transaction signing and PDA derivation tests Using the Ed25519 shadow, these tests cover previously-blocked crypto paths: transaction construction, signing, verification, encode/decode roundtrips, and all PDA derivation functions (associated accounts, VM accounts, deposit, timelock, swap, omnibus). --- .../getcode/solana/SolanaTransactionTest.kt | 259 ++++++++++++++++++ .../solana/extensions/PdaDerivationTest.kt | 203 ++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 libs/crypto/solana/src/test/kotlin/com/getcode/solana/SolanaTransactionTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/extensions/PdaDerivationTest.kt diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/SolanaTransactionTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/SolanaTransactionTest.kt new file mode 100644 index 000000000..e260ab540 --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/SolanaTransactionTest.kt @@ -0,0 +1,259 @@ +package com.getcode.solana + +import com.getcode.ed25519.Ed25519 +import com.getcode.solana.keys.AccountMeta +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.PublicKey +import com.getcode.solana.keys.Signature +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.assertFailsWith + +class SolanaTransactionTest { + + private fun testHash(): Hash = + Hash(ByteArray(32) { 0xAB.toByte() }.toList()) + + // --- Construction --- + + @Test + fun newInstanceCreatesTransactionWithPlaceholderSignatures() { + val seed = ByteArray(32) { 1 } + val keyPair = Ed25519.createKeyPair(seed) + val payer = PublicKey(keyPair.publicKeyBytes.toList()) + + val tx = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = testHash(), + instructions = emptyList() + ) + + assertEquals(1, tx.signatures.size) + assertEquals(Signature.zero, tx.signatures[0]) + } + + @Test + fun newInstanceWithInstructionIncludesProgram() { + val seed = ByteArray(32) { 1 } + val keyPair = Ed25519.createKeyPair(seed) + val payer = PublicKey(keyPair.publicKeyBytes.toList()) + val program = PublicKey(ByteArray(32) { 2 }.toList()) + + val instruction = Instruction( + program = program, + accounts = emptyList(), + data = listOf(0x01) + ) + + val tx = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = testHash(), + instructions = listOf(instruction) + ) + + assertTrue(tx.message.accounts.any { it.publicKey == program }) + } + + // --- Signing --- + + @Test + fun signProducesValidSignature() { + val seed = ByteArray(32) { 1 } + val keyPair = Ed25519.createKeyPair(seed) + val payer = PublicKey(keyPair.publicKeyBytes.toList()) + + val tx = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = testHash(), + instructions = emptyList() + ) + + val signatures = tx.sign(keyPair) + assertEquals(1, signatures.size) + assertEquals(64, signatures[0].bytes.size) + // Signature should not be all zeros + assertTrue(signatures[0] != Signature.zero) + } + + @Test + fun signatureIsVerifiable() { + val seed = ByteArray(32) { 1 } + val keyPair = Ed25519.createKeyPair(seed) + val payer = PublicKey(keyPair.publicKeyBytes.toList()) + + val tx = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = testHash(), + instructions = emptyList() + ) + + val signatures = tx.sign(keyPair) + val messageData = tx.message.encode() + + assertTrue( + Ed25519.verify( + signatures[0].bytes.toByteArray(), + messageData, + keyPair.publicKeyBytes + ) + ) + } + + @Test + fun signIsDeterministic() { + val seed = ByteArray(32) { 1 } + val keyPair = Ed25519.createKeyPair(seed) + val payer = PublicKey(keyPair.publicKeyBytes.toList()) + + val tx1 = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = testHash(), + instructions = emptyList() + ) + + val tx2 = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = testHash(), + instructions = emptyList() + ) + + val sig1 = tx1.sign(keyPair) + val sig2 = tx2.sign(keyPair) + assertEquals(sig1[0].bytes, sig2[0].bytes) + } + + @Test + fun signRejectsKeyNotInAccountList() { + val seed1 = ByteArray(32) { 1 } + val seed2 = ByteArray(32) { 2 } + val payerKp = Ed25519.createKeyPair(seed1) + val otherKp = Ed25519.createKeyPair(seed2) + val payer = PublicKey(payerKp.publicKeyBytes.toList()) + + val tx = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = testHash(), + instructions = emptyList() + ) + + assertFailsWith { + tx.sign(otherKp) + } + } + + @Test + fun signRejectsTooManySigners() { + val seed1 = ByteArray(32) { 1 } + val seed2 = ByteArray(32) { 2 } + val kp1 = Ed25519.createKeyPair(seed1) + val kp2 = Ed25519.createKeyPair(seed2) + val payer = PublicKey(kp1.publicKeyBytes.toList()) + + // Transaction with only 1 required signature (payer) + val tx = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = testHash(), + instructions = emptyList() + ) + + assertFailsWith { + tx.sign(kp1, kp2) + } + } + + // --- Encode / Decode roundtrip --- + + @Test + fun encodeDecodeRoundtrip() { + val seed = ByteArray(32) { 1 } + val keyPair = Ed25519.createKeyPair(seed) + val payer = PublicKey(keyPair.publicKeyBytes.toList()) + + val tx = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = testHash(), + instructions = emptyList() + ) + + val encoded = tx.encode() + val decoded = SolanaTransaction.fromList(encoded) + + assertNotNull(decoded) + assertEquals(tx.message.header, decoded.message.header) + assertEquals(tx.message.recentBlockhash, decoded.message.recentBlockhash) + assertEquals(tx.signatures.size, decoded.signatures.size) + } + + @Test + fun encodeDecodeWithSignatureRoundtrip() { + val seed = ByteArray(32) { 1 } + val keyPair = Ed25519.createKeyPair(seed) + val payer = PublicKey(keyPair.publicKeyBytes.toList()) + + val tx = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = testHash(), + instructions = emptyList() + ) + + val signatures = tx.sign(keyPair) + val signedTx = tx.copy(signatures = signatures) + + val encoded = signedTx.encode() + val decoded = SolanaTransaction.fromList(encoded) + + assertNotNull(decoded) + assertEquals(signatures[0].bytes, decoded.signatures[0].bytes) + } + + @Test + fun encodeDecodeWithInstructionRoundtrip() { + val seed = ByteArray(32) { 1 } + val keyPair = Ed25519.createKeyPair(seed) + val payer = PublicKey(keyPair.publicKeyBytes.toList()) + val program = PublicKey(ByteArray(32) { 3 }.toList()) + + val instruction = Instruction( + program = program, + accounts = listOf(AccountMeta.payer(payer)), + data = listOf(0xCA.toByte(), 0xFE.toByte()) + ) + + val tx = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = testHash(), + instructions = listOf(instruction) + ) + + val encoded = tx.encode() + val decoded = SolanaTransaction.fromList(encoded) + + assertNotNull(decoded) + assertEquals(1, decoded.message.instructions.size) + assertEquals( + listOf(0xCA.toByte(), 0xFE.toByte()), + decoded.message.instructions[0].data + ) + } + + // --- Blockhash --- + + @Test + fun recentBlockhashIsSettable() { + val seed = ByteArray(32) { 1 } + val keyPair = Ed25519.createKeyPair(seed) + val payer = PublicKey(keyPair.publicKeyBytes.toList()) + + val tx = SolanaTransaction.newInstance( + payer = payer, + recentBlockhash = testHash(), + instructions = emptyList() + ) + + val newHash = Hash(ByteArray(32) { 0xCD.toByte() }.toList()) + tx.recentBlockhash = newHash + assertEquals(newHash, tx.recentBlockhash) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/extensions/PdaDerivationTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/extensions/PdaDerivationTest.kt new file mode 100644 index 000000000..7cee06d6f --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/solana/extensions/PdaDerivationTest.kt @@ -0,0 +1,203 @@ +package com.getcode.opencode.internal.solana.extensions + +import com.getcode.ed25519.Ed25519 +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import com.getcode.vendor.Base58 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class PdaDerivationTest { + + private fun testKey(seed: Int): PublicKey = + PublicKey(ByteArray(32) { seed.toByte() }.toList()) + + // --- deriveAssociatedAccount --- + + @Test + fun associatedAccountIsDeterministic() { + val owner = testKey(1) + val mint = testKey(2) + val result1 = PublicKey.deriveAssociatedAccount(owner, mint) + val result2 = PublicKey.deriveAssociatedAccount(owner, mint) + assertEquals(result1.publicKey, result2.publicKey) + assertEquals(result1.bump, result2.bump) + } + + @Test + fun associatedAccountBumpInRange() { + val owner = testKey(1) + val mint = testKey(2) + val result = PublicKey.deriveAssociatedAccount(owner, mint) + assertTrue(result.bump in 0..255) + } + + @Test + fun associatedAccountIsOffCurve() { + val owner = testKey(1) + val mint = testKey(2) + val result = PublicKey.deriveAssociatedAccount(owner, mint) + assertTrue(!Ed25519.onCurve(result.publicKey.bytes.toByteArray())) + } + + @Test + fun associatedAccountDiffersForDifferentOwners() { + val mint = testKey(2) + val result1 = PublicKey.deriveAssociatedAccount(testKey(1), mint) + val result2 = PublicKey.deriveAssociatedAccount(testKey(3), mint) + assertNotEquals(result1.publicKey, result2.publicKey) + } + + @Test + fun associatedAccountDiffersForDifferentMints() { + val owner = testKey(1) + val result1 = PublicKey.deriveAssociatedAccount(owner, testKey(2)) + val result2 = PublicKey.deriveAssociatedAccount(owner, testKey(3)) + assertNotEquals(result1.publicKey, result2.publicKey) + } + + // --- deriveVirtualMachineAccount --- + + @Test + fun vmAccountIsDeterministic() { + val mint = testKey(1) + val authority = testKey(2) + val result1 = PublicKey.deriveVirtualMachineAccount(mint, authority, 21u) + val result2 = PublicKey.deriveVirtualMachineAccount(mint, authority, 21u) + assertEquals(result1.publicKey, result2.publicKey) + assertEquals(result1.bump, result2.bump) + } + + @Test + fun vmAccountDiffersForDifferentLockout() { + val mint = testKey(1) + val authority = testKey(2) + val result1 = PublicKey.deriveVirtualMachineAccount(mint, authority, 21u) + val result2 = PublicKey.deriveVirtualMachineAccount(mint, authority, 7u) + assertNotEquals(result1.publicKey, result2.publicKey) + } + + @Test + fun vmAccountIsOffCurve() { + val mint = testKey(1) + val authority = testKey(2) + val result = PublicKey.deriveVirtualMachineAccount(mint, authority, 21u) + assertTrue(!Ed25519.onCurve(result.publicKey.bytes.toByteArray())) + } + + // --- deriveDepositAccount --- + + @Test + fun depositAccountIsDeterministic() { + val vm = testKey(1) + val depositor = testKey(2) + val result1 = PublicKey.deriveDepositAccount(vm, depositor) + val result2 = PublicKey.deriveDepositAccount(vm, depositor) + assertEquals(result1.publicKey, result2.publicKey) + } + + @Test + fun depositAccountDiffersForDifferentDepositors() { + val vm = testKey(1) + val result1 = PublicKey.deriveDepositAccount(vm, testKey(2)) + val result2 = PublicKey.deriveDepositAccount(vm, testKey(3)) + assertNotEquals(result1.publicKey, result2.publicKey) + } + + // --- deriveTimelockStateAccount --- + + @Test + fun timelockStateIsDeterministic() { + val owner = testKey(1) + val mint = Mint(ByteArray(32) { 2 }.toList()) + val authority = testKey(3) + val result1 = PublicKey.deriveTimelockStateAccount(owner, mint, authority, 21u) + val result2 = PublicKey.deriveTimelockStateAccount(owner, mint, authority, 21u) + assertEquals(result1.publicKey, result2.publicKey) + assertEquals(result1.bump, result2.bump) + } + + @Test + fun timelockStateDiffersForDifferentOwners() { + val mint = Mint(ByteArray(32) { 2 }.toList()) + val authority = testKey(3) + val result1 = PublicKey.deriveTimelockStateAccount(testKey(1), mint, authority, 21u) + val result2 = PublicKey.deriveTimelockStateAccount(testKey(4), mint, authority, 21u) + assertNotEquals(result1.publicKey, result2.publicKey) + } + + // --- deriveTimelockVaultAccount --- + + @Test + fun timelockVaultIsDeterministic() { + val stateAccount = testKey(1) + val result1 = PublicKey.deriveTimelockVaultAccount(stateAccount, 0) + val result2 = PublicKey.deriveTimelockVaultAccount(stateAccount, 0) + assertEquals(result1.publicKey, result2.publicKey) + } + + @Test + fun timelockVaultDiffersForDifferentVersions() { + val stateAccount = testKey(1) + val result1 = PublicKey.deriveTimelockVaultAccount(stateAccount, 0) + val result2 = PublicKey.deriveTimelockVaultAccount(stateAccount, 1) + assertNotEquals(result1.publicKey, result2.publicKey) + } + + // --- deriveVmOmnibusAddress --- + + @Test + fun vmOmnibusIsDeterministic() { + val vm = testKey(1) + val result1 = PublicKey.deriveVmOmnibusAddress(vm) + val result2 = PublicKey.deriveVmOmnibusAddress(vm) + assertEquals(result1.publicKey, result2.publicKey) + } + + @Test + fun vmOmnibusDiffersForDifferentVm() { + val result1 = PublicKey.deriveVmOmnibusAddress(testKey(1)) + val result2 = PublicKey.deriveVmOmnibusAddress(testKey(2)) + assertNotEquals(result1.publicKey, result2.publicKey) + } + + // --- deriveSwapAddress --- + + @Test + fun swapAddressIsDeterministic() { + val owner = testKey(1) + val mint = testKey(2) + val authority = testKey(3) + val result1 = PublicKey.deriveSwapAddress(owner, mint, authority, 21u) + val result2 = PublicKey.deriveSwapAddress(owner, mint, authority, 21u) + assertEquals(result1.publicKey, result2.publicKey) + assertEquals(result1.bump, result2.bump) + } + + @Test + fun swapAddressDiffersForDifferentOwners() { + val mint = testKey(2) + val authority = testKey(3) + val result1 = PublicKey.deriveSwapAddress(testKey(1), mint, authority, 21u) + val result2 = PublicKey.deriveSwapAddress(testKey(4), mint, authority, 21u) + assertNotEquals(result1.publicKey, result2.publicKey) + } + + // --- Known value: well-known associated token address --- + + @Test + fun associatedAccountForKnownMintProducesExpectedLength() { + // USDC mint on Solana: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v + val usdcMintBytes = Base58.decode("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + val usdcMint = PublicKey(usdcMintBytes.toList()) + + val ownerBytes = Base58.decode("codeHy87wGD5oMRLG75qKqsSi1vWE3oxNyYmXo5F9YR") + val owner = PublicKey(ownerBytes.toList()) + + val result = PublicKey.deriveAssociatedAccount(owner, usdcMint) + assertEquals(32, result.publicKey.bytes.size) + assertTrue(result.bump in 0..255) + } +} From cd98a7135db8098eab522c8097a3aebb8c8aed48 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 5 Apr 2026 18:21:43 -0400 Subject: [PATCH 14/16] test: add error mapping tests for Coinbase, SubmitIntent, and Swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CoinbaseOnRampEventHandler: 15 tests covering event routing (success, cancel, auto-click, all 4 error event types), unknown/invalid JSON - CoinbaseOnRampWebError: 3 tests for tryValueOf parsing - SubmitIntentError.typed(): 11 tests for proto→error mapping with reason string extraction from ErrorDetails - SwapError.typed(): 8 tests for proto→error mapping with deny/reason extraction --- .../CoinbaseOnRampEventHandlerTest.kt | 172 ++++++++++++++++++ .../core/errors/SubmitIntentErrorTest.kt | 156 ++++++++++++++++ .../model/core/errors/SwapErrorTest.kt | 123 +++++++++++++ 3 files changed, 451 insertions(+) create mode 100644 apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandlerTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt create mode 100644 services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SwapErrorTest.kt diff --git a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandlerTest.kt b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandlerTest.kt new file mode 100644 index 000000000..7fb9b3163 --- /dev/null +++ b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandlerTest.kt @@ -0,0 +1,172 @@ +package com.flipcash.app.onramp.internal + +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class CoinbaseOnRampEventHandlerTest { + + @Before + fun setUp() { + mockkStatic("com.getcode.utils.LoggingKt") + every { com.getcode.utils.trace(any(), any(), any(), any(), any()) } returns Unit + } + + @After + fun tearDown() { + unmockkStatic("com.getcode.utils.LoggingKt") + } + + private var successCount = 0 + private var cancelCount = 0 + private var autoClickCount = 0 + private var lastError: CoinbaseOnRampWebError? = null + + private val handler = CoinbaseOnRampEventHandler( + onPaymentSuccess = { successCount++ }, + onPaymentFailure = { lastError = it }, + onCancel = { cancelCount++ }, + onAutoClickGPay = { autoClickCount++ }, + ) + + // --- Event routing --- + + @Test + fun loadSuccessTriggersAutoClick() { + handler.handleEvent("""{"eventName":"onramp_api.load_success"}""") + assertEquals(1, autoClickCount) + } + + @Test + fun commitSuccessTriggersPaymentSuccess() { + handler.handleEvent("""{"eventName":"onramp_api.commit_success"}""") + assertEquals(1, successCount) + } + + @Test + fun pollingSuccessTriggersPaymentSuccess() { + handler.handleEvent("""{"eventName":"onramp_api.polling_success"}""") + assertEquals(1, successCount) + } + + @Test + fun cancelTriggersOnCancel() { + handler.handleEvent("""{"eventName":"onramp_api.cancel"}""") + assertEquals(1, cancelCount) + } + + // --- Error events --- + + @Test + fun commitErrorTriggersFailure() { + handler.handleEvent("""{"eventName":"onramp_api.commit_error","data":{"errorCode":"ERROR_CODE_INTERNAL"}}""") + assertEquals(CoinbaseOnRampWebError.ERROR_CODE_INTERNAL, lastError) + } + + @Test + fun loadErrorTriggersFailure() { + handler.handleEvent("""{"eventName":"onramp_api.load_error","data":{"errorCode":"ERROR_CODE_GUEST_GOOGLE_PAY_ERROR"}}""") + assertEquals(CoinbaseOnRampWebError.ERROR_CODE_GUEST_GOOGLE_PAY_ERROR, lastError) + } + + @Test + fun pollingErrorTriggersFailure() { + handler.handleEvent("""{"eventName":"onramp_api.polling_error","data":{"errorCode":"ERROR_CODE_GUEST_TRANSACTION_BUY_FAILED"}}""") + assertEquals(CoinbaseOnRampWebError.ERROR_CODE_GUEST_TRANSACTION_BUY_FAILED, lastError) + } + + @Test + fun sessionErrorTriggersFailure() { + handler.handleEvent("""{"eventName":"onramp_api.session_error","data":{"errorCode":"ERROR_CODE_GUEST_CARD_NOT_DEBIT"}}""") + assertEquals(CoinbaseOnRampWebError.ERROR_CODE_GUEST_CARD_NOT_DEBIT, lastError) + } + + @Test + fun errorWithUnknownCodeFallsBackToUnknown() { + handler.handleEvent("""{"eventName":"onramp_api.commit_error","data":{"errorCode":"SOME_NEW_ERROR"}}""") + assertEquals(CoinbaseOnRampWebError.UNKNOWN, lastError) + } + + @Test + fun errorWithMissingDataFallsBackToUnknown() { + handler.handleEvent("""{"eventName":"onramp_api.commit_error"}""") + assertEquals(CoinbaseOnRampWebError.UNKNOWN, lastError) + } + + @Test + fun errorWithEmptyErrorCodeFallsBackToUnknown() { + handler.handleEvent("""{"eventName":"onramp_api.commit_error","data":{"errorCode":""}}""") + assertEquals(CoinbaseOnRampWebError.UNKNOWN, lastError) + } + + // --- Edge cases --- + + @Test + fun invalidJsonDoesNotCrash() { + handler.handleEvent("not json") + assertEquals(0, successCount) + assertEquals(0, cancelCount) + } + + @Test + fun unknownEventNameIsIgnored() { + handler.handleEvent("""{"eventName":"onramp_api.unknown_event"}""") + assertEquals(0, successCount) + assertEquals(0, cancelCount) + assertTrue(lastError == null) + } + + @Test + fun missingEventNameIsIgnored() { + handler.handleEvent("""{"data":{"errorCode":"ERROR_CODE_INTERNAL"}}""") + assertEquals(0, successCount) + assertTrue(lastError == null) + } +} + +class CoinbaseOnRampWebErrorTest { + + @Test + fun tryValueOfAllKnownCodes() { + val expected = mapOf( + "ERROR_CODE_MISSING_TRANSACTION_UUID" to CoinbaseOnRampWebError.ERROR_CODE_MISSING_TRANSACTION_UUID, + "ERROR_CODE_GUEST_CARD_NOT_DEBIT" to CoinbaseOnRampWebError.ERROR_CODE_GUEST_CARD_NOT_DEBIT, + "ERROR_CODE_GUEST_GOOGLE_PAY_ERROR" to CoinbaseOnRampWebError.ERROR_CODE_GUEST_GOOGLE_PAY_ERROR, + "ERROR_CODE_GUEST_TRANSACTION_BUY_FAILED" to CoinbaseOnRampWebError.ERROR_CODE_GUEST_TRANSACTION_BUY_FAILED, + "ERROR_CODE_GUEST_TRANSACTION_SEND_FAILED" to CoinbaseOnRampWebError.ERROR_CODE_GUEST_TRANSACTION_SEND_FAILED, + "ERROR_CODE_GUEST_TRANSACTION_AVS_VALIDATION_FAILED" to CoinbaseOnRampWebError.ERROR_CODE_GUEST_TRANSACTION_AVS_VALIDATION_FAILED, + "ERROR_CODE_GUEST_TRANSACTION_TRANSACTION_FAILED" to CoinbaseOnRampWebError.ERROR_CODE_GUEST_TRANSACTION_TRANSACTION_FAILED, + "ERROR_CODE_INTERNAL" to CoinbaseOnRampWebError.ERROR_CODE_INTERNAL, + "ERROR_CODE_GOOGLE_PAY_BUTTON_NOT_FOUND" to CoinbaseOnRampWebError.ERROR_CODE_GOOGLE_PAY_BUTTON_NOT_FOUND, + ) + + for ((code, expectedError) in expected) { + assertEquals(expectedError, CoinbaseOnRampWebError.tryValueOf(code), "Failed for code: $code") + } + } + + @Test + fun tryValueOfUnknownCodeReturnsUnknown() { + assertEquals(CoinbaseOnRampWebError.UNKNOWN, CoinbaseOnRampWebError.tryValueOf("SOMETHING_NEW")) + } + + @Test + fun tryValueOfEmptyStringReturnsUnknown() { + assertEquals(CoinbaseOnRampWebError.UNKNOWN, CoinbaseOnRampWebError.tryValueOf("")) + } + + @Test + fun tryValueOfCaseSensitive() { + assertEquals(CoinbaseOnRampWebError.UNKNOWN, CoinbaseOnRampWebError.tryValueOf("error_code_internal")) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt new file mode 100644 index 000000000..27246337c --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SubmitIntentErrorTest.kt @@ -0,0 +1,156 @@ +package com.getcode.opencode.model.core.errors + +import com.codeinc.opencode.gen.transaction.v1.TransactionService.SubmitIntentResponse +import com.codeinc.opencode.gen.transaction.v1.errorDetails +import com.codeinc.opencode.gen.transaction.v1.reasonStringErrorDetails +import com.codeinc.opencode.gen.transaction.v1.deniedErrorDetails +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class SubmitIntentErrorTest { + + private fun buildError( + code: SubmitIntentResponse.Error.Code, + reasonStrings: List = emptyList(), + deniedReasons: List = emptyList(), + ): SubmitIntentResponse.Error { + val builder = SubmitIntentResponse.Error.newBuilder() + .setCode(code) + + reasonStrings.forEach { reason -> + builder.addErrorDetails(errorDetails { + reasonString = reasonStringErrorDetails { this.reason = reason } + }) + } + + deniedReasons.forEach { reason -> + builder.addErrorDetails(errorDetails { + denied = deniedErrorDetails { this.reason = reason } + }) + } + + return builder.build() + } + + // --- Code mapping --- + + @Test + fun deniedCodeMapsToDenied() { + val error = SubmitIntentError.typed( + buildError(SubmitIntentResponse.Error.Code.DENIED) + ) + assertIs(error) + } + + @Test + fun invalidIntentCodeMapsToInvalidIntent() { + val error = SubmitIntentError.typed( + buildError(SubmitIntentResponse.Error.Code.INVALID_INTENT) + ) + assertIs(error) + } + + @Test + fun signatureErrorCodeMapsToSignature() { + val error = SubmitIntentError.typed( + buildError(SubmitIntentResponse.Error.Code.SIGNATURE_ERROR) + ) + assertIs(error) + } + + @Test + fun staleStateCodeMapsToStaleState() { + val error = SubmitIntentError.typed( + buildError(SubmitIntentResponse.Error.Code.STALE_STATE) + ) + assertIs(error) + } + + // Note: UNRECOGNIZED cannot be set via proto builders (throws IllegalArgumentException). + // That code path is only reachable when the server sends an unknown enum value. + + // --- Reason string extraction --- + + @Test + fun invalidIntentExtractsReasonStrings() { + val error = SubmitIntentError.typed( + buildError( + SubmitIntentResponse.Error.Code.INVALID_INTENT, + reasonStrings = listOf("bad amount", "missing account") + ) + ) + assertIs(error) + assertTrue(error.message!!.contains("bad amount")) + assertTrue(error.message!!.contains("missing account")) + } + + @Test + fun staleStateExtractsReasonStrings() { + val error = SubmitIntentError.typed( + buildError( + SubmitIntentResponse.Error.Code.STALE_STATE, + reasonStrings = listOf("nonce expired") + ) + ) + assertIs(error) + assertEquals("nonce expired", error.message) + } + + @Test + fun deniedExtractsDeniedReasons() { + val error = SubmitIntentError.typed( + buildError( + SubmitIntentResponse.Error.Code.DENIED, + deniedReasons = listOf("spam detected") + ) + ) + assertIs(error) + assertTrue(error.message!!.contains("spam detected")) + } + + @Test + fun invalidIntentWithNoReasonsHasEmptyMessage() { + val error = SubmitIntentError.typed( + buildError(SubmitIntentResponse.Error.Code.INVALID_INTENT) + ) + assertIs(error) + assertEquals("", error.message) + } + + @Test + fun emptyReasonStringsAreFiltered() { + val error = SubmitIntentError.typed( + buildError( + SubmitIntentResponse.Error.Code.INVALID_INTENT, + reasonStrings = listOf("", "real reason", "") + ) + ) + assertIs(error) + assertEquals("real reason", error.message) + } + + // --- Inheritance --- + + @Test + fun allVariantsAreThrowable() { + val errors = listOf( + SubmitIntentError.Denied(listOf("reason")), + SubmitIntentError.InvalidIntent(listOf("reason")), + SubmitIntentError.Signature(), + SubmitIntentError.StaleState(listOf("reason")), + SubmitIntentError.Unrecognized(), + SubmitIntentError.Other(RuntimeException("test")), + ) + errors.forEach { assertTrue(it is Throwable) } + } + + @Test + fun otherWrausesCause() { + val cause = RuntimeException("root cause") + val error = SubmitIntentError.Other(cause) + assertEquals(cause, error.cause) + assertEquals("root cause", error.message) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SwapErrorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SwapErrorTest.kt new file mode 100644 index 000000000..35074d4ad --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/errors/SwapErrorTest.kt @@ -0,0 +1,123 @@ +package com.getcode.opencode.model.core.errors + +import com.codeinc.opencode.gen.transaction.v1.TransactionService.StatefulSwapResponse +import com.codeinc.opencode.gen.transaction.v1.errorDetails +import com.codeinc.opencode.gen.transaction.v1.reasonStringErrorDetails +import com.codeinc.opencode.gen.transaction.v1.deniedErrorDetails +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class SwapErrorTest { + + private fun buildError( + code: StatefulSwapResponse.Error.Code, + reasonStrings: List = emptyList(), + deniedReasons: List = emptyList(), + ): StatefulSwapResponse.Error { + val builder = StatefulSwapResponse.Error.newBuilder() + .setCode(code) + + reasonStrings.forEach { reason -> + builder.addErrorDetails(errorDetails { + reasonString = reasonStringErrorDetails { this.reason = reason } + }) + } + + deniedReasons.forEach { reason -> + builder.addErrorDetails(errorDetails { + denied = deniedErrorDetails { this.reason = reason } + }) + } + + return builder.build() + } + + // --- Code mapping --- + + @Test + fun deniedCodeMapsToDenied() { + val error = SwapError.typed( + buildError(StatefulSwapResponse.Error.Code.DENIED) + ) + assertIs(error) + } + + @Test + fun signatureErrorCodeMapsToSignature() { + val error = SwapError.typed( + buildError(StatefulSwapResponse.Error.Code.SIGNATURE_ERROR) + ) + assertIs(error) + } + + @Test + fun invalidSwapCodeMapsToInvalidSwap() { + val error = SwapError.typed( + buildError(StatefulSwapResponse.Error.Code.INVALID_SWAP) + ) + assertIs(error) + } + + // Note: UNRECOGNIZED cannot be set via proto builders (throws IllegalArgumentException). + // That code path is only reachable when the server sends an unknown enum value. + + // --- Reason extraction --- + + @Test + fun deniedExtractsDeniedReasons() { + val error = SwapError.typed( + buildError( + StatefulSwapResponse.Error.Code.DENIED, + deniedReasons = listOf("insufficient balance") + ) + ) + assertIs(error) + assertTrue(error.message!!.contains("insufficient balance")) + } + + @Test + fun invalidSwapExtractsReasonStrings() { + val error = SwapError.typed( + buildError( + StatefulSwapResponse.Error.Code.INVALID_SWAP, + reasonStrings = listOf("slippage too high", "pool depleted") + ) + ) + assertIs(error) + assertTrue(error.message!!.contains("slippage too high")) + assertTrue(error.message!!.contains("pool depleted")) + } + + @Test + fun deniedWithNoReasonsHasEmptyMessage() { + val error = SwapError.typed( + buildError(StatefulSwapResponse.Error.Code.DENIED) + ) + assertIs(error) + assertEquals("", error.message) + } + + // --- Inheritance --- + + @Test + fun allVariantsAreThrowable() { + val errors = listOf( + SwapError.Denied(listOf("reason")), + SwapError.Signature(), + SwapError.Unrecognized(), + SwapError.InvalidSwap(listOf("reason")), + SwapError.Other(RuntimeException("test")), + ) + errors.forEach { assertTrue(it is Throwable) } + } + + @Test + fun otherWrapsCause() { + val cause = RuntimeException("swap failed") + val error = SwapError.Other(cause) + assertEquals(cause, error.cause) + assertEquals("swap failed", error.message) + } +} From ea543e760cac1f7819cc70bed14527e7878350af Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 5 Apr 2026 18:50:04 -0400 Subject: [PATCH 15/16] test(keys): add AccountMeta ordering, factory, and dedup tests --- .../getcode/solana/keys/AccountMetaTest.kt | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/AccountMetaTest.kt diff --git a/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/AccountMetaTest.kt b/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/AccountMetaTest.kt new file mode 100644 index 000000000..f0498abc4 --- /dev/null +++ b/libs/encryption/keys/src/test/kotlin/com/getcode/solana/keys/AccountMetaTest.kt @@ -0,0 +1,226 @@ +package com.getcode.solana.keys + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AccountMetaTest { + + private fun key(byte: Int) = PublicKey(ByteArray(32) { byte.toByte() }.toList()) + + // --- Factory methods --- + + @Test + fun payerIsSigner() { + val meta = AccountMeta.payer(key(1)) + assertTrue(meta.isSigner) + } + + @Test + fun payerIsWritable() { + val meta = AccountMeta.payer(key(1)) + assertTrue(meta.isWritable) + } + + @Test + fun payerIsPayer() { + val meta = AccountMeta.payer(key(1)) + assertTrue(meta.isPayer) + } + + @Test + fun payerIsNotProgram() { + val meta = AccountMeta.payer(key(1)) + assertFalse(meta.isProgram) + } + + @Test + fun writableIsWritable() { + val meta = AccountMeta.writable(key(2)) + assertTrue(meta.isWritable) + assertFalse(meta.isSigner) + } + + @Test + fun writableWithSignerIsSigner() { + val meta = AccountMeta.writable(key(2), signer = true) + assertTrue(meta.isWritable) + assertTrue(meta.isSigner) + } + + @Test + fun readonlyIsNotWritable() { + val meta = AccountMeta.readonly(key(3)) + assertFalse(meta.isWritable) + assertFalse(meta.isSigner) + } + + @Test + fun readonlyWithSignerIsSigner() { + val meta = AccountMeta.readonly(key(3), signer = true) + assertFalse(meta.isWritable) + assertTrue(meta.isSigner) + } + + @Test + fun programIsProgram() { + val meta = AccountMeta.program(key(4)) + assertTrue(meta.isProgram) + assertFalse(meta.isSigner) + assertFalse(meta.isWritable) + assertFalse(meta.isPayer) + } + + // --- Ordering (compareTo) --- + + @Test + fun payerSortsBeforeNonPayer() { + val payer = AccountMeta.payer(key(2)) + val writable = AccountMeta.writable(key(1)) + assertTrue(payer < writable) + } + + @Test + fun nonProgramSortsBeforeProgram() { + val writable = AccountMeta.writable(key(2)) + val program = AccountMeta.program(key(1)) + assertTrue(writable < program) + } + + @Test + fun signerSortsBeforeNonSigner() { + val signer = AccountMeta.writable(key(2), signer = true) + val nonSigner = AccountMeta.writable(key(1)) + assertTrue(signer < nonSigner) + } + + @Test + fun writableSortsBeforeReadonly() { + val writable = AccountMeta.writable(key(2)) + val readonly = AccountMeta.readonly(key(1)) + assertTrue(writable < readonly) + } + + @Test + fun equalFlagsSortByLexicographicKey() { + val lower = AccountMeta.writable(key(1)) + val higher = AccountMeta.writable(key(2)) + assertTrue(lower < higher) + } + + @Test + fun sortedListProducesExpectedOrder() { + val payer = AccountMeta.payer(key(1)) + val signer = AccountMeta.writable(key(2), signer = true) + val writable = AccountMeta.writable(key(3)) + val readonly = AccountMeta.readonly(key(4)) + val program = AccountMeta.program(key(5)) + + val sorted = listOf(program, readonly, writable, signer, payer).sorted() + assertEquals(listOf(payer, signer, writable, readonly, program), sorted) + } + + // --- compareLexicographically --- + + @Test + fun compareLexicographicallyEqualArraysReturnZero() { + val a = byteArrayOf(1, 2, 3) + assertEquals(0, AccountMeta.compareLexicographically(a, a.copyOf())) + } + + @Test + fun compareLexicographicallyFirstByteDeterminesOrder() { + val a = byteArrayOf(1, 0) + val b = byteArrayOf(2, 0) + assertTrue(AccountMeta.compareLexicographically(a, b) < 0) + assertTrue(AccountMeta.compareLexicographically(b, a) > 0) + } + + @Test + fun compareLexicographicallyTreatsAsUnsigned() { + val low = byteArrayOf(0x00) + val high = byteArrayOf(0xFF.toByte()) + assertTrue(AccountMeta.compareLexicographically(low, high) < 0) + } + + @Test + fun compareLexicographicallyShorterIsSmaller() { + val short = byteArrayOf(1, 2) + val long = byteArrayOf(1, 2, 3) + assertTrue(AccountMeta.compareLexicographically(short, long) < 0) + } + + // --- filterUniqueAccounts --- + + @Test + fun filterUniqueAccountsDeduplicatesByPublicKey() { + val list = listOf( + AccountMeta.readonly(key(1)), + AccountMeta.readonly(key(1)), + ) + assertEquals(1, list.filterUniqueAccounts().size) + } + + @Test + fun filterUniqueAccountsPromotesToWritable() { + val list = listOf( + AccountMeta.readonly(key(1)), + AccountMeta.writable(key(1)), + ) + val unique = list.filterUniqueAccounts() + assertEquals(1, unique.size) + assertTrue(unique[0].isWritable) + } + + @Test + fun filterUniqueAccountsPromotesToSigner() { + val list = listOf( + AccountMeta.writable(key(1)), + AccountMeta.writable(key(1), signer = true), + ) + val unique = list.filterUniqueAccounts() + assertEquals(1, unique.size) + assertTrue(unique[0].isSigner) + } + + @Test + fun filterUniqueAccountsPromotesToPayer() { + val list = listOf( + AccountMeta.readonly(key(1)), + AccountMeta.payer(key(1)), + ) + val unique = list.filterUniqueAccounts() + assertEquals(1, unique.size) + assertTrue(unique[0].isPayer) + } + + @Test + fun filterUniqueAccountsPreservesDistinctKeys() { + val list = listOf( + AccountMeta.readonly(key(1)), + AccountMeta.writable(key(2)), + AccountMeta.program(key(3)), + ) + assertEquals(3, list.filterUniqueAccounts().size) + } + + @Test + fun filterUniqueAccountsEmptyList() { + assertEquals(0, emptyList().filterUniqueAccounts().size) + } + + // --- description --- + + @Test + fun descriptionShowsPayerFlags() { + val meta = AccountMeta.payer(key(0)) + assertTrue(meta.description.startsWith("[ps")) + } + + @Test + fun descriptionShowsProgramFlags() { + val meta = AccountMeta.program(key(0)) + assertTrue(meta.description.startsWith("[--")) + } +} From 86feebceb505c69d3577bf841be12f06366238d7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 6 Apr 2026 11:02:42 -0400 Subject: [PATCH 16/16] chore: extract Ed25519 shadow setup into convention plugin Consolidates the repeated test sourceSet + eddsa dependency into a single `flipcash.android.ed25519.shadow` convention plugin. --- build-logic/convention/build.gradle.kts | 4 +++ .../AndroidEd25519ShadowConventionPlugin.kt | 25 +++++++++++++++++++ gradle/libs.versions.toml | 1 + libs/crypto/solana/build.gradle.kts | 6 +---- libs/encryption/mnemonic/build.gradle.kts | 6 +---- services/opencode/build.gradle.kts | 6 +---- 6 files changed, 33 insertions(+), 15 deletions(-) create mode 100644 build-logic/convention/src/main/kotlin/AndroidEd25519ShadowConventionPlugin.kt diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 1be637b2f..f1ef2a7b1 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -37,5 +37,9 @@ gradlePlugin { id = "flipcash.android.feature" implementationClass = "AndroidFeatureConventionPlugin" } + register("androidEd25519Shadow") { + id = "flipcash.android.ed25519.shadow" + implementationClass = "AndroidEd25519ShadowConventionPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/AndroidEd25519ShadowConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidEd25519ShadowConventionPlugin.kt new file mode 100644 index 000000000..472d39815 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidEd25519ShadowConventionPlugin.kt @@ -0,0 +1,25 @@ +import com.android.build.gradle.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.getByType + +class AndroidEd25519ShadowConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + extensions.configure { + sourceSets.getByName("test") { + java.srcDir(rootProject.file("testing/ed25519-shadow")) + } + } + + val libs = extensions.getByType().named("libs") + + dependencies { + "testImplementation"(libs.findLibrary("eddsa").get()) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ead306a44..4d652b584 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -313,6 +313,7 @@ grpc = ["grpc-okhttp", "grpc-kotlin", "grpc-protobuf-lite", "grpc-stub"] flipcash-android-library = { id = "flipcash.android.library" } flipcash-android-library-compose = { id = "flipcash.android.library.compose" } flipcash-android-feature = { id = "flipcash.android.feature" } +flipcash-android-ed25519-shadow = { id = "flipcash.android.ed25519.shadow" } android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/libs/crypto/solana/build.gradle.kts b/libs/crypto/solana/build.gradle.kts index b390e47c5..41a1b9b89 100644 --- a/libs/crypto/solana/build.gradle.kts +++ b/libs/crypto/solana/build.gradle.kts @@ -1,15 +1,12 @@ plugins { alias(libs.plugins.flipcash.android.library) + alias(libs.plugins.flipcash.android.ed25519.shadow) id("com.google.devtools.ksp") id("dagger.hilt.android.plugin") } android { namespace = "${Gradle.codeNamespace}.vendor.solana" - - sourceSets.getByName("test") { - java.srcDir(rootProject.file("testing/ed25519-shadow")) - } } dependencies { @@ -36,5 +33,4 @@ dependencies { ksp(libs.hilt.compiler) testImplementation(kotlin("test")) - testImplementation(libs.eddsa) } diff --git a/libs/encryption/mnemonic/build.gradle.kts b/libs/encryption/mnemonic/build.gradle.kts index eff807b02..b86234976 100644 --- a/libs/encryption/mnemonic/build.gradle.kts +++ b/libs/encryption/mnemonic/build.gradle.kts @@ -1,13 +1,10 @@ plugins { alias(libs.plugins.flipcash.android.library) + alias(libs.plugins.flipcash.android.ed25519.shadow) } android { namespace = "${Gradle.codeNamespace}.encryption.mnemonic" - - sourceSets.getByName("test") { - java.srcDir(rootProject.file("testing/ed25519-shadow")) - } } dependencies { @@ -22,5 +19,4 @@ dependencies { implementation(libs.androidx.core) testImplementation(kotlin("test")) - testImplementation(libs.eddsa) } diff --git a/services/opencode/build.gradle.kts b/services/opencode/build.gradle.kts index 72d0e50cf..61893f14c 100644 --- a/services/opencode/build.gradle.kts +++ b/services/opencode/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.flipcash.android.library) + alias(libs.plugins.flipcash.android.ed25519.shadow) id("com.google.devtools.ksp") id("dagger.hilt.android.plugin") id("org.jetbrains.kotlin.plugin.parcelize") @@ -8,10 +9,6 @@ plugins { android { namespace = "${Gradle.codeNamespace}.services.opencode" - sourceSets.getByName("test") { - java.srcDir(rootProject.file("testing/ed25519-shadow")) - } - defaultConfig { consumerProguardFiles("consumer-rules.pro") @@ -97,6 +94,5 @@ dependencies { implementation(libs.event.bus) testImplementation(kotlin("test")) - testImplementation(libs.eddsa) testImplementation(libs.bundles.unit.testing) }