Updated on 2026-08-14
This commit is contained in:
parent
4390b59231
commit
5f9b1a9d58
22 changed files with 786 additions and 0 deletions
|
|
@ -126,5 +126,9 @@
|
||||||
{
|
{
|
||||||
"name": "AND_15368_VISA_PAY_REDESIGN",
|
"name": "AND_15368_VISA_PAY_REDESIGN",
|
||||||
"version": "undefined"
|
"version": "undefined"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TWI_83_ADDRESS_BOOK_ENABLED",
|
||||||
|
"version": "undefined"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
27
domain/address-book/build.gradle.kts
Normal file
27
domain/address-book/build.gradle.kts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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<AddressEntry>,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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<Error, ContactName> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<List<Contact>>
|
||||||
|
|
||||||
|
/** Contacts across several wallets, flattened. Each [Contact] keeps its own [Contact.walletId]. */
|
||||||
|
fun getContacts(userWalletIds: Set<UserWalletId>): Flow<List<Contact>>
|
||||||
|
|
||||||
|
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
|
||||||
|
|
||||||
|
/** Inserts or updates a [contact]. */
|
||||||
|
suspend fun saveContact(contact: Contact)
|
||||||
|
|
||||||
|
suspend fun deleteContact(id: ContactId)
|
||||||
|
}
|
||||||
|
|
@ -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<AddressEntry>,
|
||||||
|
): Either<SaveContactError, Contact> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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<UserWalletId>): Flow<List<Contact>> = repository.getContacts(userWalletIds)
|
||||||
|
}
|
||||||
|
|
@ -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<AddressEntry>,
|
||||||
|
): Either<SaveContactError, Contact> = either {
|
||||||
|
val validName = ContactName(name)
|
||||||
|
.mapLeft { SaveContactError.Name(ContactNameValidationError.Format(it)) }
|
||||||
|
.bind()
|
||||||
|
|
||||||
|
val updated = contact.copy(
|
||||||
|
name = validName,
|
||||||
|
addressEntries = addressEntries,
|
||||||
|
)
|
||||||
|
repository.saveContact(updated)
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<AddressValidation.Error, Unit> = either {
|
||||||
|
val senderAddresses = getNetworkAddressesUseCase.invokeSync(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
networkRawId = network.id.rawId,
|
||||||
|
)
|
||||||
|
validateWalletAddressUseCase(
|
||||||
|
userWalletId = userWalletId,
|
||||||
|
network = network,
|
||||||
|
address = address,
|
||||||
|
senderAddresses = senderAddresses,
|
||||||
|
allowSelfSend = true,
|
||||||
|
).bind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ContactNameValidationError, ContactName> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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("<script>alert(1)</script>").leftOrNull())
|
||||||
|
.isEqualTo(ContactName.Error.InvalidCharacters)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `special symbols are rejected`() {
|
||||||
|
assertThat(ContactName("Alice@!").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Contact>()
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<Contact>()
|
||||||
|
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<UserWalletId>()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<CryptoCurrencyAddress>(mockk())
|
||||||
|
coEvery { getNetworkAddressesUseCase.invokeSync(walletId, networkRawId) } returns senderAddresses
|
||||||
|
coEvery {
|
||||||
|
validateWalletAddressUseCase(any(), any(), any(), any<List<CryptoCurrencyAddress>>(), 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<List<CryptoCurrencyAddress>>(), any())
|
||||||
|
} returns AddressValidation.Error.InvalidAddress.left()
|
||||||
|
|
||||||
|
val result = useCase(walletId, network, "bad")
|
||||||
|
|
||||||
|
assertThat(result.leftOrNull()).isEqualTo(AddressValidation.Error.InvalidAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -342,6 +342,7 @@ include(":domain:legacy")
|
||||||
|
|
||||||
include(":domain:account")
|
include(":domain:account")
|
||||||
include(":domain:account:status")
|
include(":domain:account:status")
|
||||||
|
include(":domain:address-book")
|
||||||
include(":domain:card")
|
include(":domain:card")
|
||||||
include(":domain:common")
|
include(":domain:common")
|
||||||
include(":domain:core")
|
include(":domain:core")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue