diff --git a/Sources/Logging/Strings/PurchaseStrings.swift b/Sources/Logging/Strings/PurchaseStrings.swift index 7a30aa5b63..bcd1aeac35 100644 --- a/Sources/Logging/Strings/PurchaseStrings.swift +++ b/Sources/Logging/Strings/PurchaseStrings.swift @@ -75,6 +75,7 @@ enum PurchaseStrings { case begin_refund_customer_info_error(entitlementID: String?) case missing_cached_customer_info case sk2_transactions_update_received_transaction(productID: String) + case transaction_poster_handling_transaction(productID: String) case sk1_purchase_too_slow case sk2_purchase_too_slow case payment_queue_wrapper_delegate_call_sk1_enabled @@ -285,6 +286,9 @@ extension PurchaseStrings: CustomStringConvertible { case let .sk2_transactions_update_received_transaction(productID): return "StoreKit.Transaction.updates: received transaction for product '\(productID)'" + case let .transaction_poster_handling_transaction(productID): + return "TransactionPoster: handling transaction for product '\(productID)'" + case .sk1_purchase_too_slow: return "StoreKit 1 purchase took longer than expected" diff --git a/Sources/Purchasing/Purchases/TransactionPoster.swift b/Sources/Purchasing/Purchases/TransactionPoster.swift index 9c7af23684..a0755e8950 100644 --- a/Sources/Purchasing/Purchases/TransactionPoster.swift +++ b/Sources/Purchasing/Purchases/TransactionPoster.swift @@ -81,6 +81,10 @@ final class TransactionPoster: TransactionPosterType { func handlePurchasedTransaction(_ transaction: StoreTransactionType, data: PurchasedTransactionData, completion: @escaping CustomerAPI.CustomerInfoResponseHandler) { + Logger.debug(Strings.purchase.transaction_poster_handling_transaction( + productID: transaction.productIdentifier + )) + self.receiptFetcher.receiptData( refreshPolicy: self.refreshRequestPolicy(forProductIdentifier: transaction.productIdentifier) ) { receiptData, receiptURL in diff --git a/Tests/BackendIntegrationTests/BaseStoreKitIntegrationTests.swift b/Tests/BackendIntegrationTests/BaseStoreKitIntegrationTests.swift index c1e0e8b4d9..b9a0be7098 100644 --- a/Tests/BackendIntegrationTests/BaseStoreKitIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/BaseStoreKitIntegrationTests.swift @@ -315,11 +315,20 @@ extension BaseStoreKitIntegrationTests { /// Purchases a product directly with StoreKit. @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) @discardableResult - func purchaseProductFromStoreKit() async throws -> Product.PurchaseResult { - let products = try await StoreKit.Product.products(for: [Self.monthlyNoIntroProductID]) + func purchaseProductFromStoreKit( + productIdentifier: String = BaseStoreKitIntegrationTests.monthlyNoIntroProductID, + finishTransaction: Bool = false + ) async throws -> Product.PurchaseResult { + let products = try await StoreKit.Product.products(for: [productIdentifier]) let product = try XCTUnwrap(products.onlyElement) - return try await product.purchase() + let result = try await product.purchase() + + if finishTransaction { + await result.verificationResult?.underlyingTransaction.finish() + } + + return result } } diff --git a/Tests/BackendIntegrationTests/StoreKitObserverModeIntegrationTests.swift b/Tests/BackendIntegrationTests/StoreKitObserverModeIntegrationTests.swift index 730d08ed5a..114859f0de 100644 --- a/Tests/BackendIntegrationTests/StoreKitObserverModeIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/StoreKitObserverModeIntegrationTests.swift @@ -59,6 +59,25 @@ class StoreKit2ObserverModeIntegrationTests: StoreKit1ObserverModeIntegrationTes ) } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testRenewalsPostReceipt() async throws { + self.testSession.timeRate = .realTime + + let productID = Self.monthlyNoIntroProductID + + try await self.purchaseProductFromStoreKit(productIdentifier: productID, finishTransaction: true) + + let logger = TestLogHandler() + + try self.testSession.forceRenewalOfSubscription(productIdentifier: productID) + + try await logger.verifyMessageIsEventuallyLogged( + Strings.network.operation_state(PostReceiptDataOperation.self, state: "Finished").description, + timeout: .seconds(3), + pollInterval: .milliseconds(100) + ) + } + } class StoreKit1ObserverModeIntegrationTests: BaseStoreKitObserverModeIntegrationTests { @@ -72,6 +91,30 @@ class StoreKit1ObserverModeIntegrationTests: BaseStoreKitObserverModeIntegration try await self.verifyEntitlementWentThrough(info) } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func testSK2RenewalsPostReceiptOnlyOnceWhenSK1IsEnabled() async throws { + try XCTSkipIf(Self.storeKit2Setting.isEnabledAndAvailable, "Test only for SK1") + + // `StoreKit2TransactionListener` is always enabled even in SK1 mode. + // This test ensures that we don't end up posting receipts multiple times when renewals come through. + + self.testSession.timeRate = .realTime + + let productID = Self.monthlyNoIntroProductID + + try await self.purchaseProductFromStoreKit(productIdentifier: productID, finishTransaction: true) + + let logger = TestLogHandler() + + try? self.testSession.forceRenewalOfSubscription(productIdentifier: productID) + + try await logger.verifyMessageIsEventuallyLogged( + "Network operation 'PostReceiptDataOperation' found with the same cache key", + timeout: .seconds(4), + pollInterval: .milliseconds(100) + ) + } + } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)