From 5f9b1a9d587c9dcd013ceb8bef62398fd42176f4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 4 Jun 2026 12:47:27 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 + domain/address-book/build.gradle.kts | 27 ++++ .../error/ContactNameValidationError.kt | 15 +++ .../addressbook/error/SaveContactError.kt | 10 ++ .../domain/addressbook/model/AddressEntry.kt | 14 ++ .../addressbook/model/AddressEntryId.kt | 8 ++ .../domain/addressbook/model/Contact.kt | 13 ++ .../domain/addressbook/model/ContactId.kt | 8 ++ .../domain/addressbook/model/ContactName.kt | 50 +++++++ .../repository/AddressBookRepository.kt | 22 ++++ .../usecase/CreateContactUseCase.kt | 43 ++++++ .../usecase/DeleteContactUseCase.kt | 11 ++ .../addressbook/usecase/GetContactsUseCase.kt | 13 ++ .../usecase/UpdateContactUseCase.kt | 37 ++++++ .../usecase/ValidateContactAddressUseCase.kt | 37 ++++++ .../usecase/ValidateContactNameUseCase.kt | 36 ++++++ .../addressbook/model/ContactNameTest.kt | 66 ++++++++++ .../usecase/CreateContactUseCaseTest.kt | 122 ++++++++++++++++++ .../usecase/UpdateContactUseCaseTest.kt | 97 ++++++++++++++ .../ValidateContactAddressUseCaseTest.kt | 75 +++++++++++ .../usecase/ValidateContactNameUseCaseTest.kt | 77 +++++++++++ settings.gradle.kts | 1 + 22 files changed, 786 insertions(+) create mode 100644 domain/address-book/build.gradle.kts create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/ContactNameValidationError.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntryId.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactId.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactName.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/DeleteContactUseCase.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactAddressUseCase.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCase.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/ContactNameTest.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactAddressUseCaseTest.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 4beb689f05..95f1a0e965 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -126,5 +126,9 @@ { "name": "AND_15368_VISA_PAY_REDESIGN", "version": "undefined" + }, + { + "name": "TWI_83_ADDRESS_BOOK_ENABLED", + "version": "undefined" } ] diff --git a/domain/address-book/build.gradle.kts b/domain/address-book/build.gradle.kts new file mode 100644 index 0000000000..ff2feec39c --- /dev/null +++ b/domain/address-book/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.domain.addressbook" +} + +dependencies { + + api(projects.domain.core) + api(projects.domain.models) + + implementation(projects.domain.transaction) + implementation(projects.domain.tokens) + + implementation(deps.arrow.core) + implementation(deps.kotlin.coroutines) + implementation(deps.kotlin.serialization) + + // region Test libraries + testImplementation(projects.test.core) + testImplementation(projects.test.mock) + // endregion +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/ContactNameValidationError.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/ContactNameValidationError.kt new file mode 100644 index 0000000000..ecce28f496 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/ContactNameValidationError.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.addressbook.error + +import com.tangem.domain.addressbook.model.ContactName +import kotlinx.serialization.Serializable + +@Serializable +sealed interface ContactNameValidationError { + + @Serializable + data class Format(val error: ContactName.Error) : ContactNameValidationError + + /** Another contact in the same wallet already uses this name (case-insensitive). */ + @Serializable + data object Duplicate : ContactNameValidationError +} \ No newline at end of file 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 new file mode 100644 index 0000000000..f2c3e979ba --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.addressbook.error + +import com.tangem.domain.transaction.error.AddressValidation + +sealed interface SaveContactError { + + data class Name(val error: ContactNameValidationError) : SaveContactError + + data class Address(val error: AddressValidation.Error) : SaveContactError +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt new file mode 100644 index 0000000000..4ed3388f27 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.addressbook.model + +import com.tangem.domain.models.network.Network +import kotlinx.serialization.Serializable + +/** A single saved address belonging to a [Contact]. */ +@Serializable +data class AddressEntry( + val id: AddressEntryId, + val address: String, + val networkId: Network.RawID, + val memo: String?, + val signature: String, +) \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntryId.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntryId.kt new file mode 100644 index 0000000000..ba3c6ac2d5 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntryId.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.addressbook.model + +import kotlinx.serialization.Serializable + +/** Client-generated UUID v4 identifier of an [AddressEntry]. */ +@Serializable +@JvmInline +value class AddressEntryId(val value: String) \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt new file mode 100644 index 0000000000..658de7ed5b --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.addressbook.model + +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.Serializable + +/** A named address stored in the user's address book for fast access when sending. */ +@Serializable +data class Contact( + val id: ContactId, + val walletId: UserWalletId, + val name: ContactName, + val addressEntries: List, +) \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactId.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactId.kt new file mode 100644 index 0000000000..01f1f8bc36 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactId.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.addressbook.model + +import kotlinx.serialization.Serializable + +/** Client-generated UUID v4 identifier of a [Contact]. */ +@Serializable +@JvmInline +value class ContactId(val value: String) \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactName.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactName.kt new file mode 100644 index 0000000000..c31f1f70f7 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/ContactName.kt @@ -0,0 +1,50 @@ +package com.tangem.domain.addressbook.model + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import kotlinx.serialization.Serializable + +/** + * Validated name of a [Contact]. + * + * The only way to obtain an instance is the validating [invoke] factory, which enforces the + * address-book naming rules. Uniqueness within a wallet is **not** enforced here — it requires + * access to the repository and lives in `ValidateContactNameUseCase`. + */ +@Serializable +@ConsistentCopyVisibility +data class ContactName private constructor(val value: String) { + + @Serializable + sealed interface Error { + + @Serializable + data object Empty : Error + + @Serializable + data object ExceedsMaxLength : Error + + @Serializable + data object InvalidCharacters : Error + } + + companion object { + + const val MIN_LENGTH = 1 + const val MAX_LENGTH = 50 + + /** Letters, numbers and spaces only — forbids emoji, new lines, tabs, special symbols and html/scripts. */ + private val allowedPattern = Regex("^[\\p{L}\\p{N} ]+$") + + operator fun invoke(value: String): Either = either { + val trimmed = value.trim() + + ensure(trimmed.length >= MIN_LENGTH) { Error.Empty } + ensure(trimmed.length <= MAX_LENGTH) { Error.ExceedsMaxLength } + ensure(allowedPattern.matches(trimmed)) { Error.InvalidCharacters } + + ContactName(trimmed) + } + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt new file mode 100644 index 0000000000..71858fae3e --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.addressbook.repository + +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +/** Persistence port for the address book. The implementation is provided by the data layer. */ +interface AddressBookRepository { + + fun getContacts(userWalletId: UserWalletId): Flow> + + /** Contacts across several wallets, flattened. Each [Contact] keeps its own [Contact.walletId]. */ + fun getContacts(userWalletIds: Set): Flow> + + suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? + + /** Inserts or updates a [contact]. */ + suspend fun saveContact(contact: Contact) + + suspend fun deleteContact(id: ContactId) +} \ 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 new file mode 100644 index 0000000000..9fca6a9f79 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt @@ -0,0 +1,43 @@ +package com.tangem.domain.addressbook.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.addressbook.error.SaveContactError +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import java.util.UUID + +/** + * Creates a new [Contact] with client-generated UUID v4 ids. The name must be valid and unique + * (case-insensitive) within the wallet. + */ +class CreateContactUseCase( + private val repository: AddressBookRepository, + private val validateContactName: ValidateContactNameUseCase, +) { + + @Suppress("LongParameterList") + suspend operator fun invoke( + userWalletId: UserWalletId, + name: String, + network: Network, + addressEntries: List, + ): Either = either { + val validName = validateContactName(userWalletId, name) + .mapLeft(SaveContactError::Name) + .bind() + + val contact = Contact( + id = ContactId(UUID.randomUUID().toString()), + walletId = userWalletId, + name = validName, + addressEntries = addressEntries, + ) + repository.saveContact(contact) + contact + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/DeleteContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/DeleteContactUseCase.kt new file mode 100644 index 0000000000..f7cefbec49 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/DeleteContactUseCase.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.addressbook.usecase + +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.domain.addressbook.repository.AddressBookRepository + +class DeleteContactUseCase( + private val repository: AddressBookRepository, +) { + + suspend operator fun invoke(id: ContactId) = repository.deleteContact(id) +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt new file mode 100644 index 0000000000..71f56c7d7c --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.addressbook.usecase + +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +class GetContactsUseCase( + private val repository: AddressBookRepository, +) { + + operator fun invoke(userWalletIds: Set): Flow> = repository.getContacts(userWalletIds) +} \ 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 new file mode 100644 index 0000000000..28782da0f1 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt @@ -0,0 +1,37 @@ +package com.tangem.domain.addressbook.usecase + +import arrow.core.Either +import arrow.core.raise.either +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.Contact +import com.tangem.domain.addressbook.model.ContactName +import com.tangem.domain.addressbook.repository.AddressBookRepository + +/** + * Updates an existing [Contact], preserving its contact id. The name is only format-checked — + * uniqueness is not re-validated on update. Address entries must be prepared and validated before + * calling this use case. + */ +class UpdateContactUseCase( + private val repository: AddressBookRepository, +) { + + suspend operator fun invoke( + contact: Contact, + name: String, + addressEntries: List, + ): Either = either { + val validName = ContactName(name) + .mapLeft { SaveContactError.Name(ContactNameValidationError.Format(it)) } + .bind() + + val updated = contact.copy( + name = validName, + addressEntries = addressEntries, + ) + repository.saveContact(updated) + updated + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactAddressUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactAddressUseCase.kt new file mode 100644 index 0000000000..13ee0802d7 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactAddressUseCase.kt @@ -0,0 +1,37 @@ +package com.tangem.domain.addressbook.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.GetNetworkAddressesUseCase +import com.tangem.domain.transaction.error.AddressValidation +import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase + +/** + * Validates a contact's address for a network, reusing the transaction-layer validation. Self-send + * is allowed since saving one's own address in the book is valid. + */ +class ValidateContactAddressUseCase( + private val validateWalletAddressUseCase: ValidateWalletAddressUseCase, + private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + network: Network, + address: String, + ): Either = either { + val senderAddresses = getNetworkAddressesUseCase.invokeSync( + userWalletId = userWalletId, + networkRawId = network.id.rawId, + ) + validateWalletAddressUseCase( + userWalletId = userWalletId, + network = network, + address = address, + senderAddresses = senderAddresses, + allowSelfSend = true, + ).bind() + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCase.kt new file mode 100644 index 0000000000..891cad072e --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCase.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.addressbook.usecase + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.tangem.domain.addressbook.error.ContactNameValidationError +import com.tangem.domain.addressbook.model.ContactName +import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.first + +/** + * Validates a contact name: format rules via [ContactName] plus case-insensitive uniqueness within + * the wallet. + */ +class ValidateContactNameUseCase( + private val repository: AddressBookRepository, +) { + + suspend operator fun invoke( + walletId: UserWalletId, + name: String, + ): Either = either { + val validName = ContactName(name) + .mapLeft(ContactNameValidationError::Format) + .bind() + + val contacts = repository.getContacts(walletId).first() + val isDuplicate = contacts.any { contact -> + contact.name.value.equals(validName.value, ignoreCase = true) + } + ensure(!isDuplicate) { ContactNameValidationError.Duplicate } + + validName + } +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/ContactNameTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/ContactNameTest.kt new file mode 100644 index 0000000000..5fe112550d --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/model/ContactNameTest.kt @@ -0,0 +1,66 @@ +package com.tangem.domain.addressbook.model + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ContactNameTest { + + @Test + fun `valid name is accepted and trimmed`() { + val result = ContactName(" Alice 1 ") + + assertThat(result.getOrNull()?.value).isEqualTo("Alice 1") + } + + @Test + fun `single character name is accepted`() { + assertThat(ContactName("A").isRight()).isTrue() + } + + @Test + fun `name of max length is accepted`() { + val name = "a".repeat(ContactName.MAX_LENGTH) + + assertThat(ContactName(name).isRight()).isTrue() + } + + @Test + fun `blank name is rejected as Empty`() { + assertThat(ContactName(" ").leftOrNull()).isEqualTo(ContactName.Error.Empty) + } + + @Test + fun `name exceeding max length is rejected`() { + val name = "a".repeat(ContactName.MAX_LENGTH + 1) + + assertThat(ContactName(name).leftOrNull()).isEqualTo(ContactName.Error.ExceedsMaxLength) + } + + @Test + fun `emoji is rejected`() { + assertThat(ContactName("Alice 😀").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters) + } + + @Test + fun `new line is rejected`() { + assertThat(ContactName("Ali\nce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters) + } + + @Test + fun `tab is rejected`() { + assertThat(ContactName("Ali\tce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters) + } + + @Test + fun `html script is rejected`() { + assertThat(ContactName("").leftOrNull()) + .isEqualTo(ContactName.Error.InvalidCharacters) + } + + @Test + fun `special symbols are rejected`() { + assertThat(ContactName("Alice@!").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters) + } +} \ 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 new file mode 100644 index 0000000000..c33c19f8fa --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt @@ -0,0 +1,122 @@ +package com.tangem.domain.addressbook.usecase + +import com.google.common.truth.Truth.assertThat +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.repository.AddressBookRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +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 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 CreateContactUseCaseTest { + + private val repository: AddressBookRepository = mockk(relaxUnitFun = true) + private val useCase = CreateContactUseCase( + repository = repository, + validateContactName = ValidateContactNameUseCase(repository), + ) + + private val walletId = UserWalletId("011") + 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 } + + private val addressEntries = listOf( + AddressEntry( + id = AddressEntryId("addr-1"), + address = "0xabc", + networkId = networkRawId, + memo = "memo", + signature = "sig", + ), + ) + + @BeforeEach + fun resetMocks() { + clearMocks(repository) + } + + @Test + fun `create generates ids and persists the contact`() = runTest { + every { repository.getContacts(walletId) } returns flowOf(emptyList()) + val saved = slot() + coEvery { repository.saveContact(capture(saved)) } returns Unit + + val result = useCase( + userWalletId = walletId, + name = "Alice", + network = network, + addressEntries = addressEntries, + ) + + val contact = result.getOrNull() + assertThat(contact).isEqualTo(saved.captured) + assertThat(contact!!.walletId).isEqualTo(walletId) + assertThat(contact.name.value).isEqualTo("Alice") + assertThat(contact.id.value).isNotEmpty() + assertThat(contact.addressEntries).isEqualTo(addressEntries) + } + + @Test + fun `duplicate name fails without persisting`() = runTest { + every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice"))) + + val result = useCase( + userWalletId = walletId, + name = "alice", + network = network, + addressEntries = addressEntries, + ) + + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Name(ContactNameValidationError.Duplicate)) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + @Test + fun `invalid name fails without persisting`() = runTest { + every { repository.getContacts(walletId) } returns flowOf(emptyList()) + + val result = useCase( + userWalletId = walletId, + name = "", + network = network, + addressEntries = addressEntries, + ) + + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty))) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + private fun contact(name: String): Contact = Contact( + id = ContactId("id-$name"), + walletId = walletId, + name = requireNotNull(ContactName(name).getOrNull()), + addressEntries = listOf( + AddressEntry( + id = AddressEntryId("addr-$name"), + address = "0xabc", + networkId = networkRawId, + memo = null, + signature = "sig", + ), + ), + ) +} \ 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 new file mode 100644 index 0000000000..e015e72b88 --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt @@ -0,0 +1,97 @@ +package com.tangem.domain.addressbook.usecase + +import com.google.common.truth.Truth.assertThat +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.repository.AddressBookRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +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 UpdateContactUseCaseTest { + + private val repository: AddressBookRepository = mockk(relaxUnitFun = true) + private val useCase = UpdateContactUseCase( + repository = repository, + ) + + private val walletId = UserWalletId("011") + private val networkRawId = Network.RawID("ethereum") + + private val updatedEntries = listOf( + AddressEntry( + id = AddressEntryId("addr-new"), + address = "0xnew", + networkId = networkRawId, + memo = "memo", + signature = "sig2", + ), + ) + + @BeforeEach + fun resetMocks() { + clearMocks(repository) + } + + @Test + fun `update preserves id and persists changes without checking uniqueness`() = runTest { + val existing = contact(name = "Alice") + val saved = slot() + coEvery { repository.saveContact(capture(saved)) } returns Unit + + val result = useCase( + contact = existing, + name = "Bob", + addressEntries = updatedEntries, + ) + + val contact = result.getOrNull() + assertThat(contact).isEqualTo(saved.captured) + assertThat(contact!!.id).isEqualTo(existing.id) + assertThat(contact.name.value).isEqualTo("Bob") + assertThat(contact.addressEntries).isEqualTo(updatedEntries) + coVerify(exactly = 0) { repository.getContacts(any()) } + } + + @Test + fun `invalid name fails without persisting`() = runTest { + val result = useCase( + contact = contact(name = "Alice"), + name = "", + addressEntries = updatedEntries, + ) + + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty))) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + private fun contact(name: String): Contact = Contact( + id = ContactId("id-$name"), + walletId = walletId, + name = requireNotNull(ContactName(name).getOrNull()), + addressEntries = listOf( + AddressEntry( + id = AddressEntryId("addr-$name"), + address = "0xabc", + networkId = networkRawId, + memo = null, + signature = "sig", + ), + ), + ) +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactAddressUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactAddressUseCaseTest.kt new file mode 100644 index 0000000000..86891b8917 --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactAddressUseCaseTest.kt @@ -0,0 +1,75 @@ +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.models.network.CryptoCurrencyAddress +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.GetNetworkAddressesUseCase +import com.tangem.domain.transaction.error.AddressValidation +import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +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 ValidateContactAddressUseCaseTest { + + private val validateWalletAddressUseCase: ValidateWalletAddressUseCase = mockk() + private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase = mockk() + private val useCase = ValidateContactAddressUseCase( + validateWalletAddressUseCase = validateWalletAddressUseCase, + getNetworkAddressesUseCase = getNetworkAddressesUseCase, + ) + + private val walletId = UserWalletId("011") + 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 } + + @BeforeEach + fun resetMocks() { + clearMocks(validateWalletAddressUseCase, getNetworkAddressesUseCase) + } + + @Test + fun `valid address forwards sender addresses and allows self-send`() = runTest { + val senderAddresses = listOf(mockk()) + coEvery { getNetworkAddressesUseCase.invokeSync(walletId, networkRawId) } returns senderAddresses + coEvery { + validateWalletAddressUseCase(any(), any(), any(), any>(), any()) + } returns AddressValidation.Success.Valid.right() + + val result = useCase(walletId, network, "0xabc") + + assertThat(result.isRight()).isTrue() + coVerify { + validateWalletAddressUseCase( + userWalletId = walletId, + network = network, + address = "0xabc", + senderAddresses = senderAddresses, + allowSelfSend = true, + ) + } + } + + @Test + fun `invalid address propagates the validation error`() = runTest { + coEvery { getNetworkAddressesUseCase.invokeSync(walletId, networkRawId) } returns emptyList() + coEvery { + validateWalletAddressUseCase(any(), any(), any(), any>(), any()) + } returns AddressValidation.Error.InvalidAddress.left() + + val result = useCase(walletId, network, "bad") + + assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress) + } +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt new file mode 100644 index 0000000000..f255050b3f --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt @@ -0,0 +1,77 @@ +package com.tangem.domain.addressbook.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.addressbook.error.ContactNameValidationError +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.repository.AddressBookRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +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 ValidateContactNameUseCaseTest { + + private val repository: AddressBookRepository = mockk(relaxUnitFun = true) + private val useCase = ValidateContactNameUseCase(repository) + + private val walletId = UserWalletId("011") + + @BeforeEach + fun resetMocks() { + clearMocks(repository) + } + + @Test + fun `format error is propagated`() = runTest { + every { repository.getContacts(walletId) } returns flowOf(emptyList()) + + val result = useCase(walletId, name = "") + + assertThat(result.leftOrNull()) + .isEqualTo(ContactNameValidationError.Format(ContactName.Error.Empty)) + } + + @Test + fun `duplicate name in same wallet is rejected case-insensitively`() = runTest { + every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice"))) + + val result = useCase(walletId, name = "alice") + + assertThat(result.leftOrNull()).isEqualTo(ContactNameValidationError.Duplicate) + } + + @Test + fun `unique name is accepted`() = runTest { + every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice"))) + + val result = useCase(walletId, name = "Bob") + + assertThat(result.getOrNull()?.value).isEqualTo("Bob") + } + + private fun contact(name: String): Contact = Contact( + id = ContactId("id-$name"), + walletId = walletId, + name = requireNotNull(ContactName(name).getOrNull()), + addressEntries = listOf( + AddressEntry( + id = AddressEntryId("addr-$name"), + address = "0xabc", + networkId = Network.RawID("ethereum"), + memo = null, + signature = "sig", + ), + ), + ) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 590257dd05..19a72b0acf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -342,6 +342,7 @@ include(":domain:legacy") include(":domain:account") include(":domain:account:status") +include(":domain:address-book") include(":domain:card") include(":domain:common") include(":domain:core")