Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Tests/IntegrationTests/Flare.storekit
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down
81 changes: 70 additions & 11 deletions Tests/IntegrationTests/Tests/FlareTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,33 +137,78 @@ final class FlareTests: StoreSessionTestCase {
expectedResult: Result<Void, IAPError>
) 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<StoreTransaction, IAPError> = await result(for: {
var purchaseResult: Result<StoreTransaction, IAPError> = 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(
options: Set<StoreKit.Product.PurchaseOption> = [.simulatesAskToBuyInSandbox(true)],
expectedResult: Result<Void, IAPError>
) 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<Void, IAPError>) 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<StoreKit.Product.PurchaseOption>
) async throws -> Result<StoreTransaction, IAPError> {
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<StoreTransaction, IAPError>> = { result in
box.result = result
expectation.fulfill()
Expand All @@ -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<StoreTransaction, IAPError>, expectedResult: Result<Void, IAPError>) -> 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
Expand Down Expand Up @@ -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
Expand Down
Loading