diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt index 7aa3f4d5ed..73c8f5927f 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt @@ -58,6 +58,33 @@ class SaveContactInteractor( signed } + /** + * Moves an existing [contact] to [targetWallet]: creates a fresh contact there (new id, name validated for + * uniqueness in the target wallet, addresses re-signed with the target wallet's key) and, once that succeeds, + * deletes the original from its source wallet. + * + * Create-then-delete is deliberate: if the target write fails the original is untouched (no data loss); if only + * the delete fails the contact ends up duplicated rather than lost. + */ + suspend fun moveContact( + targetWallet: UserWallet, + contact: Contact, + name: String, + iconColor: String, + addresses: List, + ): Either = either { + val created = createContact( + userWallet = targetWallet, + name = name, + iconColor = iconColor, + addresses = addresses, + ).bind() + repository.deleteContact(contact.id) + .mapLeft(SaveContactError::Backend) + .bind() + created + } + suspend fun updateContact( userWallet: UserWallet, contact: Contact, diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt index 3a2c8531b1..703d3c081e 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt @@ -7,25 +7,17 @@ import com.tangem.common.test.domain.wallet.MockUserWalletFactory import com.tangem.domain.addressbook.error.AddressBookSyncError import com.tangem.domain.addressbook.error.ContactNameValidationError import com.tangem.domain.addressbook.error.SaveContactError -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.AddressEntryId -import com.tangem.domain.addressbook.model.Contact -import com.tangem.domain.addressbook.model.ContactId -import com.tangem.domain.addressbook.model.ContactName +import com.tangem.domain.addressbook.model.* import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.addressbook.validation.ContactNameValidator import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.error.SignHashesError import com.tangem.domain.transaction.usecase.SignUseCase import com.tangem.utils.extensions.toHexString -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot +import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested @@ -304,6 +296,92 @@ internal class SaveContactInteractorTest { } } + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class MoveContact { + + // A distinct target wallet the contact is moved to. + private val targetWallet: UserWallet = MockUserWalletFactory.create() + .copy(walletId = UserWalletId("beef")) + private val entries = listOf(entry(id = "addr-1", address = "0xabc", memo = "memo")) + + @Test + fun `GIVEN valid move WHEN moveContact THEN creates in target AND deletes original`() = runTest { + // Arrange + val existing = contact(name = "Alice") + coEvery { contactNameValidator.validate(targetWallet.walletId, "Alice") } returns + requireNotNull(ContactName("Alice").getOrNull()).right() + coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(targetWallet)) } returns + listOf(byteArrayOf(0x01)).right() + val saved = slot() + coEvery { repository.saveContact(capture(saved)) } returns Unit.right() + coEvery { repository.deleteContact(existing.id) } returns Unit.right() + + // Act + val result = interactor.moveContact( + targetWallet = targetWallet, + contact = existing, + name = "Alice", + iconColor = "TestColor", + addresses = entries, + ) + + // Assert — the new contact lives in the target wallet with a fresh id, and the original is removed. + val created = result.getOrNull() + assertThat(created).isEqualTo(saved.captured) + assertThat(created!!.walletId).isEqualTo(targetWallet.walletId) + assertThat(created.id).isNotEqualTo(existing.id) + coVerify(exactly = 1) { repository.deleteContact(existing.id) } + } + + @Test + fun `GIVEN target create fails WHEN moveContact THEN original is not deleted`() = runTest { + // Arrange + val existing = contact(name = "Alice") + coEvery { contactNameValidator.validate(targetWallet.walletId, "Alice") } returns + ContactNameValidationError.Duplicate.left() + + // Act + val result = interactor.moveContact( + targetWallet = targetWallet, + contact = existing, + name = "Alice", + iconColor = "TestColor", + addresses = entries, + ) + + // Assert — nothing persisted, original left intact (no data loss). + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Name(ContactNameValidationError.Duplicate)) + coVerify(exactly = 0) { repository.saveContact(any()) } + coVerify(exactly = 0) { repository.deleteContact(any()) } + } + + @Test + fun `GIVEN delete of original fails WHEN moveContact THEN Backend error propagated`() = runTest { + // Arrange + val existing = contact(name = "Alice") + coEvery { contactNameValidator.validate(targetWallet.walletId, "Alice") } returns + requireNotNull(ContactName("Alice").getOrNull()).right() + coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(targetWallet)) } returns + listOf(byteArrayOf(0x01)).right() + coEvery { repository.saveContact(any()) } returns Unit.right() + coEvery { repository.deleteContact(existing.id) } returns AddressBookSyncError.Network.left() + + // Act + val result = interactor.moveContact( + targetWallet = targetWallet, + contact = existing, + name = "Alice", + iconColor = "TestColor", + addresses = entries, + ) + + // Assert + assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Backend(AddressBookSyncError.Network)) + } + } + private fun contact(name: String): Contact = Contact( id = ContactId("id-$name"), walletId = userWallet.walletId, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt index 5c3b874720..ac64bcc2ba 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt @@ -100,8 +100,9 @@ internal class EditContactModel @Inject constructor( .stateIn(modelScope, SharingStarted.Eagerly, null) /** - * The wallet the contact is saved to. For an existing contact it is fixed to the contact's wallet; for a new - * contact it follows the selector pick and falls back to the app's currently selected wallet. + * The wallet the contact is saved to. A user pick in the selector always wins (this is how an existing contact + * is moved to another wallet). With no pick yet it defaults to the existing contact's own wallet, or — for a new + * contact — the app's currently selected wallet. */ private val selectedWallet: StateFlow = combine( pickedWallet, @@ -110,8 +111,8 @@ internal class EditContactModel @Inject constructor( userWalletsListRepository.userWallets, ) { picked, contact, currentSelected, wallets -> when { - contact != null -> wallets?.firstOrNull { it.walletId == contact.walletId } picked != null -> picked + contact != null -> wallets?.firstOrNull { it.walletId == contact.walletId } else -> currentSelected } }.stateIn(modelScope, SharingStarted.Eagerly, null) @@ -304,19 +305,26 @@ internal class EditContactModel @Inject constructor( val ui = stateController.uiState.value val addresses = ContactAddressEntriesConverter().convert(ui.addresses) val existing = loadedContact.value + val isWalletChanged = existing != null && existing.walletId != userWallet.walletId saveJob = modelScope.launch { - val result = if (existing != null) { - saveContactInteractor.updateContact( + val result = when { + existing == null -> saveContactInteractor.createContact( userWallet = userWallet, + name = ui.name, + iconColor = ui.colors.selected.name, + addresses = addresses, + ) + isWalletChanged -> saveContactInteractor.moveContact( + targetWallet = userWallet, contact = existing, name = ui.name, iconColor = ui.colors.selected.name, addresses = addresses, ) - } else { - saveContactInteractor.createContact( + else -> saveContactInteractor.updateContact( userWallet = userWallet, + contact = existing, name = ui.name, iconColor = ui.colors.selected.name, addresses = addresses, @@ -426,13 +434,13 @@ internal class EditContactModel @Inject constructor( } private fun isWalletChangeable(wallets: List?): Boolean { - val unlockedWalletsCount = wallets.orEmpty().count { !it.isLocked } - return params.contactId == null && unlockedWalletsCount > 1 + return wallets.orEmpty().count { !it.isLocked } > 1 } private suspend fun validateName(name: String, walletId: UserWalletId): ContactNameValidationError? { if (name.isBlank()) return null - if (name == loadedContact.value?.name?.value) return null + val loaded = loadedContact.value + if (loaded != null && name == loaded.name.value && walletId == loaded.walletId) return null val error = contactNameValidator.validate(walletId, name).leftOrNull() ?: return null if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null return error @@ -538,8 +546,9 @@ internal class EditContactModel @Inject constructor( /** Dirty when the current editor differs from its baseline — the loaded contact, or the empty new contact. */ private fun isDirty(): Boolean { - val baseline = loadedContact.value?.toSnapshot() ?: newContactBaseline - return currentSnapshot() != baseline + val loaded = loadedContact.value ?: return currentSnapshot() != newContactBaseline + val isWalletChanged = selectedWallet.value?.walletId?.let { it != loaded.walletId } == true + return isWalletChanged || currentSnapshot() != loaded.toSnapshot() } private fun currentSnapshot(): EditSnapshot { diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt index 2f03b3b7ab..d82118a103 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt @@ -658,6 +658,69 @@ internal class EditContactModelTest { coVerify(exactly = 0) { saveContactInteractor.createContact(any(), any(), any(), any()) } } + @Test + fun `GIVEN existing contact AND multiple unlocked wallets WHEN created THEN wallet block changeable`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + val walletB = createWallet(id = "bb", name = "Wallet B") + setupWallets(wallets = listOf(walletA, walletB), selected = walletA) + every { getContactByIdUseCase(ContactId("c-1")) } returns + MutableStateFlow(existingContact(walletId = "aa", name = "Alice", address = "0xABC")) + + // Act + val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1"))) + advanceUntilIdle() + + // Assert — an existing contact can now be moved, so its wallet block is changeable. + assertThat(model.state.value.walletBlock.isChangeable).isTrue() + } + + @Test + fun `GIVEN existing contact AND wallet changed WHEN save clicked THEN moveContact called`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + val walletB = createWallet(id = "bb", name = "Wallet B") + setupWallets(wallets = listOf(walletA, walletB), selected = walletA) + val contact = existingContact(walletId = "aa", name = "Alice", address = "0xABC") + every { getContactByIdUseCase(ContactId("c-1")) } returns MutableStateFlow(contact) + val moved = mockk { every { id } returns ContactId(value = "moved-1") } + coEvery { saveContactInteractor.moveContact(any(), any(), any(), any(), any()) } returns moved.right() + val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1"))) + advanceUntilIdle() + + // Act — pick wallet B in the selector, then save. + selectedWalletData.tryEmit(walletB to mockk()) + advanceUntilIdle() + model.state.value.saveButton.onClick() + advanceUntilIdle() + + // Assert — the contact is moved to wallet B; plain update/create are not used. + coVerify(exactly = 1) { saveContactInteractor.moveContact(walletB, contact, "Alice", any(), any()) } + coVerify(exactly = 0) { saveContactInteractor.updateContact(any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { saveContactInteractor.createContact(any(), any(), any(), any()) } + } + + @Test + fun `GIVEN existing contact AND wallet unchanged WHEN save clicked THEN updateContact used not move`() = runTest { + // Arrange + val walletA = createWallet(id = "aa", name = "Wallet A") + val walletB = createWallet(id = "bb", name = "Wallet B") + setupWallets(wallets = listOf(walletA, walletB), selected = walletA) + val contact = existingContact(walletId = "aa", name = "Alice", address = "0xABC") + every { getContactByIdUseCase(ContactId("c-1")) } returns MutableStateFlow(contact) + coEvery { saveContactInteractor.updateContact(any(), any(), any(), any(), any()) } returns contact.right() + val model = createModel(testScope = this, params = createParams(contactId = ContactId("c-1"))) + advanceUntilIdle() + + // Act — no wallet pick, so it stays in its own wallet. + model.state.value.saveButton.onClick() + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { saveContactInteractor.updateContact(walletA, contact, "Alice", any(), any()) } + coVerify(exactly = 0) { saveContactInteractor.moveContact(any(), any(), any(), any(), any()) } + } + @Test fun `GIVEN new contact saved WHEN success THEN contact-added snackbar shown`() = runTest { // Arrange