From f2427b13bb6c1a4f05f308ec8de45ce6bf6fe81a Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 22 Jul 2026 11:28:20 +0200 Subject: [PATCH 1/2] refactor: batch paykit contact cleanup --- .../bitkit/repositories/PrivatePaykitRepo.kt | 151 ++++++++++++------ .../repositories/PrivatePaykitRepoTest.kt | 83 ++++++++++ 2 files changed, 184 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 440b3eb72..73d93b6d6 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -107,6 +107,12 @@ class PrivatePaykitRepo @Inject constructor( val firstError: Throwable?, ) + private data class PrivateEndpointCleanupPreparation( + val clearedRetryKeys: List, + val failedPublicKeys: Set, + val firstError: Throwable?, + ) + private data class PrivateMessageDrainRetryKey( val publicKey: String, val receiverPath: String, @@ -703,6 +709,7 @@ class PrivatePaykitRepo @Inject constructor( var firstError: Throwable? = null val updates = mutableListOf() val linkRetryKeys = mutableListOf() + val linkedReceiverPaths = linkedReceiverPathsByPublicKey() for (publicKey in publicKeys) { val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } @@ -719,15 +726,12 @@ class PrivatePaykitRepo @Inject constructor( val publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths receiverPathSelection.error?.let { firstError = firstError ?: it - Logger.warn( - "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) + logPrivateReceiverPathSelectionFailure(publicKey, reason, it) } val cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( publicKey = publicKey, excludedReceiverPaths = publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, + linkedReceiverPaths = linkedReceiverPaths[publicKey].orEmpty(), ) (linkableReceiverPaths + cleanupReceiverPaths).distinct().forEach { receiverPath -> @@ -837,6 +841,18 @@ class PrivatePaykitRepo @Inject constructor( } } + private fun logPrivateReceiverPathSelectionFailure( + publicKey: String, + reason: String, + error: Throwable, + ) { + Logger.warn( + "Failed to inspect private Paykit receiver markers for '${redacted(publicKey)}' during '$reason'", + error, + context = TAG, + ) + } + private suspend fun applyPrivatePaymentListDeliveryReport( report: PrivatePaymentListDeliveryReport, reason: String, @@ -1190,41 +1206,77 @@ class PrivatePaykitRepo @Inject constructor( } private suspend fun removePublishedEndpoints(): Result = withContext(serializedDispatcher) { - runSuspendCatching { - val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) - .distinct() - val firstError = keys.mapNotNull { publicKey -> - removePublishedEndpoints(publicKey).exceptionOrNull() - }.firstOrNull() - if (firstError != null) throw firstError - } + val keys = (knownSavedContactKeys + ensureState().contacts.keys + pendingDeletedContactCleanupPublicKeys()) + .distinct() + removePublishedEndpoints(keys) } - private suspend fun removePublishedEndpoints(publicKey: String): Result = withContext(serializedDispatcher) { - runSuspendCatching { - var firstError: Throwable? = null - receiverPathsForCleanup(publicKey).forEach { receiverPath -> - val result = runSuspendCatching { - val report = paykitSdkService.clearPrivatePaymentList( - counterparty = publicKey, - receiverPath = receiverPath, + private suspend fun removePublishedEndpoints(publicKey: String): Result = + removePublishedEndpoints(listOf(publicKey)) + + private suspend fun removePublishedEndpoints(publicKeys: Collection): Result = + withContext(serializedDispatcher) { + runSuspendCatching { + val publicKeys = publicKeys.mapNotNull(::normalizedPublicKey).distinct() + if (publicKeys.isEmpty()) return@runSuspendCatching Unit + + val linkedReceiverPaths = linkedReceiverPathsByPublicKey() + val preparation = clearPrivatePaymentLists(publicKeys, linkedReceiverPaths) + val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() + var firstError = preparation.firstError + + if (preparation.clearedRetryKeys.isNotEmpty()) { + drainPendingPrivateMessages( + reason = "private endpoint cleanup", + advancingLinksFor = preparation.clearedRetryKeys, ) - if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { - throw PrivatePaykitError.PrivateUnavailable + val pendingRetryKeys = pendingPrivateMessageDrainKeys(preparation.clearedRetryKeys) + if (pendingRetryKeys.isNotEmpty()) { + failedPublicKeys += pendingRetryKeys.map { it.publicKey } + firstError = firstError ?: PrivatePaykitError.PrivateUnavailable } - val retryKey = PrivateMessageDrainRetryKey(publicKey, receiverPath) - drainPendingPrivateMessages("private endpoint cleanup", advancingLinksFor = listOf(retryKey)) - if (retryKey in pendingPrivateMessageDrainKeys(listOf(retryKey))) { + } + + val successfulPublicKeys = publicKeys.filterNot { it in failedPublicKeys } + clearPublishedEndpointCache(successfulPublicKeys) + + firstError?.let { throw it } + Unit + } + } + + private suspend fun clearPrivatePaymentLists( + publicKeys: Collection, + linkedReceiverPaths: Map>, + ): PrivateEndpointCleanupPreparation { + val failedPublicKeys = mutableSetOf() + val clearedRetryKeys = mutableListOf() + var firstError: Throwable? = null + + publicKeys.forEach { publicKey -> + receiverPathsForCleanup( + publicKey = publicKey, + linkedReceiverPaths = linkedReceiverPaths[publicKey].orEmpty(), + ).forEach { receiverPath -> + runSuspendCatching { + val report = paykitSdkService.clearPrivatePaymentList(publicKey, receiverPath) + if (report.failedToQueue.isNotEmpty() || report.failedToDeliver.isNotEmpty()) { throw PrivatePaykitError.PrivateUnavailable } - } - if (result.isFailure) { - firstError = firstError ?: result.exceptionOrNull() + }.onSuccess { + clearedRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) + }.onFailure { + failedPublicKeys += publicKey + firstError = firstError ?: it } } + } - firstError?.let { throw it } + return PrivateEndpointCleanupPreparation(clearedRetryKeys, failedPublicKeys, firstError) + } + private suspend fun clearPublishedEndpointCache(publicKeys: Collection) { + publicKeys.forEach { publicKey -> state?.contacts?.get(publicKey)?.let { contactState -> contactState.remoteEndpoints = emptyList() contactState.localInvoicesByReceiverPath = emptyMap() @@ -1234,6 +1286,9 @@ class PrivatePaykitRepo @Inject constructor( } } updateDeletedContactCleanupPending(publicKey, isPending = false) + } + + if (publicKeys.isNotEmpty()) { persistState(markWalletBackup = true) } } @@ -1246,42 +1301,38 @@ class PrivatePaykitRepo @Inject constructor( return paths.ifEmpty { listOf(PaykitReceiverPaths.WALLET) } } - private suspend fun receiverPathsForPrivateEndpointCleanup( + private fun receiverPathsForPrivateEndpointCleanup( publicKey: String, excludedReceiverPaths: List, + linkedReceiverPaths: Collection, ): List { val publishedPaths = publishedPrivatePaymentReceiverPaths(publicKey) - val linkedPaths = linkedReceiverPaths(publicKey) - return (publishedPaths + linkedPaths) + return (publishedPaths + linkedReceiverPaths) .filter { it in PaykitReceiverPaths.supported } .filterNot { it in excludedReceiverPaths } .distinct() .sorted() } - private suspend fun receiverPathsForCleanup(publicKey: String): List { - val paths = ( - linkedReceiverPaths(publicKey) + - publishedPrivatePaymentReceiverPaths(publicKey) - ) + private fun receiverPathsForCleanup( + publicKey: String, + linkedReceiverPaths: Collection, + ): List { + return (linkedReceiverPaths + publishedPrivatePaymentReceiverPaths(publicKey)) .filter { it in PaykitReceiverPaths.supported } .distinct() .sorted() - return paths } - private suspend fun linkedReceiverPaths(publicKey: String): List { - val normalizedKey = normalizedPublicKey(publicKey) ?: return emptyList() - val paths = paykitSdkService.linkedPeers() - .mapNotNull { peer -> - val peerKey = normalizedPublicKey(peer.counterparty) - peer.counterpartyReceiverPath.takeIf { - peerKey == normalizedKey && it in PaykitReceiverPaths.supported - } + private suspend fun linkedReceiverPathsByPublicKey(): Map> { + val linkedPaths = mutableMapOf>() + paykitSdkService.linkedPeers().forEach { peer -> + val publicKey = normalizedPublicKey(peer.counterparty) ?: return@forEach + if (peer.counterpartyReceiverPath in PaykitReceiverPaths.supported) { + linkedPaths.getOrPut(publicKey, ::mutableSetOf) += peer.counterpartyReceiverPath } - .distinct() - .sorted() - return paths + } + return linkedPaths } private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List { diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 52e591b16..8c14c93d8 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -45,6 +45,7 @@ import to.bitkit.App import to.bitkit.CurrentActivity import to.bitkit.data.PrivatePaykitCacheData import to.bitkit.data.PrivatePaykitCacheStore +import to.bitkit.data.PrivatePaykitContactCacheData import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.models.NodeLifecycleState @@ -386,6 +387,26 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { ) } + @Test + fun `prepareSavedContacts reads linked peers once for multiple contacts`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitLightningEnabled = false, + publicPaykitOnchainEnabled = true, + ) + whenever { paykitSdkService.privateReceiverPathSelection(any(), any()) }.thenReturn( + privateReceiverPathSelection( + publishableReceiverPaths = emptyList(), + linkableReceiverPaths = emptyList(), + ), + ) + + val result = sut.prepareSavedContacts(listOf(CONTACT_KEY, OTHER_CONTACT_KEY)) + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService, times(1)) { linkedPeers() } + } + @Test fun `private message drain keeps retrying while link is still pending`() = test { settingsData.value = SettingsData( @@ -633,6 +654,64 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(CONTACT_KEY, SERVER_RECEIVER_PATH) } } + @Test + fun `cleanup drains all contacts in one batch`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + whenever { paykitSdkService.linkedPeers() }.thenReturn( + listOf( + linkedPeer(CONTACT_KEY, LinkedPeerState.LINKED), + linkedPeer(OTHER_CONTACT_KEY, LinkedPeerState.LINKED, SERVER_RECEIVER_PATH), + ), + ) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isSuccess, result.exceptionOrNull().toString()) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } + verifyBlocking(paykitSdkService, times(2)) { linkedPeers() } + verifyBlocking(paykitSdkService, times(1)) { pendingOutboundPrivateCounterparties() } + verifyBlocking(paykitSdkService, times(2)) { processPendingPrivateMessages() } + verifyBlocking(paykitSdkService, times(2)) { receivePrivateMessagesFromLinkedPeers() } + assertTrue(cacheData.value.contacts.isEmpty()) + } + + @Test + fun `cleanup isolates a failed contact while clearing successful contacts`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + whenever { paykitSdkService.clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) }.thenReturn( + privateListDeliveryReport( + failedToQueue = listOf( + PrivatePaymentListSyncChange( + counterparty = CONTACT_KEY, + counterpartyReceiverPath = WALLET_RECEIVER_PATH, + outboundMessageId = null, + error = "failed", + ), + ), + ), + ) + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isFailure) + assertTrue(CONTACT_KEY in cacheData.value.contacts) + assertTrue(OTHER_CONTACT_KEY !in cacheData.value.contacts) + assertTrue(cacheData.value.cleanupPending) + } + @Test fun `prepareSavedContacts records queued contacts when another contact cannot publish`() = test { settingsData.value = SettingsData( @@ -1127,6 +1206,10 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { failedToDeliver = emptyList(), ) + private fun cachedPublishedContact(receiverPath: String) = PrivatePaykitContactCacheData( + publishedPrivatePaymentReceiverPaths = setOf(receiverPath), + ) + private fun privateReceiverPathSelection( publishableReceiverPaths: List, linkableReceiverPaths: List = publishableReceiverPaths, From 0b91ae2d3cc6d481e397f568399bbc4221fe5b0e Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 22 Jul 2026 11:52:21 +0200 Subject: [PATCH 2/2] fix: preserve paykit cleanup retries --- .../bitkit/repositories/PrivatePaykitRepo.kt | 126 +++++++++++++++--- .../repositories/PrivatePaykitRepoTest.kt | 45 +++++++ 2 files changed, 152 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 73d93b6d6..ac648f1fb 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -113,6 +113,16 @@ class PrivatePaykitRepo @Inject constructor( val firstError: Throwable?, ) + private data class NormalizedPublicKeyBatch( + val publicKeys: List, + val hadInvalidKey: Boolean, + ) + + private data class LinkedReceiverPathInspection( + val receiverPaths: Set, + val error: Throwable?, + ) + private data class PrivateMessageDrainRetryKey( val publicKey: String, val receiverPath: String, @@ -709,7 +719,7 @@ class PrivatePaykitRepo @Inject constructor( var firstError: Throwable? = null val updates = mutableListOf() val linkRetryKeys = mutableListOf() - val linkedReceiverPaths = linkedReceiverPathsByPublicKey() + val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshotOrNull(reason) for (publicKey in publicKeys) { val receiverPaths = runSuspendCatching { receiverPathsForSavedContact(publicKey) } @@ -728,22 +738,19 @@ class PrivatePaykitRepo @Inject constructor( firstError = firstError ?: it logPrivateReceiverPathSelectionFailure(publicKey, reason, it) } + val linkedReceiverPathInspection = inspectLinkedReceiverPaths( + publicKey, + linkedReceiverPathsSnapshot, + reason, + ) + firstError = firstError ?: linkedReceiverPathInspection.error val cleanupReceiverPaths = receiverPathsForPrivateEndpointCleanup( publicKey = publicKey, excludedReceiverPaths = publicationReceiverPaths + receiverPathSelection.cleanupProtectedReceiverPaths, - linkedReceiverPaths = linkedReceiverPaths[publicKey].orEmpty(), + linkedReceiverPaths = linkedReceiverPathInspection.receiverPaths, ) - (linkableReceiverPaths + cleanupReceiverPaths).distinct().forEach { receiverPath -> - linkRetryKeys += PrivateMessageDrainRetryKey(publicKey, receiverPath) - runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { - Logger.warn( - "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", - it, - context = TAG, - ) - } - } + linkRetryKeys += preparePrivateLinks(publicKey, linkableReceiverPaths + cleanupReceiverPaths, reason) cleanupReceiverPaths.forEach { receiverPath -> updates += PrivatePaymentListReservationUpdateInput( @@ -795,6 +802,21 @@ class PrivatePaykitRepo @Inject constructor( drainAndSchedulePrivateLinkRetries(reason, retryKeys.distinct()) } + private suspend fun preparePrivateLinks( + publicKey: String, + receiverPaths: Collection, + reason: String, + ): List = receiverPaths.distinct().map { receiverPath -> + runSuspendCatching { paykitSdkService.ensureLinkWithPeer(publicKey, receiverPath) }.onFailure { + Logger.warn( + "Failed to prepare private Paykit link for '${redacted(publicKey)}' during '$reason'", + it, + context = TAG, + ) + } + PrivateMessageDrainRetryKey(publicKey, receiverPath) + } + private suspend fun drainAndSchedulePrivateLinkRetries( reason: String, retryKeys: Collection, @@ -1217,13 +1239,20 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun removePublishedEndpoints(publicKeys: Collection): Result = withContext(serializedDispatcher) { runSuspendCatching { - val publicKeys = publicKeys.mapNotNull(::normalizedPublicKey).distinct() - if (publicKeys.isEmpty()) return@runSuspendCatching Unit + val normalizedBatch = normalizedPublicKeyBatch(publicKeys) + val publicKeys = normalizedBatch.publicKeys + var firstError: Throwable? = PrivatePaykitError.InvalidPublicKey.takeIf { + normalizedBatch.hadInvalidKey + } + if (publicKeys.isEmpty()) { + firstError?.let { throw it } + return@runSuspendCatching Unit + } - val linkedReceiverPaths = linkedReceiverPathsByPublicKey() - val preparation = clearPrivatePaymentLists(publicKeys, linkedReceiverPaths) + val linkedReceiverPathsSnapshot = linkedReceiverPathsSnapshotOrNull("private endpoint cleanup") + val preparation = clearPrivatePaymentLists(publicKeys, linkedReceiverPathsSnapshot) val failedPublicKeys = preparation.failedPublicKeys.toMutableSet() - var firstError = preparation.firstError + firstError = firstError ?: preparation.firstError if (preparation.clearedRetryKeys.isNotEmpty()) { drainPendingPrivateMessages( @@ -1247,16 +1276,28 @@ class PrivatePaykitRepo @Inject constructor( private suspend fun clearPrivatePaymentLists( publicKeys: Collection, - linkedReceiverPaths: Map>, + linkedReceiverPathsSnapshot: Map>?, ): PrivateEndpointCleanupPreparation { val failedPublicKeys = mutableSetOf() val clearedRetryKeys = mutableListOf() var firstError: Throwable? = null publicKeys.forEach { publicKey -> + val contactLinkedReceiverPaths = linkedReceiverPaths( + publicKey = publicKey, + snapshot = linkedReceiverPathsSnapshot, + ).onFailure { + failedPublicKeys += publicKey + firstError = firstError ?: it + Logger.warn( + "Failed to inspect private Paykit links for '${redacted(publicKey)}' during cleanup", + it, + context = TAG, + ) + }.getOrDefault(emptySet()) receiverPathsForCleanup( publicKey = publicKey, - linkedReceiverPaths = linkedReceiverPaths[publicKey].orEmpty(), + linkedReceiverPaths = contactLinkedReceiverPaths, ).forEach { receiverPath -> runSuspendCatching { val report = paykitSdkService.clearPrivatePaymentList(publicKey, receiverPath) @@ -1335,6 +1376,53 @@ class PrivatePaykitRepo @Inject constructor( return linkedPaths } + private suspend fun linkedReceiverPathsSnapshotOrNull(reason: String): Map>? = + runSuspendCatching { linkedReceiverPathsByPublicKey() } + .onFailure { + Logger.warn( + "Failed to inspect private Paykit links during '$reason'; retrying per contact", + it, + context = TAG, + ) + }.getOrNull() + + private suspend fun linkedReceiverPaths( + publicKey: String, + snapshot: Map>?, + ): Result> = if (snapshot != null) { + Result.success(snapshot[publicKey].orEmpty()) + } else { + runSuspendCatching { linkedReceiverPathsByPublicKey()[publicKey].orEmpty() } + } + + private suspend fun inspectLinkedReceiverPaths( + publicKey: String, + snapshot: Map>?, + reason: String, + ): LinkedReceiverPathInspection { + val result = linkedReceiverPaths(publicKey, snapshot) + val error = result.exceptionOrNull() + if (error != null) { + Logger.warn( + "Failed to inspect private Paykit links for '${redacted(publicKey)}' during '$reason'", + error, + context = TAG, + ) + } + return LinkedReceiverPathInspection(result.getOrDefault(emptySet()), error) + } + + private fun normalizedPublicKeyBatch(publicKeys: Collection): NormalizedPublicKeyBatch { + var hadInvalidKey = false + val normalizedKeys = publicKeys.mapNotNull { publicKey -> + normalizedPublicKey(publicKey) ?: run { + hadInvalidKey = true + null + } + }.distinct() + return NormalizedPublicKeyBatch(normalizedKeys, hadInvalidKey) + } + private fun publishedPrivatePaymentReceiverPaths(publicKey: String): List { val contactState = state?.contacts?.get(publicKey) ?: return emptyList() return contactState.publishedPrivatePaymentReceiverPaths.toList() diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 8c14c93d8..c4eb193c4 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -632,6 +632,51 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(result.isFailure) assertTrue(cacheData.value.cleanupPending) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + assertEquals( + setOf(WALLET_RECEIVER_PATH), + cacheData.value.contacts.getValue(CONTACT_KEY).publishedPrivatePaymentReceiverPaths, + ) + } + + @Test + fun `cleanup falls back per contact when shared linked receiver inspection fails`() = test { + cacheData.value = PrivatePaykitCacheData( + contacts = mapOf( + CONTACT_KEY to cachedPublishedContact(WALLET_RECEIVER_PATH), + OTHER_CONTACT_KEY to cachedPublishedContact(SERVER_RECEIVER_PATH), + ), + ) + sut = createSut() + var linkedPeerReads = 0 + whenever { paykitSdkService.linkedPeers() }.thenAnswer { + linkedPeerReads += 1 + if (linkedPeerReads <= 2) error("link inspection failed") + emptyList() + } + + val result = sut.removePublishedEndpointsForCleanup("test") + + assertTrue(result.isFailure) + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(CONTACT_KEY, WALLET_RECEIVER_PATH) } + verifyBlocking(paykitSdkService) { clearPrivatePaymentList(OTHER_CONTACT_KEY, SERVER_RECEIVER_PATH) } + assertTrue(CONTACT_KEY in cacheData.value.contacts) + assertTrue(OTHER_CONTACT_KEY !in cacheData.value.contacts) + assertTrue(cacheData.value.cleanupPending) + } + + @Test + fun `invalid deleted contact key remains pending for cleanup`() = test { + val invalidPublicKey = "not-a-pubky" + cacheData.value = PrivatePaykitCacheData( + deletedContactCleanupPendingPublicKeys = setOf(invalidPublicKey), + ) + sut = createSut() + + val result = sut.retryPendingEndpointRemoval(emptyList()) + + assertTrue(result.isFailure) + assertEquals(setOf(invalidPublicKey), cacheData.value.deletedContactCleanupPendingPublicKeys) verifyBlocking(paykitSdkService, never()) { clearPrivatePaymentList(any(), any()) } }