Files
purchases-ios/Tests/StoreKitUnitTests/StoreKit2/StoreKit2ObserverModePurchaseDetectorTests.swift
Josh Holtz 1bae5cf915 Avoid repeated rejected SK2 observer-mode history submissions (#8142)
* fix: throttle rejected SK2 observer history submissions

* fix: name old receipt errors and bound observer cooldowns
2026-10-29 18:38:06 -05:00

295 lines
14 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// StoreKit2ObserverModePurchaseDetectorTests.swift
//
// Created by Will Taylor on 5/1/24.
import Foundation
import Nimble
@testable import RevenueCat
import StoreKitTest
import XCTest
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
// Despite only supporting StoreKit 2 on iOS 16.0+, we still need to test observer mode
// purchase detection on iOS 15.0+ because it's possible for a customer to make purchases
// with StoreKit 2 on iOS 15, and we support detecting those purchases through
// observer mode.
// swiftlint:disable type_name
class StoreKit2ObserverModePurchaseDetectorTests: StoreKitConfigTestCase {
private let appUserID = "mockAppUserID"
private var deviceCache: MockDeviceCache!
private var observerModePurchaseDetector: StoreKit2ObserverModePurchaseDetector!
override func setUp() async throws {
try AvailabilityChecks.iOS16APIAvailableOrSkipTest()
deviceCache = .init()
try await super.setUp()
}
func testDetectUnobservedTransactionsDoesntCallDelegateWith0Transactions() async {
let allTransactionsProvider = MockAllTransactionsProvider(mockedTransactions: [])
let delegate = MockStoreKit2ObserverModePurchaseDetectorDelegate()
observerModePurchaseDetector = .init(
deviceCache: deviceCache,
allTransactionsProvider: allTransactionsProvider
)
await observerModePurchaseDetector.detectUnobservedTransactions(delegate: delegate)
expect(delegate.handleSK2ObserverModeTransactionCalled) == false
expect(self.deviceCache.invokedReadCachedSyncedSK2ObserverModeTransactionIDs) == false
expect(self.deviceCache.invokedRegisterNewSyncedSK2ObserverModeTransactionID) == false
}
func testDetectUnobservedTransactionsCallsDelegateUnobservedTransactions() async throws {
let txn1 = try await self.createFinishedTransactionFixture()
let allTransactionsProvider = MockAllTransactionsProvider(mockedTransactions: [txn1])
let delegate = MockStoreKit2ObserverModePurchaseDetectorDelegate()
observerModePurchaseDetector = .init(
deviceCache: deviceCache,
allTransactionsProvider: allTransactionsProvider
)
await observerModePurchaseDetector.detectUnobservedTransactions(delegate: delegate)
// Validate calls to delegate
expect(delegate.handleSK2ObserverModeTransactionCalled) == true
expect(delegate.handleSK2ObserverModeTransactionCount) == 1
guard let delegateInvocation = delegate.handleSK2ObserverModeTransactionInvocations.first else {
fail("No delegate invocations were found.")
return
}
expect(delegateInvocation.verifiedTransaction) == txn1.verifiedTransaction
expect(delegateInvocation.jwsRepresentation) == txn1.jwsRepresentation
// Validate cache state
expect(self.deviceCache.invokedReadCachedSyncedSK2ObserverModeTransactionIDs) == true
expect(self.deviceCache.invokedRegisterNewSyncedSK2ObserverModeTransactionID) == true
}
// Since the transaction is cached when it is detected for the first time, we don't expect
// the delegate to be called again for this transaction in the future.
func testDetectUnobservedTransactionsCallsDelegateOncePerUnobservedTransactions() async throws {
let txn1 = try await self.createFinishedTransactionFixture()
let allTransactionsProvider = MockAllTransactionsProvider(mockedTransactions: [txn1])
let delegate = MockStoreKit2ObserverModePurchaseDetectorDelegate()
observerModePurchaseDetector = .init(
deviceCache: deviceCache,
allTransactionsProvider: allTransactionsProvider
)
await observerModePurchaseDetector.detectUnobservedTransactions(delegate: delegate)
await observerModePurchaseDetector.detectUnobservedTransactions(delegate: delegate)
// Validate calls to delegate
expect(delegate.handleSK2ObserverModeTransactionCalled) == true
expect(delegate.handleSK2ObserverModeTransactionCount) == 1
guard let delegateInvocation = delegate.handleSK2ObserverModeTransactionInvocations.first else {
fail("No delegate invocations were found.")
return
}
expect(delegateInvocation.verifiedTransaction) == txn1.verifiedTransaction
expect(delegateInvocation.jwsRepresentation) == txn1.jwsRepresentation
// Validate cache state
expect(self.deviceCache.invokedReadCachedSyncedSK2ObserverModeTransactionIDs) == true
expect(self.deviceCache.invokedRegisterNewSyncedSK2ObserverModeTransactionID) == true
}
func testRejectedReceiptIsNotSyncedAndCooldownSurvivesRecreation() async throws {
let transaction = try await self.createFinishedTransactionFixture()
let provider = MockAllTransactionsProvider(mockedTransactions: [transaction])
let clock = MockCurrentDateProvider()
let delegate = MockStoreKit2ObserverModePurchaseDetectorDelegate()
delegate.error = Self.rejectedReceiptError
let detector = StoreKit2ObserverModePurchaseDetector(
deviceCache: self.deviceCache, allTransactionsProvider: provider, dateProvider: clock
)
await detector.detectUnobservedTransactions(delegate: delegate)
await detector.detectUnobservedTransactions(delegate: delegate)
let recreatedDetector = StoreKit2ObserverModePurchaseDetector(
deviceCache: self.deviceCache, allTransactionsProvider: provider, dateProvider: clock
)
await recreatedDetector.detectUnobservedTransactions(delegate: delegate)
expect(delegate.handleSK2ObserverModeTransactionCount) == 1
expect(self.deviceCache.cachedSyncedSK2ObserverModeTransactionIDs()).to(beEmpty())
expect(self.deviceCache.cachedSK2ObserverModeReceiptRetry()).toNot(beNil())
clock.advance(by: 5 * 60)
delegate.error = nil
await recreatedDetector.detectUnobservedTransactions(delegate: delegate)
await recreatedDetector.detectUnobservedTransactions(delegate: delegate)
expect(delegate.handleSK2ObserverModeTransactionCount) == 2
expect(self.deviceCache.cachedSyncedSK2ObserverModeTransactionIDs()).toNot(beEmpty())
expect(self.deviceCache.cachedSK2ObserverModeReceiptRetry()).to(beNil())
}
func testCooldownForAnotherTransactionDoesNotDelayNewTransaction() async throws {
let transaction = try await self.createFinishedTransactionFixture()
let clock = MockCurrentDateProvider()
let transactionID = try XCTUnwrap(transaction.verifiedTransaction?.id)
self.deviceCache.cacheSK2ObserverModeReceiptRetry(.init(
transactionID: transactionID + 1,
retryAfter: clock.now().addingTimeInterval(5 * 60)
))
let delegate = MockStoreKit2ObserverModePurchaseDetectorDelegate()
let detector = StoreKit2ObserverModePurchaseDetector(
deviceCache: self.deviceCache,
allTransactionsProvider: MockAllTransactionsProvider(mockedTransactions: [transaction]),
dateProvider: clock
)
await detector.detectUnobservedTransactions(delegate: delegate)
expect(delegate.handleSK2ObserverModeTransactionCount) == 1
}
func testClockCorrectionDoesNotExtendReceiptCooldown() async throws {
let transaction = try await self.createFinishedTransactionFixture()
let provider = MockAllTransactionsProvider(mockedTransactions: [transaction])
let clock = MockCurrentDateProvider()
clock.advance(by: 60 * 60)
let delegate = MockStoreKit2ObserverModePurchaseDetectorDelegate()
delegate.error = Self.rejectedReceiptError
let detector = StoreKit2ObserverModePurchaseDetector(
deviceCache: self.deviceCache, allTransactionsProvider: provider, dateProvider: clock
)
await detector.detectUnobservedTransactions(delegate: delegate)
expect(delegate.handleSK2ObserverModeTransactionCount) == 1
clock.advance(by: -60 * 60)
delegate.error = nil
let recreatedDetector = StoreKit2ObserverModePurchaseDetector(
deviceCache: self.deviceCache, allTransactionsProvider: provider, dateProvider: clock
)
await recreatedDetector.detectUnobservedTransactions(delegate: delegate)
expect(delegate.handleSK2ObserverModeTransactionCount) == 2
expect(self.deviceCache.cachedSyncedSK2ObserverModeTransactionIDs()).toNot(beEmpty())
expect(self.deviceCache.cachedSK2ObserverModeReceiptRetry()).to(beNil())
}
func testOtherErrorsRemainRetryableWithoutReceiptCooldown() async throws {
let transaction = try await self.createFinishedTransactionFixture()
let delegate = MockStoreKit2ObserverModePurchaseDetectorDelegate()
delegate.error = BackendError.networkError(.networkError(URLError(.notConnectedToInternet)))
let detector = StoreKit2ObserverModePurchaseDetector(
deviceCache: self.deviceCache,
allTransactionsProvider: MockAllTransactionsProvider(mockedTransactions: [transaction])
)
await detector.detectUnobservedTransactions(delegate: delegate)
await detector.detectUnobservedTransactions(delegate: delegate)
expect(delegate.handleSK2ObserverModeTransactionCount) == 2
expect(self.deviceCache.cachedSK2ObserverModeReceiptRetry()).to(beNil())
expect(self.deviceCache.cachedSyncedSK2ObserverModeTransactionIDs()).to(beEmpty())
}
func testConcurrentDetectionsDoNotPostSameTransactionTwice() async throws {
let transaction = try await self.createFinishedTransactionFixture()
let delegate = MockStoreKit2ObserverModePurchaseDetectorDelegate()
let detector = StoreKit2ObserverModePurchaseDetector(
deviceCache: self.deviceCache,
allTransactionsProvider: MockAllTransactionsProvider(mockedTransactions: [transaction])
)
let started = self.expectation(description: "First receipt post started")
let gate = AsyncStream<Void>.makeStream()
let startedOnce: Atomic<Bool> = false
delegate.beforeCompletion = {
if !startedOnce.getAndSet(true) { started.fulfill() }
for await _ in gate.stream { break }
}
let first = Task { await detector.detectUnobservedTransactions(delegate: delegate) }
await self.fulfillment(of: [started], timeout: 5)
let secondReturned = self.expectation(description: "Concurrent detection returned without another post")
let second = Task {
await detector.detectUnobservedTransactions(delegate: delegate)
secondReturned.fulfill()
}
await self.fulfillment(of: [secondReturned], timeout: 5)
gate.continuation.finish()
await first.value
await second.value
expect(delegate.handleSK2ObserverModeTransactionCount) == 1
}
private static var rejectedReceiptError: BackendError {
.networkError(.errorResponse(
.init(code: .oldReplayedReceipt,
originalCode: BackendErrorCode.oldReplayedReceipt.rawValue,
message: "Rejecting receipt."),
.invalidRequest
))
}
private func createFinishedTransactionFixture() async throws -> StoreKit.VerificationResult<StoreKit.Transaction> {
// buyProduct(identifier:) was added in Xcode 15.
#if compiler(>=5.9)
if #available(iOS 27.0, tvOS 27.0, watchOS 27.0, macOS 27.0, *) {
// On a fresh iOS 27 test host, Product.purchase() can attempt Sandbox authentication
// despite an active SKTestSession. These tests need a signed transaction, not the purchase UI.
let transaction = try await self.testSession.buyProduct(identifier: Self.productID)
let result: Atomic<StoreKit.VerificationResult<StoreKit.Transaction>?> = .init(nil)
try await asyncWait(description: "Fixture transaction did not become available to StoreKit") {
let latest = await StoreKit.Transaction.latest(for: Self.productID)
result.value = latest
return latest?.verifiedTransaction?.id == transaction.id
}
await transaction.finish()
return try XCTUnwrap(result.value)
}
#endif
return try await self.simulateAnyPurchase(finishTransaction: true)
}
}
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
// Unchecked since it's a mock
final class MockStoreKit2ObserverModePurchaseDetectorDelegate: StoreKit2ObserverModePurchaseDetectorDelegate,
@unchecked Sendable {
var handleSK2ObserverModeTransactionCalled = false
var handleSK2ObserverModeTransactionCount = 0
var error: Error?
var beforeCompletion: (@Sendable () async -> Void)?
var handleSK2ObserverModeTransactionInvocations: [HandleSK2ObserverModeTransactionInvocation] = []
struct HandleSK2ObserverModeTransactionInvocation {
let verifiedTransaction: Transaction
let jwsRepresentation: String
}
func handleSK2ObserverModeTransaction(verifiedTransaction: Transaction, jwsRepresentation: String) async throws {
handleSK2ObserverModeTransactionCalled = true
handleSK2ObserverModeTransactionCount += 1
handleSK2ObserverModeTransactionInvocations.append(.init(verifiedTransaction: verifiedTransaction,
jwsRepresentation: jwsRepresentation))
await self.beforeCompletion?()
if let error = self.error { throw error }
}
}