mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
**This is an automatic release.** ### New Features * Paywalls with custom purchase and restore logic handlers (#3973) via James Borthwick (@jamesrb1) ### Bugfixes * Prevent paywall PurchaseHandler from being cleared on rerender (#4035) via Josh Holtz (@joshdholtz) * Update Purchase Tester for 5.0.0 (#4015) via Will Taylor (@fire-at-will) ### Dependency Updates * Bump fastlane from 2.221.0 to 2.221.1 (#3977) via dependabot[bot] (@dependabot[bot]) ### Other Changes * Bring official `xcodes` back to CI (#4029) via Cesar de la Vega (@vegaro) * Paywalls tester with sandbox purchases (#4024) via James Borthwick (@jamesrb1) * Update v5 migration guide to contain current latest version (#4019) via Toni Rico (@tonidero) * CI Build Docs Improvements (#4014) via Will Taylor (@fire-at-will) * Use available resource class for backend-integration-tests-offline-job (#4013) via Will Taylor (@fire-at-will) * Add `X-Preferred-Locales` header (#4008) via Cesar de la Vega (@vegaro) --------- Co-authored-by: Toni Rico <antonio.rico.diez@revenuecat.com> Co-authored-by: Josh Holtz <me@joshholtz.com>
402 lines
14 KiB
Swift
402 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
|
|
//
|
|
// OfflineStoreKitIntegrationTests.swift
|
|
//
|
|
// Created by Nacho Soto on 5/17/23.
|
|
|
|
import Nimble
|
|
@testable import RevenueCat
|
|
import StoreKit
|
|
import StoreKitTest
|
|
import XCTest
|
|
|
|
// swiftlint:disable file_length
|
|
class BaseOfflineStoreKitIntegrationTests: BaseStoreKitIntegrationTests {
|
|
|
|
override func setUp() async throws {
|
|
self.serverUp()
|
|
try await super.setUp()
|
|
|
|
await self.waitForPendingCustomerInfoRequests()
|
|
}
|
|
|
|
}
|
|
|
|
class OfflineStoreKit2IntegrationTests: OfflineStoreKit1IntegrationTests {
|
|
|
|
override class var storeKitVersion: StoreKitVersion { .storeKit2 }
|
|
|
|
}
|
|
|
|
class OfflineStoreKit1IntegrationTests: BaseOfflineStoreKitIntegrationTests {
|
|
|
|
override class var storeKitVersion: StoreKitVersion { .storeKit1 }
|
|
|
|
override func setUp() async throws {
|
|
try await super.setUp()
|
|
|
|
if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) {
|
|
try await self.ensureEntitlementMappingIsAvailable()
|
|
}
|
|
}
|
|
|
|
func testOfferingsAreCachedInMemory() async throws {
|
|
let onlineOfferings = try await self.purchases.offerings()
|
|
expect(onlineOfferings.all).toNot(beEmpty())
|
|
|
|
self.serverDown()
|
|
|
|
let offlineOfferings = try await self.purchases.offerings()
|
|
expect(offlineOfferings) === onlineOfferings
|
|
}
|
|
|
|
func testOfferingsAreCachedOnDisk() async throws {
|
|
let onlineOfferings = try await self.purchases.offerings()
|
|
expect(onlineOfferings.all).toNot(beEmpty())
|
|
|
|
self.serverDown()
|
|
await self.resetSingleton()
|
|
|
|
let offlineOfferings = try await self.purchases.offerings()
|
|
expect(offlineOfferings.response) == onlineOfferings.response
|
|
|
|
let offering = try XCTUnwrap(offlineOfferings.current)
|
|
expect(offering.availablePackages.count) == onlineOfferings.current?.availablePackages.count
|
|
expect(offering.monthly?.storeProduct.productIdentifier) == "com.revenuecat.monthly_4.99.1_week_intro"
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testOfflineCustomerInfoWithNoPurchases() async throws {
|
|
try self.purchases.invalidateCustomerInfoCache()
|
|
|
|
self.serverDown()
|
|
|
|
let info = try await self.purchases.customerInfo()
|
|
expect(info.entitlements.all).to(beEmpty())
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testReturnsCachedCustomerInfo() async throws {
|
|
self.serverDown()
|
|
|
|
_ = try await self.purchases.customerInfo()
|
|
|
|
self.logger.verifyMessageWasNotLogged(Strings.customerInfo.customerinfo_updated_offline)
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testOfflineCustomerInfoWithOnePurchase() async throws {
|
|
try await self.purchaseMonthlyOffering()
|
|
|
|
try self.purchases.invalidateCustomerInfoCache()
|
|
self.serverDown()
|
|
|
|
let info = try await self.purchases.customerInfo()
|
|
expect(info.entitlements.all).toNot(beEmpty())
|
|
try await self.verifyEntitlementWentThrough(info)
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testPurchaseWhileServerIsDownSucceedsButDoesNotFinishTransaction() async throws {
|
|
self.logger.clearMessages()
|
|
|
|
self.serverDown()
|
|
try await self.purchaseMonthlyProduct(allowOfflineEntitlements: true)
|
|
|
|
self.verifyCustomerInfoWasComputedOffline()
|
|
self.verifyNoTransactionsWereFinished()
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testPurchaseWhileServerIsDownPostsReceiptAfterServerComesBack() async throws {
|
|
self.logger.clearMessages()
|
|
|
|
// 1. Purchase while server is down
|
|
self.serverDown()
|
|
try await self.purchaseMonthlyProduct(allowOfflineEntitlements: true)
|
|
|
|
self.verifyNoTransactionsWereFinished()
|
|
|
|
// 2. "Re-open" the app after the server is back
|
|
self.serverUp()
|
|
try self.purchases.invalidateCustomerInfoCache()
|
|
await self.resetSingleton()
|
|
|
|
// 3. Ensure delegate is notified of subscription
|
|
try await asyncWait(
|
|
description: "Subscription never became active",
|
|
timeout: .seconds(5),
|
|
pollInterval: .milliseconds(200)
|
|
) { [delegate = self.purchasesDelegate] in
|
|
delegate?.customerInfo?.activeSubscriptions.isEmpty == false
|
|
}
|
|
|
|
// 4. Ensure transaction is eventually finished
|
|
try await self.verifyTransactionIsEventuallyFinished()
|
|
|
|
// 5. Restart app again
|
|
try self.purchases.invalidateCustomerInfoCache()
|
|
await self.resetSingleton()
|
|
|
|
// 6. To ensure (with a clean cache) that the receipt was posted
|
|
let info = try await self.purchases.customerInfo()
|
|
try await self.verifyEntitlementWentThrough(info)
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testReopeningAppWithOfflineEntitlementsDoesNotReturnStaleCache() async throws {
|
|
// 1. Purchase while server is down
|
|
self.serverDown()
|
|
try await self.purchaseMonthlyProduct(allowOfflineEntitlements: true)
|
|
|
|
// 2. "Re-open" the app
|
|
await self.resetSingleton()
|
|
|
|
// 3. `CustomerInfo` should contain offline purchase
|
|
let info = try await self.purchases.customerInfo()
|
|
try await self.verifyEntitlementWentThrough(info)
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testPurchaseAgainAfterServerRecovers() async throws {
|
|
// 1. Purchase while server is down
|
|
self.serverDown()
|
|
try await self.purchaseMonthlyProduct(allowOfflineEntitlements: true)
|
|
|
|
// 2. Purchase again when the server is back up
|
|
// (maybe the app failed the first time?)
|
|
self.serverUp()
|
|
try await self.purchaseMonthlyProduct()
|
|
|
|
// 3. `CustomerInfo` should contain the purchase
|
|
let info = try await self.purchases.customerInfo()
|
|
try await self.verifyEntitlementWentThrough(info)
|
|
expect(info.activeSubscriptions).to(haveCount(1))
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testPurchasingTwoProductsWhileServerIsDown() async throws {
|
|
let product1 = try await self.monthlyPackage.storeProduct
|
|
let product2 = try await self.annualPackage.storeProduct
|
|
|
|
self.serverDown()
|
|
|
|
_ = try await self.purchases.purchase(product: product1)
|
|
let info = try await self.purchases.purchase(product: product2).customerInfo
|
|
|
|
try await self.verifyEntitlementWentThrough(info)
|
|
expect(info.allPurchasedProductIdentifiers) == [
|
|
product1.productIdentifier,
|
|
product2.productIdentifier
|
|
]
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testPurchasingSecondProductAfterServerIsUp() async throws {
|
|
let product1 = try await self.monthlyPackage.storeProduct
|
|
let product2 = try await self.annualPackage.storeProduct
|
|
|
|
self.serverDown()
|
|
|
|
_ = try await self.purchases.purchase(product: product1)
|
|
|
|
self.serverUp()
|
|
|
|
let info = try await self.purchases.purchase(product: product2).customerInfo
|
|
|
|
try await self.verifyEntitlementWentThrough(info)
|
|
expect(info.allPurchasedProductIdentifiers) == [
|
|
product1.productIdentifier,
|
|
product2.productIdentifier
|
|
]
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testSimultanousCallsToGetCustomerInfoWithPendingTransactionPostsReceiptOnlyOnce() async throws {
|
|
self.serverDown()
|
|
|
|
_ = try await self.purchaseMonthlyProduct(allowOfflineEntitlements: true)
|
|
|
|
self.serverUp()
|
|
|
|
let task1 = Task { try await self.purchases.customerInfo(fetchPolicy: .fetchCurrent) }
|
|
let task2 = Task { try await self.purchases.customerInfo(fetchPolicy: .fetchCurrent) }
|
|
|
|
let info1 = try await task1.value
|
|
let info2 = try await task2.value
|
|
try await self.verifyEntitlementWentThrough(info1)
|
|
try await self.verifyEntitlementWentThrough(info2)
|
|
|
|
self.logger.verifyMessageWasLogged(
|
|
"API request completed: POST '/v1/receipts'",
|
|
level: .debug,
|
|
expectedCount: 1
|
|
)
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testCallToGetCustomerInfoWithPendingTransactionsPostsReceiptOnlyOnce() async throws {
|
|
// This test requires the "production" behavior to make sure
|
|
// we don't refresh the receipt a second time when posting the second transaction.
|
|
self.enableReceiptFetchRetry = false
|
|
|
|
self.serverDown()
|
|
|
|
try await self.purchaseMonthlyProduct(allowOfflineEntitlements: true)
|
|
try self.testSession.forceRenewalOfSubscription(
|
|
productIdentifier: await self.monthlyPackage.storeProduct.productIdentifier
|
|
)
|
|
|
|
try await self.waitUntilUnfinishedTransactions { $0 >= 2 }
|
|
|
|
self.serverUp()
|
|
|
|
let customerInfo = try await self.purchases.customerInfo(fetchPolicy: .fetchCurrent)
|
|
try await self.verifyEntitlementWentThrough(customerInfo)
|
|
|
|
self.logger.verifyMessageWasLogged(
|
|
"unfinished transactions, will post receipt in lieu of fetching CustomerInfo",
|
|
level: .debug,
|
|
expectedCount: 1
|
|
)
|
|
|
|
try await self.logger.verifyMessageIsEventuallyLogged(
|
|
"Network operation 'PostReceiptDataOperation' found with the same cache key",
|
|
level: .debug
|
|
)
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testPurchasingConsumableInvalidatesOfflineMode() async throws {
|
|
self.serverDown()
|
|
|
|
do {
|
|
_ = try await self.purchaseConsumablePackage()
|
|
fail("Expected error")
|
|
} catch let error as ErrorCode {
|
|
expect(error).to(matchError(ErrorCode.unknownBackendError))
|
|
|
|
self.logger.verifyMessageWasLogged(
|
|
Strings.offlineEntitlements.computing_offline_customer_info_failed(
|
|
PurchasedProductsFetcher.Error.foundConsumablePurchase
|
|
),
|
|
level: .error
|
|
)
|
|
} catch {
|
|
fail("Unexpected error: \(error)")
|
|
}
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testPurchaseWhileServerIsDownPostsReceiptWhenForegroundingApp() async throws {
|
|
self.logger.clearMessages()
|
|
|
|
// 1. Purchase while server is down
|
|
self.serverDown()
|
|
try await self.purchaseMonthlyProduct(allowOfflineEntitlements: true)
|
|
|
|
self.verifyNoTransactionsWereFinished()
|
|
|
|
// 2. Server is back
|
|
self.serverUp()
|
|
|
|
// 3. Request current CustomerInfo
|
|
let info1 = try await self.purchases.customerInfo()
|
|
try await self.verifyEntitlementWentThrough(info1)
|
|
|
|
// 4. Ensure transaction is finished
|
|
self.verifyTransactionWasFinished()
|
|
|
|
// 5. Restart app
|
|
try self.purchases.invalidateCustomerInfoCache()
|
|
await self.resetSingleton()
|
|
|
|
// 6. To ensure (with a clean cache) that the receipt was posted
|
|
let info2 = try await self.purchases.customerInfo()
|
|
try await self.verifyEntitlementWentThrough(info2)
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testPurchasingMultipleProductsWhileServerIsDownHandlesAllTransactionsWhenForegroundingApp() async throws {
|
|
self.logger.clearMessages()
|
|
|
|
// 1. Purchase while server is down
|
|
self.serverDown()
|
|
|
|
try await self.purchaseMonthlyProduct(allowOfflineEntitlements: true)
|
|
do {
|
|
try await self.purchaseConsumablePackage()
|
|
fail("Consumable purchases should fail while offline")
|
|
} catch {}
|
|
|
|
self.verifyNoTransactionsWereFinished()
|
|
|
|
// 2. Server is back
|
|
self.serverUp()
|
|
|
|
// 3. Request current CustomerInfo
|
|
let info = try await self.purchases.customerInfo()
|
|
|
|
// 4. Verify subscription is active
|
|
try await self.verifyEntitlementWentThrough(info)
|
|
|
|
// 5. Verify consumable
|
|
expect(info.nonSubscriptions).to(haveCount(1))
|
|
expect(info.nonSubscriptions.onlyElement?.productIdentifier) == Self.consumable10Coins
|
|
|
|
// 6. Ensure transactions are finished
|
|
try await self.verifyTransactionIsEventuallyFinished(count: 2)
|
|
}
|
|
|
|
}
|
|
|
|
// swiftlint:disable type_name
|
|
|
|
class OfflineWithNoMappingStoreKitIntegrationTests: BaseOfflineStoreKitIntegrationTests {
|
|
|
|
override var forceServerErrors: Bool { return true }
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func testOfflineCustomerInfoFailsIfNoEntitlementMapping() async throws {
|
|
try self.purchases.invalidateCustomerInfoCache()
|
|
|
|
do {
|
|
_ = try await self.purchases.customerInfo(fetchPolicy: .fetchCurrent)
|
|
fail("Fetch should have failed")
|
|
} catch let error as ErrorCode {
|
|
expect(error).to(matchError(ErrorCode.unknownBackendError))
|
|
} catch let error {
|
|
fail("Unexpected error: \(error)")
|
|
}
|
|
|
|
self.logger.verifyMessageWasLogged(
|
|
Strings.offlineEntitlements.computing_offline_customer_info_with_no_entitlement_mapping
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
// swiftlint:enable type_name
|
|
|
|
// MARK: -
|
|
|
|
private extension BaseOfflineStoreKitIntegrationTests {
|
|
|
|
func waitForPendingCustomerInfoRequests() async {
|
|
_ = try? await self.purchases.customerInfo()
|
|
}
|
|
|
|
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
|
|
func ensureEntitlementMappingIsAvailable() async throws {
|
|
_ = try await self.purchases.productEntitlementMapping()
|
|
}
|
|
|
|
}
|