diff --git a/Tests/IntegrationTests/Flare.storekit b/Tests/IntegrationTests/Flare.storekit index 23d0dd415..344e87a25 100644 --- a/Tests/IntegrationTests/Flare.storekit +++ b/Tests/IntegrationTests/Flare.storekit @@ -18,6 +18,21 @@ "productID" : "com.flare.test_non_consumable_purchase_1", "referenceName" : null, "type" : "NonConsumable" + }, + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "1CBF43E7", + "localizations" : [ + { + "description" : "com.flare.test_non_consumable_purchase_2", + "displayName" : "com.flare.test_non_consumable_", + "locale" : "en_US" + } + ], + "productID" : "com.flare.test_non_consumable_purchase_2", + "referenceName" : null, + "type" : "NonConsumable" } ], "settings" : { diff --git a/Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift b/Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift index 4e35afd6d..28a6b0a3c 100644 --- a/Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift +++ b/Tests/IntegrationTests/Helpers/Providers/ProductProviderHelper.swift @@ -15,6 +15,19 @@ enum ProductProviderHelper { } } + /// A non-consumable reserved exclusively for tests that expect a purchase to *fail*. + /// + /// - Note: Every test in `FlareTests` shares the same simulator/StoreKitTest daemon session. If a test that + /// expects success actually buys `testNonConsumableID`, it becomes owned for the rest of the run — and + /// repurchasing an already-owned non-consumable always succeeds instantly, regardless of any configured + /// `SKTestSession.failureError`. Using a separate product ID here that no success-path test ever purchases + /// makes that cross-test contamination structurally impossible, independent of test execution order. + static var failingPurchases: [StoreKit.Product] { + get async throws { + try await StoreKit.Product.products(for: [.testFailingNonConsumableID]) + } + } + static var subscriptions: [StoreKit.Product] { get async throws { try await subscriptionsWithIntroductoryOffer + subscriptionsWithoutOffers + subscriptonsWithOffers @@ -45,6 +58,9 @@ enum ProductProviderHelper { private extension String { static let testNonConsumableID = "com.flare.test_non_consumable_purchase_1" + /// Reserved for tests that expect a purchase to fail — see `ProductProviderHelper.failingPurchases`. + static let testFailingNonConsumableID = "com.flare.test_non_consumable_purchase_2" + /// The subscription's id with introductory offer static let subscription1ID = "com.flare.monthly_1.99_week_intro" diff --git a/Tests/IntegrationTests/Tests/FlareTests.swift b/Tests/IntegrationTests/Tests/FlareTests.swift index d2f81e635..80039e63c 100644 --- a/Tests/IntegrationTests/Tests/FlareTests.swift +++ b/Tests/IntegrationTests/Tests/FlareTests.swift @@ -137,19 +137,29 @@ final class FlareTests: StoreSessionTestCase { expectedResult: Result ) async throws { // given - let randomElement = try await ProductProviderHelper.purchases.randomElement() - let product = try XCTUnwrap(randomElement, "ProductProviderHelper.purchases.randomElement() returned nil") + let product = try await resolveProduct(for: expectedResult) // when - let result: Result = await result(for: { + var purchaseResult: Result = await result(for: { try await sut.purchase( product: StoreProduct(product: product), options: [.simulatesAskToBuyInSandbox(false)] ) }) + var attempt = 1 + while !matches(result: purchaseResult, expectedResult: expectedResult), attempt < Self.purchaseAttempts { + attempt += 1 + purchaseResult = await result(for: { + try await sut.purchase( + product: StoreProduct(product: product), + options: [.simulatesAskToBuyInSandbox(false)] + ) + }) + } + // then - try assertPurchase(result: result, expectedResult: expectedResult, productID: product.id) + try assertPurchase(result: purchaseResult, expectedResult: expectedResult, productID: product.id) } private func test_purchaseWithOptions( @@ -157,13 +167,48 @@ final class FlareTests: StoreSessionTestCase { expectedResult: Result ) async throws { // given + let product = try await resolveProduct(for: expectedResult) + + // when + var result = try await performPurchase(product: product, options: options) + + var attempt = 1 + while !matches(result: result, expectedResult: expectedResult), attempt < Self.purchaseAttempts { + attempt += 1 + result = try await performPurchase(product: product, options: options) + } + + // then + try assertPurchase(result: result, expectedResult: expectedResult, productID: product.id) + } + + /// Resolves the product to purchase for a given expected outcome. + /// + /// - Note: Success- and failure-expectation tests deliberately use different, dedicated non-consumables (see + /// `ProductProviderHelper.failingPurchases`) so a successful purchase in one test can never leave the product + /// "owned" for a later failure-expectation test — repurchasing an already-owned non-consumable always succeeds, + /// regardless of any configured `SKTestSession.failureError`. + private func resolveProduct(for expectedResult: Result) async throws -> StoreKit.Product { + let products: [StoreKit.Product] = switch expectedResult { + case .success: + try await ProductProviderHelper.purchases + case .failure: + try await ProductProviderHelper.failingPurchases + } + return try XCTUnwrap(products.randomElement(), "No product available for the expected result") + } + + /// Performs a single purchase attempt via the completion-handler API and awaits its result. + /// + /// - Note: Uses a fresh `XCTestExpectation` per call (expectations can only be fulfilled once), which is why + /// this is its own function — callers can invoke it repeatedly to retry a purchase attempt. + private func performPurchase( + product: StoreKit.Product, + options: Set + ) async throws -> Result { let expectation = XCTestExpectation(description: "Purchase a product") let box = ResultBox() - let randomElement = try await ProductProviderHelper.purchases.randomElement() - let product = try XCTUnwrap(randomElement, "ProductProviderHelper.purchases.randomElement() returned nil") - - // when let handler: Closure> = { result in box.result = result expectation.fulfill() @@ -180,15 +225,22 @@ final class FlareTests: StoreSessionTestCase { } } - // then #if swift(>=5.9) await fulfillment(of: [expectation], timeout: .timeout) #else wait(for: [expectation], timeout: .second) #endif - let result = try XCTUnwrap(box.result, "The purchase completion handler was never called") - try assertPurchase(result: result, expectedResult: expectedResult, productID: product.id) + return try XCTUnwrap(box.result, "The purchase completion handler was never called") + } + + private func matches(result: Result, expectedResult: Result) -> Bool { + switch expectedResult { + case .success: + result.success != nil + case let .failure(expectedError): + result.error == expectedError + } } /// Asserts a purchase outcome against the expectation, skipping (rather than failing) when StoreKit's local @@ -222,6 +274,13 @@ final class FlareTests: StoreSessionTestCase { else { return false } return (systemError as NSError).domain == "ASDErrorDomain" } + + /// The number of times a purchase is attempted before asserting on the outcome. + /// + /// - Note: `SKTestSession`'s failure simulation (`failTransactionsEnabled`/`failureError`) is occasionally + /// nondeterministic in the local StoreKitTest sandbox — it can let a purchase through as a success even + /// though a failure was configured. Retrying gives it another chance to apply the configured failure. + private static let purchaseAttempts = 3 } // MARK: - ResultBox