diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationAddressTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationAddressTransformer.kt index 28b4d121c0..c7d58b15dc 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationAddressTransformer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationAddressTransformer.kt @@ -12,7 +12,16 @@ internal class SendDestinationAddressTransformer( val state = prevState as? DestinationUM.Content ?: return prevState return state.copy( - addressTextField = state.addressTextField.copy(value = address, isValuePasted = isPasted), + addressTextField = state.addressTextField.copy( + value = address, + isValuePasted = isPasted, + // Editing the recipient invalidates any previously resolved/recognized data. It is + // recomputed by the following validation; keeping it would let a stale canonical + // address (blockchainAddress) leak into the transaction for the new, not-yet-validated value. + blockchainAddress = null, + contactName = null, + contactIcon = null, + ), ) } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationValidationStartedTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationValidationStartedTransformer.kt index 5df806b461..1547cfdabf 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationValidationStartedTransformer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationValidationStartedTransformer.kt @@ -7,6 +7,9 @@ internal object SendDestinationValidationStartedTransformer : Transformer>(), any()) + } returns AddressValidation.Success.ValidNamedAddress("0xCanonicalA1").right() + coEvery { + validateWalletAddressUseCase(any(), any(), eq("r2.eth"), any>(), any()) + } coAnswers { awaitCancellation() } + val sut = buildModel() + advanceUntilIdle() + + // R1 resolved: canonical address stored, primary button enabled + sut.onRecipientAddressValueChange("r1.eth", EnterAddressSource.InputField) + advanceUntilIdle() + assertThat(content(sut).addressTextField.blockchainAddress).isEqualTo("0xCanonicalA1") + assertThat(content(sut).isPrimaryButtonEnabled).isTrue() + + // Act — replace with R2 while its validation is still in flight + sut.onRecipientAddressValueChange("r2.eth", EnterAddressSource.InputField) + runCurrent() + + // Assert — no stale A1 leaks: actualAddress is the raw R2 and the button is disabled while pending + val field = content(sut).addressTextField + assertThat(field.value).isEqualTo("r2.eth") + assertThat(field.blockchainAddress).isNull() + assertThat(field.actualAddress).isEqualTo("r2.eth") + assertThat(content(sut).isValidating).isTrue() + assertThat(content(sut).isPrimaryButtonEnabled).isFalse() + } + + @Test + fun `GIVEN edited recipient WHEN new value resolves valid THEN button re-enabled with new address`() = runTest { + // Arrange — both names resolve, to different canonical addresses + coEvery { + validateWalletAddressUseCase(any(), any(), eq("r1.eth"), any>(), any()) + } returns AddressValidation.Success.ValidNamedAddress("0xCanonicalA1").right() + coEvery { + validateWalletAddressUseCase(any(), any(), eq("r2.eth"), any>(), any()) + } returns AddressValidation.Success.ValidNamedAddress("0xCanonicalA2").right() + val sut = buildModel() + advanceUntilIdle() + sut.onRecipientAddressValueChange("r1.eth", EnterAddressSource.InputField) + advanceUntilIdle() + + // Act — edit to R2 and let its validation complete + sut.onRecipientAddressValueChange("r2.eth", EnterAddressSource.InputField) + advanceUntilIdle() + + // Assert — the new canonical address replaces the old one and the button is enabled again + val field = content(sut).addressTextField + assertThat(field.value).isEqualTo("r2.eth") + assertThat(field.blockchainAddress).isEqualTo("0xCanonicalA2") + assertThat(field.actualAddress).isEqualTo("0xCanonicalA2") + assertThat(content(sut).isPrimaryButtonEnabled).isTrue() + } + } + // region fixtures private fun TestScope.buildModel( diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationAddressTransformerTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationAddressTransformerTest.kt new file mode 100644 index 0000000000..a5b05be19c --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationAddressTransformerTest.kt @@ -0,0 +1,83 @@ +package com.tangem.features.send.subcomponents.destination.model.transformers + +import androidx.compose.foundation.text.KeyboardOptions +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +class SendDestinationAddressTransformerTest { + + @Test + fun `GIVEN previously resolved address WHEN recipient edited THEN blockchainAddress and contact reset`() { + // Arrange — R1 was resolved to canonical A1 and recognized as a contact + val state = contentState( + value = "r1.eth", + blockchainAddress = "0xCanonicalAddressA1", + contactName = "Alice", + contactIcon = mockk(), + ) + + // Act — user replaces the recipient with a new, not-yet-validated name R2 + val result = SendDestinationAddressTransformer(address = "r2.eth", isPasted = false) + .transform(state) as DestinationUM.Content + + // Assert — the new value is shown and all previously resolved/recognized data is cleared, + // so actualAddress falls back to the raw R2 instead of the stale canonical A1 + val addressField = result.addressTextField + assertThat(addressField.value).isEqualTo("r2.eth") + assertThat(addressField.blockchainAddress).isNull() + assertThat(addressField.contactName).isNull() + assertThat(addressField.contactIcon).isNull() + assertThat(addressField.actualAddress).isEqualTo("r2.eth") + assertThat(addressField.isValuePasted).isFalse() + } + + @Test + fun `GIVEN pasted value WHEN recipient edited THEN isValuePasted propagated`() { + val state = contentState(value = "") + + val result = SendDestinationAddressTransformer(address = "0xPasted", isPasted = true) + .transform(state) as DestinationUM.Content + + assertThat(result.addressTextField.value).isEqualTo("0xPasted") + assertThat(result.addressTextField.isValuePasted).isTrue() + } + + @Test + fun `GIVEN empty state WHEN transform THEN state unchanged`() { + val state = DestinationUM.Empty() + + val result = SendDestinationAddressTransformer(address = "r2.eth", isPasted = false).transform(state) + + assertThat(result).isEqualTo(state) + } + + private fun contentState( + value: String, + blockchainAddress: String? = null, + contactName: String? = null, + contactIcon: AccountIconUM.CryptoPortfolio? = null, + ) = DestinationUM.Content( + isPrimaryButtonEnabled = false, + addressTextField = DestinationTextFieldUM.RecipientAddress( + value = value, + keyboardOptions = KeyboardOptions.Default, + placeholder = TextReference.EMPTY, + label = TextReference.EMPTY, + isValuePasted = false, + blockchainAddress = blockchainAddress, + contactName = contactName, + contactIcon = contactIcon, + ), + memoTextField = null, + recent = persistentListOf(), + wallets = persistentListOf(), + networkName = "Ethereum", + isRecentHidden = false, + ) +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationValidationStartedTransformerTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationValidationStartedTransformerTest.kt new file mode 100644 index 0000000000..7116b931c5 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationValidationStartedTransformerTest.kt @@ -0,0 +1,50 @@ +package com.tangem.features.send.subcomponents.destination.model.transformers + +import androidx.compose.foundation.text.KeyboardOptions +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +class SendDestinationValidationStartedTransformerTest { + + @Test + fun `GIVEN previously enabled button WHEN validation started THEN validating and button disabled`() { + // Arrange — button was enabled by a prior successful validation + val state = contentState(isPrimaryButtonEnabled = true) + + // Act — a new validation begins for the edited value + val result = SendDestinationValidationStartedTransformer.transform(state) as DestinationUM.Content + + // Assert — button cannot be pressed with the stale enablement while validation is pending + assertThat(result.isValidating).isTrue() + assertThat(result.isPrimaryButtonEnabled).isFalse() + } + + @Test + fun `GIVEN empty state WHEN transform THEN state unchanged`() { + val state = DestinationUM.Empty() + + val result = SendDestinationValidationStartedTransformer.transform(state) + + assertThat(result).isEqualTo(state) + } + + private fun contentState(isPrimaryButtonEnabled: Boolean) = DestinationUM.Content( + isPrimaryButtonEnabled = isPrimaryButtonEnabled, + addressTextField = DestinationTextFieldUM.RecipientAddress( + value = "r2.eth", + keyboardOptions = KeyboardOptions.Default, + placeholder = TextReference.EMPTY, + label = TextReference.EMPTY, + isValuePasted = false, + ), + memoTextField = null, + recent = persistentListOf(), + wallets = persistentListOf(), + networkName = "Ethereum", + isRecentHidden = false, + ) +} \ No newline at end of file