Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-03 17:01:57 +01:00
parent e8cba7d699
commit 75cefb2fba
11 changed files with 247 additions and 42 deletions

View file

@ -102,7 +102,7 @@ internal class DefaultAddressBookRepository(
?: return@withLock AddressBookSyncError.Unknown.left()
val current = currentContacts(contact.walletId, userWallet)
val merged = current.filterNot { it.id == contact.id } + contact
persist(userWallet, AddressBook(walletId = contact.walletId, contacts = merged))
persist(userWallet, AddressBook(contacts = merged))
}
}
@ -181,7 +181,7 @@ internal class DefaultAddressBookRepository(
)
AddressBookSyncError.Unknown
}
.flatMap { blob -> pushBlob(addressBook.walletId, blob) }
.flatMap { blob -> pushBlob(userWallet.walletId, blob) }
}
private suspend fun pushBlob(
@ -210,7 +210,14 @@ internal class DefaultAddressBookRepository(
},
onError = { error ->
TangemLogger.e(messageString = "Failed to push address book for wallet $userWalletId: $error")
error.toSyncError().left()
val syncError = error.toSyncError()
// A 412 means the local etag is stale relative to the backend. Refresh the local blob + etag so the
// next save attempt (user re-taps Save) starts from the current backend state. We do NOT re-push here
// on purpose — the write stays single-shot; the conflict is still surfaced so the UI can prompt.
if (syncError is AddressBookSyncError.Conflict) {
syncAddressBooks()
}
syncError.left()
},
)
}

View file

@ -78,7 +78,7 @@ internal class DefaultAddressBookRepositoryTest {
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob()
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right()
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
// Act
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
@ -93,7 +93,7 @@ internal class DefaultAddressBookRepositoryTest {
val contact = createContact(id = "c1", name = "Alice")
val blob = createBlob()
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right()
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
// Act
repository.getContacts(UserWalletId(WALLET_A)).first()
@ -112,7 +112,7 @@ internal class DefaultAddressBookRepositoryTest {
val blob = createBlob()
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob))
every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right()
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
// Act
val result = repository.getAllContacts().first()
@ -128,7 +128,7 @@ internal class DefaultAddressBookRepositoryTest {
val blob = createBlob()
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob))
every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right()
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
// Act
repository.getAllContacts().first()
@ -174,7 +174,7 @@ internal class DefaultAddressBookRepositoryTest {
val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(UserWalletId(WALLET_A), listOf(existing)).right()
AddressBook(listOf(existing)).right()
val bookSlot = slot<AddressBook>()
val newBlob = createBlob()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
@ -242,6 +242,37 @@ internal class DefaultAddressBookRepositoryTest {
coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) }
}
@Test
fun `GIVEN etag conflict WHEN saveContact THEN re-syncs once without retrying the write`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns
errorResponse(ApiResponseError.HttpException.Code.PRECONDITION_FAILED)
// Act
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
assertThat(result).isEqualTo(AddressBookSyncError.Conflict.left())
coVerify(exactly = 1) { addressBookApi.syncAddressBooks(any()) }
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, any(), any()) }
}
@Test
fun `GIVEN network error WHEN saveContact THEN does not re-sync`() = runTest {
// Arrange
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns networkErrorResponse()
// Act
repository.saveContact(createContact(id = "c1", name = "Alice"))
// Assert
coVerify(exactly = 0) { addressBookApi.syncAddressBooks(any()) }
}
@Test
fun `GIVEN no network WHEN saveContact THEN returns Network and does not store locally`() = runTest {
// Arrange
@ -265,7 +296,7 @@ internal class DefaultAddressBookRepositoryTest {
val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(UserWalletId(WALLET_A), listOf(original)).right()
AddressBook(listOf(original)).right()
val bookSlot = slot<AddressBook>()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right()
coEvery { blobStore.storeBlob(any()) } returns Unit
@ -286,7 +317,7 @@ internal class DefaultAddressBookRepositoryTest {
val storedBlob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
every { cipher.decrypt(storedBlob, userWallet) } returns
AddressBook(UserWalletId(WALLET_A), listOf(kept, removed)).right()
AddressBook(listOf(kept, removed)).right()
val bookSlot = slot<AddressBook>()
val newBlob = createBlob()
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
@ -348,7 +379,7 @@ internal class DefaultAddressBookRepositoryTest {
val blob = createBlob()
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns blob
every { cipher.decrypt(blob, userWallet) } returns
AddressBook(UserWalletId(WALLET_A), listOf(alice, bob)).right()
AddressBook(listOf(alice, bob)).right()
// Act
val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob")