Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-21 10:38:56 +02:00
parent beeb77543b
commit 15daf08afe
4 changed files with 200 additions and 23 deletions

View file

@ -58,6 +58,33 @@ class SaveContactInteractor(
signed 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<AddressEntry>,
): Either<SaveContactError, Contact> = 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( suspend fun updateContact(
userWallet: UserWallet, userWallet: UserWallet,
contact: Contact, contact: Contact,

View file

@ -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.AddressBookSyncError
import com.tangem.domain.addressbook.error.ContactNameValidationError import com.tangem.domain.addressbook.error.ContactNameValidationError
import com.tangem.domain.addressbook.error.SaveContactError import com.tangem.domain.addressbook.error.SaveContactError
import com.tangem.domain.addressbook.model.AddressEntry import com.tangem.domain.addressbook.model.*
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.repository.AddressBookRepository import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.addressbook.validation.ContactNameValidator import com.tangem.domain.addressbook.validation.ContactNameValidator
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet 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.error.SignHashesError
import com.tangem.domain.transaction.usecase.SignUseCase import com.tangem.domain.transaction.usecase.SignUseCase
import com.tangem.utils.extensions.toHexString import com.tangem.utils.extensions.toHexString
import io.mockk.clearMocks import io.mockk.*
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested 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<Contact>()
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( private fun contact(name: String): Contact = Contact(
id = ContactId("id-$name"), id = ContactId("id-$name"),
walletId = userWallet.walletId, walletId = userWallet.walletId,

View file

@ -100,8 +100,9 @@ internal class EditContactModel @Inject constructor(
.stateIn(modelScope, SharingStarted.Eagerly, null) .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 * The wallet the contact is saved to. A user pick in the selector always wins (this is how an existing contact
* contact it follows the selector pick and falls back to the app's currently selected wallet. * 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<UserWallet?> = combine( private val selectedWallet: StateFlow<UserWallet?> = combine(
pickedWallet, pickedWallet,
@ -110,8 +111,8 @@ internal class EditContactModel @Inject constructor(
userWalletsListRepository.userWallets, userWalletsListRepository.userWallets,
) { picked, contact, currentSelected, wallets -> ) { picked, contact, currentSelected, wallets ->
when { when {
contact != null -> wallets?.firstOrNull { it.walletId == contact.walletId }
picked != null -> picked picked != null -> picked
contact != null -> wallets?.firstOrNull { it.walletId == contact.walletId }
else -> currentSelected else -> currentSelected
} }
}.stateIn(modelScope, SharingStarted.Eagerly, null) }.stateIn(modelScope, SharingStarted.Eagerly, null)
@ -304,19 +305,26 @@ internal class EditContactModel @Inject constructor(
val ui = stateController.uiState.value val ui = stateController.uiState.value
val addresses = ContactAddressEntriesConverter().convert(ui.addresses) val addresses = ContactAddressEntriesConverter().convert(ui.addresses)
val existing = loadedContact.value val existing = loadedContact.value
val isWalletChanged = existing != null && existing.walletId != userWallet.walletId
saveJob = modelScope.launch { saveJob = modelScope.launch {
val result = if (existing != null) { val result = when {
saveContactInteractor.updateContact( existing == null -> saveContactInteractor.createContact(
userWallet = userWallet, userWallet = userWallet,
name = ui.name,
iconColor = ui.colors.selected.name,
addresses = addresses,
)
isWalletChanged -> saveContactInteractor.moveContact(
targetWallet = userWallet,
contact = existing, contact = existing,
name = ui.name, name = ui.name,
iconColor = ui.colors.selected.name, iconColor = ui.colors.selected.name,
addresses = addresses, addresses = addresses,
) )
} else { else -> saveContactInteractor.updateContact(
saveContactInteractor.createContact(
userWallet = userWallet, userWallet = userWallet,
contact = existing,
name = ui.name, name = ui.name,
iconColor = ui.colors.selected.name, iconColor = ui.colors.selected.name,
addresses = addresses, addresses = addresses,
@ -426,13 +434,13 @@ internal class EditContactModel @Inject constructor(
} }
private fun isWalletChangeable(wallets: List<UserWallet>?): Boolean { private fun isWalletChangeable(wallets: List<UserWallet>?): Boolean {
val unlockedWalletsCount = wallets.orEmpty().count { !it.isLocked } return wallets.orEmpty().count { !it.isLocked } > 1
return params.contactId == null && unlockedWalletsCount > 1
} }
private suspend fun validateName(name: String, walletId: UserWalletId): ContactNameValidationError? { private suspend fun validateName(name: String, walletId: UserWalletId): ContactNameValidationError? {
if (name.isBlank()) return null 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 val error = contactNameValidator.validate(walletId, name).leftOrNull() ?: return null
if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null
return error 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. */ /** Dirty when the current editor differs from its baseline — the loaded contact, or the empty new contact. */
private fun isDirty(): Boolean { private fun isDirty(): Boolean {
val baseline = loadedContact.value?.toSnapshot() ?: newContactBaseline val loaded = loadedContact.value ?: return currentSnapshot() != newContactBaseline
return currentSnapshot() != baseline val isWalletChanged = selectedWallet.value?.walletId?.let { it != loaded.walletId } == true
return isWalletChanged || currentSnapshot() != loaded.toSnapshot()
} }
private fun currentSnapshot(): EditSnapshot { private fun currentSnapshot(): EditSnapshot {

View file

@ -658,6 +658,69 @@ internal class EditContactModelTest {
coVerify(exactly = 0) { saveContactInteractor.createContact(any(), any(), any(), any()) } 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<Contact> { 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 @Test
fun `GIVEN new contact saved WHEN success THEN contact-added snackbar shown`() = runTest { fun `GIVEN new contact saved WHEN success THEN contact-added snackbar shown`() = runTest {
// Arrange // Arrange