diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 18891aa14..1db93432c 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -712,11 +712,13 @@ struct AppScene: View { .task(id: pendingPaykitProofRefresh) { guard let request = pendingPaykitProofRefresh else { return } do { - try await PaykitPaymentActivity.shared.waitUntilIdle() + try await PaykitPaymentActivity.shared.waitUntilIdle(includingReviews: true) } catch { return } guard !Task.isCancelled, request == pendingPaykitProofRefresh else { return } - await refreshIncomingPaykitPaymentRequests(mode: .stored, forceFresh: true, automatic: false) - guard !Task.isCancelled else { return } + let refreshed = await refreshIncomingPaykitPaymentRequests( + mode: .stored, forceFresh: true, refreshPriority: .background, automatic: false + ) + guard !Task.isCancelled, refreshed else { return } paykitProofRefresh.complete(request) } .onReceive(PaykitAllowanceExecutor.eventPublisher.receive(on: DispatchQueue.main)) { event in @@ -1451,6 +1453,7 @@ struct AppScene: View { mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, messagePriority: PaykitSdkOperationLock.Priority = .background, + refreshPriority: PaykitSdkOperationLock.Priority? = nil, onlyWhileActive: Bool = false, automatic: Bool = true ) async -> Bool { @@ -1482,8 +1485,10 @@ struct AppScene: View { guard canRefresh() else { return false } let identity = session.publicKey let refreshed = await paykitPaymentRequestManager.refresh( - mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, canRefresh: canRefresh + mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, + refreshPriority: refreshPriority, canRefresh: canRefresh ) + guard refreshed else { return false } // Deleting the profile withdraws the payment lists an automatic payment would consume. if canRefresh(), pubkyProfile.automaticPaykitSession == session, !pubkyProfile.isDeletingProfile { await paykitAllowanceManager.refresh() diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 45af20c82..770fb2f7b 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1818,13 +1818,14 @@ final class PaykitPaymentRequestManager { mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, messagePriority: PaykitSdkOperationLock.Priority = .background, + refreshPriority: PaykitSdkOperationLock.Priority? = nil, canRefresh: @escaping @MainActor () -> Bool = { true } ) async -> Bool { await refresh( excludingProtectedRequestId: nil, mode: mode, forceFresh: forceFresh, - messagePriority: forceFresh ? .ordered : messagePriority, + messagePriority: refreshPriority ?? (forceFresh ? .ordered : messagePriority), canRefresh: canRefresh ) } diff --git a/BitkitTests/PaykitPaymentActivityTests.swift b/BitkitTests/PaykitPaymentActivityTests.swift index af3dddaf9..612260967 100644 --- a/BitkitTests/PaykitPaymentActivityTests.swift +++ b/BitkitTests/PaykitPaymentActivityTests.swift @@ -50,13 +50,14 @@ final class PaykitPaymentActivityTests: XCTestCase { XCTAssertTrue(didRefresh) } - func testProofInvalidationsCoalesceUntilForegroundAndPaymentIdle() async throws { + func testProofInvalidationsCoalesceUntilForegroundAndPaymentReviewIdle() async throws { let profile = PubkyProfileManager() profile.publicKey = "pubky\(String(repeating: "z", count: 52))" let session = try XCTUnwrap(profile.currentSession) var state = PaykitPaymentProofRefreshState() let activity = PaykitPaymentActivity() let payment = activity.begin() + let review = activity.beginReview() for _ in 0 ..< 3 { state.invalidate(session: session) XCTAssertNil(state.request(session: session, isActive: false)) @@ -64,13 +65,17 @@ final class PaykitPaymentActivityTests: XCTestCase { let request = try XCTUnwrap(state.request(session: session, isActive: true)) var refreshes = 0 let work = Task { - try await activity.waitUntilIdle() + try await activity.waitUntilIdle(includingReviews: true) refreshes += 1 state.complete(request) } await Task.yield() XCTAssertEqual(refreshes, 0) activity.end(payment) + await Task.yield() + XCTAssertEqual(refreshes, 0) + XCTAssertEqual(state.request(session: session, isActive: true), request) + activity.endReview(review) try await work.value XCTAssertEqual(refreshes, 1) XCTAssertNil(state.request(session: session, isActive: true)) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 1fcfa9fcf..862e1a941 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -364,31 +364,35 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testFreshRefreshRereadsRequestsChangedAfterSnapshot() async throws { - let record = try paymentRequestRecord() - let sdk = PaymentRequestSdkMock(records: [record]) - let manager = paymentRequestManager(sdk: sdk) - await sdk.pauseNextPaymentRequestList() - let first = Task { await manager.refresh(messagePriority: .background) } - try await waitUntil { await sdk.paymentRequestListIsPaused() } + let refreshPriorities: [PaykitSdkOperationLock.Priority?] = [nil, .background] + for refreshPriority in refreshPriorities { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let first = Task { await manager.refresh(messagePriority: .background) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } - await sdk.setRecords([]) - let refreshStarted = expectation(description: "Request state refresh started") - let fresh = Task { - refreshStarted.fulfill() - await manager.refresh(forceFresh: true, messagePriority: .background) - } - await fulfillment(of: [refreshStarted], timeout: 1) - await sdk.resumePaymentRequestList() - _ = await first.value - await fresh.value + await sdk.setRecords([]) + let refreshStarted = expectation(description: "Request state refresh started") + let fresh = Task { + refreshStarted.fulfill() + await manager.refresh(forceFresh: true, messagePriority: .background, refreshPriority: refreshPriority) + } + await fulfillment(of: [refreshStarted], timeout: 1) + await sdk.resumePaymentRequestList() + _ = await first.value + await fresh.value - XCTAssertTrue(manager.pendingRequests.isEmpty) - let snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.paymentRequestListCallCount, 2) - let priorities = await sdk.operationPriorities - XCTAssertEqual(priorities["pending"], [.background, .ordered]) - XCTAssertEqual(priorities["receive"], [.background, .ordered]) - XCTAssertEqual(priorities["observation"], [.background, .ordered]) + XCTAssertTrue(manager.pendingRequests.isEmpty) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.paymentRequestListCallCount, 2) + let priorities = await sdk.operationPriorities + let expectedPriority = refreshPriority ?? .ordered + XCTAssertEqual(priorities["pending"], [.background, expectedPriority]) + XCTAssertEqual(priorities["receive"], [.background, expectedPriority]) + XCTAssertEqual(priorities["observation"], [.background, expectedPriority]) + } } func testFreshRefreshRereadsProofStateChangedDuringNotificationSynchronization() async throws { @@ -503,31 +507,34 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testFreshRefreshDoesNotOutliveCancellationOrIdentityChange() async throws { - for invalidation in ["cancel", "clear", "identity"] { - let sdk = PaymentRequestSdkMock(records: []) - let manager = paymentRequestManager(sdk: sdk) - await sdk.pauseNextPaymentRequestList() - let first = Task { await manager.refresh() } - try await waitUntil { await sdk.paymentRequestListIsPaused() } - let refreshStarted = expectation(description: invalidation) - let fresh = Task { - refreshStarted.fulfill() - await manager.refresh(forceFresh: true) - } - await fulfillment(of: [refreshStarted], timeout: 1) + let refreshPriorities: [PaykitSdkOperationLock.Priority?] = [nil, .background] + for refreshPriority in refreshPriorities { + for invalidation in ["cancel", "clear", "identity"] { + let sdk = PaymentRequestSdkMock(records: []) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let first = Task { await manager.refresh() } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let refreshStarted = expectation(description: invalidation) + let fresh = Task { + refreshStarted.fulfill() + await manager.refresh(forceFresh: true, refreshPriority: refreshPriority) + } + await fulfillment(of: [refreshStarted], timeout: 1) - switch invalidation { - case "cancel": fresh.cancel() - case "clear": manager.clear() - default: manager.activate(identity: "pubky\(String(repeating: "y", count: 52))") - } - await sdk.resumePaymentRequestList() - _ = await first.value - await fresh.value + switch invalidation { + case "cancel": fresh.cancel() + case "clear": manager.clear() + default: manager.activate(identity: "pubky\(String(repeating: "y", count: 52))") + } + await sdk.resumePaymentRequestList() + _ = await first.value + await fresh.value - let snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, invalidation) - XCTAssertTrue(manager.pendingRequests.isEmpty, invalidation) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, invalidation) + XCTAssertTrue(manager.pendingRequests.isEmpty, invalidation) + } } } @@ -4186,6 +4193,48 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.pendingRequests.isEmpty) } + func testBackgroundStateChangeRefreshDefersWithoutConsumingSnapshotOrBlockingRecovery() async throws { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk) + let loaded = await manager.refresh(mode: .stored) + XCTAssertTrue(loaded) + let pending = manager.pendingRequests + await sdk.pauseNextPaymentRequestList() + let admitted = Task { await manager.refresh(mode: .stored) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let started = expectation(description: "Proof display refresh waiting for admitted snapshot") + let fresh = Task { + started.fulfill() + return await manager.refresh(mode: .stored, forceFresh: true, refreshPriority: .background) + } + await fulfillment(of: [started], timeout: 1) + await sdk.setBackgroundObservationDeferred(true) + await sdk.setRecords([]) + await sdk.resumePaymentRequestList() + let admittedResult = await admitted.value + let deferredResult = await fresh.value + XCTAssertTrue(admittedResult) + XCTAssertFalse(deferredResult) + XCTAssertEqual(manager.pendingRequests, pending) + + let recovered = await manager.refresh(mode: .stored, forceFresh: true) + XCTAssertTrue(recovered) + XCTAssertTrue(manager.pendingRequests.isEmpty) + await sdk.setBackgroundObservationDeferred(false) + let latest = try paymentRequestRecord(id: "latest-request") + await sdk.setRecords([latest]) + let retried = await manager.refresh(mode: .stored) + XCTAssertTrue(retried) + XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), [latest.paymentRequestId]) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["observation"], [.background, .background, .background, .ordered, .background]) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.paymentRequestListCallCount, 4) + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.receiveCallCount, 0) + } + func testRefreshContinuesWhenPendingResponseDeliveryFails() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) await sdk.failNextProcess() @@ -9531,6 +9580,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private var incomingRecords: [PaymentRequestRecord] = [] private var paymentRequestListCallCount = 0 private var paymentRequestListError: Error? + private var isBackgroundObservationDeferred = false private var peerRecords: [LinkedPeerRecord] = [] private var requestCapabilitiesByPublicKey: [String: Bool] = [:] private var liveSessionAvailable = true @@ -9659,6 +9709,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym func observeState(priority: PaykitSdkOperationLock.Priority, expectedIdentity: String?) async throws -> PaykitStateObservation { operationPriorities["observation", default: []].append(priority) + if priority == .background, isBackgroundObservationDeferred { + throw PaykitPaymentRequestError.operationInProgress + } let identity = activeIdentity if let expectedIdentity { guard PubkyPublicKeyFormat.matches(identity, expectedIdentity) else { throw PubkyServiceError.identityChanged } @@ -9705,6 +9758,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym paymentRequestListError = error } + func setBackgroundObservationDeferred(_ deferred: Bool) { + isBackgroundObservationDeferred = deferred + } + func identityStatus() -> IdentityStatus? { IdentityStatus(publicKey: activeIdentity, capability: liveSessionAvailable ? .privateLinkCapable : .signedOut) } diff --git a/changelog.d/next/1066.fixed.md b/changelog.d/next/1066.fixed.md new file mode 100644 index 000000000..7585296c7 --- /dev/null +++ b/changelog.d/next/1066.fixed.md @@ -0,0 +1 @@ +Background payment-request updates now give priority to payment review and submission.