mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-10 22:45:55 +05:45
* fix: respect presentation context in Test Store * refactor: keep default presentation context internal * refactor: use generalized presentation context * refactor: simplify presentation platform branches * refactor: group simulated store purchase context * docs: clarify purchase presentation context support * fix: cancel dismissed test store sheets * refactor: unify presentation context declaration * refactor: require simulated store presentation context argument * fix: handle unavailable macOS presentation windows * fix: preserve explicit presentation context failures * docs: clarify scene purchase presentation behavior * docs: clarify window purchase presentation behavior * fix: resolve macOS sheet window from presentation context
357 lines
14 KiB
Swift
357 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
|
|
//
|
|
// SimulatedStorePurchaseHandlerTests.swift
|
|
//
|
|
// Created by Antonio Pallares on 1/8/25.
|
|
|
|
import Nimble
|
|
@_spi(Internal) @testable import RevenueCat
|
|
import XCTest
|
|
|
|
#if canImport(UIKit) && !os(watchOS)
|
|
import UIKit
|
|
#endif
|
|
|
|
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
|
|
import AppKit
|
|
#endif
|
|
|
|
class SimulatedStorePurchaseHandlerTests: TestCase {
|
|
|
|
private var mockSimulatedStorePurchaseUI: MockSimulatedStorePurchaseUI!
|
|
private var mockDateProvider: DateProvider!
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
self.mockSimulatedStorePurchaseUI = MockSimulatedStorePurchaseUI()
|
|
self.mockDateProvider = MockDateProvider(stubbedNow: Self.mockDate)
|
|
}
|
|
|
|
func testPurchaseProductCallsPurchaseUI() async {
|
|
let hander = SimulatedStorePurchaseHandler(purchaseUI: mockSimulatedStorePurchaseUI,
|
|
dateProvider: self.mockDateProvider)
|
|
|
|
_ = await hander.purchase(product: Self.testStoreProduct)
|
|
|
|
XCTAssertTrue(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUI.value)
|
|
XCTAssertNil(self.mockSimulatedStorePurchaseUI.invokedPresentationContext.value)
|
|
}
|
|
|
|
#if canImport(UIKit) && !os(watchOS)
|
|
@MainActor
|
|
func testPurchasePassesPresentationSceneToPurchaseUI() async throws {
|
|
let handler = SimulatedStorePurchaseHandler(purchaseUI: mockSimulatedStorePurchaseUI,
|
|
dateProvider: self.mockDateProvider)
|
|
let scene = try XCTUnwrap(UIScene.mock())
|
|
let context = PresentationContext(scene: scene)
|
|
|
|
_ = await handler.purchase(product: Self.testStoreProduct, presentationContext: context)
|
|
|
|
XCTAssertTrue(self.mockSimulatedStorePurchaseUI.invokedPresentationContext.value?.scene === scene)
|
|
}
|
|
|
|
@MainActor
|
|
func testPurchaseUIPrefersProvidedPresentationScene() throws {
|
|
let scene = try XCTUnwrap(UIWindowScene.mockWindowScene())
|
|
let fallbackScene = try XCTUnwrap(UIWindowScene.mockWindowScene())
|
|
let purchaseUI = DefaultSimulatedStorePurchaseUI(
|
|
defaultPresentationContextProvider: { PresentationContext(scene: fallbackScene) }
|
|
)
|
|
let context = PresentationContext(scene: scene)
|
|
|
|
let resolvedContext = purchaseUI.resolvedPresentationContext(context)
|
|
|
|
XCTAssertTrue(resolvedContext?.scene === scene)
|
|
}
|
|
|
|
@MainActor
|
|
func testPurchaseUIDoesNotFallBackWhenProvidedSceneHasNoPresentationViewController() throws {
|
|
let scene = try XCTUnwrap(UIWindowScene.mockWindowScene())
|
|
let fallbackScene = try XCTUnwrap(UIWindowScene.mockWindowScene())
|
|
let purchaseUI = DefaultSimulatedStorePurchaseUI(
|
|
defaultPresentationContextProvider: { PresentationContext(scene: fallbackScene) }
|
|
)
|
|
let context = PresentationContext(scene: scene)
|
|
|
|
let viewController = purchaseUI.findTopViewController(presentationContext: context)
|
|
|
|
XCTAssertNil(viewController)
|
|
}
|
|
|
|
@MainActor
|
|
func testPurchaseUIFallsBackToApplicationWithoutPresentationScene() throws {
|
|
let fallbackScene = try XCTUnwrap(UIWindowScene.mockWindowScene())
|
|
let purchaseUI = DefaultSimulatedStorePurchaseUI(
|
|
defaultPresentationContextProvider: { PresentationContext(scene: fallbackScene) }
|
|
)
|
|
|
|
let resolvedContext = purchaseUI.resolvedPresentationContext(nil)
|
|
|
|
XCTAssertTrue(resolvedContext?.scene === fallbackScene)
|
|
}
|
|
|
|
@MainActor
|
|
func testPurchaseUIDoesNotFallBackForNonWindowScene() throws {
|
|
let scene = try XCTUnwrap(UIScene.mock())
|
|
let fallbackScene = try XCTUnwrap(UIWindowScene.mockWindowScene())
|
|
let purchaseUI = DefaultSimulatedStorePurchaseUI(
|
|
defaultPresentationContextProvider: { PresentationContext(scene: fallbackScene) }
|
|
)
|
|
let context = PresentationContext(scene: scene)
|
|
|
|
let resolvedContext = purchaseUI.resolvedPresentationContext(context)
|
|
|
|
XCTAssertNil(resolvedContext)
|
|
}
|
|
#endif
|
|
|
|
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
|
|
@MainActor
|
|
func testPurchaseUIPrefersProvidedPresentationWindow() {
|
|
let window = NSWindow()
|
|
let fallbackWindow = NSWindow()
|
|
let context = PresentationContext(window: window)
|
|
let purchaseUI = DefaultSimulatedStorePurchaseUI(
|
|
defaultPresentationContextProvider: { PresentationContext(window: fallbackWindow) }
|
|
)
|
|
|
|
let presentationWindow = purchaseUI.findPresentationWindow(presentationContext: context)
|
|
|
|
XCTAssertTrue(presentationWindow === window)
|
|
}
|
|
|
|
@MainActor
|
|
func testPurchaseUIFallsBackToDefaultPresentationWindow() {
|
|
let fallbackWindow = NSWindow()
|
|
let purchaseUI = DefaultSimulatedStorePurchaseUI(
|
|
defaultPresentationContextProvider: { PresentationContext(window: fallbackWindow) }
|
|
)
|
|
|
|
let presentationWindow = purchaseUI.findPresentationWindow(presentationContext: nil)
|
|
|
|
XCTAssertTrue(presentationWindow === fallbackWindow)
|
|
}
|
|
|
|
@MainActor
|
|
func testPurchaseUIUsesDeepestAttachedSheet() {
|
|
let deepestSheet = StubPresentationWindow(isVisible: true)
|
|
let attachedSheet = StubPresentationWindow(isVisible: true, attachedSheet: deepestSheet)
|
|
let window = StubPresentationWindow(isVisible: true, attachedSheet: attachedSheet)
|
|
let context = PresentationContext(window: window)
|
|
|
|
let presentationWindow = context.sheetPresentationWindow
|
|
|
|
XCTAssertTrue(presentationWindow === deepestSheet)
|
|
}
|
|
|
|
@MainActor
|
|
func testPurchaseUIFallsBackToAppModalForHiddenPresentationWindow() {
|
|
let window = StubPresentationWindow(isVisible: false)
|
|
let context = PresentationContext(window: window)
|
|
|
|
let presentationWindow = context.sheetPresentationWindow
|
|
|
|
XCTAssertNil(presentationWindow)
|
|
}
|
|
|
|
func testPurchaseUIMapsOnlyButtonResponsesToActions() {
|
|
XCTAssertEqual(DefaultSimulatedStorePurchaseUI.actionIndex(for: .alertFirstButtonReturn), 0)
|
|
XCTAssertEqual(DefaultSimulatedStorePurchaseUI.actionIndex(for: .alertSecondButtonReturn), 1)
|
|
XCTAssertEqual(DefaultSimulatedStorePurchaseUI.actionIndex(for: .alertThirdButtonReturn), 2)
|
|
XCTAssertNil(DefaultSimulatedStorePurchaseUI.actionIndex(for: .abort))
|
|
XCTAssertNil(DefaultSimulatedStorePurchaseUI.actionIndex(for: .stop))
|
|
}
|
|
#endif
|
|
|
|
func testSubsequentPurchaseProductCallsOnlyCallPurchaseUIOnce() async {
|
|
|
|
#if compiler(>=5.9)
|
|
let expectation = self.expectation(description: "All purchase product calls happened")
|
|
#else
|
|
let canProceed = Atomic<Bool>(false)
|
|
#endif
|
|
|
|
mockSimulatedStorePurchaseUI.stubbedPurchaseResult.value = {
|
|
#if compiler(>=5.9)
|
|
await self.fulfillment(of: [expectation])
|
|
#else
|
|
while !canProceed.value {
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
}
|
|
#endif
|
|
return .cancel
|
|
}
|
|
let hander = SimulatedStorePurchaseHandler(purchaseUI: mockSimulatedStorePurchaseUI,
|
|
dateProvider: self.mockDateProvider)
|
|
|
|
async let result0 = hander.purchase(product: Self.testStoreProduct)
|
|
|
|
await expect(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUI.value).toEventually(beTrue())
|
|
await expect(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUICount.value).toEventually(equal(1))
|
|
|
|
async let result1 = hander.purchase(product: Self.testStoreProduct)
|
|
async let result2 = hander.purchase(product: Self.testStoreProduct)
|
|
|
|
#if compiler(>=5.9)
|
|
expectation.fulfill()
|
|
#else
|
|
canProceed.value = true
|
|
#endif
|
|
|
|
let results = await (result0, result1, result2)
|
|
|
|
XCTAssertTrue(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUI.value)
|
|
XCTAssertEqual(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUICount.value, 1)
|
|
|
|
if case .cancel = results.0 {
|
|
// Expected result
|
|
} else {
|
|
XCTFail("Expected .cancel result, got \(results.0)")
|
|
}
|
|
|
|
if case .failure = results.1 {
|
|
// Expected result
|
|
} else {
|
|
XCTFail("Expected .failure result, got \(results.1)")
|
|
}
|
|
|
|
if case .failure = results.2 {
|
|
// Expected result
|
|
} else {
|
|
XCTFail("Expected .failure result, got \(results.2)")
|
|
}
|
|
}
|
|
|
|
func testPurchaseProductWithSimulatedSuccess() async {
|
|
mockSimulatedStorePurchaseUI.stubbedPurchaseResult.value = { return .simulateSuccess }
|
|
let hander = SimulatedStorePurchaseHandler(purchaseUI: mockSimulatedStorePurchaseUI,
|
|
dateProvider: self.mockDateProvider)
|
|
|
|
let result = await hander.purchase(product: Self.testStoreProduct)
|
|
|
|
XCTAssertTrue(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUI.value)
|
|
XCTAssertEqual(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUICount.value, 1)
|
|
|
|
if case .success = result {
|
|
// Expected result
|
|
} else {
|
|
XCTFail("Expected .success result, got \(result)")
|
|
}
|
|
}
|
|
|
|
func testPurchaseProductWithSimulatedFailure() async {
|
|
mockSimulatedStorePurchaseUI.stubbedPurchaseResult.value = { return .simulateFailure }
|
|
let hander = SimulatedStorePurchaseHandler(purchaseUI: mockSimulatedStorePurchaseUI,
|
|
dateProvider: self.mockDateProvider)
|
|
|
|
let result = await hander.purchase(product: Self.testStoreProduct)
|
|
|
|
XCTAssertTrue(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUI.value)
|
|
XCTAssertEqual(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUICount.value, 1)
|
|
|
|
if case .failure = result {
|
|
// Expected result
|
|
} else {
|
|
XCTFail("Expected .failure result, got \(result)")
|
|
}
|
|
}
|
|
|
|
func testPurchaseProductWithSimulatedCancel() async {
|
|
mockSimulatedStorePurchaseUI.stubbedPurchaseResult.value = { return .cancel }
|
|
let hander = SimulatedStorePurchaseHandler(purchaseUI: mockSimulatedStorePurchaseUI,
|
|
dateProvider: self.mockDateProvider)
|
|
|
|
let result = await hander.purchase(product: Self.testStoreProduct)
|
|
|
|
XCTAssertTrue(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUI.value)
|
|
XCTAssertEqual(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUICount.value, 1)
|
|
|
|
if case .cancel = result {
|
|
// Expected result
|
|
} else {
|
|
XCTFail("Expected .cancel result, got \(result)")
|
|
}
|
|
}
|
|
|
|
func testPurchaseProductWithUIError() async {
|
|
mockSimulatedStorePurchaseUI.stubbedPurchaseResult.value = { return .error(ErrorUtils.unknownError()) }
|
|
let hander = SimulatedStorePurchaseHandler(purchaseUI: mockSimulatedStorePurchaseUI,
|
|
dateProvider: self.mockDateProvider)
|
|
|
|
let result = await hander.purchase(product: Self.testStoreProduct)
|
|
|
|
XCTAssertTrue(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUI.value)
|
|
XCTAssertEqual(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUICount.value, 1)
|
|
|
|
if case .failure = result {
|
|
// Expected result
|
|
} else {
|
|
XCTFail("Expected .failure result, got \(result)")
|
|
}
|
|
}
|
|
|
|
func testStoreTransactionWhenPurchasingProduct() async throws {
|
|
mockSimulatedStorePurchaseUI.stubbedPurchaseResult.value = { return .simulateSuccess }
|
|
let hander = SimulatedStorePurchaseHandler(purchaseUI: mockSimulatedStorePurchaseUI,
|
|
dateProvider: self.mockDateProvider)
|
|
|
|
let result = await hander.purchase(product: Self.testStoreProduct)
|
|
|
|
XCTAssertTrue(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUI.value)
|
|
XCTAssertEqual(self.mockSimulatedStorePurchaseUI.invokedPresentPurchaseUICount.value, 1)
|
|
|
|
if case .success(let transaction) = result {
|
|
expect(transaction.productIdentifier).to(equal(Self.testStoreProduct.productIdentifier))
|
|
expect(transaction.purchaseDate) == Self.mockDate
|
|
|
|
// Expect a specific token format for the jwsRepresentation property
|
|
let token = try XCTUnwrap(transaction.jwsRepresentation)
|
|
expect(token.hasPrefix("test_1756796794912_")).to(beTrue())
|
|
var uuidSuffix = token
|
|
uuidSuffix.removeFirst("test_1756796794912_".count)
|
|
XCTAssertNotNil(UUID(uuidString: uuidSuffix))
|
|
|
|
// Expect the same token for the transaction identifier
|
|
expect(transaction.transactionIdentifier) == token
|
|
} else {
|
|
XCTFail("Expected .success result, got \(result)")
|
|
}
|
|
}
|
|
|
|
private static let testStoreProduct = TestStoreProduct(localizedTitle: "Title",
|
|
price: 1.99,
|
|
currencyCode: "USD",
|
|
localizedPriceString: "$1.99",
|
|
productIdentifier: "product",
|
|
productType: .autoRenewableSubscription,
|
|
localizedDescription: "Description",
|
|
locale: .current)
|
|
|
|
private static let mockDate = Date(millisecondsSince1970: 1756796794912) // Sep 02 2025 07:06:34.912 UTC
|
|
}
|
|
|
|
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
|
|
private final class StubPresentationWindow: NSWindow {
|
|
|
|
private let stubbedIsVisible: Bool
|
|
private let stubbedAttachedSheet: NSWindow?
|
|
|
|
override var isVisible: Bool { return self.stubbedIsVisible }
|
|
override var attachedSheet: NSWindow? { return self.stubbedAttachedSheet }
|
|
|
|
init(isVisible: Bool, attachedSheet: NSWindow? = nil) {
|
|
self.stubbedIsVisible = isVisible
|
|
self.stubbedAttachedSheet = attachedSheet
|
|
super.init(contentRect: .zero, styleMask: [], backing: .buffered, defer: false)
|
|
}
|
|
|
|
}
|
|
#endif
|