Files
purchases-ios/Tests/UnitTests/SimulatedStore/SimulatedStorePurchaseHandlerTests.swift
Rick 33d2211a36 fix: present Test Store UI in the passed presentationContext (#7978)
* 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
2026-11-05 12:10:56 +01:00

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