From 50c35995882c96ccfedbbca55a39ef407b801542 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Jun 2026 10:51:26 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/AddressBookDomainModule.kt | 78 ++++++++++++ domain/address-book/build.gradle.kts | 1 + .../addressbook/error/SaveContactError.kt | 3 + .../addressbook/model/VerifiedContact.kt | 12 ++ .../usecase/CreateContactUseCase.kt | 16 ++- .../usecase/GetVerifiedContactsUseCase.kt | 28 +++++ .../usecase/UpdateContactUseCase.kt | 15 +-- .../usecase/CreateContactUseCaseTest.kt | 41 ++++++- .../usecase/GetVerifiedContactsUseCaseTest.kt | 116 ++++++++++++++++++ .../usecase/UpdateContactUseCaseTest.kt | 35 +++++- 10 files changed, 324 insertions(+), 21 deletions(-) create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/VerifiedContact.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt index 3fa8efefb9..5535d25f92 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt @@ -1,11 +1,21 @@ package com.tangem.tap.di.domain import com.tangem.domain.addressbook.crypto.AddressBookCipher +import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.DefaultIsoTimestampProvider import com.tangem.domain.addressbook.time.IsoTimestampProvider +import com.tangem.domain.addressbook.usecase.CreateContactUseCase +import com.tangem.domain.addressbook.usecase.DeleteContactUseCase +import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.addressbook.usecase.GetVerifiedContactsUseCase +import com.tangem.domain.addressbook.usecase.SignAddressEntriesUseCase +import com.tangem.domain.addressbook.usecase.UpdateContactUseCase import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase +import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase import com.tangem.domain.addressbook.usecase.VerifyAddressEntriesUseCase +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.tokens.GetNetworkAddressesUseCase +import com.tangem.domain.transaction.usecase.SignUseCase import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase import dagger.Module @@ -38,6 +48,74 @@ object AddressBookDomainModule { return VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) } + @Provides + @Singleton + fun provideSignAddressEntriesUseCase(signUseCase: SignUseCase): SignAddressEntriesUseCase { + return SignAddressEntriesUseCase(signUseCase = signUseCase) + } + + @Provides + @Singleton + fun provideValidateContactNameUseCase(repository: AddressBookRepository): ValidateContactNameUseCase { + return ValidateContactNameUseCase(repository = repository) + } + + @Provides + @Singleton + fun provideGetContactsUseCase(repository: AddressBookRepository): GetContactsUseCase { + return GetContactsUseCase(repository = repository) + } + + @Provides + @Singleton + fun provideGetVerifiedContactsUseCase( + getContactsUseCase: GetContactsUseCase, + verifyAddressEntriesUseCase: VerifyAddressEntriesUseCase, + userWalletsListRepository: UserWalletsListRepository, + ): GetVerifiedContactsUseCase { + return GetVerifiedContactsUseCase( + getContacts = getContactsUseCase, + verifyAddressEntries = verifyAddressEntriesUseCase, + userWalletsListRepository = userWalletsListRepository, + ) + } + + @Provides + @Singleton + fun provideCreateContactUseCase( + repository: AddressBookRepository, + validateContactNameUseCase: ValidateContactNameUseCase, + signAddressEntriesUseCase: SignAddressEntriesUseCase, + timestampProvider: IsoTimestampProvider, + ): CreateContactUseCase { + return CreateContactUseCase( + repository = repository, + validateContactName = validateContactNameUseCase, + signAddressEntries = signAddressEntriesUseCase, + timestampProvider = timestampProvider, + ) + } + + @Provides + @Singleton + fun provideUpdateContactUseCase( + repository: AddressBookRepository, + signAddressEntriesUseCase: SignAddressEntriesUseCase, + timestampProvider: IsoTimestampProvider, + ): UpdateContactUseCase { + return UpdateContactUseCase( + repository = repository, + signAddressEntries = signAddressEntriesUseCase, + timestampProvider = timestampProvider, + ) + } + + @Provides + @Singleton + fun provideDeleteContactUseCase(repository: AddressBookRepository): DeleteContactUseCase { + return DeleteContactUseCase(repository = repository) + } + @Provides @Singleton fun provideAddressBookCipher(): AddressBookCipher = AddressBookCipher() diff --git a/domain/address-book/build.gradle.kts b/domain/address-book/build.gradle.kts index 02180ffe1e..9f15edfeb6 100644 --- a/domain/address-book/build.gradle.kts +++ b/domain/address-book/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { api(projects.domain.core) api(projects.domain.models) + implementation(projects.domain.common) implementation(projects.domain.transaction) implementation(projects.domain.tokens) diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt index f2c3e979ba..22f8b6a8d9 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt @@ -1,10 +1,13 @@ package com.tangem.domain.addressbook.error import com.tangem.domain.transaction.error.AddressValidation +import com.tangem.domain.transaction.error.SignHashesError sealed interface SaveContactError { data class Name(val error: ContactNameValidationError) : SaveContactError data class Address(val error: AddressValidation.Error) : SaveContactError + + data class Signing(val error: SignHashesError) : SaveContactError } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/VerifiedContact.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/VerifiedContact.kt new file mode 100644 index 0000000000..4568d5d3d7 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/VerifiedContact.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.addressbook.model + +/** + * @property contact the contact carrying only the entries whose signatures verified against the + * wallet — what should be shown to the user. + * @property invalidEntries entries that failed verification (tampered, signed by another wallet, or + * malformed). Hidden from the UI but kept for analytics. + */ +data class VerifiedContact( + val contact: Contact, + val invalidEntries: List, +) \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt index f7e861cd6f..45d623fe55 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt @@ -9,27 +9,30 @@ import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.UserWallet import java.util.UUID /** * Creates a new [Contact] with client-generated UUID v4 ids. The name must be valid and unique - * the current time. + * the current time. Every address entry is signed with [userWallet]'s key before the contact is + * persisted, so only signed contacts are ever stored. */ class CreateContactUseCase( private val repository: AddressBookRepository, private val validateContactName: ValidateContactNameUseCase, + private val signAddressEntries: SignAddressEntriesUseCase, private val timestampProvider: IsoTimestampProvider, ) { @Suppress("LongParameterList") suspend operator fun invoke( - userWalletId: UserWalletId, + userWallet: UserWallet, name: String, network: Network, addressEntries: List, ): Either = either { + val userWalletId = userWallet.walletId val validName = validateContactName(userWalletId, name) .mapLeft(SaveContactError::Name) .bind() @@ -43,7 +46,10 @@ class CreateContactUseCase( updatedAt = now, addressEntries = addressEntries, ) - repository.saveContact(contact) - contact + val signed = signAddressEntries(userWallet, contact) + .mapLeft(SaveContactError::Signing) + .bind() + repository.saveContact(signed) + signed } } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt new file mode 100644 index 0000000000..be933b28e7 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt @@ -0,0 +1,28 @@ +package com.tangem.domain.addressbook.usecase + +import com.tangem.domain.addressbook.model.VerifiedContact +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetVerifiedContactsUseCase( + private val getContacts: GetContactsUseCase, + private val verifyAddressEntries: VerifyAddressEntriesUseCase, + private val userWalletsListRepository: UserWalletsListRepository, +) { + + operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow> { + return getContacts(query, userWalletId).map { contacts -> + val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId } + contacts.mapNotNull { contact -> + val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null + val verification = verifyAddressEntries(userWallet, contact).getOrNull() ?: return@mapNotNull null + VerifiedContact( + contact = contact.copy(addressEntries = verification.valid), + invalidEntries = verification.invalid, + ) + } + } + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt index 3f6580f0f4..3a6ed87bf1 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt @@ -9,18 +9,16 @@ import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.ContactName import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider +import com.tangem.domain.models.wallet.UserWallet -/** - - * format-checked — uniqueness is not re-validated on update. Address entries must be prepared and - * validated before calling this use case. [Contact.updatedAt] is restamped with the current time. - */ class UpdateContactUseCase( private val repository: AddressBookRepository, + private val signAddressEntries: SignAddressEntriesUseCase, private val timestampProvider: IsoTimestampProvider, ) { suspend operator fun invoke( + userWallet: UserWallet, contact: Contact, name: String, addressEntries: List, @@ -34,7 +32,10 @@ class UpdateContactUseCase( addressEntries = addressEntries, updatedAt = timestampProvider.now(), ) - repository.saveContact(updated) - updated + val signed = signAddressEntries(userWallet, updated) + .mapLeft(SaveContactError::Signing) + .bind() + repository.saveContact(signed) + signed } } \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt index c37c8c7d07..60a42a7729 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt @@ -1,5 +1,7 @@ package com.tangem.domain.addressbook.usecase +import arrow.core.left +import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.domain.addressbook.error.ContactNameValidationError import com.tangem.domain.addressbook.error.SaveContactError @@ -11,7 +13,9 @@ import com.tangem.domain.addressbook.model.ContactName import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider 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 io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify @@ -32,13 +36,16 @@ class CreateContactUseCaseTest { private val timestampProvider: IsoTimestampProvider = mockk { every { now() } returns expectedTimestamp } + private val signAddressEntries: SignAddressEntriesUseCase = mockk() private val useCase = CreateContactUseCase( repository = repository, validateContactName = ValidateContactNameUseCase(repository), + signAddressEntries = signAddressEntries, timestampProvider = timestampProvider, ) private val walletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns this@CreateContactUseCaseTest.walletId } private val networkRawId = Network.RawID("ethereum") private val networkId = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None) private val network: Network = mockk { every { id } returns networkId } @@ -53,19 +60,25 @@ class CreateContactUseCaseTest { ), ) + private val signedEntries = listOf(addressEntries.first().copy(signature = "signed")) + @BeforeEach fun resetMocks() { - clearMocks(repository) + clearMocks(repository, signAddressEntries) + // Sign returns the contact with signed entries; the persisted contact must be the signed one. + coEvery { signAddressEntries(eq(userWallet), any()) } answers { + secondArg().copy(addressEntries = signedEntries).right() + } } @Test - fun `create generates ids and persists the contact`() = runTest { + fun `create generates ids and persists the signed contact`() = runTest { every { repository.getContacts(walletId) } returns flowOf(emptyList()) val saved = slot() coEvery { repository.saveContact(capture(saved)) } returns Unit val result = useCase( - userWalletId = walletId, + userWallet = userWallet, name = "Alice", network = network, addressEntries = addressEntries, @@ -76,17 +89,33 @@ class CreateContactUseCaseTest { assertThat(contact!!.walletId).isEqualTo(walletId) assertThat(contact.name.value).isEqualTo("Alice") assertThat(contact.id.value).isNotEmpty() - assertThat(contact.addressEntries).isEqualTo(addressEntries) + assertThat(contact.addressEntries).isEqualTo(signedEntries) assertThat(contact.createdAt).isEqualTo(expectedTimestamp) assertThat(contact.updatedAt).isEqualTo(expectedTimestamp) } + @Test + fun `signing failure fails without persisting`() = runTest { + every { repository.getContacts(walletId) } returns flowOf(emptyList()) + coEvery { signAddressEntries(eq(userWallet), any()) } returns SignHashesError.NoSigningKey.left() + + val result = useCase( + userWallet = userWallet, + name = "Alice", + network = network, + addressEntries = addressEntries, + ) + + assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey)) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + @Test fun `duplicate name fails without persisting`() = runTest { every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice"))) val result = useCase( - userWalletId = walletId, + userWallet = userWallet, name = "alice", network = network, addressEntries = addressEntries, @@ -102,7 +131,7 @@ class CreateContactUseCaseTest { every { repository.getContacts(walletId) } returns flowOf(emptyList()) val result = useCase( - userWalletId = walletId, + userWallet = userWallet, name = "", network = network, addressEntries = addressEntries, diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt new file mode 100644 index 0000000000..4c4e20d359 --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt @@ -0,0 +1,116 @@ +package com.tangem.domain.addressbook.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.addressbook.model.AddressEntriesVerification +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.VerifiedContact +import com.tangem.domain.common.wallets.UserWalletsListRepository +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.VerifyMessagesError +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class GetVerifiedContactsUseCaseTest { + + private val getContacts: GetContactsUseCase = mockk() + private val verifyAddressEntries: VerifyAddressEntriesUseCase = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + + private val useCase = GetVerifiedContactsUseCase( + getContacts = getContacts, + verifyAddressEntries = verifyAddressEntries, + userWalletsListRepository = userWalletsListRepository, + ) + + private val walletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns this@GetVerifiedContactsUseCaseTest.walletId } + + private val validEntry = entry(id = "valid", address = "0xvalid") + private val invalidEntry = entry(id = "invalid", address = "0xinvalid") + private val contact = contact(name = "Alice", entries = listOf(validEntry, invalidEntry)) + + @BeforeEach + fun resetMocks() { + clearMocks(getContacts, verifyAddressEntries, userWalletsListRepository) + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + } + + @Test + fun `GIVEN mixed entries WHEN invoke THEN displays only valid AND keeps invalid for analytics`() = runTest { + // Arrange + every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact)) + every { verifyAddressEntries(userWallet, contact) } returns + AddressEntriesVerification(valid = listOf(validEntry), invalid = listOf(invalidEntry)).right() + + // Act + val result = useCase(query = "").first() + + // Assert + assertThat(result).containsExactly( + VerifiedContact( + contact = contact.copy(addressEntries = listOf(validEntry)), + invalidEntries = listOf(invalidEntry), + ), + ) + } + + @Test + fun `GIVEN wallet cannot be resolved WHEN invoke THEN contact is dropped`() = runTest { + // Arrange + coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList() + every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact)) + + // Act + val result = useCase(query = "").first() + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `GIVEN verification fails WHEN invoke THEN contact is dropped`() = runTest { + // Arrange + every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact)) + every { verifyAddressEntries(userWallet, contact) } returns VerifyMessagesError.NoSigningKey.left() + + // Act + val result = useCase(query = "").first() + + // Assert + assertThat(result).isEmpty() + } + + private fun entry(id: String, address: String): AddressEntry = AddressEntry( + id = AddressEntryId(id), + address = address, + networkId = Network.RawID("ethereum"), + memo = null, + signature = "sig-$id", + ) + + private fun contact(name: String, entries: List): Contact = Contact( + id = ContactId("id-$name"), + walletId = walletId, + name = requireNotNull(ContactName(name).getOrNull()), + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-01-01T00:00:00.000Z", + addressEntries = entries, + ) +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt index 043697d440..7e3929dbe3 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt @@ -1,5 +1,7 @@ package com.tangem.domain.addressbook.usecase +import arrow.core.left +import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.domain.addressbook.error.ContactNameValidationError import com.tangem.domain.addressbook.error.SaveContactError @@ -11,7 +13,9 @@ import com.tangem.domain.addressbook.model.ContactName import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider 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 io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify @@ -32,12 +36,15 @@ class UpdateContactUseCaseTest { private val timestampProvider: IsoTimestampProvider = mockk { every { now() } returns newTimestamp } + private val signAddressEntries: SignAddressEntriesUseCase = mockk() private val useCase = UpdateContactUseCase( repository = repository, + signAddressEntries = signAddressEntries, timestampProvider = timestampProvider, ) private val walletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns this@UpdateContactUseCaseTest.walletId } private val networkRawId = Network.RawID("ethereum") private val updatedEntries = listOf( @@ -50,18 +57,24 @@ class UpdateContactUseCaseTest { ), ) + private val signedEntries = listOf(updatedEntries.first().copy(signature = "signed")) + @BeforeEach fun resetMocks() { - clearMocks(repository) + clearMocks(repository, signAddressEntries) + coEvery { signAddressEntries(eq(userWallet), any()) } answers { + secondArg().copy(addressEntries = signedEntries).right() + } } @Test - fun `update preserves id and persists changes without checking uniqueness`() = runTest { + fun `update preserves id and persists signed changes without checking uniqueness`() = runTest { val existing = contact(name = "Alice") val saved = slot() coEvery { repository.saveContact(capture(saved)) } returns Unit val result = useCase( + userWallet = userWallet, contact = existing, name = "Bob", addressEntries = updatedEntries, @@ -71,15 +84,31 @@ class UpdateContactUseCaseTest { assertThat(contact).isEqualTo(saved.captured) assertThat(contact!!.id).isEqualTo(existing.id) assertThat(contact.name.value).isEqualTo("Bob") - assertThat(contact.addressEntries).isEqualTo(updatedEntries) + assertThat(contact.addressEntries).isEqualTo(signedEntries) assertThat(contact.createdAt).isEqualTo(originalTimestamp) // preserved assertThat(contact.updatedAt).isEqualTo(newTimestamp) // restamped coVerify(exactly = 0) { repository.getContacts(any()) } } + @Test + fun `signing failure fails without persisting`() = runTest { + coEvery { signAddressEntries(eq(userWallet), any()) } returns SignHashesError.NoSigningKey.left() + + val result = useCase( + userWallet = userWallet, + contact = contact(name = "Alice"), + name = "Bob", + addressEntries = updatedEntries, + ) + + assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey)) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + @Test fun `invalid name fails without persisting`() = runTest { val result = useCase( + userWallet = userWallet, contact = contact(name = "Alice"), name = "", addressEntries = updatedEntries,