Updated on 2026-08-14
This commit is contained in:
parent
12d2634cea
commit
3fd67fb474
10 changed files with 429 additions and 627 deletions
|
|
@ -2,14 +2,12 @@ package com.tangem.tap.di.domain
|
|||
|
||||
import com.tangem.domain.addressbook.crypto.AddressBookCipher
|
||||
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
|
||||
import com.tangem.domain.addressbook.interactor.SaveContactInteractor
|
||||
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.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.common.wallets.UserWalletsListRepository
|
||||
|
|
@ -39,12 +37,6 @@ object AddressBookDomainModule {
|
|||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSignAddressEntriesUseCase(signUseCase: SignUseCase): SignAddressEntriesUseCase {
|
||||
return SignAddressEntriesUseCase(signUseCase = signUseCase)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideValidateContactNameUseCase(repository: AddressBookRepository): ValidateContactNameUseCase {
|
||||
|
|
@ -73,30 +65,16 @@ object AddressBookDomainModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCreateContactUseCase(
|
||||
fun provideSaveContactInteractor(
|
||||
repository: AddressBookRepository,
|
||||
validateContactNameUseCase: ValidateContactNameUseCase,
|
||||
signAddressEntriesUseCase: SignAddressEntriesUseCase,
|
||||
signUseCase: SignUseCase,
|
||||
timestampProvider: IsoTimestampProvider,
|
||||
): CreateContactUseCase {
|
||||
return CreateContactUseCase(
|
||||
): SaveContactInteractor {
|
||||
return SaveContactInteractor(
|
||||
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,
|
||||
signUseCase = signUseCase,
|
||||
timestampProvider = timestampProvider,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
package com.tangem.domain.addressbook.interactor
|
||||
|
||||
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.ContactId
|
||||
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.addressbook.usecase.ValidateContactNameUseCase
|
||||
import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.transaction.error.SignHashesError
|
||||
import com.tangem.domain.transaction.usecase.SignUseCase
|
||||
import com.tangem.domain.transaction.usecase.primarySecp256k1PublicKey
|
||||
import com.tangem.utils.extensions.toHexString
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
||||
class SaveContactInteractor(
|
||||
private val repository: AddressBookRepository,
|
||||
private val validateContactName: ValidateContactNameUseCase,
|
||||
private val signUseCase: SignUseCase,
|
||||
private val timestampProvider: IsoTimestampProvider,
|
||||
) {
|
||||
|
||||
suspend fun createContact(
|
||||
userWallet: UserWallet,
|
||||
name: String,
|
||||
iconColor: String,
|
||||
addressEntries: List<AddressEntry>,
|
||||
): Either<SaveContactError, Contact> = either {
|
||||
val userWalletId = userWallet.walletId
|
||||
val validName = validateContactName(userWalletId, name)
|
||||
.mapLeft(SaveContactError::Name)
|
||||
.bind()
|
||||
|
||||
val now = timestampProvider.now()
|
||||
val contact = Contact(
|
||||
id = ContactId(UUID.randomUUID().toString()),
|
||||
walletId = userWalletId,
|
||||
name = validName,
|
||||
icon = "",
|
||||
iconColor = iconColor,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
addressEntries = addressEntries,
|
||||
)
|
||||
val signed = signAddressEntries(userWallet, contact)
|
||||
.mapLeft(SaveContactError::Signing)
|
||||
.bind()
|
||||
repository.saveContact(signed)
|
||||
signed
|
||||
}
|
||||
|
||||
suspend fun updateContact(
|
||||
userWallet: UserWallet,
|
||||
contact: Contact,
|
||||
name: String,
|
||||
iconColor: 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,
|
||||
iconColor = iconColor,
|
||||
addressEntries = addressEntries,
|
||||
updatedAt = timestampProvider.now(),
|
||||
)
|
||||
val signed = signAddressEntries(userWallet, updated)
|
||||
.mapLeft(SaveContactError::Signing)
|
||||
.bind()
|
||||
repository.saveContact(signed)
|
||||
signed
|
||||
}
|
||||
|
||||
private suspend fun signAddressEntries(
|
||||
userWallet: UserWallet,
|
||||
contact: Contact,
|
||||
): Either<SignHashesError, Contact> = either {
|
||||
val entries = contact.addressEntries
|
||||
if (entries.isEmpty()) return@either contact
|
||||
|
||||
val publicKey = userWallet.primarySecp256k1PublicKey() ?: raise(SignHashesError.NoSigningKey)
|
||||
val hashes = entries.map { entry -> hashEntry(contact, entry) }
|
||||
val signatures = signUseCase(hashes = hashes, publicKey = publicKey, userWallet = userWallet).bind()
|
||||
|
||||
val signedEntries = entries.mapIndexed { index, entry ->
|
||||
entry.copy(signature = signatures[index].toHexString())
|
||||
}
|
||||
contact.copy(addressEntries = signedEntries)
|
||||
}
|
||||
|
||||
private fun hashEntry(contact: Contact, entry: AddressEntry): ByteArray {
|
||||
val payload = buildAddressEntryPayload(contact, entry)
|
||||
return MessageDigest.getInstance("SHA-256").digest(payload)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import com.tangem.domain.addressbook.model.Contact
|
|||
* Builds the canonical bytes that are signed for a single [AddressEntry]:
|
||||
* `address + networkId + memo + contactId + name`.
|
||||
*
|
||||
* Shared by [SignAddressEntriesUseCase] (which hashes and signs it) and `GetVerifiedContactsInteractor`
|
||||
* Shared by `SaveContactInteractor` (which hashes and signs it) and `GetVerifiedContactsInteractor`
|
||||
* (which verifies the signature against it), so the signed and verified payloads can never diverge.
|
||||
*/
|
||||
internal fun buildAddressEntryPayload(contact: Contact, entry: AddressEntry): ByteArray {
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
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.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.models.network.Network
|
||||
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. 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,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWallet: UserWallet,
|
||||
name: String,
|
||||
iconColor: String,
|
||||
network: Network,
|
||||
addressEntries: List<AddressEntry>,
|
||||
): Either<SaveContactError, Contact> = either {
|
||||
val userWalletId = userWallet.walletId
|
||||
val validName = validateContactName(userWalletId, name)
|
||||
.mapLeft(SaveContactError::Name)
|
||||
.bind()
|
||||
|
||||
val now = timestampProvider.now()
|
||||
val contact = Contact(
|
||||
id = ContactId(UUID.randomUUID().toString()),
|
||||
walletId = userWalletId,
|
||||
name = validName,
|
||||
icon = "",
|
||||
iconColor = iconColor,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
addressEntries = addressEntries,
|
||||
)
|
||||
val signed = signAddressEntries(userWallet, contact)
|
||||
.mapLeft(SaveContactError::Signing)
|
||||
.bind()
|
||||
repository.saveContact(signed)
|
||||
signed
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.domain.addressbook.model.AddressEntry
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.transaction.error.SignHashesError
|
||||
import com.tangem.domain.transaction.usecase.SignUseCase
|
||||
import com.tangem.domain.transaction.usecase.primarySecp256k1PublicKey
|
||||
import com.tangem.utils.extensions.toHexString
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Signs every [AddressEntry] of a [Contact] with the wallet's primary secp256k1 key in a single
|
||||
* signing session (one card tap). Each entry is hashed as `SHA-256(address + networkId + memo +
|
||||
* contactId + name)` and the produced signature is stored back into [AddressEntry.signature].
|
||||
*/
|
||||
class SignAddressEntriesUseCase(
|
||||
private val signUseCase: SignUseCase,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(userWallet: UserWallet, contact: Contact): Either<SignHashesError, Contact> = either {
|
||||
val entries = contact.addressEntries
|
||||
if (entries.isEmpty()) return@either contact
|
||||
|
||||
val publicKey = userWallet.primarySecp256k1PublicKey() ?: raise(SignHashesError.NoSigningKey)
|
||||
val hashes = entries.map { entry -> hashEntry(contact, entry) }
|
||||
val signatures = signUseCase(hashes = hashes, publicKey = publicKey, userWallet = userWallet).bind()
|
||||
|
||||
val signedEntries = entries.mapIndexed { index, entry ->
|
||||
entry.copy(signature = signatures[index].toHexString())
|
||||
}
|
||||
contact.copy(addressEntries = signedEntries)
|
||||
}
|
||||
|
||||
private fun hashEntry(contact: Contact, entry: AddressEntry): ByteArray {
|
||||
val payload = buildAddressEntryPayload(contact, entry)
|
||||
return MessageDigest.getInstance("SHA-256").digest(payload)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
|
||||
class UpdateContactUseCase(
|
||||
private val repository: AddressBookRepository,
|
||||
private val signAddressEntries: SignAddressEntriesUseCase,
|
||||
private val timestampProvider: IsoTimestampProvider,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWallet: UserWallet,
|
||||
contact: Contact,
|
||||
name: String,
|
||||
iconColor: 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,
|
||||
iconColor = iconColor,
|
||||
addressEntries = addressEntries,
|
||||
updatedAt = timestampProvider.now(),
|
||||
)
|
||||
val signed = signAddressEntries(userWallet, updated)
|
||||
.mapLeft(SaveContactError::Signing)
|
||||
.bind()
|
||||
repository.saveContact(signed)
|
||||
signed
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
package com.tangem.domain.addressbook.interactor
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
|
||||
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.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
|
||||
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 com.tangem.domain.transaction.usecase.SignUseCase
|
||||
import com.tangem.utils.extensions.toHexString
|
||||
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.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.security.MessageDigest
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SaveContactInteractorTest {
|
||||
|
||||
private val repository: AddressBookRepository = mockk(relaxUnitFun = true)
|
||||
private val signUseCase: SignUseCase = mockk()
|
||||
private val timestampProvider: IsoTimestampProvider = mockk {
|
||||
every { now() } returns NEW_TIMESTAMP
|
||||
}
|
||||
private val interactor = SaveContactInteractor(
|
||||
repository = repository,
|
||||
validateContactName = ValidateContactNameUseCase(repository),
|
||||
signUseCase = signUseCase,
|
||||
timestampProvider = timestampProvider,
|
||||
)
|
||||
|
||||
// MockUserWalletFactory builds each wallet key with publicKey = curve.name bytes → secp256k1 key is "Secp256k1"
|
||||
private val userWallet: UserWallet = MockUserWalletFactory.create()
|
||||
private val secp256k1Key = "Secp256k1".toByteArray()
|
||||
private val networkRawId = Network.RawID("ethereum")
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository, signUseCase, answers = false)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class CreateContact {
|
||||
|
||||
private val entries = listOf(entry(id = "addr-1", address = "0xabc", memo = "memo"))
|
||||
|
||||
@Test
|
||||
fun `GIVEN unique name WHEN createContact THEN generates ids AND persists the signed contact`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()))
|
||||
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns
|
||||
signatures.right()
|
||||
val saved = slot<Contact>()
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit
|
||||
|
||||
// Act
|
||||
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries)
|
||||
|
||||
// Assert
|
||||
val contact = result.getOrNull()
|
||||
assertThat(contact).isEqualTo(saved.captured)
|
||||
assertThat(contact!!.walletId).isEqualTo(userWallet.walletId)
|
||||
assertThat(contact.name.value).isEqualTo("Alice")
|
||||
assertThat(contact.id.value).isNotEmpty()
|
||||
assertThat(contact.createdAt).isEqualTo(NEW_TIMESTAMP)
|
||||
assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP)
|
||||
assertThat(contact.addressEntries.map { it.signature })
|
||||
.containsExactly(signatures[0].toHexString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN entries WHEN createContact THEN signs each with the wallet key over the canonical payload`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
val twoEntries = listOf(
|
||||
entry(id = "addr-1", address = "0xabc", memo = "memo"),
|
||||
entry(id = "addr-2", address = "0xdef", memo = null),
|
||||
)
|
||||
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte()))
|
||||
val hashesSlot = slot<List<ByteArray>>()
|
||||
val publicKeySlot = slot<ByteArray>()
|
||||
coEvery {
|
||||
signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet))
|
||||
} returns signatures.right()
|
||||
val saved = slot<Contact>()
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit
|
||||
|
||||
// Act
|
||||
interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", twoEntries)
|
||||
|
||||
// Assert
|
||||
assertThat(publicKeySlot.captured).isEqualTo(secp256k1Key)
|
||||
val persisted = saved.captured
|
||||
assertThat(hashesSlot.captured.map { it.toHexString() })
|
||||
.containsExactly(
|
||||
expectedHash(persisted, twoEntries[0]).toHexString(),
|
||||
expectedHash(persisted, twoEntries[1]).toHexString(),
|
||||
)
|
||||
.inOrder()
|
||||
assertThat(persisted.addressEntries.map { it.signature })
|
||||
.containsExactly(signatures[0].toHexString(), signatures[1].toHexString())
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no entries WHEN createContact THEN persists without signing`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
val saved = slot<Contact>()
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit
|
||||
|
||||
// Act
|
||||
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", emptyList())
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()).isEqualTo(saved.captured)
|
||||
assertThat(saved.captured.addressEntries).isEmpty()
|
||||
coVerify(exactly = 0) { signUseCase(any<List<ByteArray>>(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN wallet without a secp256k1 key WHEN createContact THEN Signing NoSigningKey without persisting`() =
|
||||
runTest {
|
||||
// Arrange — a locked hot wallet exposes no key; validation must still pass first
|
||||
val lockedWallet = mockk<UserWallet.Hot> {
|
||||
every { walletId } returns userWallet.walletId
|
||||
every { wallets } returns null
|
||||
}
|
||||
stubNoExistingContacts()
|
||||
|
||||
// Act
|
||||
val result = interactor.createContact(lockedWallet, name = "Alice", iconColor = "TestColor", entries)
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey))
|
||||
coVerify(exactly = 0) { repository.saveContact(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN signUseCase fails WHEN createContact THEN propagates Signing error without persisting`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
|
||||
SignHashesError.SigningFailed(message = "canceled").left()
|
||||
|
||||
// Act
|
||||
val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries)
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(SaveContactError.Signing(SignHashesError.SigningFailed(message = "canceled")))
|
||||
coVerify(exactly = 0) { repository.saveContact(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN duplicate name WHEN createContact THEN Name Duplicate without persisting`() = runTest {
|
||||
// Arrange
|
||||
every { repository.getContacts(userWallet.walletId) } returns flowOf(listOf(contact(name = "Alice")))
|
||||
|
||||
// Act
|
||||
val result = interactor.createContact(userWallet, name = "alice", iconColor = "TestColor", entries)
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Duplicate))
|
||||
coVerify(exactly = 0) { repository.saveContact(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blank name WHEN createContact THEN Name Format without persisting`() = runTest {
|
||||
// Arrange
|
||||
stubNoExistingContacts()
|
||||
|
||||
// Act
|
||||
val result = interactor.createContact(userWallet, name = "", iconColor = "TestColor", entries)
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty)))
|
||||
coVerify(exactly = 0) { repository.saveContact(any()) }
|
||||
}
|
||||
|
||||
private fun stubNoExistingContacts() {
|
||||
every { repository.getContacts(userWallet.walletId) } returns flowOf(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class UpdateContact {
|
||||
|
||||
private val updatedEntries = listOf(entry(id = "addr-new", address = "0xnew", memo = "memo"))
|
||||
|
||||
@Test
|
||||
fun `GIVEN existing contact WHEN updateContact THEN preserves id AND restamps AND persists without uniqueness check`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val existing = contact(name = "Alice")
|
||||
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()))
|
||||
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns
|
||||
signatures.right()
|
||||
val saved = slot<Contact>()
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit
|
||||
|
||||
// Act
|
||||
val result = interactor.updateContact(
|
||||
userWallet = userWallet,
|
||||
contact = existing,
|
||||
name = "Bob",
|
||||
iconColor = "TestColor",
|
||||
addressEntries = updatedEntries,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val contact = result.getOrNull()
|
||||
assertThat(contact).isEqualTo(saved.captured)
|
||||
assertThat(contact!!.id).isEqualTo(existing.id)
|
||||
assertThat(contact.name.value).isEqualTo("Bob")
|
||||
assertThat(contact.createdAt).isEqualTo(ORIGINAL_TIMESTAMP)
|
||||
assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP)
|
||||
assertThat(contact.addressEntries.map { it.signature })
|
||||
.containsExactly(signatures[0].toHexString())
|
||||
coVerify(exactly = 0) { repository.getContacts(any<UserWalletId>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN signUseCase fails WHEN updateContact THEN propagates Signing error without persisting`() = runTest {
|
||||
// Arrange
|
||||
coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns
|
||||
SignHashesError.NoSigningKey.left()
|
||||
|
||||
// Act
|
||||
val result = interactor.updateContact(
|
||||
userWallet = userWallet,
|
||||
contact = contact(name = "Alice"),
|
||||
name = "Bob",
|
||||
iconColor = "TestColor",
|
||||
addressEntries = updatedEntries,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey))
|
||||
coVerify(exactly = 0) { repository.saveContact(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blank name WHEN updateContact THEN Name Format without persisting`() = runTest {
|
||||
// Act
|
||||
val result = interactor.updateContact(
|
||||
userWallet = userWallet,
|
||||
contact = contact(name = "Alice"),
|
||||
name = "",
|
||||
iconColor = "TestColor",
|
||||
addressEntries = updatedEntries,
|
||||
)
|
||||
|
||||
// Assert
|
||||
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 = userWallet.walletId,
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
icon = "",
|
||||
iconColor = "TestColor",
|
||||
createdAt = ORIGINAL_TIMESTAMP,
|
||||
updatedAt = ORIGINAL_TIMESTAMP,
|
||||
addressEntries = listOf(entry(id = "addr-$name", address = "0xabc", memo = null)),
|
||||
)
|
||||
|
||||
private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry(
|
||||
id = AddressEntryId(id),
|
||||
address = address,
|
||||
networkId = networkRawId,
|
||||
memo = memo,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
)
|
||||
|
||||
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {
|
||||
val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() +
|
||||
contact.id.value + contact.name.value
|
||||
return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val NEW_TIMESTAMP = "2026-06-10T14:30:00.000Z"
|
||||
const val ORIGINAL_TIMESTAMP = "2026-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
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
|
||||
import com.tangem.domain.addressbook.model.*
|
||||
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.*
|
||||
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 expectedTimestamp = "2026-06-10T14:30:00.000Z"
|
||||
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 }
|
||||
|
||||
private val addressEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-1"),
|
||||
address = "0xabc",
|
||||
networkId = networkRawId,
|
||||
memo = "memo",
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
)
|
||||
|
||||
private val signedEntries = listOf(addressEntries.first().copy(signature = "signed"))
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
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<Contact>().copy(addressEntries = signedEntries).right()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create generates ids and persists the signed contact`() = runTest {
|
||||
every { repository.getContacts(walletId) } returns flowOf(emptyList())
|
||||
val saved = slot<Contact>()
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
name = "Alice",
|
||||
iconColor = "TestColor",
|
||||
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(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",
|
||||
iconColor = "TestColor",
|
||||
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", iconColor = "TestColor")
|
||||
)
|
||||
)
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
name = "alice",
|
||||
iconColor = "TestColor",
|
||||
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(
|
||||
userWallet = userWallet,
|
||||
name = "",
|
||||
iconColor = "TestColor",
|
||||
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, iconColor: String): Contact = Contact(
|
||||
id = ContactId("id-$name"),
|
||||
walletId = walletId,
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
icon = "",
|
||||
iconColor = iconColor,
|
||||
createdAt = expectedTimestamp,
|
||||
updatedAt = expectedTimestamp,
|
||||
addressEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = "0xabc",
|
||||
networkId = networkRawId,
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
package com.tangem.domain.addressbook.usecase
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
|
||||
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.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 com.tangem.domain.transaction.usecase.SignUseCase
|
||||
import com.tangem.utils.extensions.toHexString
|
||||
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.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.security.MessageDigest
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class SignAddressEntriesUseCaseTest {
|
||||
|
||||
private val signUseCase: SignUseCase = mockk()
|
||||
private val useCase = SignAddressEntriesUseCase(signUseCase = signUseCase)
|
||||
|
||||
// The mock factory builds each wallet key with publicKey = curve.name bytes, so the secp256k1 key is "Secp256k1"
|
||||
private val userWallet: UserWallet = MockUserWalletFactory.create()
|
||||
private val secp256k1Key = "Secp256k1".toByteArray()
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(signUseCase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with entries WHEN invoke THEN every entry receives its signature`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact(
|
||||
entry(id = "addr-1", address = "0xabc", memo = "memo"),
|
||||
entry(id = "addr-2", address = "0xdef", memo = null),
|
||||
)
|
||||
val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte()))
|
||||
val hashesSlot = slot<List<ByteArray>>()
|
||||
val publicKeySlot = slot<ByteArray>()
|
||||
coEvery {
|
||||
signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet))
|
||||
} returns signatures.right()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet, contact)
|
||||
|
||||
// Assert
|
||||
// Signatures are applied in entry order, hex-encoded; all other fields are preserved
|
||||
val expected = contact.copy(
|
||||
addressEntries = listOf(
|
||||
contact.addressEntries[0].copy(signature = signatures[0].toHexString()),
|
||||
contact.addressEntries[1].copy(signature = signatures[1].toHexString()),
|
||||
),
|
||||
)
|
||||
assertThat(result.getOrNull()).isEqualTo(expected)
|
||||
// The wallet's primary secp256k1 key is the one signing
|
||||
assertThat(publicKeySlot.captured).isEqualTo(secp256k1Key)
|
||||
// Each entry is hashed as SHA-256(address + networkId + memo + contactId + name), in order
|
||||
assertThat(hashesSlot.captured.map { it.toHexString() })
|
||||
.containsExactly(
|
||||
expectedHash(contact, contact.addressEntries[0]).toHexString(),
|
||||
expectedHash(contact, contact.addressEntries[1]).toHexString(),
|
||||
)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact with no entries WHEN invoke THEN returns contact unchanged without signing`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet, contact)
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()).isEqualTo(contact)
|
||||
coVerify(exactly = 0) { signUseCase(any<List<ByteArray>>(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN wallet without a secp256k1 key WHEN invoke THEN returns NoSigningKey without signing`() = runTest {
|
||||
// Arrange — a locked hot wallet exposes no key
|
||||
val lockedWallet = mockk<UserWallet.Hot> { every { wallets } returns null }
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null))
|
||||
|
||||
// Act
|
||||
val result = useCase(lockedWallet, contact)
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull()).isEqualTo(SignHashesError.NoSigningKey)
|
||||
coVerify(exactly = 0) { signUseCase(any<List<ByteArray>>(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN signUseCase returns error WHEN invoke THEN propagates the error`() = runTest {
|
||||
// Arrange
|
||||
val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null))
|
||||
coEvery { signUseCase(any<List<ByteArray>>(), any(), any()) } returns
|
||||
SignHashesError.SigningFailed(message = "canceled").left()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet, contact)
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "canceled"))
|
||||
}
|
||||
|
||||
private fun contact(vararg entries: AddressEntry): Contact = Contact(
|
||||
id = ContactId("contact-1"),
|
||||
walletId = UserWalletId("011"),
|
||||
name = requireNotNull(ContactName("Alice").getOrNull()),
|
||||
icon = "",
|
||||
iconColor = "KekColor",
|
||||
createdAt = "2026-01-01T00:00:00.000Z",
|
||||
updatedAt = "2026-01-01T00:00:00.000Z",
|
||||
addressEntries = entries.toList(),
|
||||
)
|
||||
|
||||
private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry(
|
||||
id = AddressEntryId(id),
|
||||
address = address,
|
||||
networkId = Network.RawID("ethereum"),
|
||||
memo = memo,
|
||||
signature = "",
|
||||
networkName = "Ethereum",
|
||||
)
|
||||
|
||||
private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray {
|
||||
val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() +
|
||||
contact.id.value + contact.name.value
|
||||
return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
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
|
||||
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.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
|
||||
import io.mockk.every
|
||||
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 newTimestamp = "2026-06-10T14:30:00.000Z"
|
||||
private val originalTimestamp = "2026-01-01T00:00:00.000Z"
|
||||
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(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-new"),
|
||||
address = "0xnew",
|
||||
networkId = networkRawId,
|
||||
memo = "memo",
|
||||
signature = "sig2",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
)
|
||||
|
||||
private val signedEntries = listOf(updatedEntries.first().copy(signature = "signed"))
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(repository, signAddressEntries)
|
||||
coEvery { signAddressEntries(eq(userWallet), any()) } answers {
|
||||
secondArg<Contact>().copy(addressEntries = signedEntries).right()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update preserves id and persists signed changes without checking uniqueness`() = runTest {
|
||||
val existing = contact(name = "Alice", iconColor = "TestColor")
|
||||
val saved = slot<Contact>()
|
||||
coEvery { repository.saveContact(capture(saved)) } returns Unit
|
||||
|
||||
val result = useCase(
|
||||
userWallet = userWallet,
|
||||
contact = existing,
|
||||
name = "Bob",
|
||||
iconColor = "TestColor",
|
||||
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(signedEntries)
|
||||
assertThat(contact.createdAt).isEqualTo(originalTimestamp) // preserved
|
||||
assertThat(contact.updatedAt).isEqualTo(newTimestamp) // restamped
|
||||
coVerify(exactly = 0) { repository.getContacts(any<UserWalletId>()) }
|
||||
}
|
||||
|
||||
@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", iconColor = "TestColor"),
|
||||
name = "Bob",
|
||||
iconColor = "TestColor",
|
||||
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", iconColor = "TestColor"),
|
||||
name = "",
|
||||
iconColor = "TestColor",
|
||||
addressEntries = updatedEntries,
|
||||
)
|
||||
|
||||
assertThat(result.leftOrNull())
|
||||
.isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty)))
|
||||
coVerify(exactly = 0) { repository.saveContact(any()) }
|
||||
}
|
||||
|
||||
private fun contact(name: String, iconColor: String): Contact = Contact(
|
||||
id = ContactId("id-$name"),
|
||||
walletId = walletId,
|
||||
name = requireNotNull(ContactName(name).getOrNull()),
|
||||
icon = "",
|
||||
iconColor = iconColor,
|
||||
createdAt = originalTimestamp,
|
||||
updatedAt = originalTimestamp,
|
||||
addressEntries = listOf(
|
||||
AddressEntry(
|
||||
id = AddressEntryId("addr-$name"),
|
||||
address = "0xabc",
|
||||
networkId = networkRawId,
|
||||
memo = null,
|
||||
signature = "sig",
|
||||
networkName = "Ethereum",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue