diff --git a/CLAUDE.md b/CLAUDE.md index 2d624a8d0..2c35bc121 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,5 +98,5 @@ The feature plugin automatically includes `:libs:logging`, `:ui:core`, `:ui:comp ## Git Conventions - Conventional commits: `feat:`, `fix:`, `chore:`, with optional scope in parens (e.g., `feat(oc):`, `fix(tokens):`) -- Main branch: `main` +- Main branch: `code/cash` - CI runs on all PRs (tests via Fastlane) \ No newline at end of file diff --git a/apps/flipcash/features/balance/build.gradle.kts b/apps/flipcash/features/balance/build.gradle.kts index 94ed75163..e3e08d040 100644 --- a/apps/flipcash/features/balance/build.gradle.kts +++ b/apps/flipcash/features/balance/build.gradle.kts @@ -7,6 +7,8 @@ android { } dependencies { + testImplementation(kotlin("test")) + implementation(libs.compose.paging) implementation(project(":apps:flipcash:shared:analytics")) diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelStateTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelStateTest.kt new file mode 100644 index 000000000..a62f9ad00 --- /dev/null +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelStateTest.kt @@ -0,0 +1,45 @@ +package com.flipcash.app.balance.internal + +import com.flipcash.services.internal.model.thirdparty.OnRampProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class BalanceViewModelStateTest { + + private val reduce = BalanceViewModel.Companion.updateStateForEvent + + @Test + fun `default state has null provider`() { + assertNull(BalanceViewModel.State().preferredOnRampProvider) + } + + @Test + fun `OnPreferredOnRampProviderChanged updates provider`() { + val provider = OnRampProvider.ManualDeposit + val updated = reduce( + BalanceViewModel.Event.OnPreferredOnRampProviderChanged(provider) + )(BalanceViewModel.State()) + assertEquals(provider, updated.preferredOnRampProvider) + } + + @Test + fun `OnPreferredOnRampProviderChanged with null clears provider`() { + val state = BalanceViewModel.State( + preferredOnRampProvider = OnRampProvider.ManualDeposit + ) + val updated = reduce( + BalanceViewModel.Event.OnPreferredOnRampProviderChanged(null) + )(state) + assertNull(updated.preferredOnRampProvider) + } + + @Test + fun `OpenCurrencySelection is no-op`() { + val state = BalanceViewModel.State( + preferredOnRampProvider = OnRampProvider.ManualDeposit + ) + val updated = reduce(BalanceViewModel.Event.OpenCurrencySelection)(state) + assertEquals(state, updated) + } +} diff --git a/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelStateTest.kt b/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelStateTest.kt new file mode 100644 index 000000000..73c88b79e --- /dev/null +++ b/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelStateTest.kt @@ -0,0 +1,157 @@ +package com.flipcash.app.cash.internal + +import com.getcode.opencode.model.financial.Currency +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import com.getcode.ui.components.text.AmountAnimatedInputUiModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CashScreenViewModelStateTest { + + private val reduce = CashScreenViewModel.Companion.updateStateForEvent + private fun mint() = Mint(ByteArray(32) { 1 }.toList()) + + // --- Default state --- + + @Test + fun `default state has null token and address`() { + val state = CashScreenViewModel.State() + assertNull(state.selectedTokenAddress) + assertNull(state.token) + assertNull(state.limits) + assertNull(state.maxForGive) + assertFalse(state.canGive) + } + + // --- State reducers --- + + @Test + fun `OnTokenSelected sets selectedTokenAddress`() { + val mint = mint() + val updated = reduce(CashScreenViewModel.Event.OnTokenSelected(mint))(CashScreenViewModel.State()) + assertEquals(mint, updated.selectedTokenAddress) + } + + @Test + fun `OnAmountChanged updates amountAnimatedModel`() { + val model = AmountAnimatedInputUiModel(lastPressedBackspace = true) + val updated = reduce(CashScreenViewModel.Event.OnAmountChanged(model))(CashScreenViewModel.State()) + assertEquals(model, updated.amountAnimatedModel) + } + + @Test + fun `UpdateLoadingState sets loading`() { + val updated = reduce( + CashScreenViewModel.Event.UpdateLoadingState(loading = true) + )(CashScreenViewModel.State()) + assertTrue(updated.generatingBill.loading) + assertFalse(updated.generatingBill.success) + } + + @Test + fun `UpdateLoadingState sets success`() { + val updated = reduce( + CashScreenViewModel.Event.UpdateLoadingState(loading = false, success = true) + )(CashScreenViewModel.State()) + assertTrue(updated.generatingBill.success) + assertFalse(updated.generatingBill.loading) + } + + @Test + fun `OnMaxDetermined sets maxForGive`() { + val updated = reduce( + CashScreenViewModel.Event.OnMaxDetermined(max = 50.0, currencyCode = CurrencyCode.USD) + )(CashScreenViewModel.State()) + assertEquals(50.0 to CurrencyCode.USD, updated.maxForGive) + } + + @Test + fun `OnLimitsChanged sets limits`() { + val updated = reduce( + CashScreenViewModel.Event.OnLimitsChanged(null) + )(CashScreenViewModel.State()) + assertNull(updated.limits) + } + + @Test + fun `OnCurrencyChanged sets currencyModel`() { + val currency = Currency(code = "EUR", name = "Euro", symbol = "€", rate = 0.92) + val updated = reduce( + CashScreenViewModel.Event.OnCurrencyChanged(currency) + )(CashScreenViewModel.State()) + assertNotNull(updated.currencyModel.selected) + assertEquals("EUR", updated.currencyModel.selected?.code) + assertEquals(CurrencyCode.EUR, updated.currencyModel.code) + } + + @Test + fun `OnCurrencyChanged preserves fractionUnits`() { + val currency = Currency(code = "JPY", name = "Japanese Yen", symbol = "¥", fractionUnits = 0) + val updated = reduce( + CashScreenViewModel.Event.OnCurrencyChanged(currency) + )(CashScreenViewModel.State()) + assertEquals(0, updated.currencyModel.fractionUnits) + } + + // --- No-op events --- + + @Test + fun `no-op events return state unchanged`() { + val state = CashScreenViewModel.State(selectedTokenAddress = mint()) + val noOpEvents = listOf( + CashScreenViewModel.Event.InitializeToken(null), + CashScreenViewModel.Event.OnBackspace, + CashScreenViewModel.Event.OnGive, + CashScreenViewModel.Event.OnEnteredNumberChanged(), + CashScreenViewModel.Event.OnNumberPressed(5), + CashScreenViewModel.Event.OnDecimalPressed, + CashScreenViewModel.Event.AddCashToWallet(Fiat.Zero), + CashScreenViewModel.Event.OpenScreen(com.flipcash.app.core.AppRoute.Loading), + ) + noOpEvents.forEach { event -> + assertEquals(state, reduce(event)(state), "Event $event should be no-op") + } + } + + // --- Computed: canGive --- + + @Test + fun `canGive is false when amount is zero`() { + assertFalse(CashScreenViewModel.State().canGive) + } + + // --- Computed: maxAvailableForGive --- + + @Test + fun `maxAvailableForGive is empty when maxForGive is null`() { + val state = CashScreenViewModel.State(maxForGive = null) + assertEquals("", state.maxAvailableForGive) + } + + @Test + fun `maxAvailableForGive is formatted when maxForGive is set`() { + val state = CashScreenViewModel.State(maxForGive = 100.0 to CurrencyCode.USD) + assertTrue(state.maxAvailableForGive.isNotEmpty()) + } + + // --- Computed: isError --- + + @Test + fun `isError is true when maxForGive is null`() { + // Default amountData has "0" text (not empty), and maxForGive is null → error + assertTrue(CashScreenViewModel.State().isError) + } + + @Test + fun `isError is false when amount within maxForGive`() { + // Default amount 0.0 <= 100.0 + val state = CashScreenViewModel.State(maxForGive = 100.0 to CurrencyCode.USD) + assertFalse(state.isError) + } +} diff --git a/apps/flipcash/features/deposit/build.gradle.kts b/apps/flipcash/features/deposit/build.gradle.kts index ac6970387..d317e6443 100644 --- a/apps/flipcash/features/deposit/build.gradle.kts +++ b/apps/flipcash/features/deposit/build.gradle.kts @@ -7,6 +7,8 @@ android { } dependencies { + testImplementation(kotlin("test")) + implementation(project(":libs:messaging")) implementation(project(":apps:flipcash:shared:featureflags")) diff --git a/apps/flipcash/features/deposit/src/test/kotlin/com/flipcash/app/deposit/internal/DepositViewModelStateTest.kt b/apps/flipcash/features/deposit/src/test/kotlin/com/flipcash/app/deposit/internal/DepositViewModelStateTest.kt new file mode 100644 index 000000000..3b3d8fbc2 --- /dev/null +++ b/apps/flipcash/features/deposit/src/test/kotlin/com/flipcash/app/deposit/internal/DepositViewModelStateTest.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.deposit.internal + +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DepositViewModelStateTest { + + private val reduce = DepositViewModel.Companion.updateStateForEvent + private fun mint() = com.getcode.solana.keys.Mint(ByteArray(32) { 1 }.toList()) + + @Test + fun `default state has null address and token name`() { + val state = DepositViewModel.State() + assertNull(state.selectedTokenAddress) + assertNull(state.tokenName) + assertEquals("", state.depositAddress) + assertFalse(state.isCopied) + } + + @Test + fun `OnMintSelected sets selected token address`() { + val mint = mint() + val updated = reduce(DepositViewModel.Event.OnMintSelected(mint))(DepositViewModel.State()) + assertEquals(mint, updated.selectedTokenAddress) + } + + @Test + fun `OnTokenChanged sets address and name`() { + val updated = reduce( + DepositViewModel.Event.OnTokenChanged(address = "abc123", name = "TestToken") + )(DepositViewModel.State()) + assertEquals("abc123", updated.depositAddress) + assertEquals("TestToken", updated.tokenName) + } + + @Test + fun `SetCopied true then false roundtrip`() { + val state = DepositViewModel.State() + val copied = reduce(DepositViewModel.Event.SetCopied(true))(state) + assertTrue(copied.isCopied) + val uncopied = reduce(DepositViewModel.Event.SetCopied(false))(copied) + assertFalse(uncopied.isCopied) + } + + @Test + fun `CopyAddress is no-op`() { + val state = DepositViewModel.State(depositAddress = "addr", tokenName = "T") + val updated = reduce(DepositViewModel.Event.CopyAddress)(state) + assertEquals(state, updated) + } + + @Test + fun `Exit is no-op`() { + val state = DepositViewModel.State(depositAddress = "addr") + val updated = reduce(DepositViewModel.Event.Exit)(state) + assertEquals(state, updated) + } +} diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index 0ef81ea23..dac262a12 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -7,6 +7,8 @@ android { } dependencies { + testImplementation(kotlin("test")) + implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:menu")) diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt new file mode 100644 index 000000000..24fa4947f --- /dev/null +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/MyAccountScreenViewModelStateTest.kt @@ -0,0 +1,123 @@ +package com.flipcash.app.myaccount.internal + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MyAccountScreenViewModelStateTest { + + private val reduce = MyAccountScreenViewModel.Companion.updateStateForEvent + + @Test + fun `default state has null user info and beta disabled`() { + val state = MyAccountScreenViewModel.State() + assertNull(state.accountId) + assertNull(state.publicKey) + assertNull(state.pushToken) + assertFalse(state.isBetaEnabled) + assertFalse(state.showAccountInfo) + } + + @Test + fun `OnUserAssociated sets user fields`() { + val updated = reduce( + MyAccountScreenViewModel.Event.OnUserAssociated( + userId = "user-123", + publicKey = "pk-abc", + pushToken = "token-xyz" + ) + )(MyAccountScreenViewModel.State()) + assertEquals("user-123", updated.accountId) + assertEquals("pk-abc", updated.publicKey) + assertEquals("token-xyz", updated.pushToken) + } + + @Test + fun `OnUserAssociated with null values`() { + val updated = reduce( + MyAccountScreenViewModel.Event.OnUserAssociated( + userId = null, + publicKey = null, + pushToken = null + ) + )(MyAccountScreenViewModel.State()) + assertNull(updated.accountId) + assertNull(updated.publicKey) + assertNull(updated.pushToken) + } + + @Test + fun `OnBetaFeaturesUnlocked true enables beta and shows all menu items`() { + val updated = reduce( + MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(true) + )(MyAccountScreenViewModel.State()) + assertTrue(updated.isBetaEnabled) + assertTrue(updated.items.any { it is VerifyPhone }) + assertTrue(updated.items.any { it is VerifyEmail }) + } + + @Test + fun `OnBetaFeaturesUnlocked false disables beta and hides verification items`() { + val state = MyAccountScreenViewModel.State(isBetaEnabled = true) + val updated = reduce( + MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(false) + )(state) + assertFalse(updated.isBetaEnabled) + assertFalse(updated.items.any { it is VerifyPhone }) + assertFalse(updated.items.any { it is VerifyEmail }) + } + + @Test + fun `menu always contains AccessKey LogOut and DeleteAccount`() { + val withBeta = reduce( + MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(true) + )(MyAccountScreenViewModel.State()) + assertTrue(withBeta.items.any { it is AccessKey }) + assertTrue(withBeta.items.any { it is LogOut }) + assertTrue(withBeta.items.any { it is DeleteAccount }) + + val withoutBeta = reduce( + MyAccountScreenViewModel.Event.OnBetaFeaturesUnlocked(false) + )(MyAccountScreenViewModel.State()) + assertTrue(withoutBeta.items.any { it is AccessKey }) + assertTrue(withoutBeta.items.any { it is LogOut }) + assertTrue(withoutBeta.items.any { it is DeleteAccount }) + } + + @Test + fun `ToggleAccountInfo sets showAccountInfo`() { + val shown = reduce( + MyAccountScreenViewModel.Event.ToggleAccountInfo(true) + )(MyAccountScreenViewModel.State()) + assertTrue(shown.showAccountInfo) + + val hidden = reduce( + MyAccountScreenViewModel.Event.ToggleAccountInfo(false) + )(shown) + assertFalse(hidden.showAccountInfo) + } + + @Test + fun `no-op events return state unchanged`() { + val state = MyAccountScreenViewModel.State(accountId = "test", isBetaEnabled = true) + val noOpEvents = listOf( + MyAccountScreenViewModel.Event.OnLogOutClicked, + MyAccountScreenViewModel.Event.OnLoggedOutCompletely, + MyAccountScreenViewModel.Event.OnVerifyPhoneClicked, + MyAccountScreenViewModel.Event.OnVerifyEmailClicked, + MyAccountScreenViewModel.Event.OnViewAccessKey, + MyAccountScreenViewModel.Event.CopyPublicKey, + MyAccountScreenViewModel.Event.CopyAccountId, + MyAccountScreenViewModel.Event.CopyPushToken, + MyAccountScreenViewModel.Event.OnTitleClicked, + MyAccountScreenViewModel.Event.OnDeleteAccountClicked, + MyAccountScreenViewModel.Event.OnAccountDeleted, + MyAccountScreenViewModel.Event.OnAccessKeyClicked, + ) + noOpEvents.forEach { event -> + assertEquals(state, reduce(event)(state), "Event $event should be no-op") + } + } +} diff --git a/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelStateTest.kt b/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelStateTest.kt new file mode 100644 index 000000000..9f1d835c1 --- /dev/null +++ b/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelStateTest.kt @@ -0,0 +1,161 @@ +package com.flipcash.app.withdrawal + +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.ui.components.text.AmountAnimatedInputUiModel +import com.getcode.view.LoadingSuccessState +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Tests for WithdrawalViewModel pure state reducers and computed properties. + */ +class WithdrawalViewModelStateTest { + + private val reduce = WithdrawalViewModel.Companion.updateStateForEvent + + // --- updateStateForEvent --- + + @Test + fun `OnFeeChanged updates fee amount`() { + val fee = Fiat(100, CurrencyCode.USD) + val updated = reduce(WithdrawalViewModel.Event.OnFeeChanged(fee))(WithdrawalViewModel.State()) + assertEquals(fee, updated.feeAmount) + } + + @Test + fun `OnFeeChanged with null clears fee`() { + val state = WithdrawalViewModel.State(feeAmount = Fiat(100, CurrencyCode.USD)) + val updated = reduce(WithdrawalViewModel.Event.OnFeeChanged(null))(state) + assertEquals(null, updated.feeAmount) + } + + @Test + fun `OnEntryRateUpdated sets entry rate`() { + val rate = Rate(1.35, CurrencyCode.CAD) + val updated = reduce(WithdrawalViewModel.Event.OnEntryRateUpdated(rate))(WithdrawalViewModel.State()) + assertEquals(rate, updated.entryRate) + } + + @Test + fun `OnAmountAccepted updates selected amount and resets confirming state`() { + val amount = VerifiedFiat(LocalFiat.Zero, null) + val state = WithdrawalViewModel.State( + amountEntryState = AmountEntryState( + confirmingAmount = LoadingSuccessState(loading = true) + ) + ) + val updated = reduce(WithdrawalViewModel.Event.OnAmountAccepted(amount))(state) + assertEquals(amount, updated.amountEntryState.selectedAmount) + assertEquals(false, updated.amountEntryState.confirmingAmount.loading) + } + + @Test + fun `UpdateConfirmingAmountState sets loading`() { + val updated = reduce( + WithdrawalViewModel.Event.UpdateConfirmingAmountState(loading = true) + )(WithdrawalViewModel.State()) + assertEquals(true, updated.amountEntryState.confirmingAmount.loading) + } + + @Test + fun `UpdateConfirmingAmountState sets success`() { + val updated = reduce( + WithdrawalViewModel.Event.UpdateConfirmingAmountState(loading = false, success = true) + )(WithdrawalViewModel.State()) + assertEquals(true, updated.amountEntryState.confirmingAmount.success) + assertEquals(false, updated.amountEntryState.confirmingAmount.loading) + } + + @Test + fun `UpdateWithdrawalState sets loading and success`() { + val updated = reduce( + WithdrawalViewModel.Event.UpdateWithdrawalState(loading = false, success = true) + )(WithdrawalViewModel.State()) + assertEquals(true, updated.withdrawalState.success) + assertEquals(false, updated.withdrawalState.loading) + } + + @Test + fun `UpdateWithdrawalState sets error`() { + val updated = reduce( + WithdrawalViewModel.Event.UpdateWithdrawalState(error = true) + )(WithdrawalViewModel.State()) + assertEquals(true, updated.withdrawalState.error) + } + + @Test + fun `OnAmountChanged updates amount model`() { + val model = AmountAnimatedInputUiModel(lastPressedBackspace = true) + val updated = reduce( + WithdrawalViewModel.Event.OnAmountChanged(model) + )(WithdrawalViewModel.State()) + assertEquals(model, updated.amountEntryState.amountAnimatedModel) + } + + @Test + fun `no-op events return state unchanged`() { + val state = WithdrawalViewModel.State(entryRate = Rate.oneToOne) + val noOpEvents = listOf( + WithdrawalViewModel.Event.OnAmountConfirmed, + WithdrawalViewModel.Event.OnWithdraw, + WithdrawalViewModel.Event.OnBackspace, + WithdrawalViewModel.Event.OnDecimalPressed, + WithdrawalViewModel.Event.PasteFromClipboard, + WithdrawalViewModel.Event.OnDestinationConfirmed, + WithdrawalViewModel.Event.OnLearnAboutFee, + WithdrawalViewModel.Event.OnWithdrawalTooSmall, + WithdrawalViewModel.Event.ConfirmWithdrawal, + WithdrawalViewModel.Event.OnWithdrawalConfirmed, + WithdrawalViewModel.Event.OnWithdrawSuccessful, + WithdrawalViewModel.Event.ProceedWithWithdrawal, + WithdrawalViewModel.Event.ProceedWithWithdrawalViaSwapper, + ) + noOpEvents.forEach { event -> + assertEquals(state, reduce(event)(state), "Event $event should be no-op") + } + } + + // --- State.canWithdraw --- + + @Test + fun `canWithdraw false when amount is zero`() { + val state = WithdrawalViewModel.State() + assertEquals(false, state.canWithdraw) + } + + // --- State.feeInEntryCurrency --- + + @Test + fun `feeInEntryCurrency is null when fee is null`() { + val state = WithdrawalViewModel.State(feeAmount = null, entryRate = Rate.oneToOne) + assertEquals(null, state.feeInEntryCurrency) + } + + @Test + fun `feeInEntryCurrency is null when entry rate is null`() { + val state = WithdrawalViewModel.State(feeAmount = Fiat(100, CurrencyCode.USD), entryRate = null) + assertEquals(null, state.feeInEntryCurrency) + } + + @Test + fun `feeInEntryCurrency is non-null when both fee and rate present`() { + val state = WithdrawalViewModel.State( + feeAmount = Fiat(100, CurrencyCode.USD), + entryRate = Rate.oneToOne + ) + val fee = state.feeInEntryCurrency + assertEquals(100.0, fee?.decimalValue) + } + + // --- State.tokenBalance --- + + @Test + fun `tokenBalance is Zero when token is null`() { + val state = WithdrawalViewModel.State(token = null) + assertEquals(Fiat.Zero, state.tokenBalance) + } +} diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/app/payments/PurchaseMethodStateTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/app/payments/PurchaseMethodStateTest.kt new file mode 100644 index 000000000..ad739e6d8 --- /dev/null +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/app/payments/PurchaseMethodStateTest.kt @@ -0,0 +1,76 @@ +package com.flipcash.app.payments + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class PurchaseMethodStateTest { + + @Test + fun defaultStateHasOnlyPhantomWallet() { + val state = PurchaseMethodState() + assertEquals(1, state.availableMethods.size) + assertIs(state.availableMethods[0]) + } + + @Test + fun hasReservesIsFalseForZeroBalance() { + val state = PurchaseMethodState() + assertFalse(state.hasReserves) + } + + @Test + fun coinbaseOnRampIncludedWhenAvailable() { + val state = PurchaseMethodState(coinbaseOnRampAvailable = true) + assertTrue(state.availableMethods.any { it is PurchaseMethod.CoinbaseOnRamp }) + } + + @Test + fun coinbaseOnRampExcludedWhenUnavailable() { + val state = PurchaseMethodState(coinbaseOnRampAvailable = false) + assertFalse(state.availableMethods.any { it is PurchaseMethod.CoinbaseOnRamp }) + } + + @Test + fun otherWalletIncludedWhenAllowed() { + val state = PurchaseMethodState(canUseOtherWallets = true) + assertTrue(state.availableMethods.any { it is PurchaseMethod.OtherWallet }) + } + + @Test + fun otherWalletExcludedWhenNotAllowed() { + val state = PurchaseMethodState(canUseOtherWallets = false) + assertFalse(state.availableMethods.any { it is PurchaseMethod.OtherWallet }) + } + + @Test + fun cashReservesExcludedWhenZeroBalance() { + val state = PurchaseMethodState() + assertFalse(state.availableMethods.any { it is PurchaseMethod.CashReserves }) + } + + @Test + fun phantomWalletAlwaysPresent() { + val state = PurchaseMethodState( + coinbaseOnRampAvailable = true, + canUseOtherWallets = true, + ) + assertTrue(state.availableMethods.any { it is PurchaseMethod.PhantomWallet }) + } + + @Test + fun orderingCoinbaseBeforePhantomBeforeOther() { + val state = PurchaseMethodState( + coinbaseOnRampAvailable = true, + canUseOtherWallets = true, + ) + val methods = state.availableMethods + val coinbaseIdx = methods.indexOfFirst { it is PurchaseMethod.CoinbaseOnRamp } + val phantomIdx = methods.indexOfFirst { it is PurchaseMethod.PhantomWallet } + val otherIdx = methods.indexOfFirst { it is PurchaseMethod.OtherWallet } + assertTrue(coinbaseIdx < phantomIdx) + assertTrue(phantomIdx < otherIdx) + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/MessageEntityToFeedMessageMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/MessageEntityToFeedMessageMapperTest.kt new file mode 100644 index 000000000..ea494dbbf --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/MessageEntityToFeedMessageMapperTest.kt @@ -0,0 +1,135 @@ +package com.flipcash.app.persistence.sources.mapper.notifications + +import com.flipcash.app.core.feed.MessageState +import com.flipcash.app.persistence.entities.MessageEntity +import com.getcode.opencode.model.financial.CurrencyCode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Instant + +class MessageEntityToFeedMessageMapperTest { + + private val mapper = MessageEntityToFeedMessageMapper() + + // Hardcoded mint addresses to avoid Mint companion object (uses Base58 + android deps) + private val USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + private val USDF_MINT = "5AMAA9JV9H97YYVxx8F6FsCMmTwXSuTTQneiup4RYAUQ" + + private fun entity( + amountUsdc: Long? = 100_000L, + amountNative: Long? = 200_000L, + nativeCurrency: String? = "cad", + rate: Double? = 1.35, + mintBase58: String? = USDC_MINT, + state: String = "COMPLETED", + metadata: String? = null, + timestamp: Long = 1_700_000_000_000L, + ) = MessageEntity( + idBase58 = "11111111111111111111111111111111", + text = "test message", + amountUsdc = amountUsdc, + amountNative = amountNative, + nativeCurrency = nativeCurrency, + rate = rate, + state = state, + timestamp = timestamp, + metadata = metadata, + mintBase58 = mintBase58, + ) + + // --- Amount resolution --- + + @Test + fun amountIsNonNullWhenAllFieldsPresent() { + val result = mapper.map(entity()) + assertNotNull(result.amount) + assertEquals(CurrencyCode.USD, result.amount!!.underlyingTokenAmount.currencyCode) + assertEquals(CurrencyCode.CAD, result.amount!!.nativeAmount.currencyCode) + } + + @Test + fun amountIsNullWhenAmountUsdcMissing() { + val result = mapper.map(entity(amountUsdc = null)) + assertNull(result.amount) + } + + @Test + fun amountIsNullWhenAmountNativeMissing() { + val result = mapper.map(entity(amountNative = null)) + assertNull(result.amount) + } + + @Test + fun amountIsNullWhenCurrencyMissing() { + val result = mapper.map(entity(nativeCurrency = null)) + assertNull(result.amount) + } + + @Test + fun amountIsNullWhenRateMissing() { + val result = mapper.map(entity(rate = null)) + assertNull(result.amount) + } + + @Test + fun amountIsNullWhenMintMissing() { + val result = mapper.map(entity(mintBase58 = null)) + assertNull(result.amount) + } + + @Test + fun amountIsNullWhenInvalidCurrency() { + val result = mapper.map(entity(nativeCurrency = "zzz_invalid")) + assertNull(result.amount) + } + + // --- USDF mint path --- + + @Test + fun usdfMintUsesUsdfLocalFiatConstructor() { + val result = mapper.map(entity(mintBase58 = USDF_MINT)) + assertNotNull(result.amount) + assertEquals(CurrencyCode.USD, result.amount!!.underlyingTokenAmount.currencyCode) + } + + // --- Timestamp mapping --- + + @Test + fun timestampMappedCorrectly() { + val ts = 1_700_000_000_000L + val result = mapper.map(entity(timestamp = ts)) + assertEquals(Instant.fromEpochMilliseconds(ts), result.timestamp) + } + + // --- State mapping --- + + @Test + fun stateMapsCompleted() { + val result = mapper.map(entity(state = "COMPLETED")) + assertEquals(MessageState.COMPLETED, result.state) + } + + @Test + fun stateMapsUnknownForInvalidValue() { + val result = mapper.map(entity(state = "not_a_real_state")) + assertEquals(MessageState.UNKNOWN, result.state) + } + + // --- Metadata mapping --- + + @Test + fun metadataNullWhenEntityMetadataNull() { + val result = mapper.map(entity(metadata = null)) + assertNull(result.metadata) + } + + // --- Text passthrough --- + + @Test + fun textPassedThrough() { + val result = mapper.map(entity()) + assertEquals("test message", result.text) + } +} diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelStateTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelStateTest.kt new file mode 100644 index 000000000..d8f202fd0 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelStateTest.kt @@ -0,0 +1,312 @@ +package com.flipcash.app.tokens.ui + +import com.flipcash.app.core.tokens.SwapPurpose +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.internal.solana.model.SwapId +import com.getcode.opencode.model.financial.Currency +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.solana.keys.Mint +import com.getcode.ui.components.text.AmountAnimatedInputUiModel +import com.getcode.view.LoadingSuccessState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SwapViewModelStateTest { + + private val reduce = SwapViewModel.Companion.updateStateForEvent + private fun mint() = Mint(ByteArray(32) { 1 }.toList()) + + // --- Default state --- + + @Test + fun `default state`() { + val state = SwapViewModel.State() + assertNull(state.purpose) + assertNull(state.tokenWithBalance) + assertNull(state.reservesWithBalance) + assertNull(state.swapId) + assertNull(state.confirmedNetTransferAmount) + assertFalse(state.loading) + assertFalse(state.canTransact) + } + + // --- State reducers --- + + @Test + fun `OnPurposeChanged sets purpose`() { + val purpose = SwapPurpose.Buy(mint()) + val updated = reduce(SwapViewModel.Event.OnPurposeChanged(purpose))(SwapViewModel.State()) + assertEquals(purpose, updated.purpose) + } + + @Test + fun `OnAmountChanged updates amount model in entry state`() { + val model = AmountAnimatedInputUiModel(lastPressedBackspace = true) + val updated = reduce(SwapViewModel.Event.OnAmountChanged(model))(SwapViewModel.State()) + assertEquals(model, updated.amountEntryState.amountAnimatedModel) + } + + @Test + fun `OnAmountAccepted sets selectedAmount and confirmedNetTransferAmount`() { + val amount = VerifiedFiat(LocalFiat.Zero, null) + val net = Fiat(10.0, CurrencyCode.USD) + val updated = reduce( + SwapViewModel.Event.OnAmountAccepted(amount, net) + )(SwapViewModel.State()) + assertEquals(amount, updated.amountEntryState.selectedAmount) + assertEquals(net, updated.confirmedNetTransferAmount) + } + + @Test + fun `OnMaxDetermined sets maxToAdd`() { + val updated = reduce( + SwapViewModel.Event.OnMaxDetermined(max = 500.0, currencyCode = CurrencyCode.EUR) + )(SwapViewModel.State()) + assertEquals(500.0 to CurrencyCode.EUR, updated.amountEntryState.maxToAdd) + } + + @Test + fun `UpdateBuyState sets buy loading and success`() { + val loading = reduce( + SwapViewModel.Event.UpdateBuyState(loading = true) + )(SwapViewModel.State()) + assertTrue(loading.buyProgress.loading) + assertFalse(loading.buyProgress.success) + + val success = reduce( + SwapViewModel.Event.UpdateBuyState(loading = false, success = true) + )(SwapViewModel.State()) + assertTrue(success.buyProgress.success) + assertFalse(success.buyProgress.loading) + } + + @Test + fun `UpdateSellState sets sell loading and success`() { + val loading = reduce( + SwapViewModel.Event.UpdateSellState(loading = true) + )(SwapViewModel.State()) + assertTrue(loading.sellProgress.loading) + + val success = reduce( + SwapViewModel.Event.UpdateSellState(loading = false, success = true) + )(SwapViewModel.State()) + assertTrue(success.sellProgress.success) + } + + @Test + fun `UpdateProcessingState sets loading, success, and error`() { + val error = reduce( + SwapViewModel.Event.UpdateProcessingState(error = true) + )(SwapViewModel.State()) + assertTrue(error.processingProgress.error) + assertFalse(error.processingProgress.loading) + } + + @Test + fun `OnLimitsChanged sets limits in entry state`() { + val updated = reduce( + SwapViewModel.Event.OnLimitsChanged(null) + )(SwapViewModel.State()) + assertNull(updated.amountEntryState.limits) + } + + @Test + fun `OnCurrencyChanged sets currencyModel in entry state`() { + val currency = Currency(code = "GBP", name = "British Pound", symbol = "£", rate = 0.79) + val updated = reduce( + SwapViewModel.Event.OnCurrencyChanged(currency) + )(SwapViewModel.State()) + assertNotNull(updated.amountEntryState.currencyModel.selected) + assertEquals("GBP", updated.amountEntryState.currencyModel.selected?.code) + assertEquals(CurrencyCode.GBP, updated.amountEntryState.currencyModel.code) + } + + @Test + fun `OnSwapIdChanged sets swapId`() { + val swapId = SwapId(ByteArray(32) { 2 }.toList()) + val updated = reduce( + SwapViewModel.Event.OnSwapIdChanged(swapId) + )(SwapViewModel.State()) + assertEquals(swapId, updated.swapId) + } + + // --- No-op events --- + + @Test + fun `no-op events return state unchanged`() { + val state = SwapViewModel.State(purpose = SwapPurpose.Buy(mint())) + val noOpEvents = listOf( + SwapViewModel.Event.OnAmountConfirmed, + SwapViewModel.Event.OnBackspace, + SwapViewModel.Event.OnDecimalPressed, + SwapViewModel.Event.OnEnteredNumberChanged(), + SwapViewModel.Event.OnNumberPressed(3), + SwapViewModel.Event.OnSellConfirmed, + SwapViewModel.Event.ProceedWithPurchase(VerifiedFiat(LocalFiat.Zero, null)), + SwapViewModel.Event.ProceedWithSale(VerifiedFiat(LocalFiat.Zero, null)), + SwapViewModel.Event.OnTransactionSuccessful, + SwapViewModel.Event.ShowSellReceipt, + SwapViewModel.Event.Exit, + ) + noOpEvents.forEach { event -> + assertEquals(state, reduce(event)(state), "Event $event should be no-op") + } + } + + // --- Computed: tokenBalance --- + + @Test + fun `tokenBalance is Zero when tokenWithBalance is null`() { + assertEquals(Fiat.Zero, SwapViewModel.State().tokenBalance) + } + + // --- Computed: reservesBalance --- + + @Test + fun `reservesBalance is Zero when reservesWithBalance is null`() { + assertEquals(Fiat.Zero, SwapViewModel.State().reservesBalance) + } + + // --- Computed: canTransact --- + + @Test + fun `canTransact is false when amount is zero`() { + assertFalse(SwapViewModel.State().canTransact) + } + + @Test + fun `canTransact is false when buyProgress is loading`() { + val state = SwapViewModel.State( + buyProgress = LoadingSuccessState(loading = true) + ) + assertFalse(state.canTransact) + } + + @Test + fun `canTransact is false when sellProgress is loading`() { + val state = SwapViewModel.State( + sellProgress = LoadingSuccessState(loading = true) + ) + assertFalse(state.canTransact) + } + + @Test + fun `canTransact is false when processingProgress is loading`() { + val state = SwapViewModel.State( + processingProgress = LoadingSuccessState(loading = true) + ) + assertFalse(state.canTransact) + } + + // --- Computed: sellFee --- + + @Test + fun `sellFee is null when tokenWithBalance is null`() { + assertNull(SwapViewModel.State().sellFee) + } + + // --- Computed: tokenName --- + + @Test + fun `tokenName is empty when tokenWithBalance is null`() { + assertEquals("", SwapViewModel.State().tokenName) + } + + // --- Computed: maxAvailableToSwap --- + + @Test + fun `maxAvailableToSwap is empty when purpose is null`() { + assertEquals("", SwapViewModel.State().maxAvailableToSwap) + } + + // --- Computed: netTransferAmount --- + + @Test + fun `netTransferAmount uses confirmedNetTransferAmount when set`() { + val confirmed = Fiat(42.0, CurrencyCode.USD) + val state = SwapViewModel.State(confirmedNetTransferAmount = confirmed) + assertEquals(confirmed, state.netTransferAmount) + } + + // --- Computed: netTransferAmount --- + + @Test + fun `netTransferAmount falls back to enteredAmount for BalanceIncrease purpose`() { + // Buy is a BalanceIncrease, so netTransferAmount = enteredAmount when confirmedNetTransferAmount is null + val state = SwapViewModel.State(purpose = SwapPurpose.Buy(mint())) + // Default enteredAmount is Fiat(0.0, CurrencyCode.USD) since tokenBalance is Zero + assertEquals(state.enteredAmount, state.netTransferAmount) + } + + @Test + fun `netTransferAmount subtracts fee for non-BalanceIncrease purpose`() { + // Sell is BalanceDecrease, so netTransferAmount = enteredAmount - feeAmount + // With no tokenWithBalance, sellFee is null, feeAmount = Zero, so net = entered - 0 = entered + val state = SwapViewModel.State(purpose = SwapPurpose.Sell(mint())) + assertEquals(state.enteredAmount.decimalValue - state.feeAmount.decimalValue, state.netTransferAmount.decimalValue, 0.001) + } + + // --- Computed: enteredAmount --- + + @Test + fun `enteredAmount defaults to zero with USD currency`() { + val state = SwapViewModel.State() + assertEquals(0.0, state.enteredAmount.decimalValue, 0.001) + assertEquals(CurrencyCode.USD, state.enteredAmount.currencyCode) + } + + // --- Computed: feeAmount --- + + @Test + fun `feeAmount is Zero when sellFee is null`() { + assertEquals(Fiat.Zero, SwapViewModel.State().feeAmount) + } + + // --- Computed: maxAvailableToSwap per purpose --- + + @Test + fun `maxAvailableToSwap for Buy uses maxToAdd`() { + val state = SwapViewModel.State( + purpose = SwapPurpose.Buy(mint()), + amountEntryState = AmountEntryState(maxToAdd = 200.0 to CurrencyCode.USD) + ) + assertTrue(state.maxAvailableToSwap.isNotEmpty()) + } + + @Test + fun `maxAvailableToSwap for Buy is empty when maxToAdd is null`() { + val state = SwapViewModel.State( + purpose = SwapPurpose.Buy(mint()), + ) + assertEquals("", state.maxAvailableToSwap) + } + + // --- Computed: transactionLimit per purpose --- + + @Test + fun `transactionLimit for Sell is tokenBalance`() { + val state = SwapViewModel.State(purpose = SwapPurpose.Sell(mint())) + // tokenWithBalance is null → tokenBalance = Fiat.Zero + assertEquals(Fiat.Zero, state.transactionLimit) + } + + // --- Computed: isError --- + + @Test + fun `isError is false when amount is empty`() { + assertFalse(SwapViewModel.State().isError) + } + + // --- Computed: transactionLimit --- + + @Test + fun `transactionLimit is Zero when purpose is null`() { + assertEquals(Fiat.Zero, SwapViewModel.State().transactionLimit) + } +} diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/AssociatedTokenProgramTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/AssociatedTokenProgramTest.kt new file mode 100644 index 000000000..8a46faedc --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/AssociatedTokenProgramTest.kt @@ -0,0 +1,34 @@ +package com.getcode.solana.instructions.programs + +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AssociatedTokenProgramTest { + + private fun key(seed: Byte) = PublicKey(ByteArray(32) { seed }.toList()) + + @Test + fun createAccountRoundtrip() { + val original = AssociatedTokenProgram_CreateAccount( + subsidizer = key(1), owner = key(2), + associatedTokenAccount = key(3), mint = key(4), + ) + val decoded = AssociatedTokenProgram_CreateAccount.newInstance(original.instruction()) + assertEquals(original.subsidizer, decoded.subsidizer) + assertEquals(original.mint, decoded.mint) + } + + @Test + fun createAccountEncodeIsEmpty() { + val inst = AssociatedTokenProgram_CreateAccount(key(1), key(2), key(3), key(4)) + assertTrue(inst.encode().isEmpty()) + } + + @Test + fun createAccountInstructionHasSevenAccounts() { + val inst = AssociatedTokenProgram_CreateAccount(key(1), key(2), key(3), key(4)) + assertEquals(7, inst.instruction().accounts.size) + } +} diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/SwapValidatorProgramTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/SwapValidatorProgramTest.kt new file mode 100644 index 000000000..ee2b30a93 --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/SwapValidatorProgramTest.kt @@ -0,0 +1,94 @@ +package com.getcode.solana.instructions.programs + +import com.getcode.solana.keys.AccountMeta +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SwapValidatorProgramTest { + + private fun key(seed: Byte) = PublicKey(ByteArray(32) { seed }.toList()) + + // --- PreSwap --- + + @Test + fun preSwapRoundtrip() { + val remaining = listOf(AccountMeta.readonly(publicKey = key(10))) + val original = SwapValidatorProgram_PreSwap( + preSwapState = key(1), user = key(2), source = key(3), destination = key(4), + nonce = key(5), payer = key(6), remainingAccounts = remaining, + ) + val decoded = SwapValidatorProgram_PreSwap.newInstance(original.instruction()) + assertEquals(original.preSwapState, decoded.preSwapState) + assertEquals(original.user, decoded.user) + assertEquals(original.source, decoded.source) + assertEquals(original.destination, decoded.destination) + assertEquals(original.nonce, decoded.nonce) + assertEquals(original.payer, decoded.payer) + } + + @Test + fun preSwapNoRemainingAccounts() { + val original = SwapValidatorProgram_PreSwap( + preSwapState = key(1), user = key(2), source = key(3), destination = key(4), + nonce = key(5), payer = key(6), remainingAccounts = emptyList(), + ) + val decoded = SwapValidatorProgram_PreSwap.newInstance(original.instruction()) + assertEquals(original.preSwapState, decoded.preSwapState) + } + + @Test + fun preSwapEncodeLength() { + val inst = SwapValidatorProgram_PreSwap( + key(1), key(2), key(3), key(4), key(5), key(6), emptyList(), + ) + // 8 bytes command only (no data fields) + assertEquals(8, inst.encode().size) + } + + // --- PostSwap --- + + @Test + fun postSwapRoundtrip() { + val original = SwapValidatorProgram_PostSwap( + stateBump = 7.toByte(), maxToSend = 5_000_000L, minToReceive = 4_800_000L, + preSwapState = key(1), source = key(2), destination = key(3), payer = key(4), + ) + val decoded = SwapValidatorProgram_PostSwap.newInstance(original.instruction()) + assertEquals(7.toByte(), decoded.stateBump) + assertEquals(5_000_000L, decoded.maxToSend) + assertEquals(4_800_000L, decoded.minToReceive) + assertEquals(original.preSwapState, decoded.preSwapState) + } + + @Test + fun postSwapZeroValues() { + val original = SwapValidatorProgram_PostSwap( + stateBump = 0, maxToSend = 0L, minToReceive = 0L, + preSwapState = key(1), source = key(2), destination = key(3), payer = key(4), + ) + val decoded = SwapValidatorProgram_PostSwap.newInstance(original.instruction()) + assertEquals(0.toByte(), decoded.stateBump) + assertEquals(0L, decoded.maxToSend) + assertEquals(0L, decoded.minToReceive) + } + + @Test + fun postSwapEncodeLength() { + val inst = SwapValidatorProgram_PostSwap( + stateBump = 0, maxToSend = 1L, minToReceive = 1L, + preSwapState = key(1), source = key(2), destination = key(3), payer = key(4), + ) + // 8 bytes command + 1 byte bump + 8 bytes maxToSend + 8 bytes minToReceive + assertEquals(25, inst.encode().size) + } + + // --- Command enum --- + + @Test + fun commandValuesAreDistinct() { + val commands = SwapValidatorProgram.Command.entries + assertEquals(commands.size, commands.map { it.value }.toSet().size) + } +} diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/SystemProgramTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/SystemProgramTest.kt new file mode 100644 index 000000000..06740dc0c --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/SystemProgramTest.kt @@ -0,0 +1,36 @@ +package com.getcode.solana.instructions.programs + +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals + +class SystemProgramTest { + + private fun key(seed: Byte) = PublicKey(ByteArray(32) { seed }.toList()) + + @Test + fun advanceNonceRoundtrip() { + val original = SystemProgram_AdvanceNonce(nonce = key(1), authority = key(2)) + val decoded = SystemProgram_AdvanceNonce.newInstance(original.instruction()) + assertEquals(original.nonce, decoded.nonce) + assertEquals(original.authority, decoded.authority) + } + + @Test + fun advanceNonceEncodeLength() { + val inst = SystemProgram_AdvanceNonce(key(1), key(2)) + // 4 bytes Int command + assertEquals(4, inst.encode().size) + } + + @Test + fun advanceNonceCommandByteValue() { + val inst = SystemProgram_AdvanceNonce(key(1), key(2)) + val encoded = inst.encode() + // advanceNonceAccount ordinal is 4 → LE bytes [4, 0, 0, 0] + assertEquals(4, encoded[0].toInt()) + assertEquals(0, encoded[1].toInt()) + assertEquals(0, encoded[2].toInt()) + assertEquals(0, encoded[3].toInt()) + } +} diff --git a/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/TimelockProgramTest.kt b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/TimelockProgramTest.kt new file mode 100644 index 000000000..91dacd228 --- /dev/null +++ b/libs/crypto/solana/src/test/kotlin/com/getcode/solana/instructions/programs/TimelockProgramTest.kt @@ -0,0 +1,192 @@ +package com.getcode.solana.instructions.programs + +import com.getcode.model.Kin +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals + +class TimelockProgramTest { + + private fun key(seed: Byte) = PublicKey(ByteArray(32) { seed }.toList()) + + // --- Initialize --- + + @Test + fun initializeRoundtrip() { + val original = TimelockProgram_Initialize( + nonce = key(1), timelock = key(2), vault = key(3), vaultOwner = key(4), + mint = key(5), timeAuthority = key(6), payer = key(7), lockout = 86400L, + ) + val decoded = TimelockProgram_Initialize.newInstance(original.instruction()) + assertEquals(86400L, decoded.lockout) + assertEquals(original.nonce, decoded.nonce) + assertEquals(original.timelock, decoded.timelock) + assertEquals(original.vault, decoded.vault) + } + + @Test + fun initializeLockoutZero() { + val original = TimelockProgram_Initialize( + nonce = key(1), timelock = key(2), vault = key(3), vaultOwner = key(4), + mint = key(5), timeAuthority = key(6), payer = key(7), lockout = 0L, + ) + val decoded = TimelockProgram_Initialize.newInstance(original.instruction()) + assertEquals(0L, decoded.lockout) + } + + @Test + fun initializeEncodeLength() { + val inst = TimelockProgram_Initialize( + key(1), key(2), key(3), key(4), key(5), key(6), key(7), lockout = 1L, + ) + // 8 bytes command + 8 bytes lockout + assertEquals(16, inst.encode().size) + } + + // --- TransferWithAuthority --- + + @Test + fun transferWithAuthorityRoundtrip() { + val original = TimelockProgram_TransferWithAuthority( + timelock = key(1), vault = key(2), vaultOwner = key(3), timeAuthority = key(4), + destination = key(5), payer = key(6), bump = 42, quarks = 1_000_000L, + ) + val decoded = TimelockProgram_TransferWithAuthority.newInstance(original.instruction()) + assertEquals(42.toByte(), decoded.bump) + assertEquals(1_000_000L, decoded.quarks) + assertEquals(original.timelock, decoded.timelock) + assertEquals(original.destination, decoded.destination) + } + + @Test + fun transferWithAuthorityZeroQuarks() { + val original = TimelockProgram_TransferWithAuthority( + timelock = key(1), vault = key(2), vaultOwner = key(3), timeAuthority = key(4), + destination = key(5), payer = key(6), bump = 0, quarks = 0L, + ) + val decoded = TimelockProgram_TransferWithAuthority.newInstance(original.instruction()) + assertEquals(0.toByte(), decoded.bump) + assertEquals(0L, decoded.quarks) + } + + @Test + fun transferWithAuthorityEncodeLength() { + val inst = TimelockProgram_TransferWithAuthority( + key(1), key(2), key(3), key(4), key(5), key(6), bump = 1, quarks = 1L, + ) + // 8 bytes command + 1 byte bump + 8 bytes quarks + assertEquals(17, inst.encode().size) + } + + // --- Withdraw --- + + @Test + fun withdrawRoundtrip() { + val original = TimelockProgram_Withdraw( + timelock = key(1), vault = key(2), vaultOwner = key(3), + destination = key(4), payer = key(5), bump = 7, + ) + val decoded = TimelockProgram_Withdraw.newInstance(original.instruction()) + assertEquals(7.toByte(), decoded.bump) + assertEquals(original.vault, decoded.vault) + assertEquals(original.destination, decoded.destination) + } + + @Test + fun withdrawEncodeLength() { + val inst = TimelockProgram_Withdraw(key(1), key(2), key(3), key(4), key(5), bump = 1) + // 8 bytes command + 1 byte bump + assertEquals(9, inst.encode().size) + } + + // --- CloseAccounts --- + + @Test + fun closeAccountsRoundtrip() { + val original = TimelockProgram_CloseAccounts( + timelock = key(1), vault = key(2), closeAuthority = key(3), + payer = key(4), bump = 3, + ) + val decoded = TimelockProgram_CloseAccounts.newInstance(original.instruction()) + assertEquals(3.toByte(), decoded.bump) + assertEquals(original.closeAuthority, decoded.closeAuthority) + } + + @Test + fun closeAccountsEncodeLength() { + val inst = TimelockProgram_CloseAccounts(key(1), key(2), key(3), key(4), bump = 1) + assertEquals(9, inst.encode().size) + } + + // --- DeactivateLock --- + + @Test + fun deactivateLockRoundtrip() { + val original = TimelockProgram_DeactivateLock( + timelock = key(1), vaultOwner = key(2), payer = key(3), bump = 99.toByte(), + ) + val decoded = TimelockProgram_DeactivateLock.newInstance(original.instruction()) + assertEquals(99.toByte(), decoded.bump) + assertEquals(original.timelock, decoded.timelock) + } + + @Test + fun deactivateLockEncodeLength() { + val inst = TimelockProgram_DeactivateLock(key(1), key(2), key(3), bump = 1) + assertEquals(9, inst.encode().size) + } + + // --- RevokeLockWithAuthority --- + + @Test + fun revokeLockRoundtrip() { + val original = TimelockProgram_RevokeLockWithAuthority( + timelock = key(1), vault = key(2), closeAuthority = key(3), + payer = key(4), bump = 5, + ) + val decoded = TimelockProgram_RevokeLockWithAuthority.newInstance(original.instruction()) + assertEquals(5.toByte(), decoded.bump) + assertEquals(original.vault, decoded.vault) + } + + @Test + fun revokeLockEncodeLength() { + val inst = TimelockProgram_RevokeLockWithAuthority(key(1), key(2), key(3), key(4), bump = 1) + assertEquals(9, inst.encode().size) + } + + // --- BurnDustWithAuthority --- + + @Test + fun burnDustRoundtrip() { + val maxAmount = Kin.fromQuarks(500_000L) + val original = TimelockProgram_BurnDustWithAuthority( + timelock = key(1), vault = key(2), vaultOwner = key(3), timeAuthority = key(4), + mint = key(5), payer = key(6), bump = 12, maxAmount = maxAmount, + ) + val decoded = TimelockProgram_BurnDustWithAuthority.newInstance(original.instruction()) + assertEquals(12.toByte(), decoded.bump) + assertEquals(maxAmount.quarks, decoded.maxAmount.quarks) + } + + @Test + fun burnDustZeroAmount() { + val original = TimelockProgram_BurnDustWithAuthority( + timelock = key(1), vault = key(2), vaultOwner = key(3), timeAuthority = key(4), + mint = key(5), payer = key(6), bump = 0, maxAmount = Kin.fromQuarks(0), + ) + val decoded = TimelockProgram_BurnDustWithAuthority.newInstance(original.instruction()) + assertEquals(0.toByte(), decoded.bump) + assertEquals(0L, decoded.maxAmount.quarks) + } + + @Test + fun burnDustEncodeLength() { + val inst = TimelockProgram_BurnDustWithAuthority( + key(1), key(2), key(3), key(4), key(5), key(6), + bump = 1, maxAmount = Kin.fromQuarks(1), + ) + // 8 bytes command + 1 byte bump + 8 bytes maxAmount + assertEquals(17, inst.encode().size) + } +} diff --git a/libs/encryption/hmac/src/test/kotlin/com/getcode/crypt/HmacTest.kt b/libs/encryption/hmac/src/test/kotlin/com/getcode/crypt/HmacTest.kt new file mode 100644 index 000000000..a474e2505 --- /dev/null +++ b/libs/encryption/hmac/src/test/kotlin/com/getcode/crypt/HmacTest.kt @@ -0,0 +1,81 @@ +package com.getcode.crypt + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class HmacTest { + + // --- RFC 4231 Test Vectors for HMAC-SHA-512 --- + + @Test + fun hmacSha512Rfc4231TestCase1() { + val key = ByteArray(20) { 0x0b.toByte() } + val message = "Hi There".toByteArray(Charsets.UTF_8) + val result = Hmac.hmac("HmacSHA512", key, message) + assertEquals(64, result.size) + assertEquals( + "87aa7cdea5ef619d4ff0b4241a1d6cb0" + + "2379f4e2ce4ec2787ad0b30545e17cde" + + "daa833b7d6b8a702038b274eaea3f4e4" + + "be9d914eeb61f1702e696c203a126854", + Hmac.bytesToHex(result), + ) + } + + @Test + fun hmacSha512Rfc4231TestCase2() { + val key = "Jefe".toByteArray(Charsets.UTF_8) + val message = "what do ya want for nothing?".toByteArray(Charsets.UTF_8) + val result = Hmac.hmac("HmacSHA512", key, message) + assertEquals( + "164b7a7bfcf819e2e395fbe73b56e0a3" + + "87bd64222e831fd610270cd7ea250554" + + "9758bf75c05a994a6d034f65f8f0e6fd" + + "caeab1a34d4a6b4b636e070a38bce737", + Hmac.bytesToHex(result), + ) + } + + @Test + fun hmacSha256KnownVector() { + val key = "key".toByteArray(Charsets.UTF_8) + val message = "The quick brown fox jumps over the lazy dog".toByteArray(Charsets.UTF_8) + val result = Hmac.hmac("HmacSHA256", key, message) + assertEquals( + "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", + Hmac.bytesToHex(result), + ) + } + + @Test + fun deterministicOutput() { + val key = "k".toByteArray() + val msg = "m".toByteArray() + val a = Hmac.hmac("HmacSHA512", key, msg) + val b = Hmac.hmac("HmacSHA512", key, msg) + assertContentEquals(a, b) + } + + // --- bytesToHex --- + + @Test + fun bytesToHexAllZero() { + assertEquals("0000000000000000", Hmac.bytesToHex(ByteArray(8) { 0 })) + } + + @Test + fun bytesToHexAllFF() { + assertEquals("ffffffffffffffff", Hmac.bytesToHex(ByteArray(8) { 0xff.toByte() })) + } + + @Test + fun bytesToHexEmpty() { + assertEquals("", Hmac.bytesToHex(ByteArray(0))) + } + + @Test + fun bytesToHexMixed() { + assertEquals("0a1bff", Hmac.bytesToHex(byteArrayOf(0x0a, 0x1b, 0xff.toByte()))) + } +} diff --git a/libs/encryption/sha256/src/test/kotlin/com/getcode/crypt/Sha256HashTest.kt b/libs/encryption/sha256/src/test/kotlin/com/getcode/crypt/Sha256HashTest.kt new file mode 100644 index 000000000..ae90549c5 --- /dev/null +++ b/libs/encryption/sha256/src/test/kotlin/com/getcode/crypt/Sha256HashTest.kt @@ -0,0 +1,174 @@ +package com.getcode.crypt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class Sha256HashTest { + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } + + // --- Known SHA-256 vectors (NIST FIPS 180-4) --- + + @Test + fun hashEmptyInput() { + val result = Sha256Hash.hash(ByteArray(0)) + assertEquals(32, result.size) + assertEquals( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + result.toHex(), + ) + } + + @Test + fun hashAbc() { + val result = Sha256Hash.hash("abc".toByteArray(Charsets.UTF_8)) + assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + result.toHex(), + ) + } + + @Test + fun hashWithOffsetAndLength() { + val input = "XXabcYY".toByteArray(Charsets.UTF_8) + val full = Sha256Hash.hash("abc".toByteArray(Charsets.UTF_8)) + val partial = Sha256Hash.hash(input, 2, 3) + assertTrue(full.contentEquals(partial)) + } + + // --- hashTwice --- + + @Test + fun hashTwiceDiffersFromSingleHash() { + val input = "abc".toByteArray(Charsets.UTF_8) + val once = Sha256Hash.hash(input) + val twice = Sha256Hash.hashTwice(input) + assertNotEquals(once.toHex(), twice.toHex()) + } + + @Test + fun hashTwiceEqualsHashOfHash() { + val input = "abc".toByteArray(Charsets.UTF_8) + val twice = Sha256Hash.hashTwice(input) + val manual = Sha256Hash.hash(Sha256Hash.hash(input)) + assertTrue(twice.contentEquals(manual)) + } + + @Test + fun hashTwiceTwoInputs() { + val a = "abc".toByteArray(Charsets.UTF_8) + val b = "def".toByteArray(Charsets.UTF_8) + val combined = Sha256Hash.hashTwice(a + b) + val split = Sha256Hash.hashTwice(a, b) + assertTrue(combined.contentEquals(split)) + } + + // --- wrap / getBytes roundtrip --- + + @Test + fun wrapAndGetBytesRoundtrip() { + val raw = ByteArray(32) { it.toByte() } + val hash = Sha256Hash.wrap(raw) + assertTrue(raw.contentEquals(hash.bytes)) + } + + @Test + fun wrapFromHexString() { + val hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + val hash = Sha256Hash.wrap(hex) + assertEquals(hex, hash.toString()) + } + + @Test + fun wrapInvalidLengthThrows() { + assertFailsWith { + Sha256Hash.wrap(ByteArray(31)) + } + } + + // --- of --- + + @Test + fun ofWrapsHash() { + val input = "test".toByteArray(Charsets.UTF_8) + val hash = Sha256Hash.of(input) + assertTrue(Sha256Hash.hash(input).contentEquals(hash.bytes)) + } + + // --- twiceOf --- + + @Test + fun twiceOfWrapsHashTwice() { + val input = "test".toByteArray(Charsets.UTF_8) + val hash = Sha256Hash.twiceOf(input) + assertTrue(Sha256Hash.hashTwice(input).contentEquals(hash.bytes)) + } + + // --- equals / hashCode --- + + @Test + fun equalsAndHashCode() { + val raw = ByteArray(32) { 1 } + val a = Sha256Hash.wrap(raw) + val b = Sha256Hash.wrap(raw.copyOf()) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun notEqualsDifferentBytes() { + val a = Sha256Hash.wrap(ByteArray(32) { 0 }) + val b = Sha256Hash.wrap(ByteArray(32) { 1 }) + assertNotEquals(a, b) + } + + // --- compareTo --- + + @Test + fun compareToOrdering() { + val small = Sha256Hash.wrap(ByteArray(32) { 0 }) + val large = Sha256Hash.wrap(ByteArray(32) { 1 }) + assertTrue(small < large) + assertTrue(large > small) + assertEquals(0, small.compareTo(small)) + } + + // --- ZERO_HASH --- + + @Test + fun zeroHashIsAllZeros() { + assertEquals(32, Sha256Hash.ZERO_HASH.bytes.size) + Sha256Hash.ZERO_HASH.bytes.forEach { assertEquals(0, it.toInt()) } + } + + // --- toString --- + + @Test + fun toStringIsLowercaseHex() { + val hash = Sha256Hash.of("abc".toByteArray(Charsets.UTF_8)) + val str = hash.toString() + assertEquals(64, str.length) + assertTrue(str.all { it in '0'..'9' || it in 'a'..'f' }) + } + + // --- wrapReversed --- + + @Test + fun wrapReversedReversesBytesBeforeWrapping() { + val raw = ByteArray(32) { it.toByte() } + val reversed = Sha256Hash.wrapReversed(raw) + assertEquals(raw[0], reversed.bytes[31]) + assertEquals(raw[31], reversed.bytes[0]) + } + + // --- toBigInteger --- + + @Test + fun toBigIntegerPositive() { + val hash = Sha256Hash.wrap(ByteArray(32) { 0xff.toByte() }) + assertTrue(hash.toBigInteger().signum() > 0) + } +} diff --git a/libs/encryption/sha512/src/test/kotlin/com/getcode/crypt/PBKDF2SHA512Test.kt b/libs/encryption/sha512/src/test/kotlin/com/getcode/crypt/PBKDF2SHA512Test.kt new file mode 100644 index 000000000..1ce4b16eb --- /dev/null +++ b/libs/encryption/sha512/src/test/kotlin/com/getcode/crypt/PBKDF2SHA512Test.kt @@ -0,0 +1,82 @@ +package com.getcode.crypt + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PBKDF2SHA512Test { + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } + + // Known PBKDF2-HMAC-SHA512 vector: + // Python: hashlib.pbkdf2_hmac('sha512', b'password', b'salt', 1, 64) + @Test + fun knownVectorC1Len64() { + val result = PBKDF2SHA512.derive("password", "salt", 1, 64) + assertEquals(64, result.size) + assertEquals( + "867f70cf1ade02cff3752599a3a53dc4" + + "af34c7a669815ae5d513554e1c8cf252" + + "c02d470a285a0501bad999bfe943c08f" + + "050235d7d68b1da55e63f73b60a57fce", + result.toHex(), + ) + } + + // Python: hashlib.pbkdf2_hmac('sha512', b'password', b'salt', 4096, 64) + @Test + fun knownVectorC4096Len64() { + val result = PBKDF2SHA512.derive("password", "salt", 4096, 64) + assertEquals(64, result.size) + assertEquals( + "d197b1b33db0143e018b12f3d1d1479e" + + "6cdebdcc97c5c0f87f6902e072f457b5" + + "143f30602641b3d55cd335988cb36b84" + + "376060ecd532e039b742a239434af2d5", + result.toHex(), + ) + } + + @Test + fun outputLengthRespected() { + assertEquals(20, PBKDF2SHA512.derive("pass", "salt", 1, 20).size) + assertEquals(32, PBKDF2SHA512.derive("pass", "salt", 1, 32).size) + assertEquals(64, PBKDF2SHA512.derive("pass", "salt", 1, 64).size) + } + + @Test + fun deterministicOutput() { + val a = PBKDF2SHA512.derive("key", "saltsalt", 10, 64) + val b = PBKDF2SHA512.derive("key", "saltsalt", 10, 64) + assertContentEquals(a, b) + } + + @Test + fun differentIterationsProduceDifferentOutput() { + val c1 = PBKDF2SHA512.derive("password", "salt", 1, 64) + val c2 = PBKDF2SHA512.derive("password", "salt", 2, 64) + assertFalse(c1.contentEquals(c2)) + } + + @Test + fun emptySalt() { + val result = PBKDF2SHA512.derive("password", "", 1, 32) + assertEquals(32, result.size) + } + + @Test + fun differentPasswordsProduceDifferentOutput() { + val a = PBKDF2SHA512.derive("password1", "salt", 1, 32) + val b = PBKDF2SHA512.derive("password2", "salt", 1, 32) + assertFalse(a.contentEquals(b)) + } + + @Test + fun differentSaltsProduceDifferentOutput() { + val a = PBKDF2SHA512.derive("password", "salt1", 1, 32) + val b = PBKDF2SHA512.derive("password", "salt2", 1, 32) + assertFalse(a.contentEquals(b)) + } +} diff --git a/libs/encryption/utils/src/test/kotlin/com/getcode/utils/UtilsTest.kt b/libs/encryption/utils/src/test/kotlin/com/getcode/utils/UtilsTest.kt new file mode 100644 index 000000000..3983e86b9 --- /dev/null +++ b/libs/encryption/utils/src/test/kotlin/com/getcode/utils/UtilsTest.kt @@ -0,0 +1,254 @@ +package com.getcode.utils + +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class UtilsTest { + + // --- bigIntegerToBytes / bytesToBigInteger roundtrip --- + + @Test + fun bigIntegerToBytesRoundtrip() { + val value = BigInteger("123456789") + val bytes = Utils.bigIntegerToBytes(value, 8) + assertEquals(8, bytes.size) + assertEquals(value, Utils.bytesToBigInteger(bytes)) + } + + @Test + fun bigIntegerToBytesZero() { + val bytes = Utils.bigIntegerToBytes(BigInteger.ZERO, 4) + assertEquals(4, bytes.size) + bytes.forEach { assertEquals(0, it.toInt()) } + } + + @Test + fun bigIntegerToBytesNegativeThrows() { + assertFailsWith { + Utils.bigIntegerToBytes(BigInteger.valueOf(-1), 4) + } + } + + @Test + fun bigIntegerToBytesTooSmallThrows() { + assertFailsWith { + Utils.bigIntegerToBytes(BigInteger.valueOf(256), 1) + } + } + + // --- uint16 LE --- + + @Test + fun uint16LEWriteRead() { + val out = ByteArray(2) + Utils.uint16ToByteArrayLE(0x1234, out, 0) + assertEquals(0x1234, Utils.readUint16(out, 0)) + } + + @Test + fun uint16LEZero() { + val out = ByteArray(2) + Utils.uint16ToByteArrayLE(0, out, 0) + assertEquals(0, Utils.readUint16(out, 0)) + } + + @Test + fun uint16LEMaxValue() { + val out = ByteArray(2) + Utils.uint16ToByteArrayLE(0xFFFF, out, 0) + assertEquals(0xFFFF, Utils.readUint16(out, 0)) + } + + // --- uint32 LE --- + + @Test + fun uint32LEWriteRead() { + val out = ByteArray(4) + Utils.uint32ToByteArrayLE(0x12345678L, out, 0) + assertEquals(0x12345678L, Utils.readUint32(out, 0)) + } + + @Test + fun uint32LEZero() { + val out = ByteArray(4) + Utils.uint32ToByteArrayLE(0L, out, 0) + assertEquals(0L, Utils.readUint32(out, 0)) + } + + // --- uint32 BE --- + + @Test + fun uint32BEWriteRead() { + val out = ByteArray(4) + Utils.uint32ToByteArrayBE(0x12345678L, out, 0) + assertEquals(0x12345678L, Utils.readUint32BE(out, 0)) + } + + // --- int64 LE --- + + @Test + fun int64LEWriteRead() { + val out = ByteArray(8) + Utils.int64ToByteArrayLE(0x123456789ABCDEF0L, out, 0) + assertEquals(0x123456789ABCDEF0L, Utils.readInt64(out, 0)) + } + + @Test + fun int64LEZero() { + val out = ByteArray(8) + Utils.int64ToByteArrayLE(0L, out, 0) + assertEquals(0L, Utils.readInt64(out, 0)) + } + + @Test + fun int64LENegative() { + val out = ByteArray(8) + Utils.int64ToByteArrayLE(-1L, out, 0) + assertEquals(-1L, Utils.readInt64(out, 0)) + } + + // --- Stream variants --- + + @Test + fun uint16StreamLERoundtrip() { + val baos = ByteArrayOutputStream() + Utils.uint16ToByteStreamLE(0xABCD, baos) + val bytes = baos.toByteArray() + assertEquals(0xABCD, Utils.readUint16(bytes, 0)) + } + + @Test + fun uint32StreamLERoundtrip() { + val baos = ByteArrayOutputStream() + Utils.uint32ToByteStreamLE(0xDEADBEEFL, baos) + val bytes = baos.toByteArray() + assertEquals(0xDEADBEEFL, Utils.readUint32(bytes, 0)) + } + + @Test + fun int64StreamLERoundtrip() { + val baos = ByteArrayOutputStream() + Utils.int64ToByteStreamLE(Long.MAX_VALUE, baos) + val bytes = baos.toByteArray() + assertEquals(Long.MAX_VALUE, Utils.readInt64(bytes, 0)) + } + + // --- reverseBytes --- + + @Test + fun reverseBytesCorrect() { + val input = byteArrayOf(1, 2, 3, 4) + assertContentEquals(byteArrayOf(4, 3, 2, 1), Utils.reverseBytes(input)) + } + + @Test + fun reverseBytesIdempotent() { + val input = byteArrayOf(1, 2, 3, 4) + assertContentEquals(input, Utils.reverseBytes(Utils.reverseBytes(input))) + } + + @Test + fun reverseBytesEmpty() { + assertContentEquals(byteArrayOf(), Utils.reverseBytes(byteArrayOf())) + } + + // --- encodeMPI / decodeMPI roundtrip --- + + @Test + fun encodeMPIDecodeRoundtripWithoutLength() { + val value = BigInteger.valueOf(12345) + val encoded = Utils.encodeMPI(value, false) + val decoded = Utils.decodeMPI(encoded, false) + assertEquals(value, decoded) + } + + @Test + fun encodeMPIZeroWithLength() { + val encoded = Utils.encodeMPI(BigInteger.ZERO, true) + assertEquals(4, encoded.size) + encoded.forEach { assertEquals(0, it.toInt()) } + } + + @Test + fun encodeMPIZeroWithoutLength() { + val encoded = Utils.encodeMPI(BigInteger.ZERO, false) + assertEquals(0, encoded.size) + } + + @Test + fun decodeMPIZeroWithLength() { + val encoded = Utils.encodeMPI(BigInteger.ZERO, true) + assertEquals(BigInteger.ZERO, Utils.decodeMPI(encoded, true)) + } + + @Test + fun decodeMPIZeroWithoutLength() { + assertEquals(BigInteger.ZERO, Utils.decodeMPI(ByteArray(0), false)) + } + + // --- encodeCompactBits / decodeCompactBits --- + + @Test + fun compactBitsRoundtrip() { + val value = BigInteger.valueOf(0x123456) + val compact = Utils.encodeCompactBits(value) + val decoded = Utils.decodeCompactBits(compact) + assertEquals(value, decoded) + } + + // --- Mock clock --- + + @Test + fun mockClockSetAndRead() { + try { + Utils.setMockClock(1000L) // 1000 seconds + assertEquals(1000L, Utils.currentTimeSeconds()) + assertEquals(1_000_000L, Utils.currentTimeMillis()) + } finally { + Utils.resetMocking() + } + } + + @Test + fun rollMockClock() { + try { + Utils.setMockClock(100L) + Utils.rollMockClock(10) + assertEquals(110L, Utils.currentTimeSeconds()) + } finally { + Utils.resetMocking() + } + } + + @Test + fun rollMockClockWithoutSetThrows() { + Utils.resetMocking() + assertFailsWith { + Utils.rollMockClock(1) + } + } + + @Test + fun resetMockingRestoresRealTime() { + Utils.setMockClock(1L) + Utils.resetMocking() + assertTrue(Utils.currentTimeSeconds() > 1L) + } + + // --- HexEncoder --- + + @Test + fun hexEncoderEmpty() { + assertEquals("", Utils.HEX.encode(byteArrayOf())) + } + + @Test + fun hexEncoderMixed() { + assertEquals("0a1bff", Utils.HEX.encode(byteArrayOf(0x0a, 0x1b, 0xff.toByte()))) + } +} diff --git a/libs/network/coinbase/onramp/src/test/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequestTest.kt b/libs/network/coinbase/onramp/src/test/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequestTest.kt new file mode 100644 index 000000000..77db24020 --- /dev/null +++ b/libs/network/coinbase/onramp/src/test/kotlin/com/coinbase/onramp/data/OnRampPurchaseRequestTest.kt @@ -0,0 +1,100 @@ +package com.coinbase.onramp.data + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OnRampPurchaseRequestTest { + + private fun inclusiveOf() = OnRampPurchaseRequest.InclusiveOfFees( + paymentAmount = "25.00", + partnerUserRef = "user-123", + paymentMethod = OnRampPaymentMethod.GUEST_CHECKOUT_GOOGLE_PAY, + email = "test@example.com", + phoneNumber = "+12125551234", + destinationAddress = "some-sol-address", + ) + + private fun exclusiveOf() = OnRampPurchaseRequest.ExclusiveOfFees( + purchaseAmount = "10.00", + partnerUserRef = "user-456", + paymentMethod = OnRampPaymentMethod.GUEST_CHECKOUT_GOOGLE_PAY, + email = "apple@example.com", + phoneNumber = "+12125559876", + destinationAddress = "another-sol-address", + ) + + // --- InclusiveOfFees --- + + @Test + fun inclusiveContainsPaymentAmount() { + assertEquals("25.00", inclusiveOf().asMap()["paymentAmount"]) + } + + @Test + fun inclusiveDoesNotContainPurchaseAmount() { + assertFalse(inclusiveOf().asMap().containsKey("purchaseAmount")) + } + + @Test + fun inclusiveHardcodesPaymentCurrency() { + assertEquals("USD", inclusiveOf().asMap()["paymentCurrency"]) + } + + @Test + fun inclusiveHardcodesPurchaseCurrency() { + assertEquals("USDF", inclusiveOf().asMap()["purchaseCurrency"]) + } + + @Test + fun inclusiveHardcodesDestinationNetwork() { + assertEquals("solana", inclusiveOf().asMap()["destinationNetwork"]) + } + + @Test + fun inclusiveContainsTimestampFields() { + val map = inclusiveOf().asMap() + assertTrue(map.containsKey("agreementAcceptedAt")) + assertTrue(map.containsKey("phoneNumberVerifiedAt")) + assertTrue(map["agreementAcceptedAt"]!!.isNotBlank()) + assertTrue(map["phoneNumberVerifiedAt"]!!.isNotBlank()) + } + + // --- ExclusiveOfFees --- + + @Test + fun exclusiveContainsPurchaseAmount() { + assertEquals("10.00", exclusiveOf().asMap()["purchaseAmount"]) + } + + @Test + fun exclusiveDoesNotContainPaymentAmount() { + assertFalse(exclusiveOf().asMap().containsKey("paymentAmount")) + } + + @Test + fun exclusiveContainsAllRequiredKeys() { + val map = exclusiveOf().asMap() + listOf( + "purchaseAmount", "partnerUserRef", "paymentMethod", "email", + "phoneNumber", "paymentCurrency", "purchaseCurrency", + "destinationAddress", "destinationNetwork", + "agreementAcceptedAt", "phoneNumberVerifiedAt", + ).forEach { key -> + assertTrue(map.containsKey(key), "Missing key: $key") + } + } + + @Test + fun paymentMethodSerialized() { + val map = inclusiveOf().asMap() + assertEquals("GUEST_CHECKOUT_GOOGLE_PAY", map["paymentMethod"]) + } + + @Test + fun destinationAddressPassedThrough() { + assertEquals("some-sol-address", inclusiveOf().asMap()["destinationAddress"]) + assertEquals("another-sol-address", exclusiveOf().asMap()["destinationAddress"]) + } +} diff --git a/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/ConnectionTypeTest.kt b/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/ConnectionTypeTest.kt new file mode 100644 index 000000000..b62fd16e1 --- /dev/null +++ b/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/ConnectionTypeTest.kt @@ -0,0 +1,68 @@ +package com.getcode.utils.network + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConnectionTypeTest { + + @Test + fun wifiIsWifi() = assertTrue(ConnectionType.Wifi.isWifi()) + + @Test + fun cellularIsNotWifi() = assertFalse(ConnectionType.Cellular.isWifi()) + + @Test + fun unknownIsNotWifi() = assertFalse(ConnectionType.Unknown.isWifi()) + + @Test + fun cellularIsCellular() = assertTrue(ConnectionType.Cellular.isCellular()) + + @Test + fun wifiIsNotCellular() = assertFalse(ConnectionType.Wifi.isCellular()) + + @Test + fun unknownIsNotCellular() = assertFalse(ConnectionType.Unknown.isCellular()) + + @Test + fun wifiIsValid() = assertTrue(ConnectionType.Wifi.isValid()) + + @Test + fun cellularIsValid() = assertTrue(ConnectionType.Cellular.isValid()) + + @Test + fun unknownIsNotValid() = assertFalse(ConnectionType.Unknown.isValid()) +} + +class SignalStrengthTest { + + @Test + fun weakIsWeakOrPoor() = assertTrue(SignalStrength.Weak.isWeakOrPoor()) + + @Test + fun poorIsWeakOrPoor() = assertTrue(SignalStrength.Poor.isWeakOrPoor()) + + @Test + fun goodIsNotWeakOrPoor() = assertFalse(SignalStrength.Good.isWeakOrPoor()) + + @Test + fun greatIsNotWeakOrPoor() = assertFalse(SignalStrength.Great.isWeakOrPoor()) + + @Test + fun strongIsNotWeakOrPoor() = assertFalse(SignalStrength.Strong.isWeakOrPoor()) + + @Test + fun unknownIsNotWeakOrPoor() = assertFalse(SignalStrength.Unknown.isWeakOrPoor()) + + @Test + fun unknownIsNotKnown() = assertFalse(SignalStrength.Unknown.isKnown()) + + @Test + fun weakIsKnown() = assertTrue(SignalStrength.Weak.isKnown()) + + @Test + fun goodIsKnown() = assertTrue(SignalStrength.Good.isKnown()) + + @Test + fun strongIsKnown() = assertTrue(SignalStrength.Strong.isKnown()) +} diff --git a/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/PollUntilTest.kt b/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/PollUntilTest.kt new file mode 100644 index 000000000..d70d0227f --- /dev/null +++ b/libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/PollUntilTest.kt @@ -0,0 +1,87 @@ +package com.getcode.utils.network + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class PollUntilTest { + + @Test + fun `succeeds on first attempt`() = runTest { + val result = pollUntil( + call = { 42 }, + required = { it == 42 }, + interval = 1.milliseconds, + ) + assertTrue(result.isSuccess) + assertEquals(42, result.getOrNull()) + } + + @Test + fun `succeeds after multiple attempts`() = runTest { + var count = 0 + val result = pollUntil( + call = { ++count }, + required = { it >= 3 }, + maxAttempts = 10, + interval = 1.milliseconds, + ) + assertTrue(result.isSuccess) + assertEquals(3, result.getOrNull()) + } + + @Test + fun `times out after max attempts`() = runTest { + val result = pollUntil( + call = { false }, + required = { it }, + maxAttempts = 2, + interval = 1.milliseconds, + tag = "test-tag", + ) + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + } + + @Test + fun `timeout exception carries tag`() = runTest { + val result = pollUntil( + call = { 0 }, + required = { false }, + maxAttempts = 3, + interval = 1.milliseconds, + tag = "my-tag", + ) + val ex = result.exceptionOrNull() + assertIs(ex) + assertTrue(ex.message!!.contains("my-tag")) + } + + @Test + fun `single attempt timeout`() = runTest { + val result = pollUntil( + call = { "nope" }, + required = { false }, + maxAttempts = 1, + interval = 1.milliseconds, + ) + assertTrue(result.isFailure) + } + + @Test + fun `call count matches attempts before success`() = runTest { + var calls = 0 + pollUntil( + call = { ++calls }, + required = { it == 5 }, + maxAttempts = 10, + interval = 1.milliseconds, + ) + assertEquals(5, calls) + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt new file mode 100644 index 000000000..ca7dec784 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt @@ -0,0 +1,216 @@ +package com.flipcash.services.internal.domain + +import com.codeinc.flipcash.gen.activity.v1.Model +import com.codeinc.flipcash.gen.activity.v1.gaveCryptoNotificationMetadata +import com.codeinc.flipcash.gen.activity.v1.notification +import com.codeinc.flipcash.gen.activity.v1.notificationId +import com.codeinc.flipcash.gen.activity.v1.receivedCryptoNotificationMetadata +import com.codeinc.flipcash.gen.activity.v1.sentCryptoNotificationMetadata +import com.codeinc.flipcash.gen.activity.v1.withdrewCryptoNotificationMetadata +import com.codeinc.flipcash.gen.activity.v1.depositedCryptoNotificationMetadata +import com.codeinc.flipcash.gen.activity.v1.boughtCryptoNotificationMetadata +import com.codeinc.flipcash.gen.activity.v1.soldCryptoNotificationMetadata +import com.codeinc.flipcash.gen.common.v1.cryptoPaymentAmount +import com.codeinc.flipcash.gen.common.v1.publicKey +import com.flipcash.services.models.NotificationMetadata +import com.flipcash.services.models.NotificationState +import com.google.protobuf.ByteString +import com.google.protobuf.Timestamp +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ActivityFeedMessageMapperTest { + + private val mapper = ActivityFeedMessageMapper() + + private val testIdBytes = ByteString.copyFrom(ByteArray(16) { it.toByte() }) + private val testTimestamp = Timestamp.newBuilder().setSeconds(1700000000).build() + + private fun baseNotification( + block: com.codeinc.flipcash.gen.activity.v1.NotificationKt.Dsl.() -> Unit = {} + ) = notification { + id = notificationId { value = testIdBytes } + localizedText = "Test notification" + ts = testTimestamp + state = Model.NotificationState.NOTIFICATION_STATE_COMPLETED + block() + } + + // --- Payment amount mapping --- + + @Test + fun `no payment amount results in null amount`() { + val proto = baseNotification() + val result = mapper.map(proto) + + assertEquals("Test notification", result.text) + assertNull(result.amount) + } + + @Test + fun `payment amount without mint produces LocalFiat via usdf path`() { + val proto = baseNotification { + paymentAmount = cryptoPaymentAmount { + currency = "USD" + nativeAmount = 5.0 + quarks = 5_000_000L + } + } + + val result = mapper.map(proto) + val amount = result.amount + + assertNotNull(amount) + assertEquals(5_000_000L, amount.underlyingTokenAmount.quarks) + assertEquals(5.0, amount.nativeAmount.decimalValue, 0.01) + } + + @Test + fun `payment amount with mint produces LocalFiat with rate`() { + val proto = baseNotification { + paymentAmount = cryptoPaymentAmount { + currency = "USD" + nativeAmount = 10.0 + quarks = 100_000_000L + mint = publicKey { + value = ByteString.copyFrom(ByteArray(32) { 1 }) + } + } + } + + val result = mapper.map(proto) + val amount = result.amount + + assertNotNull(amount) + assertEquals(100_000_000L, amount.underlyingTokenAmount.quarks) + assertEquals(10.0, amount.nativeAmount.decimalValue, 0.01) + } + + // --- Timestamp mapping --- + + @Test + fun `timestamp is mapped from proto seconds`() { + val proto = baseNotification() + val result = mapper.map(proto) + + assertEquals(1700000000L, result.timestamp.epochSeconds) + } + + // --- State mapping --- + + @Test + fun `PENDING state mapped correctly`() { + val proto = baseNotification { + state = Model.NotificationState.NOTIFICATION_STATE_PENDING + } + assertEquals(NotificationState.PENDING, mapper.map(proto).state) + } + + @Test + fun `COMPLETED state mapped correctly`() { + val proto = baseNotification { + state = Model.NotificationState.NOTIFICATION_STATE_COMPLETED + } + assertEquals(NotificationState.COMPLETED, mapper.map(proto).state) + } + + @Test + fun `UNKNOWN state mapped correctly`() { + val proto = baseNotification { + state = Model.NotificationState.NOTIFICATION_STATE_UNKNOWN + } + assertEquals(NotificationState.UNKNOWN, mapper.map(proto).state) + } + + // --- Metadata mapping --- + + @Test + fun `gave crypto metadata`() { + val proto = baseNotification { + gaveCrypto = gaveCryptoNotificationMetadata {} + } + assertEquals(NotificationMetadata.GaveCrypto, mapper.map(proto).metadata) + } + + @Test + fun `received crypto metadata`() { + val proto = baseNotification { + receivedCrypto = receivedCryptoNotificationMetadata {} + } + assertEquals(NotificationMetadata.ReceivedCrypto, mapper.map(proto).metadata) + } + + @Test + fun `withdrew crypto metadata`() { + val proto = baseNotification { + withdrewCrypto = withdrewCryptoNotificationMetadata {} + } + assertEquals(NotificationMetadata.WithdrewCrypto, mapper.map(proto).metadata) + } + + @Test + fun `deposited crypto metadata`() { + val proto = baseNotification { + depositedCrypto = depositedCryptoNotificationMetadata {} + } + assertEquals(NotificationMetadata.DepositedCrypto, mapper.map(proto).metadata) + } + + @Test + fun `bought crypto metadata`() { + val proto = baseNotification { + boughtCrypto = boughtCryptoNotificationMetadata {} + } + assertEquals(NotificationMetadata.BoughtToken, mapper.map(proto).metadata) + } + + @Test + fun `sold crypto metadata`() { + val proto = baseNotification { + soldCrypto = soldCryptoNotificationMetadata {} + } + assertEquals(NotificationMetadata.SoldToken, mapper.map(proto).metadata) + } + + @Test + fun `sent crypto metadata has creator and canCancel`() { + val vaultBytes = ByteArray(32) { 42 } + val proto = baseNotification { + sentCrypto = sentCryptoNotificationMetadata { + vault = publicKey { value = ByteString.copyFrom(vaultBytes) } + canInitiateCancelAction = true + } + } + + val metadata = mapper.map(proto).metadata + assertIs(metadata) + assertTrue(metadata.canCancel) + assertEquals(vaultBytes.toList(), metadata.creator.bytes) + } + + @Test + fun `no metadata set maps to Unknown`() { + val proto = baseNotification() + assertEquals(NotificationMetadata.Unknown, mapper.map(proto).metadata) + } + + // --- Currency code mapping --- + + @Test + fun `invalid currency code falls back to USD`() { + val proto = baseNotification { + paymentAmount = cryptoPaymentAmount { + currency = "INVALID" + nativeAmount = 1.0 + quarks = 1_000_000L + } + } + + val result = mapper.map(proto) + assertNotNull(result.amount) + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserProfileMapperTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserProfileMapperTest.kt new file mode 100644 index 000000000..7e048e688 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserProfileMapperTest.kt @@ -0,0 +1,84 @@ +package com.flipcash.services.internal.domain + +import com.codeinc.flipcash.gen.email.v1.emailAddress +import com.codeinc.flipcash.gen.phone.v1.phoneNumber +import com.codeinc.flipcash.gen.profile.v1.Model +import com.codeinc.flipcash.gen.profile.v1.socialProfile +import com.codeinc.flipcash.gen.profile.v1.xProfile +import com.flipcash.services.models.SocialAccount +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class UserProfileMapperTest { + + private val mapper = UserProfileMapper(socialMapper = SocialAccountMapper()) + + private fun userProfile(block: Model.UserProfile.Builder.() -> Unit): Model.UserProfile = + Model.UserProfile.newBuilder().apply(block).build() + + @Test + fun `maps display name`() { + val proto = userProfile { displayName = "Alice" } + assertEquals("Alice", mapper.map(proto).displayName) + } + + @Test + fun `maps social profiles`() { + val proto = userProfile { + displayName = "Bob" + addSocialProfiles(socialProfile { + x = xProfile { + id = "x-123" + username = "bob" + } + }) + } + + val result = mapper.map(proto) + assertEquals(1, result.socialAccounts.size) + assertIs(result.socialAccounts.first()) + assertEquals("bob", (result.socialAccounts.first() as SocialAccount.TwitterX).username) + } + + @Test + fun `filters out null social profiles`() { + val proto = userProfile { + displayName = "Charlie" + addSocialProfiles(socialProfile { }) // TYPE_NOT_SET -> null + } + + val result = mapper.map(proto) + assertEquals(0, result.socialAccounts.size) + } + + @Test + fun `maps verified phone number`() { + val proto = userProfile { + displayName = "Dave" + phoneNumber = phoneNumber { value = "+15551234567" } + } + + assertEquals("+15551234567", mapper.map(proto).verifiedPhoneNumber) + } + + @Test + fun `maps verified email address`() { + val proto = userProfile { + displayName = "Eve" + emailAddress = emailAddress { value = "eve@example.com" } + } + + assertEquals("eve@example.com", mapper.map(proto).verifiedEmailAddress) + } + + @Test + fun `no phone or email returns null`() { + val proto = userProfile { displayName = "Frank" } + + val result = mapper.map(proto) + assertNull(result.verifiedPhoneNumber) + assertNull(result.verifiedEmailAddress) + } +}