mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-10 22:45:55 +05:45
* fix: throttle rejected SK2 observer history submissions * fix: name old receipt errors and bound observer cooldowns
295 lines
14 KiB
Swift
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 }
|
|
}
|
|
|
|
}
|