Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -481,12 +481,20 @@ class PrivatePaykitRepo @Inject constructor(
}
}

suspend fun removePublishedEndpointsForCleanup(context: String): Result<Unit> = withContext(serializedDispatcher) {
suspend fun removePublishedEndpointsForCleanup(context: String): Result<Unit> =
removePublishedEndpointsForCleanup(context, deferAppSync = false)

suspend fun removePublishedEndpointsForCleanup(
context: String,
deferAppSync: Boolean,
): Result<Unit> = withContext(serializedDispatcher) {
runSuspendCatching {
updateContactSharingCleanupPending(true)
removePublishedEndpoints().getOrThrow()
syncPaykitAppAfterCleanup().getOrThrow()
updateContactSharingCleanupPending(false)
if (!deferAppSync) {
syncPaykitAppAfterCleanup().getOrThrow()
updateContactSharingCleanupPending(false)
}
}.onFailure {
updateContactSharingCleanupPending(true)
Logger.warn("Failed to remove private Paykit endpoints during '$context'", it, context = TAG)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ class EditProfileViewModel @Inject constructor(
}
try {
privatePaykitRepo.beginProfileDeletion()
privatePaykitRepo.removePublishedEndpointsForCleanup(TAG)
privatePaykitRepo.removePublishedEndpointsForCleanup(TAG, deferAppSync = true)
val result = pubkyRepo.deleteProfileWithSessionRetry()
if (result.isSuccess) {
privatePaykitRepo.closeAndClear()
Expand Down
67 changes: 67 additions & 0 deletions app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2308,6 +2308,73 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) {
)
}

@Test
fun `profile deletion withdraws private lists without an intermediate registry sync`() = test {
cacheData.value = PrivatePaykitCacheData(contacts = mapOf(CONTACT_KEY to cachedPublishedContact()))
sut = createSut()

sut.removePublishedEndpointsForCleanup("profile deletion", deferAppSync = true).getOrThrow()

verify(paykitSdkService).clearPrivatePaymentLists(listOf(CONTACT_KEY))
verify(publicPaykitRepo, never()).syncPaykitApp()
assertTrue(cacheData.value.contacts.isEmpty())
assertTrue(cacheData.value.cleanupPending)

sut.closeAndClear().getOrThrow()
assertFalse(cacheData.value.cleanupPending)
}

@Test
fun `interrupted profile deletion retries registry sync from persisted cleanup state`() = test {
sut.removePublishedEndpointsForCleanup("profile deletion", deferAppSync = true).getOrThrow()
assertTrue(cacheData.value.cleanupPending)
sut = createSut()
whenever(publicPaykitRepo.syncPaykitApp()).thenReturn(Result.failure(AppError("Offline")))

assertTrue(sut.retryPendingEndpointRemoval(emptyList()).isFailure)
assertTrue(cacheData.value.cleanupPending)
assertTrue(settingsData.value.publicPaykitCleanupPending)

whenever(publicPaykitRepo.syncPaykitApp()).thenReturn(Result.success(Unit))
sut.retryPendingEndpointRemoval(emptyList()).getOrThrow()
assertFalse(cacheData.value.cleanupPending)
verify(publicPaykitRepo, times(2)).syncPaykitApp()
}

@Test
fun `cancelled profile withdrawal preserves cleanup for retry`() = test {
cacheData.value = PrivatePaykitCacheData(contacts = mapOf(CONTACT_KEY to cachedPublishedContact()))
sut = createSut()
val started = CompletableDeferred<Unit>()
whenever(paykitSdkService.clearPrivatePaymentLists(listOf(CONTACT_KEY))).doSuspendableAnswer {
started.complete(Unit)
awaitCancellation()
}
val cleanup = async { sut.removePublishedEndpointsForCleanup("profile deletion", deferAppSync = true) }
started.await()
cleanup.cancel()
assertFailsWith<CancellationException> { cleanup.await() }

assertTrue(cacheData.value.cleanupPending)
assertTrue(cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList)
verify(publicPaykitRepo, never()).syncPaykitApp()
}

@Test
fun `deferred registry sync does not acknowledge undelivered private withdrawals`() = test {
cacheData.value = PrivatePaykitCacheData(contacts = mapOf(CONTACT_KEY to cachedPublishedContact()))
sut = createSut()
whenever(paykitSdkService.pendingOutboundPrivateCounterparties()).thenReturn(listOf(CONTACT_KEY))

val result = sut.removePublishedEndpointsForCleanup("profile deletion", deferAppSync = true)

assertTrue(result.isFailure)
assertTrue(cacheData.value.cleanupPending)
assertTrue(cacheData.value.contacts.getValue(CONTACT_KEY).hasPublishedPrivatePaymentList)
verify(publicPaykitRepo, never()).syncPaykitApp()
sut.closeAndClear().getOrThrow()
}

@Test
fun `cleanup removal marks cleanup pending when private endpoint removal fails`() = test {
settingsData.value = SettingsData(
Expand Down
29 changes: 29 additions & 0 deletions app/src/test/java/to/bitkit/services/PubkyServiceTest.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package to.bitkit.services

import com.synonym.paykit.EndpointSyncReport
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancelAndJoin
Expand All @@ -12,6 +13,7 @@ import org.mockito.Mockito.mockStatic
import org.mockito.kotlin.KInvocationOnMock
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doSuspendableAnswer
import org.mockito.kotlin.inOrder
import org.mockito.kotlin.mock
import org.mockito.kotlin.spy
import org.mockito.kotlin.whenever
Expand All @@ -29,6 +31,33 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds

class PubkyServiceTest : BaseUnitTest() {
@Test
fun `public cleanup reconciles disabled capabilities even after endpoint removal fails`() = test {
for (failure in listOf(null, "endpoints", "registry")) {
val paykit = mock<PaykitSdkService>()
val sut = PubkyService(paykit)
val expected = AppError("Cleanup failed")
whenever(paykit.syncPublicEndpoints(emptyList())).thenAnswer {
if (failure == "endpoints") throw expected
EndpointSyncReport(published = emptyList(), removed = emptyList(), failed = emptyList())
}
whenever(paykit.syncPaykitApp(privatePaymentsEnabled = false)).thenAnswer {
if (failure == "registry") throw expected
}

val result = runSuspendCatching { sut.removeBitkitPaymentEndpoints() }

assertEquals(failure == null, result.isSuccess)
if (failure != null) {
assertEquals(expected.message, assertIs<AppError>(result.exceptionOrNull()).message)
}
inOrder(paykit).apply {
verify(paykit).syncPublicEndpoints(emptyList())
verify(paykit).syncPaykitApp(privatePaymentsEnabled = false)
}
}
}

@Test
fun `cancelling a public read cancels the underlying call`() = test {
val paykit = mock<PaykitSdkService>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.junit.Test
import org.mockito.Mockito.clearInvocations
import org.mockito.kotlin.any
import org.mockito.kotlin.doSuspendableAnswer
import org.mockito.kotlin.eq
import org.mockito.kotlin.inOrder
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
Expand Down Expand Up @@ -194,7 +195,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
}
assertFalse(sut.uiState.value.showDeleteFailureDialog)
inOrder(privatePaykitRepo, pubkyRepo).apply {
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any())
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(pubkyRepo).deleteProfileWithSessionRetry()
verify(privatePaykitRepo).closeAndClear()
}
Expand All @@ -215,7 +216,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
}
assertFalse(sut.uiState.value.showDeleteFailureDialog)
inOrder(privatePaykitRepo, pubkyRepo).apply {
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any())
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(pubkyRepo).deleteProfileWithSessionRetry()
verify(privatePaykitRepo).closeAndClear()
}
Expand All @@ -235,6 +236,8 @@ class EditProfileViewModelTest : BaseUnitTest() {

assertTrue(sut.uiState.value.showDeleteFailureDialog)
assertFalse(sut.uiState.value.isDeleting)
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(privatePaykitRepo, never()).closeAndClear()
}

@Test
Expand Down Expand Up @@ -296,7 +299,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
@Test
fun `deleteProfile should continue when private cleanup fails`() = test {
val sut = createSut()
whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) }
whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) }
.thenReturn(Result.failure(TestAppError("cleanup failed")))
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit))
advanceUntilIdle()
Expand All @@ -310,7 +313,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
assertFalse(sut.uiState.value.showDeleteFailureDialog)
assertFalse(sut.uiState.value.isDeleting)
inOrder(privatePaykitRepo, pubkyRepo).apply {
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any())
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(pubkyRepo).deleteProfileWithSessionRetry()
verify(privatePaykitRepo).closeAndClear()
}
Expand Down Expand Up @@ -339,6 +342,8 @@ class EditProfileViewModelTest : BaseUnitTest() {
whenever(pubkyRepo.publicKey).thenReturn(MutableStateFlow(TEST_PUBLIC_KEY))
whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) }
.thenReturn(Result.success(Unit))
whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) }
.thenReturn(Result.success(Unit))
whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit))

return EditProfileViewModel(
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1538.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Avoid an unnecessary registry update during profile deletion while preserving pending cleanup and retries.
4 changes: 4 additions & 0 deletions journeys/profile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ For the reported background-resume case, verify returning directly to Profile wi
Also check both Contacts entry points during delayed or failed session restoration: the drawer and Continue on the Contacts intro. They must keep a saved identity on the recovery screen, including when identity lookup is still pending. A wallet without a saved identity must still reach onboarding.

With an imported grant that cannot be restored and Ring unavailable, Disconnect must forget the local identity and return to onboarding without requiring server access. The remote profile is not deleted by Disconnect. Repeat with a live session and failed endpoint cleanup or revocation: it must report failure and retain the identity. These checks require session/network fault injection beyond the journey runner capabilities.

Profile deletion withdraws private payment lists before removing the profile and performs App Registry reconciliation during final public cleanup. An intermediate registry update is deferred; the existing private cleanup marker stays pending until deletion succeeds or normal cleanup retry completes. Public endpoint removal, capability checks, session revocation and uncertain-write safety waits remain required.

For controlled fault testing, interrupt deletion after private withdrawal but before profile removal, then restart with the same identity. Verify cleanup remains pending, normal retry reconciles the registry, and a failed retry keeps the marker. Also hold a private withdrawal undelivered: it must remain pending, not be acknowledged by the deferred registry step. Check that Disconnect and disabling private sharing still reconcile the registry. These checks require operation gates and network fault injection beyond the journey runner capabilities.
1 change: 1 addition & 0 deletions journeys/profile/delete-profile.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Precondition: an onboarded disposable test wallet with Paykit enabled and a profile created
through Bitkit. The profile and its contacts will be deleted; do not use a personal identity.
Record the sequence so progress can be checked even when deletion completes quickly.
Start with zero contacts and record the deletion time without a fixed latency target.
Repeat with 62 imported contacts, once after preparation settles and once immediately after import.
Measure confirmation to onboarding and check logs for any uncertain-write safety wait.
During deletion, check the indicator on Delete Profile. Save stays disabled with its label visible.
Expand Down
Loading