Updated on 2026-08-14
This commit is contained in:
parent
beeb77543b
commit
15daf08afe
4 changed files with 200 additions and 23 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue