Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-03 11:37:25 +02:00
parent 391e0562cc
commit 12cebc413d
17 changed files with 686 additions and 7 deletions

View file

@ -16,6 +16,8 @@ interface AddressBookRepository {
/** Contacts across all wallets (flattened). Each [Contact] keeps its own [Contact.walletId]. */
fun getAllContacts(): Flow<List<Contact>>
suspend fun getContactsSync(userWalletId: UserWalletId): List<Contact>
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit>

View file

@ -0,0 +1,38 @@
package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.models.wallet.UserWalletId
/**
* Enforces the `network + address` uniqueness rule within a wallet's address book: checks whether the
* ([networkId], [address]) pair is already saved and, if so, returns the name of the contact that holds it
* so the UI can tell the user under which name it is stored. Returns `null` when the pair is free.
*
* The same address in a different network is allowed. [excludeContactId] lets an in-place edit skip the
* contact currently being edited. Address comparison is exact (matching the in-editor dedup in
* `AddValidatedAddressTransformer`), so case-sensitive chains are not falsely flagged.
*
* Reads the local snapshot ([AddressBookRepository.getContactsSync]) rather than the syncing flow, so validating on
* every keystroke/selection change never triggers a backend sync.
*/
class CheckAddressDuplicateUseCase(
private val repository: AddressBookRepository,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
networkId: String,
address: String,
excludeContactId: ContactId? = null,
): String? {
val contacts = repository.getContactsSync(userWalletId)
return contacts
.firstOrNull { contact ->
contact.id != excludeContactId && contact.addressEntries.any { entry ->
entry.networkId.value == networkId && entry.address == address
}
}
?.name?.value
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.repository.AddressBookRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetContactByIdUseCase(
private val repository: AddressBookRepository,
) {
operator fun invoke(id: ContactId): Flow<Contact?> =
repository.getAllContacts().map { contacts -> contacts.find { it.id == id } }
}

View file

@ -0,0 +1,102 @@
package com.tangem.domain.addressbook.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.*
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.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 CheckAddressDuplicateUseCaseTest {
private val repository: AddressBookRepository = mockk()
private val useCase = CheckAddressDuplicateUseCase(repository)
private val walletId = UserWalletId("0001")
@BeforeEach
fun resetMocks() {
clearMocks(repository)
}
@Test
fun `GIVEN network and address already saved WHEN invoke THEN returns owning contact name`() = runTest {
// Arrange
coEvery { repository.getContactsSync(walletId) } returns listOf(contact("Binance", "0xAAA", ETHEREUM))
// Act
val result = useCase(walletId, networkId = ETHEREUM, address = "0xAAA")
// Assert
assertThat(result).isEqualTo("Binance")
}
@Test
fun `GIVEN same address in a different network WHEN invoke THEN returns null`() = runTest {
// Arrange
coEvery { repository.getContactsSync(walletId) } returns listOf(contact("Binance", "0xAAA", ETHEREUM))
// Act
val result = useCase(walletId, networkId = TRON, address = "0xAAA")
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN the pair belongs to the excluded contact WHEN invoke THEN returns null`() = runTest {
// Arrange
val contact = contact("Binance", "0xAAA", ETHEREUM, id = "id-1")
coEvery { repository.getContactsSync(walletId) } returns listOf(contact)
// Act
val result = useCase(walletId, networkId = ETHEREUM, address = "0xAAA", excludeContactId = ContactId("id-1"))
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN free pair WHEN invoke THEN returns null`() = runTest {
// Arrange
coEvery { repository.getContactsSync(walletId) } returns listOf(contact("Binance", "0xAAA", ETHEREUM))
// Act
val result = useCase(walletId, networkId = ETHEREUM, address = "0xBBB")
// Assert
assertThat(result).isNull()
}
private fun contact(name: String, address: String, networkId: String, id: String = "id-$name"): Contact = Contact(
id = ContactId(id),
walletId = walletId,
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = "Azure",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addressEntries = listOf(
AddressEntry(
id = AddressEntryId("addr-$name"),
address = address,
networkId = Network.RawID(networkId),
memo = null,
signature = "sig",
networkName = "Ethereum",
),
),
)
private companion object {
const val ETHEREUM = "ethereum"
const val TRON = "tron"
}
}

View file

@ -0,0 +1,65 @@
package com.tangem.domain.addressbook.usecase
import com.google.common.truth.Truth.assertThat
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.wallet.UserWalletId
import io.mockk.clearMocks
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 GetContactByIdUseCaseTest {
private val repository: AddressBookRepository = mockk()
private val useCase = GetContactByIdUseCase(repository)
@BeforeEach
fun resetMocks() {
clearMocks(repository)
}
@Test
fun `GIVEN matching id WHEN invoke THEN emits that contact`() = runTest {
// Arrange
val target = contact("id-2", "Bob")
every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice"), target))
// Act
val result = useCase(ContactId("id-2")).first()
// Assert
assertThat(result).isEqualTo(target)
}
@Test
fun `GIVEN no matching id WHEN invoke THEN emits null`() = runTest {
// Arrange
every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice")))
// Act
val result = useCase(ContactId("missing")).first()
// Assert
assertThat(result).isNull()
}
private fun contact(id: String, name: String): Contact = Contact(
id = ContactId(id),
walletId = UserWalletId("0001"),
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = "Azure",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addressEntries = emptyList(),
)
}