From 71477067c018b04e7397feebccdca5ba0534e5d7 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Mon, 11 Dec 2023 15:48:28 +0100 Subject: [PATCH 01/27] Implement a common store kit product - Create an `ISKProduct` interface that describes a common `StoreKit` object - Create `SK1StoreProduct` and `SK2StoreProduct` classes that describe products for StoreKit and StoreKit2, respectively --- .../xcschemes/FlareTests.xcscheme | 52 +++++++++ .../Formatters/NumberFormatter+.swift | 23 ++++ .../Locale/Locale+CurrencyCode.swift | 20 ++++ .../Classes/Extensions/ProductType+.swift | 40 +++++++ .../Internal/Protocols/ISKProduct.swift | 36 +++++++ .../Models/Internal/SK1StoreProduct.swift | 71 +++++++++++++ .../Models/Internal/SK2StoreProduct.swift | 71 +++++++++++++ .../Classes/Models/ProductCategory.swift | 14 +++ .../Flare/Classes/Models/ProductType.swift | 21 ++++ .../Flare/Classes/Models/StoreProduct.swift | 74 +++++++++++++ .../Classes/Models/SubscriptionPeriod.swift | 100 ++++++++++++++++++ 11 files changed, 522 insertions(+) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/FlareTests.xcscheme create mode 100644 Sources/Flare/Classes/Extensions/Formatters/NumberFormatter+.swift create mode 100644 Sources/Flare/Classes/Extensions/Locale/Locale+CurrencyCode.swift create mode 100644 Sources/Flare/Classes/Extensions/ProductType+.swift create mode 100644 Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift create mode 100644 Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift create mode 100644 Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift create mode 100644 Sources/Flare/Classes/Models/ProductCategory.swift create mode 100644 Sources/Flare/Classes/Models/ProductType.swift create mode 100644 Sources/Flare/Classes/Models/StoreProduct.swift create mode 100644 Sources/Flare/Classes/Models/SubscriptionPeriod.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/FlareTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/FlareTests.xcscheme new file mode 100644 index 000000000..576a97b6c --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/FlareTests.xcscheme @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/Flare/Classes/Extensions/Formatters/NumberFormatter+.swift b/Sources/Flare/Classes/Extensions/Formatters/NumberFormatter+.swift new file mode 100644 index 000000000..c044972af --- /dev/null +++ b/Sources/Flare/Classes/Extensions/Formatters/NumberFormatter+.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension NumberFormatter { + static func numberFormatter(with locale: Locale) -> NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = locale + return formatter + } + + func numberFormatter(with currencyCode: String, locale: Locale = .autoupdatingCurrent) -> NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = locale + formatter.currencyCode = currencyCode + return formatter + } +} diff --git a/Sources/Flare/Classes/Extensions/Locale/Locale+CurrencyCode.swift b/Sources/Flare/Classes/Extensions/Locale/Locale+CurrencyCode.swift new file mode 100644 index 000000000..c54b4cccc --- /dev/null +++ b/Sources/Flare/Classes/Extensions/Locale/Locale+CurrencyCode.swift @@ -0,0 +1,20 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension Locale { + var currencyCodeID: String? { + #if swift(>=5.9) + if #available(macOS 13, iOS 16, tvOS 16, watchOS 9, visionOS 1.0, *) { + return self.currency?.identifier + } else { + return currencyCode + } + #else + return currencyCode + #endif + } +} diff --git a/Sources/Flare/Classes/Extensions/ProductType+.swift b/Sources/Flare/Classes/Extensions/ProductType+.swift new file mode 100644 index 000000000..73c936ca6 --- /dev/null +++ b/Sources/Flare/Classes/Extensions/ProductType+.swift @@ -0,0 +1,40 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension ProductType { + init(_ type: StoreKit.Product.ProductType) { + switch type { + case .consumable: + self = .consumable + case .nonConsumable: + self = .nonConsumable + case .nonRenewable: + self = .nonRenewableSubscription + case .autoRenewable: + self = .autoRenewableSubscription + default: + self = .nonConsumable + } + } +} + +extension ProductType { + var productCategory: ProductCategory { + switch self { + case .consumable: + return .nonSubscription + case .nonConsumable: + return .nonSubscription + case .nonRenewableSubscription: + return .subscription + case .autoRenewableSubscription: + return .subscription + } + } +} diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift new file mode 100644 index 000000000..825632ca4 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift @@ -0,0 +1,36 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// Protocol representing a Store Kit product. +protocol ISKProduct { + /// A localized description of the product. + var localizedDescription: String { get } + + /// A localized title or name of the product. + var localizedTitle: String { get } + + /// The currency code for the product's price. + var currencyCode: String? { get } + + /// The price of the product in decimal format. + var price: Decimal { get } + + /// A localized string representing the price of the product. + var localizedPriceString: String? { get } + + /// The unique identifier for the product. + var productIdentifier: String { get } + + /// The type of product (e.g., consumable, non-consumable). + var productType: ProductType? { get } + + /// The category to which the product belongs. + var productCategory: ProductCategory? { get } + + /// The subscription period for the product, if applicable. + var subscriptionPeriod: SubscriptionPeriod? { get } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift new file mode 100644 index 000000000..41f2ea759 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift @@ -0,0 +1,71 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - SK1StoreProduct + +final class SK1StoreProduct { + // MARK: Properties + + /// The store kit product. + private let product: SKProduct + /// The price formatter. + private lazy var numberFormatter: NumberFormatter = .numberFormatter(with: self.product.priceLocale) + + // MARK: Initialization + + init(_ product: SKProduct) { + self.product = product + } +} + +// MARK: ISKProduct + +extension SK1StoreProduct: ISKProduct { + var localizedDescription: String { + product.localizedDescription + } + + var localizedTitle: String { + product.localizedTitle + } + + var currencyCode: String? { + product.priceLocale.currencyCodeID + } + + var price: Decimal { + product.price as Decimal + } + + var localizedPriceString: String? { + numberFormatter.string(from: product.price) + } + + var productIdentifier: String { + product.productIdentifier + } + + var productType: ProductType? { + nil + } + + var productCategory: ProductCategory? { + guard #available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) else { + return .nonSubscription + } + return subscriptionPeriod == nil ? .nonSubscription : .subscription + } + + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + var subscriptionPeriod: SubscriptionPeriod? { + guard let subscriptionPeriod = product.subscriptionPeriod, subscriptionPeriod.numberOfUnits > 0 else { + return nil + } + return SubscriptionPeriod.from(subscriptionPeriod: subscriptionPeriod) + } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift new file mode 100644 index 000000000..d6be36907 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift @@ -0,0 +1,71 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - SK2StoreProduct + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +final class SK2StoreProduct { + // MARK: Properties + + /// The store kit product. + private let product: StoreKit.Product + /// The currency format. + private var currencyFormat: Decimal.FormatStyle.Currency { + product.priceFormatStyle + } + + // MARK: Initialization + + init(_ product: StoreKit.Product) { + self.product = product + } +} + +// MARK: ISKProduct + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension SK2StoreProduct: ISKProduct { + var localizedDescription: String { + product.description + } + + var localizedTitle: String { + product.displayName + } + + var currencyCode: String? { + currencyFormat.currencyCode + } + + var price: Decimal { + product.price + } + + var localizedPriceString: String? { + product.displayPrice + } + + var productIdentifier: String { + product.id + } + + var productType: ProductType? { + ProductType(product.type) + } + + var productCategory: ProductCategory? { + productType?.productCategory + } + + var subscriptionPeriod: SubscriptionPeriod? { + guard let subscriptionPeriod = product.subscription?.subscriptionPeriod else { + return nil + } + return SubscriptionPeriod.from(subscriptionPeriod: subscriptionPeriod) + } +} diff --git a/Sources/Flare/Classes/Models/ProductCategory.swift b/Sources/Flare/Classes/Models/ProductCategory.swift new file mode 100644 index 000000000..b9522c35b --- /dev/null +++ b/Sources/Flare/Classes/Models/ProductCategory.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +public enum ProductCategory: Int { + /// A non-renewable or auto-renewable subscription. + case subscription + + /// A consumable or non-consumable in-app purchase. + case nonSubscription +} diff --git a/Sources/Flare/Classes/Models/ProductType.swift b/Sources/Flare/Classes/Models/ProductType.swift new file mode 100644 index 000000000..482b86073 --- /dev/null +++ b/Sources/Flare/Classes/Models/ProductType.swift @@ -0,0 +1,21 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// The type of product, equivalent to StoreKit's `Product.ProductType`. +public enum ProductType: Int { + /// A consumable in-app purchase. + case consumable + + /// A non-consumable in-app purchase. + case nonConsumable + + /// A non-renewing subscription. + case nonRenewableSubscription + + /// An auto-renewable subscription. + case autoRenewableSubscription +} diff --git a/Sources/Flare/Classes/Models/StoreProduct.swift b/Sources/Flare/Classes/Models/StoreProduct.swift new file mode 100644 index 000000000..fd01218bf --- /dev/null +++ b/Sources/Flare/Classes/Models/StoreProduct.swift @@ -0,0 +1,74 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - StoreProduct + +public final class StoreProduct: NSObject { + // MARK: Properties + + private let product: ISKProduct + + // MARK: Initialization + + private init(_ product: ISKProduct) { + self.product = product + } +} + +// MARK: - Convinience Initializators + +public extension StoreProduct { + convenience init(skProduct: SKProduct) { + self.init(SK1StoreProduct(skProduct)) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + convenience init(product: StoreKit.Product) { + self.init(SK2StoreProduct(product)) + } +} + +// MARK: ISKProduct + +extension StoreProduct: ISKProduct { + var localizedDescription: String { + product.localizedDescription + } + + var localizedTitle: String { + product.localizedTitle + } + + var currencyCode: String? { + product.currencyCode + } + + var price: Decimal { + product.price + } + + var localizedPriceString: String? { + product.localizedPriceString + } + + var productIdentifier: String { + product.productIdentifier + } + + var productType: ProductType? { + product.productType + } + + var productCategory: ProductCategory? { + product.productCategory + } + + var subscriptionPeriod: SubscriptionPeriod? { + product.subscriptionPeriod + } +} diff --git a/Sources/Flare/Classes/Models/SubscriptionPeriod.swift b/Sources/Flare/Classes/Models/SubscriptionPeriod.swift new file mode 100644 index 000000000..9dedab4cb --- /dev/null +++ b/Sources/Flare/Classes/Models/SubscriptionPeriod.swift @@ -0,0 +1,100 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - SubscriptionPeriod + +// A class representing a subscription period with a specific value and unit. +public final class SubscriptionPeriod: NSObject { + // MARK: Types + + public enum Unit: Int { + /// A subscription period unit of a day. + case day = 0 + /// A subscription period unit of a week. + case week = 1 + /// A subscription period unit of a month. + case month = 2 + /// A subscription period unit of a year. + case year = 3 + } + + // MARK: Properties + + /// The numeric value of the subscription period. + public let value: Int + /// The unit of the subscription period (day, week, month, year). + public let unit: Unit + + // MARK: Initialization + + /// Initializes a new `SubscriptionPeriod` instance. + /// + /// - Parameters: + /// - value: The numeric value of the subscription period. + /// - unit: The unit of the subscription period. + public init(value: Int, unit: Unit) { + self.value = value + self.unit = unit + } +} + +// MARK: - Helpers + +extension SubscriptionPeriod { + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + static func from(subscriptionPeriod: SKProductSubscriptionPeriod) -> SubscriptionPeriod? { + guard let unit = SubscriptionPeriod.Unit.from(unit: subscriptionPeriod.unit) else { + return nil + } + return SubscriptionPeriod(value: subscriptionPeriod.numberOfUnits, unit: unit) + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8, *) + static func from(subscriptionPeriod: StoreKit.Product.SubscriptionPeriod) -> SubscriptionPeriod? { + guard let unit = SubscriptionPeriod.Unit.from(unit: subscriptionPeriod.unit) else { + return nil + } + return SubscriptionPeriod(value: subscriptionPeriod.value, unit: unit) + } +} + +// MARK: - Extensions + +private extension SubscriptionPeriod.Unit { + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + static func from(unit: SKProduct.PeriodUnit) -> Self? { + switch unit { + case .day: + return .day + case .week: + return .week + case .month: + return .month + case .year: + return .year + @unknown default: + return nil + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8, *) + static func from(unit: StoreKit.Product.SubscriptionPeriod.Unit) -> Self? { + switch unit { + case .day: + return .day + case .week: + return .week + case .month: + return .month + case .year: + return .year + @unknown default: + return nil + } + } +} From 42e3584ed27c6bea8a5614437e51ce33df2dac79 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Mon, 18 Dec 2023 15:06:34 +0100 Subject: [PATCH 02/27] Implement fetching products using the `StoreKit2` API Currently, a `ProductProvider` provides the ability to fetch products via the new `StoreKit` API. If the current OS version is higher than iOS 15, tvOS 15, watchOS 8, macOS 12, the new API can be used when calling `fetch(_:) async throws -> [StoreProduct]`; otherwise, the old API will be called. Additionally, this commit includes: - Remove unnecessary access control modifiers - Write code comments and provide usage examples --- .../xcshareddata/xcschemes/Flare.xcscheme | 3 + Package.swift | 3 + Package@swift-5.7.swift | 3 + .../Classes/Helpers/Async/AsyncHandler.swift | 22 ++++ .../Models/Internal/SK1StoreProduct.swift | 3 +- .../Flare/Classes/Models/StoreProduct.swift | 13 ++- .../Providers/IAPProvider/IAPProvider.swift | 46 ++++++-- .../Providers/IAPProvider/IIAPProvider.swift | 4 +- .../PaymentProvider/IPaymentProvider.swift | 2 +- .../PaymentProvider/PaymentProvider.swift | 13 +++ .../ProductProvider/IProductProvider.swift | 24 ++-- .../ProductProvider/ProductProvider.swift | 41 ++++++- .../IReceiptRefreshProvider.swift | 2 +- Sources/Flare/Flare.swift | 10 +- Sources/Flare/IFlare.swift | 4 +- Tests/FlareTests/Flare.storekit | 106 ++++++++++++++++++ .../Helpers/AvailabilityChecker.swift | 14 +++ .../Helpers/ProductProviderHelper.swift | 42 +++++++ .../Helpers/TestCase/IAPTestCase.swift | 44 ++++++++ .../Helpers/TestCase/ISKTestSession.swift | 15 +++ Tests/FlareTests/Mocks/IAPProviderMock.swift | 10 +- .../Mocks/ProductProviderMock.swift | 31 ++++- Tests/FlareTests/UnitTests/FlareTests.swift | 6 +- .../Providers/IAPProviderTests.swift | 37 ++++-- .../Providers/ProductProviderTests.swift | 8 +- 25 files changed, 460 insertions(+), 46 deletions(-) create mode 100644 Sources/Flare/Classes/Helpers/Async/AsyncHandler.swift create mode 100644 Tests/FlareTests/Flare.storekit create mode 100644 Tests/FlareTests/Helpers/AvailabilityChecker.swift create mode 100644 Tests/FlareTests/Helpers/ProductProviderHelper.swift create mode 100644 Tests/FlareTests/Helpers/TestCase/IAPTestCase.swift create mode 100644 Tests/FlareTests/Helpers/TestCase/ISKTestSession.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme index 46bcfc41a..fe0e9794c 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme @@ -75,6 +75,9 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> + + ( + completion: @escaping (Result) -> Void, + asyncMethod method: @escaping () async throws -> T + ) { + _ = Task { + do { + try completion(.success(await method())) + } catch { + completion(.failure(error)) + } + } + } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift index 41f2ea759..92d48806c 100644 --- a/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift @@ -12,7 +12,8 @@ final class SK1StoreProduct { // MARK: Properties /// The store kit product. - private let product: SKProduct + let product: SKProduct + /// The price formatter. private lazy var numberFormatter: NumberFormatter = .numberFormatter(with: self.product.priceLocale) diff --git a/Sources/Flare/Classes/Models/StoreProduct.swift b/Sources/Flare/Classes/Models/StoreProduct.swift index fd01218bf..402de3537 100644 --- a/Sources/Flare/Classes/Models/StoreProduct.swift +++ b/Sources/Flare/Classes/Models/StoreProduct.swift @@ -8,14 +8,19 @@ import StoreKit // MARK: - StoreProduct +/// An object represents a StoreKit product. public final class StoreProduct: NSObject { // MARK: Properties + /// Protocol representing a Store Kit product. private let product: ISKProduct // MARK: Initialization - private init(_ product: ISKProduct) { + /// Creates a new `StoreProduct` instance. + /// + /// - Parameter product: The StoreKit product. + init(_ product: ISKProduct) { self.product = product } } @@ -23,10 +28,16 @@ public final class StoreProduct: NSObject { // MARK: - Convinience Initializators public extension StoreProduct { + /// Creates a new `StoreProduct` instance. + /// + /// - Parameter skProduct: The StoreKit product. convenience init(skProduct: SKProduct) { self.init(SK1StoreProduct(skProduct)) } + /// Creates a new `StoreProduct` instance. + /// + /// - Parameter product: The StoreKit product. @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) convenience init(product: StoreKit.Product) { self.init(SK2StoreProduct(product)) diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index 85fc9be86..a9d953058 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -38,15 +38,27 @@ final class IAPProvider: IIAPProvider { paymentQueue.canMakePayments } - func fetch(productIDs: Set, completion: @escaping Closure>) { - productProvider.fetch( - productIDs: productIDs, - requestID: UUID().uuidString, - completion: completion - ) + func fetch(productIDs: Set, completion: @escaping Closure>) { + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + AsyncHandler.call( + completion: { [weak self] result in + self?.handleFetchResult(result: result, completion) + }, + asyncMethod: { + try await self.productProvider.fetch(productIDs: productIDs) + } + ) + } else { + productProvider.fetch( + productIDs: productIDs, + requestID: UUID().uuidString + ) { [weak self] result in + self?.handleFetchResult(result: result, completion) + } + } } - func fetch(productIDs: Set) async throws -> [SKProduct] { + func fetch(productIDs: Set) async throws -> [StoreProduct] { try await withCheckedThrowingContinuation { continuation in self.fetch(productIDs: productIDs) { result in continuation.resume(with: result) @@ -63,7 +75,7 @@ final class IAPProvider: IIAPProvider { return } - let payment = SKPayment(product: product) + let payment = SKPayment(product: product.product) self.paymentProvider.add(payment: payment) { _, result in switch result { @@ -139,4 +151,22 @@ final class IAPProvider: IIAPProvider { try await refundProvider.beginRefundRequest(productID: productID) } #endif + + // MARK: Private + + private func handleFetchResult( + result: Result<[T], E>, + _ completion: @escaping (Result<[StoreProduct], IAPError>) -> Void + ) { + switch result { + case let .success(products): + completion(.success(products.map { StoreProduct($0) })) + case let .failure(error): + if let iapError = error as? IAPError { + completion(.failure(iapError)) + } else { + completion(.failure(IAPError(error: error))) + } + } + } } diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift index 45d08b835..479dcd96c 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift @@ -15,7 +15,7 @@ public protocol IIAPProvider { /// - Parameters: /// - productIDs: The list of product identifiers for which you wish to retrieve descriptions. /// - completion: The completion containing the response of retrieving products. - func fetch(productIDs: Set, completion: @escaping Closure>) + func fetch(productIDs: Set, completion: @escaping Closure>) /// Retrieves localized information from the App Store about a specified list of products. /// @@ -24,7 +24,7 @@ public protocol IIAPProvider { /// - Throws: `IAPError(error:)` if the request did fail with error. /// /// - Returns: An array of products. - func fetch(productIDs: Set) async throws -> [SKProduct] + func fetch(productIDs: Set) async throws -> [StoreProduct] /// Performs a purchase of a product with a given ID. /// diff --git a/Sources/Flare/Classes/Providers/PaymentProvider/IPaymentProvider.swift b/Sources/Flare/Classes/Providers/PaymentProvider/IPaymentProvider.swift index 2d511767d..dcc812907 100644 --- a/Sources/Flare/Classes/Providers/PaymentProvider/IPaymentProvider.swift +++ b/Sources/Flare/Classes/Providers/PaymentProvider/IPaymentProvider.swift @@ -6,7 +6,7 @@ import StoreKit /// Type that provides payment functionality. -public protocol IPaymentProvider: AnyObject { +protocol IPaymentProvider: AnyObject { /// False if this device is not able or allowed to make payments var canMakePayments: Bool { get } diff --git a/Sources/Flare/Classes/Providers/PaymentProvider/PaymentProvider.swift b/Sources/Flare/Classes/Providers/PaymentProvider/PaymentProvider.swift index aacf89156..3261143d0 100644 --- a/Sources/Flare/Classes/Providers/PaymentProvider/PaymentProvider.swift +++ b/Sources/Flare/Classes/Providers/PaymentProvider/PaymentProvider.swift @@ -8,20 +8,33 @@ import StoreKit // MARK: - PaymentProvider +/// A class provides functionality to make payments. final class PaymentProvider: NSObject { // MARK: Properties + /// The queue of payment transactions to be processed by the App Store. private let paymentQueue: PaymentQueue + /// Dictionary to store payment handlers associated with transaction identifiers. private var paymentHandlers: [String: [PaymentHandler]] = [:] + /// Array to store restore handlers for completed transactions. private var restoreHandlers: [RestoreHandler] = [] + /// Optional fallback handler for handling payments if no specific handler is found. private var fallbackHandler: PaymentHandler? + /// Optional handler to determine whether to add a payment to the App Store. private var shouldAddStorePaymentHandler: ShouldAddStorePaymentHandler? + /// The dispatch queue factory for handling concurrent tasks. private var dispatchQueueFactory: IDispatchQueueFactory + /// Lazy-initialized private dispatch queue for handling tasks related to payment processing. private lazy var privateQueue: IDispatchQueue = dispatchQueueFactory.privateQueue(label: String(describing: self)) // MARK: Initialization + /// Creates a new `PaymentProvider` instance. + /// + /// - Parameters: + /// - paymentQueue: The queue of payment transactions to be processed by the App Store. + /// - dispatchQueueFactory: The dispatch queue factory. init( paymentQueue: PaymentQueue = SKPaymentQueue.default(), dispatchQueueFactory: IDispatchQueueFactory = DispatchQueueFactory() diff --git a/Sources/Flare/Classes/Providers/ProductProvider/IProductProvider.swift b/Sources/Flare/Classes/Providers/ProductProvider/IProductProvider.swift index f9e5376ba..156263321 100644 --- a/Sources/Flare/Classes/Providers/ProductProvider/IProductProvider.swift +++ b/Sources/Flare/Classes/Providers/ProductProvider/IProductProvider.swift @@ -5,16 +5,16 @@ import StoreKit -public typealias ProductHandler = (_ result: Result<[SKProduct], IAPError>) -> Void -public typealias PaymentHandler = (_ queue: PaymentQueue, _ result: Result) -> Void -public typealias RestoreHandler = (_ queue: SKPaymentQueue, _ error: IAPError?) -> Void -public typealias ShouldAddStorePaymentHandler = (_ queue: SKPaymentQueue, _ payment: SKPayment, _ product: SKProduct) -> Bool -public typealias ReceiptRefreshHandler = (Result) -> Void +typealias PaymentHandler = (_ queue: PaymentQueue, _ result: Result) -> Void +typealias RestoreHandler = (_ queue: SKPaymentQueue, _ error: IAPError?) -> Void +typealias ShouldAddStorePaymentHandler = (_ queue: SKPaymentQueue, _ payment: SKPayment, _ product: SKProduct) -> Bool +typealias ReceiptRefreshHandler = (Result) -> Void // MARK: - IProductProvider -public protocol IProductProvider { - typealias ProductsHandler = Closure> +/// A type that is responsible for retrieving StoreKit products. +protocol IProductProvider { + typealias ProductsHandler = Closure> /// Retrieves localized information from the App Store about a specified list of products. /// @@ -23,4 +23,14 @@ public protocol IProductProvider { /// - requestID: The request identifier. /// - completion: The completion containing the response of retrieving products. func fetch(productIDs: Set, requestID: String, completion: @escaping ProductsHandler) + + /// Retrieves localized information from the App Store about a specified list of products. + /// + /// - Note:This method utilizes the new `StoreKit2` API. + /// + /// - Parameter productIDs: The list of product identifiers for which you wish to retrieve descriptions. + /// + /// - Returns: The requested products. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func fetch(productIDs: Set) async throws -> [SK2StoreProduct] } diff --git a/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift b/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift index c9f3dc6b1..183741bbb 100644 --- a/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift +++ b/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift @@ -8,9 +8,29 @@ import StoreKit // MARK: - ProductProvider +/// A class is responsible for fetching StoreKit products. +/// +/// This implementation supports two ways of fetching products using the old way API and the new StoreKit2 API. +/// +/// Example: +/// +/// ``` +/// let productProvider = ProductProvider() +/// productProvider.fetch(productIDs: ["productID"], requestID: UUID().uuidString) { result in +/// switch result { +/// case let .success(products): +/// // The `products` array contains all fetched products with the given IDs. +/// case let .failure(error): +/// // An error occurred; you can handle it here. +/// } +/// } +/// ``` final class ProductProvider: NSObject, IProductProvider { // MARK: Lifecycle + /// Creates a new `ProductProvider` instance. + /// + /// - Parameter dispatchQueueFactory: The dispatch queue factory. init(dispatchQueueFactory: IDispatchQueueFactory = DispatchQueueFactory()) { self.dispatchQueueFactory = dispatchQueueFactory } @@ -22,13 +42,27 @@ final class ProductProvider: NSObject, IProductProvider { fetch(request: request, completion: completion) } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func fetch(productIDs ids: Set) async throws -> [SK2StoreProduct] { + try await StoreKit.Product.products(for: ids).map(SK2StoreProduct.init) + } + // MARK: Private + /// Dictionary to store request handlers with their corresponding request IDs. private var handlers: [String: ProductsHandler] = [:] + /// The dispatch queue factory for handling concurrent tasks. private let dispatchQueueFactory: IDispatchQueueFactory + /// Lazy-initialized private dispatch queue for handling tasks related to product fetching. private lazy var dispatchQueue: IDispatchQueue = dispatchQueueFactory.privateQueue(label: String(describing: self)) + /// Creates a StoreKit product request with the specified product IDs and request ID. + /// + /// - Parameters: + /// - ids: The set of product IDs to include in the request. + /// - requestID: The identifier for the request. + /// - Returns: An instance of `SKProductsRequest`. private func makeRequest(ids: Set, requestID: String) -> SKProductsRequest { let request = SKProductsRequest(productIdentifiers: ids) request.id = requestID @@ -36,6 +70,11 @@ final class ProductProvider: NSObject, IProductProvider { return request } + /// Initiates the product fetch request and handles the associated completion closure. + /// + /// - Parameters: + /// - request: The `SKProductsRequest` to be initiated. + /// - completion: A closure to be called upon completion with the fetched products. private func fetch(request: SKProductsRequest, completion: @escaping ProductsHandler) { dispatchQueue.async { self.handlers[request.id] = completion @@ -70,7 +109,7 @@ extension ProductProvider: SKProductsRequestDelegate { } self.dispatchQueueFactory.main().async { - handler?(.success(response.products)) + handler?(.success(response.products.map { SK1StoreProduct($0) })) } } } diff --git a/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/IReceiptRefreshProvider.swift b/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/IReceiptRefreshProvider.swift index 44c9c1c9a..9344e93ff 100644 --- a/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/IReceiptRefreshProvider.swift +++ b/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/IReceiptRefreshProvider.swift @@ -6,7 +6,7 @@ import StoreKit /// A type that can refresh the bundle's App Store receipt. -public protocol IReceiptRefreshProvider { +protocol IReceiptRefreshProvider { /// The bundle’s App Store receipt. var receipt: String? { get } diff --git a/Sources/Flare/Flare.swift b/Sources/Flare/Flare.swift index a4505be9b..a0a1ad2e0 100644 --- a/Sources/Flare/Flare.swift +++ b/Sources/Flare/Flare.swift @@ -11,30 +11,36 @@ import StoreKit // MARK: - Flare +/// The class creates and manages in-app purchases. public final class Flare { // MARK: Initialization + /// Creates a new `Flare` instance. + /// + /// - Parameter iapProvider: The in-app purchase provider. init(iapProvider: IIAPProvider = IAPProvider()) { self.iapProvider = iapProvider } // MARK: Public + /// Returns a default `Flare` object. public static let `default`: IFlare = Flare() // MARK: Private + /// The in-app purchase provider. private let iapProvider: IIAPProvider } // MARK: IFlare extension Flare: IFlare { - public func fetch(productIDs: Set, completion: @escaping Closure>) { + public func fetch(productIDs: Set, completion: @escaping Closure>) { iapProvider.fetch(productIDs: productIDs, completion: completion) } - public func fetch(productIDs: Set) async throws -> [SKProduct] { + public func fetch(productIDs: Set) async throws -> [StoreProduct] { try await iapProvider.fetch(productIDs: productIDs) } diff --git a/Sources/Flare/IFlare.swift b/Sources/Flare/IFlare.swift index 6d6a146b7..972dd4eea 100644 --- a/Sources/Flare/IFlare.swift +++ b/Sources/Flare/IFlare.swift @@ -13,7 +13,7 @@ public protocol IFlare { /// - Parameters: /// - productIDs: The list of product identifiers for which you wish to retrieve descriptions. /// - completion: The completion containing the response of retrieving products. - func fetch(productIDs: Set, completion: @escaping Closure>) + func fetch(productIDs: Set, completion: @escaping Closure>) /// Retrieves localized information from the App Store about a specified list of products. /// @@ -22,7 +22,7 @@ public protocol IFlare { /// - Throws: `IAPError(error:)` if the request did fail with error. /// /// - Returns: An array of products. - func fetch(productIDs: Set) async throws -> [SKProduct] + func fetch(productIDs: Set) async throws -> [StoreProduct] /// Performs a purchase of a product with a given ID. /// diff --git a/Tests/FlareTests/Flare.storekit b/Tests/FlareTests/Flare.storekit new file mode 100644 index 000000000..22ccc94e1 --- /dev/null +++ b/Tests/FlareTests/Flare.storekit @@ -0,0 +1,106 @@ +{ + "identifier" : "8783124E", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "630EB7DE", + "localizations" : [ + { + "description" : "Temp Subscription", + "displayName" : "Temp Subscription", + "locale" : "en_US" + } + ], + "productID" : "com.flare.test_purchase_1", + "referenceName" : "TestPurchase1", + "type" : "Consumable" + }, + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "17CD4A2E", + "localizations" : [ + { + "description" : "Temp Subscription", + "displayName" : "Temp Subscription", + "locale" : "en_US" + } + ], + "productID" : "com.flare.test_purchase_2", + "referenceName" : "TestPurchase2", + "type" : "Consumable" + } + ], + "settings" : { + + }, + "subscriptionGroups" : [ + { + "id" : "791CA910", + "localizations" : [ + + ], + "name" : "flare_subscription", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "E6933AB5", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "com.flare.test_subscription_1", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "TestSubscription1", + "subscriptionGroupID" : "791CA910", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "1.29", + "familyShareable" : true, + "groupNumber" : 1, + "internalID" : "1F6680BD", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "com.flare.test_subscription_2", + "recurringSubscriptionPeriod" : "P3M", + "referenceName" : "TestSubscription2", + "subscriptionGroupID" : "791CA910", + "type" : "RecurringSubscription" + } + ] + } + ], + "version" : { + "major" : 2, + "minor" : 0 + } +} diff --git a/Tests/FlareTests/Helpers/AvailabilityChecker.swift b/Tests/FlareTests/Helpers/AvailabilityChecker.swift new file mode 100644 index 000000000..7cec99218 --- /dev/null +++ b/Tests/FlareTests/Helpers/AvailabilityChecker.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import XCTest + +enum AvailabilityChecker { + static func iOS15APINotAvailableOrSkipTest() throws { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + throw XCTSkip("Test only for older devices") + } + } +} diff --git a/Tests/FlareTests/Helpers/ProductProviderHelper.swift b/Tests/FlareTests/Helpers/ProductProviderHelper.swift new file mode 100644 index 000000000..2289ed7b2 --- /dev/null +++ b/Tests/FlareTests/Helpers/ProductProviderHelper.swift @@ -0,0 +1,42 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import StoreKit + +// MARK: - ProductProviderHelper + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +enum ProductProviderHelper { + static var purchases: [StoreKit.Product] { + get async throws { + try await StoreKit.Product.products(for: [.testPurchase1ID, .testPurchase2ID]) + } + } + + static var subscriptions: [StoreKit.Product] { + get async throws { + try await StoreKit.Product.products(for: [.testSubscription1ID, .testSubscription2ID]) + } + } + + static var all: [StoreKit.Product] { + get async throws { + let purchases = try await self.purchases + let subscriptions = try await self.subscriptions + + return purchases + subscriptions + } + } +} + +// MARK: - Constants + +private extension String { + static let testPurchase1ID = "com.flare.test_purchase_1" + static let testPurchase2ID = "com.flare.test_purchase_2" + + static let testSubscription1ID = "com.flare.test_subscription_1" + static let testSubscription2ID = "com.flare.test_subscription_2" +} diff --git a/Tests/FlareTests/Helpers/TestCase/IAPTestCase.swift b/Tests/FlareTests/Helpers/TestCase/IAPTestCase.swift new file mode 100644 index 000000000..84d48042f --- /dev/null +++ b/Tests/FlareTests/Helpers/TestCase/IAPTestCase.swift @@ -0,0 +1,44 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import StoreKitTest +import XCTest + +// MARK: - IAPTestCase + +class IAPTestCase: XCTestCase { + // MARK: Private + + private var session: ISKTestSession? + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + configureTestSession() + } + + override func tearDown() { + session = nil + super.tearDown() + } + + // MARK: Private + + private func configureTestSession() { + if #available(macOS 11, iOS 14, tvOS 14, watchOS 7, visionOS 1.0, *) { + if let url = Bundle.module.url(forResource: .resourceName, withExtension: .ext) { + session = try? SKTestSession(contentsOf: url) + } + } + } +} + +// MARK: - Constants + +private extension String { + static let resourceName = "Flare" + static let ext = "storekit" +} diff --git a/Tests/FlareTests/Helpers/TestCase/ISKTestSession.swift b/Tests/FlareTests/Helpers/TestCase/ISKTestSession.swift new file mode 100644 index 000000000..57fc74cbb --- /dev/null +++ b/Tests/FlareTests/Helpers/TestCase/ISKTestSession.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import StoreKitTest + +// MARK: - ISKTestSession + +protocol ISKTestSession {} + +// MARK: - SKTestSession + ISKTestSession + +@available(macOS 11, iOS 14, tvOS 14, watchOS 7, visionOS 1.0, *) +extension SKTestSession: ISKTestSession {} diff --git a/Tests/FlareTests/Mocks/IAPProviderMock.swift b/Tests/FlareTests/Mocks/IAPProviderMock.swift index 5b97f58eb..6a7cd8702 100644 --- a/Tests/FlareTests/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/Mocks/IAPProviderMock.swift @@ -19,10 +19,10 @@ final class IAPProviderMock: IIAPProvider { var invokedFetch = false var invokedFetchCount = 0 - var invokedFetchParameters: (productIDs: Set, completion: Closure>)? - var invokedFetchParametersList = [(productIDs: Set, completion: Closure>)]() + var invokedFetchParameters: (productIDs: Set, completion: Closure>)? + var invokedFetchParametersList = [(productIDs: Set, completion: Closure>)]() - func fetch(productIDs: Set, completion: @escaping Closure>) { + func fetch(productIDs: Set, completion: @escaping Closure>) { invokedFetch = true invokedFetchCount += 1 invokedFetchParameters = (productIDs, completion) @@ -94,9 +94,9 @@ final class IAPProviderMock: IIAPProvider { var invokedFetchAsyncCount = 0 var invokedFetchAsyncParameters: (productIDs: Set, Void)? var invokedFetchAsyncParametersList = [(productIDs: Set, Void)]() - var fetchAsyncResult: [SKProduct] = [] + var fetchAsyncResult: [StoreProduct] = [] - func fetch(productIDs: Set) async throws -> [SKProduct] { + func fetch(productIDs: Set) async throws -> [StoreProduct] { invokedFetchAsync = true invokedFetchAsyncCount += 1 invokedFetchAsyncParameters = (productIDs, ()) diff --git a/Tests/FlareTests/Mocks/ProductProviderMock.swift b/Tests/FlareTests/Mocks/ProductProviderMock.swift index 5480426d4..8b83b0254 100644 --- a/Tests/FlareTests/Mocks/ProductProviderMock.swift +++ b/Tests/FlareTests/Mocks/ProductProviderMock.swift @@ -4,14 +4,14 @@ // @testable import Flare -import class StoreKit.SKProduct +import StoreKit final class ProductProviderMock: IProductProvider { var invokedFetch = false var invokedFetchCount = 0 var invokedFetchParameters: (productIDs: Set, requestID: String, completion: ProductsHandler)? var invokedFetchParamtersList = [(productIDs: Set, requestID: String, completion: ProductsHandler)]() - var stubbedFetchResult: Result<[SKProduct], IAPError>? + var stubbedFetchResult: Result<[SK1StoreProduct], IAPError>? func fetch(productIDs: Set, requestID: String, completion: @escaping ProductsHandler) { invokedFetch = true @@ -23,4 +23,31 @@ final class ProductProviderMock: IProductProvider { completion(result) } } + + var invokedAsyncFetch = false + var invokedAsyncFetchCount = 0 + var invokedAsyncFetchParameters: (productIDs: Set, Void)? + var invokedAsyncFetchParamtersList = [(productIDs: Set, Void)]() + var stubbedAsyncFetchResult: Result<[ISKProduct], IAPError>? + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func fetch(productIDs: Set) async throws -> [SK2StoreProduct] { + invokedAsyncFetch = true + invokedAsyncFetchCount += 1 + invokedAsyncFetchParameters = (productIDs, ()) + invokedAsyncFetchParamtersList.append((productIDs, ())) + + switch stubbedAsyncFetchResult { + case let .success(products): + if let products = products as? [SK2StoreProduct] { + return products + } else { + return [] + } + case let .failure(error): + throw error + default: + return [] + } + } } diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 87aed5ebe..91f7bfc58 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -41,7 +41,11 @@ class FlareTests: XCTestCase { func test_thatFlareFetchesProductsWithGivenProductIDs() async throws { // given - let productMocks = [ProductMock(), ProductMock(), ProductMock()] + let productMocks = [ + StoreProduct(skProduct: ProductMock()), + StoreProduct(skProduct: ProductMock()), + StoreProduct(skProduct: ProductMock()), + ] iapProviderMock.fetchAsyncResult = productMocks // when diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index 06c322ac5..64b2bcbc7 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -5,11 +5,12 @@ @testable import Flare import StoreKit +import StoreKitTest import XCTest // MARK: - IAPProviderTests -class IAPProviderTests: XCTestCase { +class IAPProviderTests: IAPTestCase { // MARK: - Properties private var paymentQueueMock: PaymentQueueMock! @@ -59,6 +60,8 @@ class IAPProviderTests: XCTestCase { } func test_thatIAPProviderFetchesProducts() throws { + try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() + // when iapProvider.fetch(productIDs: .productIDs, completion: { _ in }) @@ -112,19 +115,37 @@ class IAPProviderTests: XCTestCase { XCTAssertTrue(paymentProviderMock.invokedRemoveTransactionObserver) } - func test_thatIAPProviderFetchesProducts_whenProducts() async throws { + // FIXME: Update test + func test_thatIAPProviderFetchesSK1Products_whenProductsAvailable() async throws { + try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() + // given - let productsMock = [SKProduct(), SKProduct(), SKProduct()] + let productsMock = [0 ... 2].map { _ in SK1StoreProduct(ProductMock()) } productProviderMock.stubbedFetchResult = .success(productsMock) // when let products = try await iapProvider.fetch(productIDs: .productIDs) // then - XCTAssertEqual(productsMock, products) + XCTAssertEqual(productsMock.count, products.count) } - func test_thatIAPProviderThrowsNoProductsError_whenProductsProductProviderReturnsError() async { + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_thatIAPProviderFetchesSK2Products_whenProductsAvailable() async throws { + // given + let productsMock = try await ProductProviderHelper.all.map(SK2StoreProduct.init) + productProviderMock.stubbedAsyncFetchResult = .success(productsMock) + + // when + let products = try await iapProvider.fetch(productIDs: .productIDs) + + // then + XCTAssertEqual(productsMock.count, products.count) + } + + func test_thatIAPProviderThrowsNoProductsError_whenProductsProductProviderReturnsError() async throws { + try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() + // given productProviderMock.stubbedFetchResult = .failure(IAPError.unknown) @@ -159,7 +180,7 @@ class IAPProviderTests: XCTestCase { func test_thatIAPProviderReturnsPaymentTransaction_whenProductsExist() { // given let paymentTransactionMock = PaymentTransactionMock() - productProviderMock.stubbedFetchResult = .success([ProductMock()]) + productProviderMock.stubbedFetchResult = .success([SK1StoreProduct(ProductMock())]) paymentProviderMock.stubbedAddResult = (paymentQueueMock, .success(paymentTransactionMock)) // when @@ -176,7 +197,7 @@ class IAPProviderTests: XCTestCase { func test_thatIAPProviderReturnsError_whenAddingPaymentFailed() { // given - productProviderMock.stubbedFetchResult = .success([ProductMock()]) + productProviderMock.stubbedFetchResult = .success([SK1StoreProduct(ProductMock())]) paymentProviderMock.stubbedAddResult = (paymentQueueMock, .failure(.unknown)) // when @@ -226,7 +247,7 @@ class IAPProviderTests: XCTestCase { func test_thatIAPProviderPurchasesForAProduct_whenProductsExist() async throws { // given let transactionMock = SKPaymentTransaction() - productProviderMock.stubbedFetchResult = .success([ProductMock()]) + productProviderMock.stubbedFetchResult = .success([SK1StoreProduct(ProductMock())]) paymentProviderMock.stubbedAddResult = (paymentQueueMock, .success(transactionMock)) // when diff --git a/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift index c2f1ada71..c3655b4d1 100644 --- a/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift @@ -38,7 +38,7 @@ class ProductProviderTests: XCTestCase { func test_thatProductProviderReturnsInvalidProductIDs_whenRequestProductsWithInvalidIDs() { // given - var fetchResult: Result<[SKProduct], IAPError>? + var fetchResult: Result<[SK1StoreProduct], IAPError>? let completionHandler: IProductProvider.ProductsHandler = { result in fetchResult = result } let request = PurchaseManagerTestHelper.makeRequest(with: .requestID) let response = ProductResponseMock() @@ -59,7 +59,7 @@ class ProductProviderTests: XCTestCase { func test_thatProductProviderReturnsProducts_whenRequestProductsWithValidProductIDs() { // given - var fetchResult: Result<[SKProduct], IAPError>? + var fetchResult: Result<[SK1StoreProduct], IAPError>? let completionHandler: IProductProvider.ProductsHandler = { result in fetchResult = result } let request = PurchaseManagerTestHelper.makeRequest(with: .requestID) let response = ProductResponseMock() @@ -70,7 +70,7 @@ class ProductProviderTests: XCTestCase { // then if case let .success(products) = fetchResult { - XCTAssertEqual(products, response.products) + XCTAssertEqual(products.map(\.product), response.products) } else { XCTFail() } @@ -78,7 +78,7 @@ class ProductProviderTests: XCTestCase { func test_thatProductProviderHandlesError_whenRequestDidFailWithError() { // given - var fetchResult: Result<[SKProduct], IAPError>? + var fetchResult: Result<[SK1StoreProduct], IAPError>? let completionHandler: IProductProvider.ProductsHandler = { result in fetchResult = result } let request = PurchaseManagerTestHelper.makeRequest(with: .requestID) let error = IAPError.emptyProducts From 7754b06003fd5c339b35dcf865ad3ae550ca966d Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Mon, 18 Dec 2023 15:28:24 +0100 Subject: [PATCH 03/27] Write comments for public interfaces - Write comments for the `IAPProvider` - Write comments for the `ReceiptRefreshProvider` --- .../Providers/IAPProvider/IAPProvider.swift | 14 +++++++++++ .../ReceiptRefreshProvider.swift | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index a9d953058..f4bdb37a7 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -5,17 +5,31 @@ import StoreKit +/// A class that provides in-app purchase functionality. final class IAPProvider: IIAPProvider { // MARK: Properties + /// The queue of payment transactions to be processed by the App Store. private let paymentQueue: PaymentQueue + /// The provider is responsible for fetching StoreKit products. private let productProvider: IProductProvider + /// The provider is responsible for making in-app payments. private let paymentProvider: IPaymentProvider + /// The provider is responsible for refreshing receipts. private let receiptRefreshProvider: IReceiptRefreshProvider + /// The provider is responsible for refunding purchases private let refundProvider: IRefundProvider // MARK: Initialization + /// Creates a new `IAPProvider` instance. + /// + /// - Parameters: + /// - paymentQueue: The queue of payment transactions to be processed by the App Store. + /// - productProvider: The provider is responsible for fetching StoreKit products. + /// - paymentProvider: The provider is responsible for making in-app payments. + /// - receiptRefreshProvider: The provider is responsible for refreshing receipts. + /// - refundProvider: The provider is responsible for refunding purchases. init( paymentQueue: PaymentQueue = SKPaymentQueue.default(), productProvider: IProductProvider = ProductProvider(), diff --git a/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/ReceiptRefreshProvider.swift b/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/ReceiptRefreshProvider.swift index fcfdc6fd0..5585f0b34 100644 --- a/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/ReceiptRefreshProvider.swift +++ b/Sources/Flare/Classes/Providers/ReceiptRefreshProvider/ReceiptRefreshProvider.swift @@ -9,20 +9,34 @@ import StoreKit // MARK: - ReceiptRefreshProvider +/// A class that can refresh the bundle's App Store receipt. final class ReceiptRefreshProvider: NSObject { // MARK: Properties + /// The dispatch queue factory. private let dispatchQueueFactory: IDispatchQueueFactory + /// A convenient interface to the contents of the file system, and the primary means of interacting with it. private let fileManager: IFileManager + /// The type that retrieves the App Store receipt URL. private let appStoreReceiptProvider: IAppStoreReceiptProvider + /// The receipt refresh request factory. private let receiptRefreshRequestFactory: IReceiptRefreshRequestFactory + /// Collection of handlers for receipt refresh requests. private var handlers: [String: ReceiptRefreshHandler] = [:] + /// Lazy-initialized private dispatch queue for handling tasks related to refreshing receipts. private lazy var dispatchQueue: IDispatchQueue = dispatchQueueFactory.privateQueue(label: String(describing: self)) // MARK: Initialization + /// Creates a new `ReceiptRefreshProvider` instance. + /// + /// - Parameters: + /// - dispatchQueueFactory: The dispatch queue factory. + /// - fileManager: A convenient interface to the contents of the file system, and the primary means of interacting with it. + /// - appStoreReceiptProvider: The type that retrieves the App Store receipt URL. + /// - receiptRefreshRequestFactory: The receipt refresh request factory. init( dispatchQueueFactory: IDispatchQueueFactory = DispatchQueueFactory(), fileManager: IFileManager = FileManager.default, @@ -37,6 +51,7 @@ final class ReceiptRefreshProvider: NSObject { // MARK: Internal + /// Computed property to retrieve the base64-encoded app store receipt string. var receipt: String? { if let appStoreReceiptURL = appStoreReceiptProvider.appStoreReceiptURL, fileManager.fileExists(atPath: appStoreReceiptURL.path) @@ -50,10 +65,20 @@ final class ReceiptRefreshProvider: NSObject { // MARK: Private + /// Creates a refresh receipt request. + /// + /// - Parameter id: The request identifier. + /// + /// - Returns: A receipt refresh request. private func makeRequest(id: String) -> IReceiptRefreshRequest { receiptRefreshRequestFactory.make(requestID: id, delegate: self) } + /// Fetches receipt information using a refresh request. + /// + /// - Parameters: + /// - request: The refresh request. + /// - handler: The closure to be executed once the refresh is complete. private func fetch(request: IReceiptRefreshRequest, handler: @escaping ReceiptRefreshHandler) { dispatchQueue.async { self.handlers[request.id] = handler From d3d4fa8b533577a88c1db673a76ab1c95b21dcf2 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Mon, 18 Dec 2023 18:36:47 +0100 Subject: [PATCH 04/27] Update `ci.yml` --- .github/workflows/ci.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d2fe1aa1..ff5df7f4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,6 @@ on: - "Source/**" - "Tests/**" -concurrency: - group: ci - cancel-in-progress: true - jobs: SwiftLint: runs-on: ubuntu-latest @@ -57,7 +53,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: ${{ matrix.name }} - run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "./${{ matrix.sdk }}.xcresult" | xcpretty -r junit + run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "./${{ matrix.sdk }}.xcresult" || exit 1 - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3.1.0 with: @@ -80,4 +76,4 @@ jobs: steps: - uses: actions/checkout@v3 - name: ${{ matrix.name }} - run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean \ No newline at end of file + run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean || exit 1 \ No newline at end of file From a8cfef0f675a8c1c4bbf97934c262a5e2e5db293 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Mon, 18 Dec 2023 18:56:30 +0100 Subject: [PATCH 05/27] Fix action errors - Update `Flare.xcscheme` - Update `test_thatIAPProviderFetchesSK2Products_whenProductsAreAvailable` test --- .../xcshareddata/xcschemes/Flare.xcscheme | 2 +- .../Helpers/TestCase/IAPTestCase.swift | 44 ------------------- .../Helpers/TestCase/ISKTestSession.swift | 15 ------- .../Providers/IAPProviderTests.swift | 29 +++++++----- 4 files changed, 18 insertions(+), 72 deletions(-) delete mode 100644 Tests/FlareTests/Helpers/TestCase/IAPTestCase.swift delete mode 100644 Tests/FlareTests/Helpers/TestCase/ISKTestSession.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme index fe0e9794c..b12f0a4ad 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme @@ -76,7 +76,7 @@ debugServiceExtension = "internal" allowLocationSimulation = "YES"> + identifier = "../../../Tests/FlareTests/Flare.storekit"> Date: Mon, 18 Dec 2023 20:19:16 +0100 Subject: [PATCH 06/27] Update the `Ruby` version from `2.7` to `3.1.4` --- .github/workflows/danger.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 1a0ab5292..158ca8723 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -15,7 +15,7 @@ jobs: - name: ruby setup uses: ruby/setup-ruby@v1 with: - ruby-version: 2.7 + ruby-version: 3.1.4 bundler-cache: true - name: Checkout code uses: actions/checkout@v2 From efa0928220249adbfd7d98c11337cc6683fcbb54 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Thu, 21 Dec 2023 15:21:00 +0100 Subject: [PATCH 07/27] Implement `StoreTransaction` model The `StoreTransaction` model is a wrapper for both `SK1StoreTransaction` and `SK2StoreTransaction` --- .../PaymentTransaction.swift | 4 + .../Protocols/IStoreTransaction.swift | 32 +++++++ .../Models/Internal/SK1StoreTransaction.swift | 65 +++++++++++++++ .../Models/Internal/SK2StoreTransaction.swift | 69 +++++++++++++++ .../Models/Internal/StoreEnvironment.swift | 59 +++++++++++++ .../Classes/Models/StoreTransaction.swift | 83 +++++++++++++++++++ .../PurchaseProvider/IPurchaseProvider.swift | 15 ++++ .../PurchaseProvider/PurchaseProvider.swift | 26 ++++++ 8 files changed, 353 insertions(+) create mode 100644 Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift create mode 100644 Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift create mode 100644 Sources/Flare/Classes/Models/Internal/SK2StoreTransaction.swift create mode 100644 Sources/Flare/Classes/Models/Internal/StoreEnvironment.swift create mode 100644 Sources/Flare/Classes/Models/StoreTransaction.swift create mode 100644 Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift create mode 100644 Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift diff --git a/Sources/Flare/Classes/Helpers/PaymentTransaction/PaymentTransaction.swift b/Sources/Flare/Classes/Helpers/PaymentTransaction/PaymentTransaction.swift index ff71880d3..fde5d04da 100644 --- a/Sources/Flare/Classes/Helpers/PaymentTransaction/PaymentTransaction.swift +++ b/Sources/Flare/Classes/Helpers/PaymentTransaction/PaymentTransaction.swift @@ -77,6 +77,10 @@ public struct PaymentTransaction: Equatable { return skTransaction.error } + public var transactionDate: Date? { + skTransaction.transactionDate + } + /// A `Bool` value indicating that the user canceled a payment request. public var isCancelled: Bool { (skTransaction.error as? SKError)?.code == SKError.Code.paymentCancelled diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift b/Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift new file mode 100644 index 000000000..addfd19db --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift @@ -0,0 +1,32 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +/// A type that represents a store transaction. +protocol IStoreTransaction { + /// The unique identifier for the product. + var productIdentifier: String { get } + /// The date when the transaction occurred. + var purchaseDate: Date { get } + /// A boolean indicating whether the purchase date is known. + var hasKnownPurchaseDate: Bool { get } + /// A unique identifier for the transaction. + var transactionIdentifier: String { get } + /// A boolean indicating whether the transaction identifier is known. + var hasKnownTransactionIdentifier: Bool { get } + /// The quantity of the product involved in the transaction. + var quantity: Int { get } + + /// The raw JWS repesentation of the transaction. + /// + /// - Note: this is only available for StoreKit 2 transactions. + var jwsRepresentation: String? { get } + + /// The server environment where the receipt was generated. + /// + /// - Note: this is only available for StoreKit 2 transactions. + var environment: StoreEnvironment? { get } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift new file mode 100644 index 000000000..a325e61ff --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift @@ -0,0 +1,65 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import StoreKit + +// MARK: - SK1StoreTransaction + +/// A struct representing the first version of the transaction. +struct SK1StoreTransaction { + // MARK: Properties + + /// The StoreKit transaction. + private let transaction: PaymentTransaction + + // MARK: Initialization + + /// Creates a new `SK1StoreTransaction` instance. + /// + /// - Parameter transaction: The StoreKit transaction. + init(transaction: PaymentTransaction) { + self.transaction = transaction + } +} + +// MARK: IStoreTransaction + +extension SK1StoreTransaction: IStoreTransaction { + var productIdentifier: String { + transaction.productIdentifier + } + + var purchaseDate: Date { + guard let date = transaction.transactionDate else { + return Date(timeIntervalSince1970: 0) + } + return date + } + + var hasKnownPurchaseDate: Bool { + transaction.transactionDate != nil + } + + var transactionIdentifier: String { + transaction.transactionIdentifier ?? "" + } + + var hasKnownTransactionIdentifier: Bool { + transaction.transactionIdentifier != nil + } + + var quantity: Int { + let payment = transaction.skTransaction.payment + return payment.quantity + } + + var jwsRepresentation: String? { + nil + } + + var environment: StoreEnvironment? { + nil + } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK2StoreTransaction.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreTransaction.swift new file mode 100644 index 000000000..d5a19933e --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK2StoreTransaction.swift @@ -0,0 +1,69 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - SK2StoreTransaction + +/// A struct representing the second version of the transaction. +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +struct SK2StoreTransaction { + // MARK: Properties + + /// The StoreKit transaction. + private let transaction: StoreKit.Transaction + /// The raw JWS repesentation of the transaction. + private let _jwsRepresentation: String? + + // MARK: Initialization + + /// Creates a new `SK1StoreTransaction` instance. + /// + /// - Parameters: + /// - transaction: The StoreKit transaction. + /// - jwsRepresentation: The raw JWS repesentation of the transaction. + init(transaction: StoreKit.Transaction, jwsRepresentation: String) { + self.transaction = transaction + _jwsRepresentation = jwsRepresentation + } +} + +// MARK: IStoreTransaction + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +extension SK2StoreTransaction: IStoreTransaction { + var productIdentifier: String { + transaction.productID + } + + var purchaseDate: Date { + transaction.purchaseDate + } + + var hasKnownPurchaseDate: Bool { + true + } + + var transactionIdentifier: String { + String(transaction.id) + } + + var hasKnownTransactionIdentifier: Bool { + true + } + + var quantity: Int { + transaction.purchasedQuantity + } + + var jwsRepresentation: String? { + _jwsRepresentation + } + + var environment: StoreEnvironment? { + StoreEnvironment(transaction: transaction) + } +} diff --git a/Sources/Flare/Classes/Models/Internal/StoreEnvironment.swift b/Sources/Flare/Classes/Models/Internal/StoreEnvironment.swift new file mode 100644 index 000000000..0744220ac --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/StoreEnvironment.swift @@ -0,0 +1,59 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - StoreEnvironment + +enum StoreEnvironment { + case production + case sandbox + case xcode +} + +extension StoreEnvironment { + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) + init?(environment: StoreKit.AppStore.Environment) { + switch environment { + case .production: + self = .production + case .sandbox: + self = .sandbox + case .xcode: + self = .xcode + default: + return nil + } + } + + init?(environment: String) { + switch environment { + case "Production": + self = .production + case "Sandbox": + self = .sandbox + case "Xcode": + self = .xcode + default: + return nil + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + init?(transaction: StoreKit.Transaction) { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + self.init(environment: transaction.environment) + } else { + #if VISION_OS + self.init(environment: transaction.environment) + #else + self.init( + environment: transaction.environmentStringRepresentation + ) + #endif + } + } +} diff --git a/Sources/Flare/Classes/Models/StoreTransaction.swift b/Sources/Flare/Classes/Models/StoreTransaction.swift new file mode 100644 index 000000000..3c2cf444c --- /dev/null +++ b/Sources/Flare/Classes/Models/StoreTransaction.swift @@ -0,0 +1,83 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - StoreTransaction + +/// A class represent a StoreKit transaction. +public final class StoreTransaction { + // MARK: Properties + + /// The StoreKit transaction. + private let storeTransaction: IStoreTransaction + + // MARK: Initialization + + /// Creates a new `StoreTransaction` instance. + /// + /// - Parameter storeTransaction: The StoreKit transaction. + init(storeTransaction: IStoreTransaction) { + self.storeTransaction = storeTransaction + } +} + +// MARK: - Convinience Initializators + +extension StoreTransaction { + /// Creates a new `StoreTransaction` instance. + /// + /// - Parameter paymentTransaction: The StoreKit transaction. + convenience init(paymentTransaction: PaymentTransaction) { + self.init(storeTransaction: SK1StoreTransaction(transaction: paymentTransaction)) + } + + /// Creates a new `StoreTransaction` instance. + /// + /// - Parameters: + /// - transaction: The StoreKit transaction. + /// - jwtRepresentation: The server environment where the receipt was generated. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + convenience init(transaction: StoreKit.Transaction, jwtRepresentation: String) { + self.init(storeTransaction: SK2StoreTransaction(transaction: transaction, jwsRepresentation: jwtRepresentation)) + } +} + +// MARK: IStoreTransaction + +extension StoreTransaction: IStoreTransaction { + var productIdentifier: String { + storeTransaction.productIdentifier + } + + var purchaseDate: Date { + storeTransaction.purchaseDate + } + + var hasKnownPurchaseDate: Bool { + storeTransaction.hasKnownPurchaseDate + } + + var transactionIdentifier: String { + storeTransaction.transactionIdentifier + } + + var hasKnownTransactionIdentifier: Bool { + storeTransaction.hasKnownTransactionIdentifier + } + + var quantity: Int { + storeTransaction.quantity + } + + var jwsRepresentation: String? { + storeTransaction.jwsRepresentation + } + + var environment: StoreEnvironment? { + storeTransaction.environment + } +} diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift new file mode 100644 index 000000000..47058acdb --- /dev/null +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +// typealias public PurchaseCompletionHandler = @MainActor @Sendable ( +// StoreTransaction, +// Void +// ) + +protocol IPurchaseProvider { + func purchase(product: StoreProduct, completion: @escaping () -> Void) +} diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift new file mode 100644 index 000000000..afcf30d9a --- /dev/null +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift @@ -0,0 +1,26 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - PurchaseProvider + +final class PurchaseProvider { + // MARK: Properties + + private let paymentProvider: IPaymentProvider + + // MARK: Initialization + + init(paymentProvider: IPaymentProvider) { + self.paymentProvider = paymentProvider + } +} + +// MARK: IPurchaseProvider + +extension PurchaseProvider: IPurchaseProvider { + func purchase(product _: StoreProduct, completion _: @escaping () -> Void) {} +} From bd616d9c57b4ee526ccd15716254c225859452ab Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 27 Dec 2023 14:49:49 +0100 Subject: [PATCH 08/27] Create a `StoreTransaction` object The `StoreTransaction` object serves as a wrapper for both `SKTransaction` and `StoreKit.Transaction`. --- .../xcshareddata/xcschemes/Flare.xcscheme | 14 ++ Package@swift-5.7.swift | 6 +- .../AsyncSequence/AsyncSequence+Stream.swift | 15 ++ .../ITransactionListener.swift | 14 ++ .../TransactionListener.swift | 81 +++++++++++ Sources/Flare/Classes/Models/IAPError.swift | 8 ++ .../Protocols/IStoreTransaction.swift | 4 +- .../Models/Internal/SK2StoreProduct.swift | 2 +- .../Flare/Classes/Models/StoreProduct.swift | 3 + .../Classes/Models/StoreTransaction.swift | 2 +- .../Classes/Models/VerificationError.swift | 10 ++ .../Providers/IAPProvider/IAPProvider.swift | 34 ++--- .../Providers/IAPProvider/IIAPProvider.swift | 4 +- .../PurchaseProvider/IPurchaseProvider.swift | 27 +++- .../PurchaseProvider/PurchaseProvider.swift | 94 +++++++++++- Sources/Flare/Flare.swift | 4 +- Sources/Flare/IFlare.swift | 7 +- .../Fakes/StoreTransactionFake.swift | 12 ++ Tests/FlareTests/Flare.storekit | 4 +- Tests/FlareTests/Mocks/IAPProviderMock.swift | 10 +- .../Mocks/PurchaseProviderMock.swift | 57 ++++++++ .../Mocks/StoreTransactionMock.swift | 89 ++++++++++++ .../Stubs/StoreTransactionStub.swift | 57 ++++++++ Tests/FlareTests/UnitTests/FlareTests.swift | 14 +- .../Providers/IAPProviderTests.swift | 110 +++++--------- .../Providers/PurchaseProviderTests.swift | 135 ++++++++++++++++++ 26 files changed, 690 insertions(+), 127 deletions(-) create mode 100644 Sources/Flare/Classes/Helpers/AsyncSequence/AsyncSequence+Stream.swift create mode 100644 Sources/Flare/Classes/Listeners/TransactionListener/ITransactionListener.swift create mode 100644 Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift create mode 100644 Sources/Flare/Classes/Models/VerificationError.swift create mode 100644 Tests/FlareTests/Fakes/StoreTransactionFake.swift create mode 100644 Tests/FlareTests/Mocks/PurchaseProviderMock.swift create mode 100644 Tests/FlareTests/Mocks/StoreTransactionMock.swift create mode 100644 Tests/FlareTests/Stubs/StoreTransactionStub.swift create mode 100644 Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme index b12f0a4ad..866e55a53 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme @@ -34,6 +34,20 @@ ReferencedContainer = "container:"> + + + + AsyncStream { + var asyncIterator = makeAsyncIterator() + return AsyncStream { + try? await asyncIterator.next() + } + } +} diff --git a/Sources/Flare/Classes/Listeners/TransactionListener/ITransactionListener.swift b/Sources/Flare/Classes/Listeners/TransactionListener/ITransactionListener.swift new file mode 100644 index 000000000..faefa7cb8 --- /dev/null +++ b/Sources/Flare/Classes/Listeners/TransactionListener/ITransactionListener.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +protocol ITransactionListener: Sendable { + func listenForTransaction() async + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + func handle(purchaseResult: StoreKit.Product.PurchaseResult) async throws -> StoreTransaction? +} diff --git a/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift b/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift new file mode 100644 index 000000000..1078ed1c3 --- /dev/null +++ b/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift @@ -0,0 +1,81 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - TransactionListener + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +actor TransactionListener { + // MARK: Types + + typealias TransactionResult = StoreKit.VerificationResult + + // MARK: Private + + private let updates: AsyncStream + private var task: Task? + + // MARK: Initialization + + init(updates: S) where S.Element == TransactionResult { + self.updates = updates.toAsyncStream() + } + + // MARK: Private + + private func handle( + transactionResult: TransactionResult, + fromTransactionUpdate _: Bool + ) async throws -> StoreTransaction { + switch transactionResult { + case let .verified(transaction): + return StoreTransaction( + transaction: transaction, + jwtRepresentation: transactionResult.jwsRepresentation + ) + case let .unverified(transaction, verificationError): + throw IAPError.verification( + error: .unverified(productID: transaction.productID, error: verificationError) + ) + } + } +} + +// MARK: ITransactionListener + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +extension TransactionListener: ITransactionListener { + func listenForTransaction() async { + task?.cancel() + task = Task(priority: .utility) { [weak self] in + guard let self = self else { return } + + for await update in self.updates { + Task.detached { + do { + _ = try await self.handle(transactionResult: update, fromTransactionUpdate: true) + } catch { + debugPrint("[TransactionListener] Error occurred: \(error.localizedDescription)") + } + } + } + } + } + + func handle(purchaseResult: Product.PurchaseResult) async throws -> StoreTransaction? { + switch purchaseResult { + case let .success(verificationResult): + return try await handle(transactionResult: verificationResult, fromTransactionUpdate: false) + case .userCancelled: + throw IAPError.paymentCancelled + case .pending: + throw IAPError.paymentDefferred + @unknown default: + throw IAPError.unknown + } + } +} diff --git a/Sources/Flare/Classes/Models/IAPError.swift b/Sources/Flare/Classes/Models/IAPError.swift index 2977011e5..96c64fc58 100644 --- a/Sources/Flare/Classes/Models/IAPError.swift +++ b/Sources/Flare/Classes/Models/IAPError.swift @@ -30,6 +30,14 @@ public enum IAPError: Swift.Error { case transactionNotFound(productID: String) /// The refund error. case refund(error: RefundError) + /// The verification error. + /// + /// - Note: This is only available for StoreKit 2 transactions. + case verification(error: VerificationError) + /// + /// + /// - Note: This is only available for StoreKit 2 transactions. + case paymentDefferred /// The unknown error occurred. case unknown } diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift b/Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift index addfd19db..db8a4cce7 100644 --- a/Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift +++ b/Sources/Flare/Classes/Models/Internal/Protocols/IStoreTransaction.swift @@ -22,11 +22,11 @@ protocol IStoreTransaction { /// The raw JWS repesentation of the transaction. /// - /// - Note: this is only available for StoreKit 2 transactions. + /// - Note: This is only available for StoreKit 2 transactions. var jwsRepresentation: String? { get } /// The server environment where the receipt was generated. /// - /// - Note: this is only available for StoreKit 2 transactions. + /// - Note: This is only available for StoreKit 2 transactions. var environment: StoreEnvironment? { get } } diff --git a/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift index d6be36907..c8fa0527d 100644 --- a/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift +++ b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift @@ -13,7 +13,7 @@ final class SK2StoreProduct { // MARK: Properties /// The store kit product. - private let product: StoreKit.Product + let product: StoreKit.Product /// The currency format. private var currencyFormat: Decimal.FormatStyle.Currency { product.priceFormatStyle diff --git a/Sources/Flare/Classes/Models/StoreProduct.swift b/Sources/Flare/Classes/Models/StoreProduct.swift index 402de3537..d240cbcde 100644 --- a/Sources/Flare/Classes/Models/StoreProduct.swift +++ b/Sources/Flare/Classes/Models/StoreProduct.swift @@ -15,6 +15,9 @@ public final class StoreProduct: NSObject { /// Protocol representing a Store Kit product. private let product: ISKProduct + /// <#Description#> + var underlyingProduct: ISKProduct { product } + // MARK: Initialization /// Creates a new `StoreProduct` instance. diff --git a/Sources/Flare/Classes/Models/StoreTransaction.swift b/Sources/Flare/Classes/Models/StoreTransaction.swift index 3c2cf444c..f9e79c024 100644 --- a/Sources/Flare/Classes/Models/StoreTransaction.swift +++ b/Sources/Flare/Classes/Models/StoreTransaction.swift @@ -13,7 +13,7 @@ public final class StoreTransaction { // MARK: Properties /// The StoreKit transaction. - private let storeTransaction: IStoreTransaction + let storeTransaction: IStoreTransaction // MARK: Initialization diff --git a/Sources/Flare/Classes/Models/VerificationError.swift b/Sources/Flare/Classes/Models/VerificationError.swift new file mode 100644 index 000000000..9ca93b178 --- /dev/null +++ b/Sources/Flare/Classes/Models/VerificationError.swift @@ -0,0 +1,10 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +public enum VerificationError: Swift.Error { + case unverified(productID: String, error: Error) +} diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index f4bdb37a7..1ba1eb76f 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -13,8 +13,8 @@ final class IAPProvider: IIAPProvider { private let paymentQueue: PaymentQueue /// The provider is responsible for fetching StoreKit products. private let productProvider: IProductProvider - /// The provider is responsible for making in-app payments. - private let paymentProvider: IPaymentProvider + /// The provider is responsible for purchasing products. + private let purchaseProvider: IPurchaseProvider /// The provider is responsible for refreshing receipts. private let receiptRefreshProvider: IReceiptRefreshProvider /// The provider is responsible for refunding purchases @@ -27,13 +27,13 @@ final class IAPProvider: IIAPProvider { /// - Parameters: /// - paymentQueue: The queue of payment transactions to be processed by the App Store. /// - productProvider: The provider is responsible for fetching StoreKit products. - /// - paymentProvider: The provider is responsible for making in-app payments. + /// - purchaseProvider: /// - receiptRefreshProvider: The provider is responsible for refreshing receipts. /// - refundProvider: The provider is responsible for refunding purchases. init( paymentQueue: PaymentQueue = SKPaymentQueue.default(), productProvider: IProductProvider = ProductProvider(), - paymentProvider: IPaymentProvider = PaymentProvider(), + purchaseProvider: IPurchaseProvider = PurchaseProvider(), receiptRefreshProvider: IReceiptRefreshProvider = ReceiptRefreshProvider(), refundProvider: IRefundProvider = RefundProvider( systemInfoProvider: SystemInfoProvider() @@ -41,7 +41,7 @@ final class IAPProvider: IIAPProvider { ) { self.paymentQueue = paymentQueue self.productProvider = productProvider - self.paymentProvider = paymentProvider + self.purchaseProvider = purchaseProvider self.receiptRefreshProvider = receiptRefreshProvider self.refundProvider = refundProvider } @@ -80,7 +80,7 @@ final class IAPProvider: IIAPProvider { } } - func purchase(productID: String, completion: @escaping Closure>) { + func purchase(productID: String, completion: @escaping Closure>) { productProvider.fetch(productIDs: [productID], requestID: UUID().uuidString) { result in switch result { case let .success(products): @@ -89,12 +89,10 @@ final class IAPProvider: IIAPProvider { return } - let payment = SKPayment(product: product.product) - - self.paymentProvider.add(payment: payment) { _, result in + self.purchaseProvider.purchase(product: StoreProduct(skProduct: product.product)) { result in switch result { case let .success(transaction): - completion(.success(PaymentTransaction(transaction))) + completion(.success(transaction)) case let .failure(error): completion(.failure(error)) } @@ -105,7 +103,7 @@ final class IAPProvider: IIAPProvider { } } - func purchase(productID: String) async throws -> PaymentTransaction { + func purchase(productID: String) async throws -> StoreTransaction { try await withCheckedThrowingContinuation { continuation in purchase(productID: productID) { result in continuation.resume(with: result) @@ -137,23 +135,15 @@ final class IAPProvider: IIAPProvider { } func finish(transaction: PaymentTransaction) { - paymentProvider.finish(transaction: transaction) + purchaseProvider.finish(transaction: transaction) } func addTransactionObserver(fallbackHandler: Closure>?) { - paymentProvider.set { _, result in - switch result { - case let .success(transaction): - fallbackHandler?(.success(PaymentTransaction(transaction))) - case let .failure(error): - fallbackHandler?(.failure(error)) - } - } - paymentProvider.addTransactionObserver() + purchaseProvider.addTransactionObserver(fallbackHandler: fallbackHandler) } func removeTransactionObserver() { - paymentProvider.removeTransactionObserver() + purchaseProvider.removeTransactionObserver() } #if os(iOS) || VISION_OS diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift index 479dcd96c..269fc9d1d 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift @@ -35,7 +35,7 @@ public protocol IIAPProvider { /// - Parameters: /// - productID: The product identifier. /// - completion: The closure to be executed once the purchase is complete. - func purchase(productID: String, completion: @escaping Closure>) + func purchase(productID: String, completion: @escaping Closure>) /// Purchases a product with a given ID. /// @@ -48,7 +48,7 @@ public protocol IIAPProvider { /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. /// /// - Returns: A payment transaction. - func purchase(productID: String) async throws -> PaymentTransaction + func purchase(productID: String) async throws -> StoreTransaction /// Refreshes the receipt, representing the user's transactions with your app. /// diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift index 47058acdb..cf6cfe882 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift @@ -5,11 +5,28 @@ import Foundation -// typealias public PurchaseCompletionHandler = @MainActor @Sendable ( -// StoreTransaction, -// Void -// ) +public typealias PurchaseCompletionHandler = @MainActor @Sendable (Result) -> Void + +// MARK: - IPurchaseProvider protocol IPurchaseProvider { - func purchase(product: StoreProduct, completion: @escaping () -> Void) + /// Removes a finished (i.e. failed or completed) transaction from the queue. + /// Attempting to finish a purchasing transaction will throw an exception. + /// + /// - Parameter transaction: An object in the payment queue. + func finish(transaction: PaymentTransaction) + + /// Adds transaction observer to the payment queue. + /// The transactions array will only be synchronized with the server while the queue has observers. + /// + /// - Note: This may require that the user authenticate. + func addTransactionObserver(fallbackHandler: Closure>?) + + /// Removes transaction observer from the payment queue. + /// The transactions array will only be synchronized with the server while the queue has observers. + /// + /// - Note: This may require that the user authenticate. + func removeTransactionObserver() + + func purchase(product: StoreProduct, completion: @escaping PurchaseCompletionHandler) } diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift index afcf30d9a..a0461a23e 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift @@ -4,23 +4,113 @@ // import Foundation +import StoreKit // MARK: - PurchaseProvider final class PurchaseProvider { // MARK: Properties + /// The provider is responsible for making in-app payments. private let paymentProvider: IPaymentProvider + /// <#Description#> + private let transactionListener: ITransactionListener? // MARK: Initialization - init(paymentProvider: IPaymentProvider) { + /// <#Description#> + /// + /// - Parameters: + /// - paymentProvider: <#paymentProvider description#> + /// - transactionListener: <#transactionListener description#> + init( + paymentProvider: IPaymentProvider = PaymentProvider(), + transactionListener: ITransactionListener? = nil + ) { self.paymentProvider = paymentProvider + + if let transactionListener = transactionListener { + self.transactionListener = transactionListener + } else if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + self.transactionListener = TransactionListener(updates: StoreKit.Transaction.updates) + } else { + self.transactionListener = nil + } + } + + // MARK: Private + + private func purchase( + sk1StoreProduct: SK1StoreProduct, + completion: @escaping @MainActor (Result) -> Void + ) { + let payment = SKPayment(product: sk1StoreProduct.product) + paymentProvider.add(payment: payment) { _, result in + Task { + switch result { + case let .success(transaction): + await completion(.success(StoreTransaction(paymentTransaction: PaymentTransaction(transaction)))) + case let .failure(error): + await completion(.failure(error)) + } + } + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private func purchase( + sk2StoreProduct: SK2StoreProduct, + completion: @escaping @MainActor (Result) -> Void + ) { + AsyncHandler.call(completion: { result in + Task { + switch result { + case let .success(result): + if let transaction = try await self.transactionListener?.handle(purchaseResult: result) { + await completion(.success(transaction)) + } else { + await completion(.failure(IAPError.unknown)) + } + case let .failure(error): + await completion(.failure(IAPError(error: error))) + } + } + }, asyncMethod: { + try await sk2StoreProduct.product.purchase(options: [.simulatesAskToBuyInSandbox(false)]) + }) } } // MARK: IPurchaseProvider extension PurchaseProvider: IPurchaseProvider { - func purchase(product _: StoreProduct, completion _: @escaping () -> Void) {} + func purchase(product: StoreProduct, completion: @escaping PurchaseCompletionHandler) { + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *), + let sk2Product = product.underlyingProduct as? SK2StoreProduct + { + self.purchase(sk2StoreProduct: sk2Product, completion: completion) + } else if let sk1Product = product.underlyingProduct as? SK1StoreProduct { + purchase(sk1StoreProduct: sk1Product, completion: completion) + } + } + + func finish(transaction: PaymentTransaction) { + paymentProvider.finish(transaction: transaction) + } + + func addTransactionObserver(fallbackHandler: Closure>?) { + paymentProvider.set { _, result in + switch result { + case let .success(transaction): + fallbackHandler?(.success(PaymentTransaction(transaction))) + case let .failure(error): + fallbackHandler?(.failure(error)) + } + } + paymentProvider.addTransactionObserver() + } + + func removeTransactionObserver() { + paymentProvider.removeTransactionObserver() + } } diff --git a/Sources/Flare/Flare.swift b/Sources/Flare/Flare.swift index a0a1ad2e0..6aac551e9 100644 --- a/Sources/Flare/Flare.swift +++ b/Sources/Flare/Flare.swift @@ -44,7 +44,7 @@ extension Flare: IFlare { try await iapProvider.fetch(productIDs: productIDs) } - public func purchase(productID: String, completion: @escaping Closure>) { + public func purchase(productID: String, completion: @escaping Closure>) { guard iapProvider.canMakePayments else { completion(.failure(.paymentNotAllowed)) return @@ -60,7 +60,7 @@ extension Flare: IFlare { } } - public func purchase(productID: String) async throws -> PaymentTransaction { + public func purchase(productID: String) async throws -> StoreTransaction { guard iapProvider.canMakePayments else { throw IAPError.paymentNotAllowed } return try await iapProvider.purchase(productID: productID) } diff --git a/Sources/Flare/IFlare.swift b/Sources/Flare/IFlare.swift index 972dd4eea..4a80aa0d6 100644 --- a/Sources/Flare/IFlare.swift +++ b/Sources/Flare/IFlare.swift @@ -33,7 +33,7 @@ public protocol IFlare { /// - Parameters: /// - productID: The product identifier. /// - completion: The closure to be executed once the purchase is complete. - func purchase(productID: String, completion: @escaping Closure>) + func purchase(productID: String, completion: @escaping Closure>) /// Purchases a product with a given ID. /// @@ -46,7 +46,10 @@ public protocol IFlare { /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. /// /// - Returns: A payment transaction. - func purchase(productID: String) async throws -> PaymentTransaction + func purchase(productID: String) async throws -> StoreTransaction + +// @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +// func purchase(productID: String, options: Set) async throws -> StoreTransaction /// Refreshes the receipt, representing the user's transactions with your app. /// diff --git a/Tests/FlareTests/Fakes/StoreTransactionFake.swift b/Tests/FlareTests/Fakes/StoreTransactionFake.swift new file mode 100644 index 000000000..002b7311b --- /dev/null +++ b/Tests/FlareTests/Fakes/StoreTransactionFake.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare + +extension StoreTransaction { + static func fakeSK1(storeTransaction: IStoreTransaction? = nil) -> StoreTransaction { + StoreTransaction(storeTransaction: storeTransaction ?? StoreTransactionStub()) + } +} diff --git a/Tests/FlareTests/Flare.storekit b/Tests/FlareTests/Flare.storekit index 22ccc94e1..c067ffaf8 100644 --- a/Tests/FlareTests/Flare.storekit +++ b/Tests/FlareTests/Flare.storekit @@ -61,7 +61,7 @@ "localizations" : [ { "description" : "", - "displayName" : "", + "displayName" : "TestSubscription1", "locale" : "en_US" } ], @@ -86,7 +86,7 @@ "localizations" : [ { "description" : "", - "displayName" : "", + "displayName" : "TestSubscription2", "locale" : "en_US" } ], diff --git a/Tests/FlareTests/Mocks/IAPProviderMock.swift b/Tests/FlareTests/Mocks/IAPProviderMock.swift index 6a7cd8702..77aac0fe0 100644 --- a/Tests/FlareTests/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/Mocks/IAPProviderMock.swift @@ -31,10 +31,10 @@ final class IAPProviderMock: IIAPProvider { var invokedPurchase = false var invokedPurchaseCount = 0 - var invokedPurchaseParameters: (productID: String, completion: Closure>)? - var invokedPurchaseParametersList = [(productID: String, completion: Closure>)]() + var invokedPurchaseParameters: (productID: String, completion: Closure>)? + var invokedPurchaseParametersList = [(productID: String, completion: Closure>)]() - func purchase(productID: String, completion: @escaping Closure>) { + func purchase(productID: String, completion: @escaping Closure>) { invokedPurchase = true invokedPurchaseCount += 1 invokedPurchaseParameters = (productID, completion) @@ -108,9 +108,9 @@ final class IAPProviderMock: IIAPProvider { var invokedAsyncPurchaseCount = 0 var invokedAsyncPurchaseParameters: (productID: String, Void)? var invokedAsyncPurchaseParametersList = [(productID: String, Void)?]() - var stubbedAsyncPurchase: PaymentTransaction! + var stubbedAsyncPurchase: StoreTransaction! - func purchase(productID: String) async throws -> PaymentTransaction { + func purchase(productID: String) async throws -> StoreTransaction { invokedAsyncPurchase = true invokedAsyncPurchaseCount += 1 invokedAsyncPurchaseParameters = (productID, ()) diff --git a/Tests/FlareTests/Mocks/PurchaseProviderMock.swift b/Tests/FlareTests/Mocks/PurchaseProviderMock.swift new file mode 100644 index 000000000..8feb555f8 --- /dev/null +++ b/Tests/FlareTests/Mocks/PurchaseProviderMock.swift @@ -0,0 +1,57 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare + +final class PurchaseProviderMock: IPurchaseProvider { + var invokedFinish = false + var invokedFinishCount = 0 + var invokedFinishParameters: (transaction: PaymentTransaction, Void)? + var invokedFinishParametersList = [(transaction: PaymentTransaction, Void)]() + + func finish(transaction: PaymentTransaction) { + invokedFinish = true + invokedFinishCount += 1 + invokedFinishParameters = (transaction, ()) + invokedFinishParametersList.append((transaction, ())) + } + + var invokedAddTransactionObserver = false + var invokedAddTransactionObserverCount = 0 + var invokedAddTransactionObserverParameters: (fallbackHandler: Closure>?, Void)? + var invokedAddTransactionObserverParametersList = [(fallbackHandler: Closure>?, Void)]() + + func addTransactionObserver(fallbackHandler: Closure>?) { + invokedAddTransactionObserver = true + invokedAddTransactionObserverCount += 1 + invokedAddTransactionObserverParameters = (fallbackHandler, ()) + invokedAddTransactionObserverParametersList.append((fallbackHandler, ())) + } + + var invokedRemoveTransactionObserver = false + var invokedRemoveTransactionObserverCount = 0 + + func removeTransactionObserver() { + invokedRemoveTransactionObserver = true + invokedRemoveTransactionObserverCount += 1 + } + + var invokedPurchase = false + var invokedPurchaseCount = 0 + var invokedPurchaseParameters: (product: StoreProduct, Void)? + var invokedPurchaseParametersList = [(product: StoreProduct, Void)]() + var stubbedPurchaseCompletionResult: (Result, Void)? + + @MainActor + func purchase(product: StoreProduct, completion: @escaping PurchaseCompletionHandler) { + invokedPurchase = true + invokedPurchaseCount += 1 + invokedPurchaseParameters = (product, ()) + invokedPurchaseParametersList.append((product, ())) + if let result = stubbedPurchaseCompletionResult { + completion(result.0) + } + } +} diff --git a/Tests/FlareTests/Mocks/StoreTransactionMock.swift b/Tests/FlareTests/Mocks/StoreTransactionMock.swift new file mode 100644 index 000000000..225e403ef --- /dev/null +++ b/Tests/FlareTests/Mocks/StoreTransactionMock.swift @@ -0,0 +1,89 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class StoreTransactionMock: IStoreTransaction { + var invokedProductIdentifierGetter = false + var invokedProductIdentifierGetterCount = 0 + var stubbedProductIdentifier: String! = "" + + var productIdentifier: String { + invokedProductIdentifierGetter = true + invokedProductIdentifierGetterCount += 1 + return stubbedProductIdentifier + } + + var invokedPurchaseDateGetter = false + var invokedPurchaseDateGetterCount = 0 + var stubbedPurchaseDate: Date! + + var purchaseDate: Date { + invokedPurchaseDateGetter = true + invokedPurchaseDateGetterCount += 1 + return stubbedPurchaseDate + } + + var invokedHasKnownPurchaseDateGetter = false + var invokedHasKnownPurchaseDateGetterCount = 0 + var stubbedHasKnownPurchaseDate: Bool! = false + + var hasKnownPurchaseDate: Bool { + invokedHasKnownPurchaseDateGetter = true + invokedHasKnownPurchaseDateGetterCount += 1 + return stubbedHasKnownPurchaseDate + } + + var invokedTransactionIdentifierGetter = false + var invokedTransactionIdentifierGetterCount = 0 + var stubbedTransactionIdentifier: String! = "" + + var transactionIdentifier: String { + invokedTransactionIdentifierGetter = true + invokedTransactionIdentifierGetterCount += 1 + return stubbedTransactionIdentifier + } + + var invokedHasKnownTransactionIdentifierGetter = false + var invokedHasKnownTransactionIdentifierGetterCount = 0 + var stubbedHasKnownTransactionIdentifier: Bool! = false + + var hasKnownTransactionIdentifier: Bool { + invokedHasKnownTransactionIdentifierGetter = true + invokedHasKnownTransactionIdentifierGetterCount += 1 + return stubbedHasKnownTransactionIdentifier + } + + var invokedQuantityGetter = false + var invokedQuantityGetterCount = 0 + var stubbedQuantity: Int! = 0 + + var quantity: Int { + invokedQuantityGetter = true + invokedQuantityGetterCount += 1 + return stubbedQuantity + } + + var invokedJwsRepresentationGetter = false + var invokedJwsRepresentationGetterCount = 0 + var stubbedJwsRepresentation: String! + + var jwsRepresentation: String? { + invokedJwsRepresentationGetter = true + invokedJwsRepresentationGetterCount += 1 + return stubbedJwsRepresentation + } + + var invokedEnvironmentGetter = false + var invokedEnvironmentGetterCount = 0 + var stubbedEnvironment: StoreEnvironment! + + var environment: StoreEnvironment? { + invokedEnvironmentGetter = true + invokedEnvironmentGetterCount += 1 + return stubbedEnvironment + } +} diff --git a/Tests/FlareTests/Stubs/StoreTransactionStub.swift b/Tests/FlareTests/Stubs/StoreTransactionStub.swift new file mode 100644 index 000000000..7b8a0c7cb --- /dev/null +++ b/Tests/FlareTests/Stubs/StoreTransactionStub.swift @@ -0,0 +1,57 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +final class StoreTransactionStub: IStoreTransaction { + var stubbedProductIdentifier: String! = UUID().uuidString + + var productIdentifier: String { + stubbedProductIdentifier + } + + var stubbedPurchaseDate: Date! + + var purchaseDate: Date { + stubbedPurchaseDate + } + + var stubbedHasKnownPurchaseDate: Bool! = false + + var hasKnownPurchaseDate: Bool { + stubbedHasKnownPurchaseDate + } + + var stubbedTransactionIdentifier: String! = "" + + var transactionIdentifier: String { + stubbedTransactionIdentifier + } + + var stubbedHasKnownTransactionIdentifier: Bool! = false + + var hasKnownTransactionIdentifier: Bool { + stubbedHasKnownTransactionIdentifier + } + + var stubbedQuantity: Int! = 0 + + var quantity: Int { + stubbedQuantity + } + + var stubbedJwsRepresentation: String! + + var jwsRepresentation: String? { + stubbedJwsRepresentation + } + + var stubbedEnvironment: StoreEnvironment! + + var environment: StoreEnvironment? { + stubbedEnvironment + } +} diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 91f7bfc58..c2a003eee 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -80,11 +80,11 @@ class FlareTests: XCTestCase { func test_thatFlarePurchasesAProduct_whenRequestCompletedSuccessfully() { // given - let paymentTransaction = PaymentTransaction(PaymentTransactionMock()) + let paymentTransaction = StoreTransaction(storeTransaction: StoreTransactionStub()) iapProviderMock.stubbedCanMakePayments = true // when - var transaction: PaymentTransaction? + var transaction: IStoreTransaction? flare.purchase(productID: .productID, completion: { result in if case let .success(result) = result { transaction = result @@ -94,7 +94,7 @@ class FlareTests: XCTestCase { // then XCTAssertTrue(iapProviderMock.invokedPurchase) - XCTAssertEqual(transaction, paymentTransaction) + XCTAssertEqual(transaction?.productIdentifier, paymentTransaction.productIdentifier) } func test_thatFlareDoesNotPurchaseAProduct_whenUnknownErrorOccurred() { @@ -119,7 +119,7 @@ class FlareTests: XCTestCase { func test_thatFlareDoesNotPurchaseAProduct_whenUserCannotMakePayments() async { // given iapProviderMock.stubbedCanMakePayments = false - iapProviderMock.stubbedAsyncPurchase = PaymentTransaction(PaymentTransactionMock()) + iapProviderMock.stubbedAsyncPurchase = StoreTransaction(storeTransaction: StoreTransactionStub()) // when var iapError: IAPError? @@ -136,13 +136,13 @@ class FlareTests: XCTestCase { func test_thatFlareDoesNotPurchaseAProduct_whenUnknownErrorOccurred() async { // given - let transactionMock = PaymentTransaction(PaymentTransactionMock()) + let transactionMock = StoreTransaction(storeTransaction: StoreTransactionStub()) iapProviderMock.stubbedCanMakePayments = true iapProviderMock.stubbedAsyncPurchase = transactionMock // when - var transaction: PaymentTransaction? + var transaction: IStoreTransaction? var iapError: IAPError? do { transaction = try await flare.purchase(productID: .productID) @@ -153,7 +153,7 @@ class FlareTests: XCTestCase { // then XCTAssertTrue(iapProviderMock.invokedAsyncPurchase) XCTAssertNil(iapError) - XCTAssertEqual(transaction, transactionMock) + XCTAssertEqual(transaction?.productIdentifier, transactionMock.productIdentifier) } func test_thatFlareFetchesReceipt_whenRequestCompletedSuccessfully() { diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index efe6ceb5f..526de30a5 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -14,7 +14,7 @@ class IAPProviderTests: XCTestCase { private var paymentQueueMock: PaymentQueueMock! private var productProviderMock: ProductProviderMock! - private var paymentProviderMock: PaymentProviderMock! + private var purchaseProvider: PurchaseProviderMock! private var receiptRefreshProviderMock: ReceiptRefreshProviderMock! private var refundProviderMock: RefundProviderMock! @@ -26,13 +26,13 @@ class IAPProviderTests: XCTestCase { super.setUp() paymentQueueMock = PaymentQueueMock() productProviderMock = ProductProviderMock() - paymentProviderMock = PaymentProviderMock() + purchaseProvider = PurchaseProviderMock() receiptRefreshProviderMock = ReceiptRefreshProviderMock() refundProviderMock = RefundProviderMock() iapProvider = IAPProvider( paymentQueue: paymentQueueMock, productProvider: productProviderMock, - paymentProvider: paymentProviderMock, + purchaseProvider: purchaseProvider, receiptRefreshProvider: receiptRefreshProviderMock, refundProvider: refundProviderMock ) @@ -41,7 +41,7 @@ class IAPProviderTests: XCTestCase { override func tearDown() { paymentQueueMock = nil productProviderMock = nil - paymentProviderMock = nil + purchaseProvider = nil receiptRefreshProviderMock = nil refundProviderMock = nil iapProvider = nil @@ -94,7 +94,7 @@ class IAPProviderTests: XCTestCase { iapProvider.finish(transaction: PaymentTransaction(transaction)) // then - XCTAssertTrue(paymentProviderMock.invokedFinishTransaction) + XCTAssertTrue(purchaseProvider.invokedFinish) } func test_thatIAPProviderAddsTransactionObserver() { @@ -102,8 +102,7 @@ class IAPProviderTests: XCTestCase { iapProvider.addTransactionObserver(fallbackHandler: { _ in }) // then - XCTAssertTrue(paymentProviderMock.invokedFallbackHandler) - XCTAssertTrue(paymentProviderMock.invokedAddTransactionObserver) + XCTAssertTrue(purchaseProvider.invokedAddTransactionObserver) } func test_thatIAPProviderRemovesTransactionObserver() { @@ -111,7 +110,7 @@ class IAPProviderTests: XCTestCase { iapProvider.removeTransactionObserver() // then - XCTAssertTrue(paymentProviderMock.invokedRemoveTransactionObserver) + XCTAssertTrue(purchaseProvider.invokedRemoveTransactionObserver) } // FIXME: Update test @@ -182,28 +181,10 @@ class IAPProviderTests: XCTestCase { XCTAssertEqual(error as? NSError, IAPError.storeProductNotAvailable as NSError) } - func test_thatIAPProviderReturnsPaymentTransaction_whenProductsExist() { - // given - let paymentTransactionMock = PaymentTransactionMock() - productProviderMock.stubbedFetchResult = .success([SK1StoreProduct(ProductMock())]) - paymentProviderMock.stubbedAddResult = (paymentQueueMock, .success(paymentTransactionMock)) - - // when - var transactionResult: PaymentTransaction? - iapProvider.purchase(productID: .productID) { result in - if case let .success(transaction) = result { - transactionResult = transaction - } - } - - // then - XCTAssertEqual(transactionResult?.skTransaction, paymentTransactionMock) - } - func test_thatIAPProviderReturnsError_whenAddingPaymentFailed() { // given productProviderMock.stubbedFetchResult = .success([SK1StoreProduct(ProductMock())]) - paymentProviderMock.stubbedAddResult = (paymentQueueMock, .failure(.unknown)) + purchaseProvider.stubbedPurchaseCompletionResult = (.failure(.unknown), ()) // when var errorResult: Error? @@ -249,19 +230,6 @@ class IAPProviderTests: XCTestCase { XCTAssertEqual(errorResult as? NSError, IAPError.storeProductNotAvailable as NSError) } - func test_thatIAPProviderPurchasesForAProduct_whenProductsExist() async throws { - // given - let transactionMock = SKPaymentTransaction() - productProviderMock.stubbedFetchResult = .success([SK1StoreProduct(ProductMock())]) - paymentProviderMock.stubbedAddResult = (paymentQueueMock, .success(transactionMock)) - - // when - let transactionResult = try await iapProvider.purchase(productID: .productID) - - // then - XCTAssertEqual(transactionMock, transactionResult.skTransaction) - } - func test_thatIAPProviderRefreshesReceipt_when() { // given receiptRefreshProviderMock.stubbedReceipt = .receipt @@ -342,38 +310,38 @@ class IAPProviderTests: XCTestCase { XCTAssertEqual(errorResult as? NSError, IAPError.receiptNotFound as NSError) } - func test_thatIAPProviderReturnsTransaction() { - // given - let transactionMock = SKPaymentTransaction() - paymentProviderMock.stubbedFallbackHandlerResult = (paymentQueueMock, .success(transactionMock)) - - // when - var transactionResult: PaymentTransaction? - iapProvider.addTransactionObserver { result in - if case let .success(transaction) = result { - transactionResult = transaction - } - } - - // then - XCTAssertEqual(transactionResult?.skTransaction, transactionMock) - } - - func test_thatIAPProviderReturnsError() { - // given - paymentProviderMock.stubbedFallbackHandlerResult = (paymentQueueMock, .failure(.unknown)) - - // when - var errorResult: Error? - iapProvider.addTransactionObserver { result in - if case let .failure(error) = result { - errorResult = error - } - } +// func test_thatIAPProviderReturnsTransaction() { +// // given +// let transactionMock = SKPaymentTransaction() +// purchaseProvider.stubbedFallbackHandlerResult = (paymentQueueMock, .success(transactionMock)) +// +// // when +// var transactionResult: PaymentTransaction? +// iapProvider.addTransactionObserver { result in +// if case let .success(transaction) = result { +// transactionResult = transaction +// } +// } +// +// // then +// XCTAssertEqual(transactionResult?.skTransaction, transactionMock) +// } - // then - XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) - } +// func test_thatIAPProviderReturnsError() { +// // given +// purchaseProvider.stubbedFallbackHandlerResult = (paymentQueueMock, .failure(.unknown)) +// +// // when +// var errorResult: Error? +// iapProvider.addTransactionObserver { result in +// if case let .failure(error) = result { +// errorResult = error +// } +// } +// +// // then +// XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) +// } #if os(iOS) || VISION_OS @available(iOS 15.0, *) diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift new file mode 100644 index 000000000..982beb6f3 --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift @@ -0,0 +1,135 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit +import StoreKitTest +import XCTest + +final class PurchaseProviderTests: XCTestCase { + // MARK: Properties + + private var paymentQueueMock: PaymentQueueMock! + private var paymentProviderMock: PaymentProviderMock! + + private var sut: PurchaseProvider! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + paymentQueueMock = PaymentQueueMock() + paymentProviderMock = PaymentProviderMock() + sut = PurchaseProvider( + paymentProvider: paymentProviderMock + ) + } + + override func tearDown() { + paymentQueueMock = nil + paymentProviderMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK1ProductExist() { + // given + let productMock = StoreProduct(skProduct: ProductMock()) + + paymentProviderMock.stubbedAddResult = (paymentQueueMock, .success(SKPaymentTransaction())) + + // when + sut.purchase(product: productMock) { result in + if case let .success(transaction) = result { + XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) + } else { + XCTFail("The products' ids must be equal") + } + } + } + +// #if os(iOS) +// @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +// func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK2ProductExist() async throws { +// guard #available(iOS 17.0, *) else { +// throw XCTSkip("This test is currently working only on iOS 17") +// } +// +// // given + //// let url = Bundle.module.url(forResource: "Flare", withExtension: "storekit")! +// let session = try SKTestSession(configurationFileNamed: "Flare") +// session.disableDialogs = true +// session.clearTransactions() +// + //// try await session.buyProduct(productIdentifier: ProductProviderHelper.all[0].id) +// +// let expectation = XCTestExpectation(description: "Purchase a product") +// let productMock = StoreProduct(product: try await ProductProviderHelper.all[0]) +// +// // when +// sut.purchase(product: productMock) { result in +// switch result { +// case let .success(transaction): +// XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) +// expectation.fulfill() +// case let .failure(error): +// print(error) +// } +// } +// +// wait(for: [expectation], timeout: 2.0) +// } +// #endif + + func test_thatPurchaseProviderFinishesTransaction() { + // given + let transaction = PurchaseManagerTestHelper.makePaymentTransaction(state: .purchased) + + // when + sut.finish(transaction: PaymentTransaction(transaction)) + + // then + XCTAssertTrue(paymentProviderMock.invokedFinishTransaction) + } + + func test_thatPurchaseProviderAddsTransactionObserver_when() { + // given + let paymentTransactionMock = SKPaymentTransaction() + paymentProviderMock.stubbedFallbackHandlerResult = (paymentQueueMock, .success(paymentTransactionMock)) + + // when + sut.addTransactionObserver(fallbackHandler: { result in + if case let .success(transaction) = result { + XCTAssertTrue(transaction.productIdentifier.isEmpty) + } else { + XCTFail("The products' ids must be equal") + } + }) + } + + func test_thatPurchaseProviderThrowsAnError_whenTransactionObserverDidFail() { + // given + paymentProviderMock.stubbedFallbackHandlerResult = (paymentQueueMock, .failure(IAPError.unknown)) + + // when + sut.addTransactionObserver(fallbackHandler: { result in + if case let .failure(error) = result { + XCTAssertEqual(error, .unknown) + } else { + XCTFail("The errors' types must be equal") + } + }) + } + + func test_thatIAPProviderRemovesTransactionObserver() { + // when + sut.removeTransactionObserver() + + // then + XCTAssertTrue(paymentProviderMock.invokedRemoveTransactionObserver) + } +} From 40ce9139023e0305f6646eaf87429bed20aea1ac Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 27 Dec 2023 15:46:41 +0100 Subject: [PATCH 09/27] Update `purchase(_:)` methods' parameters Replace purchasing a product by ID with passing a product object. --- Package@swift-5.7.swift | 3 - Sources/Flare/Classes/Common/Types.swift | 2 + .../Providers/IAPProvider/IAPProvider.swift | 42 ++++++++------ .../Providers/IAPProvider/IIAPProvider.swift | 47 ++++++++++++++-- .../PurchaseProvider/IPurchaseProvider.swift | 8 +++ .../PurchaseProvider/PurchaseProvider.swift | 26 +++++++-- Sources/Flare/Flare.swift | 29 ++++++++-- Sources/Flare/IFlare.swift | 50 ++++++++++++++--- Tests/FlareTests/Fakes/SKProduct+Fake.swift | 15 +++++ .../FlareTests/Fakes/StoreProduct+Fake.swift | 13 +++++ Tests/FlareTests/Mocks/IAPProviderMock.swift | 55 +++++++++++++++---- .../Mocks/PurchaseProviderMock.swift | 24 ++++++++ Tests/FlareTests/UnitTests/FlareTests.swift | 14 ++--- .../Providers/IAPProviderTests.swift | 44 ++------------- 14 files changed, 274 insertions(+), 98 deletions(-) create mode 100644 Tests/FlareTests/Fakes/SKProduct+Fake.swift create mode 100644 Tests/FlareTests/Fakes/StoreProduct+Fake.swift diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift index 61fb058d1..f933cf240 100644 --- a/Package@swift-5.7.swift +++ b/Package@swift-5.7.swift @@ -33,9 +33,6 @@ let package = Package( .product(name: "ObjectsFactory", package: "objects-factory"), .product(name: "TestConcurrency", package: "concurrency"), ] -// resources: [ -// .process("Flare.storekit"), -// ] ), ] ) diff --git a/Sources/Flare/Classes/Common/Types.swift b/Sources/Flare/Classes/Common/Types.swift index 6d71b8f40..74d4d65d2 100644 --- a/Sources/Flare/Classes/Common/Types.swift +++ b/Sources/Flare/Classes/Common/Types.swift @@ -7,3 +7,5 @@ import Foundation public typealias Closure = (T) -> Void public typealias Closure2 = (T, U) -> Void + +public typealias SendableClosure = @Sendable (T) -> Void diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index 1ba1eb76f..118e27606 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -80,32 +80,38 @@ final class IAPProvider: IIAPProvider { } } - func purchase(productID: String, completion: @escaping Closure>) { - productProvider.fetch(productIDs: [productID], requestID: UUID().uuidString) { result in + func purchase(product: StoreProduct, completion: @escaping Closure>) { + purchaseProvider.purchase(product: product) { result in switch result { - case let .success(products): - guard let product = products.first else { - completion(.failure(.storeProductNotAvailable)) - return - } - - self.purchaseProvider.purchase(product: StoreProduct(skProduct: product.product)) { result in - switch result { - case let .success(transaction): - completion(.success(transaction)) - case let .failure(error): - completion(.failure(error)) - } - } + case let .success(transaction): + completion(.success(transaction)) case let .failure(error): completion(.failure(error)) } } } - func purchase(productID: String) async throws -> StoreTransaction { + func purchase(product: StoreProduct) async throws -> StoreTransaction { + try await withCheckedThrowingContinuation { continuation in + purchase(product: product) { result in + continuation.resume(with: result) + } + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping SendableClosure> + ) { + purchaseProvider.purchase(product: product, options: options, completion: completion) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase(product: StoreProduct, options: Set) async throws -> StoreTransaction { try await withCheckedThrowingContinuation { continuation in - purchase(productID: productID) { result in + purchase(product: product, options: options) { result in continuation.resume(with: result) } } diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift index 269fc9d1d..a7197ef93 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift @@ -26,16 +26,50 @@ public protocol IIAPProvider { /// - Returns: An array of products. func fetch(productIDs: Set) async throws -> [StoreProduct] - /// Performs a purchase of a product with a given ID. + /// Performs a purchase of a product. /// /// - Note: The method automatically checks if the user can purchase a product. /// If the user can't make a payment, the method returns an error /// with the type `IAPError.paymentNotAllowed`. /// /// - Parameters: - /// - productID: The product identifier. + /// - product: The product to be purchased. /// - completion: The closure to be executed once the purchase is complete. - func purchase(productID: String, completion: @escaping Closure>) + func purchase(product: StoreProduct, completion: @escaping Closure>) + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameter product: The product to be purchased. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + func purchase(product: StoreProduct) async throws -> StoreTransaction + + /// Purchases a product with a given ID. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - completion: The closure to be executed once the purchase is complete. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping SendableClosure> + ) /// Purchases a product with a given ID. /// @@ -43,12 +77,15 @@ public protocol IIAPProvider { /// If the user can't make a payment, the method returns an error /// with the type `IAPError.paymentNotAllowed`. /// - /// - Parameter productID: The product identifier. + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. /// /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. /// /// - Returns: A payment transaction. - func purchase(productID: String) async throws -> StoreTransaction + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase(product: StoreProduct, options: Set) async throws -> StoreTransaction /// Refreshes the receipt, representing the user's transactions with your app. /// diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift index cf6cfe882..a58abbb6c 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift @@ -4,6 +4,7 @@ // import Foundation +import StoreKit public typealias PurchaseCompletionHandler = @MainActor @Sendable (Result) -> Void @@ -29,4 +30,11 @@ protocol IPurchaseProvider { func removeTransactionObserver() func purchase(product: StoreProduct, completion: @escaping PurchaseCompletionHandler) + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping PurchaseCompletionHandler + ) } diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift index a0461a23e..95112a083 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift @@ -13,16 +13,16 @@ final class PurchaseProvider { /// The provider is responsible for making in-app payments. private let paymentProvider: IPaymentProvider - /// <#Description#> + /// The transaction listener. private let transactionListener: ITransactionListener? // MARK: Initialization - /// <#Description#> + /// Creates a new `PurchaseProvider` isntance. /// /// - Parameters: - /// - paymentProvider: <#paymentProvider description#> - /// - transactionListener: <#transactionListener description#> + /// - paymentProvider: The provider is responsible for purchasing products. + /// - transactionListener: The transaction listener. init( paymentProvider: IPaymentProvider = PaymentProvider(), transactionListener: ITransactionListener? = nil @@ -60,6 +60,7 @@ final class PurchaseProvider { @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) private func purchase( sk2StoreProduct: SK2StoreProduct, + options: Set? = nil, completion: @escaping @MainActor (Result) -> Void ) { AsyncHandler.call(completion: { result in @@ -76,7 +77,7 @@ final class PurchaseProvider { } } }, asyncMethod: { - try await sk2StoreProduct.product.purchase(options: [.simulatesAskToBuyInSandbox(false)]) + try await sk2StoreProduct.product.purchase(options: options ?? []) }) } } @@ -94,6 +95,21 @@ extension PurchaseProvider: IPurchaseProvider { } } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping PurchaseCompletionHandler + ) { + if let sk2Product = product.underlyingProduct as? SK2StoreProduct { + purchase(sk2StoreProduct: sk2Product, options: options, completion: completion) + } else { + Task { + await completion(.failure(.unknown)) + } + } + } + func finish(transaction: PaymentTransaction) { paymentProvider.finish(transaction: transaction) } diff --git a/Sources/Flare/Flare.swift b/Sources/Flare/Flare.swift index 6aac551e9..84d57e759 100644 --- a/Sources/Flare/Flare.swift +++ b/Sources/Flare/Flare.swift @@ -44,13 +44,13 @@ extension Flare: IFlare { try await iapProvider.fetch(productIDs: productIDs) } - public func purchase(productID: String, completion: @escaping Closure>) { + public func purchase(product: StoreProduct, completion: @escaping Closure>) { guard iapProvider.canMakePayments else { completion(.failure(.paymentNotAllowed)) return } - iapProvider.purchase(productID: productID) { result in + iapProvider.purchase(product: product) { result in switch result { case let .success(transaction): completion(.success(transaction)) @@ -60,9 +60,30 @@ extension Flare: IFlare { } } - public func purchase(productID: String) async throws -> StoreTransaction { + public func purchase(product: StoreProduct) async throws -> StoreTransaction { guard iapProvider.canMakePayments else { throw IAPError.paymentNotAllowed } - return try await iapProvider.purchase(productID: productID) + return try await iapProvider.purchase(product: product) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public func purchase( + product: StoreProduct, + options: Set, + completion: @escaping SendableClosure> + ) { + guard iapProvider.canMakePayments else { + completion(.failure(.paymentNotAllowed)) + return + } + iapProvider.purchase(product: product, options: options, completion: completion) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public func purchase( + product: StoreProduct, + options: Set + ) async throws -> StoreTransaction { + try await iapProvider.purchase(product: product, options: options) } public func receipt(completion: @escaping Closure>) { diff --git a/Sources/Flare/IFlare.swift b/Sources/Flare/IFlare.swift index 4a80aa0d6..6ee5794c1 100644 --- a/Sources/Flare/IFlare.swift +++ b/Sources/Flare/IFlare.swift @@ -24,32 +24,66 @@ public protocol IFlare { /// - Returns: An array of products. func fetch(productIDs: Set) async throws -> [StoreProduct] - /// Performs a purchase of a product with a given ID. + /// Performs a purchase of a product. /// /// - Note: The method automatically checks if the user can purchase a product. /// If the user can't make a payment, the method returns an error /// with the type `IAPError.paymentNotAllowed`. /// /// - Parameters: - /// - productID: The product identifier. + /// - product: The product to be purchased. /// - completion: The closure to be executed once the purchase is complete. - func purchase(productID: String, completion: @escaping Closure>) + func purchase(product: StoreProduct, completion: @escaping Closure>) - /// Purchases a product with a given ID. + /// Purchases a product. /// /// - Note: The method automatically checks if the user can purchase a product. /// If the user can't make a payment, the method returns an error /// with the type `IAPError.paymentNotAllowed`. /// - /// - Parameter productID: The product identifier. + /// - Parameter product: The product to be purchased. /// /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. /// /// - Returns: A payment transaction. - func purchase(productID: String) async throws -> StoreTransaction + func purchase(product: StoreProduct) async throws -> StoreTransaction -// @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) -// func purchase(productID: String, options: Set) async throws -> StoreTransaction + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - completion: The closure to be executed once the purchase is complete. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping SendableClosure> + ) + + /// Purchases a product. + /// + /// - Note: The method automatically checks if the user can purchase a product. + /// If the user can't make a payment, the method returns an error + /// with the type `IAPError.paymentNotAllowed`. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// + /// - Throws: `IAPError.paymentNotAllowed` if user can't make payment. + /// + /// - Returns: A payment transaction. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase(product: StoreProduct, options: Set) async throws -> StoreTransaction /// Refreshes the receipt, representing the user's transactions with your app. /// diff --git a/Tests/FlareTests/Fakes/SKProduct+Fake.swift b/Tests/FlareTests/Fakes/SKProduct+Fake.swift new file mode 100644 index 000000000..a748b6270 --- /dev/null +++ b/Tests/FlareTests/Fakes/SKProduct+Fake.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +extension SKProduct { + static func fake(id: String) -> SKProduct { + let product = ProductMock() + product.stubbedProductIdentifier = id + return product + } +} diff --git a/Tests/FlareTests/Fakes/StoreProduct+Fake.swift b/Tests/FlareTests/Fakes/StoreProduct+Fake.swift new file mode 100644 index 000000000..4f93ff255 --- /dev/null +++ b/Tests/FlareTests/Fakes/StoreProduct+Fake.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Flare +import StoreKit + +extension StoreProduct { + static func fake(skProduct: SKProduct = ProductMock()) -> StoreProduct { + StoreProduct(skProduct: skProduct) + } +} diff --git a/Tests/FlareTests/Mocks/IAPProviderMock.swift b/Tests/FlareTests/Mocks/IAPProviderMock.swift index 77aac0fe0..59e21f5f9 100644 --- a/Tests/FlareTests/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/Mocks/IAPProviderMock.swift @@ -31,14 +31,14 @@ final class IAPProviderMock: IIAPProvider { var invokedPurchase = false var invokedPurchaseCount = 0 - var invokedPurchaseParameters: (productID: String, completion: Closure>)? - var invokedPurchaseParametersList = [(productID: String, completion: Closure>)]() + var invokedPurchaseParameters: (product: StoreProduct, completion: Closure>)? + var invokedPurchaseParametersList = [(product: StoreProduct, completion: Closure>)]() - func purchase(productID: String, completion: @escaping Closure>) { + func purchase(product: StoreProduct, completion: @escaping Closure>) { invokedPurchase = true invokedPurchaseCount += 1 - invokedPurchaseParameters = (productID, completion) - invokedPurchaseParametersList.append((productID, completion)) + invokedPurchaseParameters = (product, completion) + invokedPurchaseParametersList.append((product, completion)) } var invokedRefreshReceipt = false @@ -106,15 +106,15 @@ final class IAPProviderMock: IIAPProvider { var invokedAsyncPurchase = false var invokedAsyncPurchaseCount = 0 - var invokedAsyncPurchaseParameters: (productID: String, Void)? - var invokedAsyncPurchaseParametersList = [(productID: String, Void)?]() + var invokedAsyncPurchaseParameters: (product: StoreProduct, Void)? + var invokedAsyncPurchaseParametersList = [(product: StoreProduct, Void)?]() var stubbedAsyncPurchase: StoreTransaction! - func purchase(productID: String) async throws -> StoreTransaction { + func purchase(product: StoreProduct) async throws -> StoreTransaction { invokedAsyncPurchase = true invokedAsyncPurchaseCount += 1 - invokedAsyncPurchaseParameters = (productID, ()) - invokedAsyncPurchaseParametersList.append((productID, ())) + invokedAsyncPurchaseParameters = (product, ()) + invokedAsyncPurchaseParametersList.append((product, ())) return stubbedAsyncPurchase } @@ -150,4 +150,39 @@ final class IAPProviderMock: IIAPProvider { invokedBeginRefundRequestParametersList.append((productID, ())) return stubbedBeginRefundRequest } + + var invokedPurchaseWithOptions = false + var invokedPurchaseWithOptionsCount = 0 + var invokedPurchaseWithOptionsParameters: (product: StoreProduct, options: Any)? + var invokedPurchaseWithOptionsParametersList = [(product: StoreProduct, options: Any)]() + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set, + completion _: @escaping SendableClosure> + ) { + invokedPurchaseWithOptions = true + invokedPurchaseWithOptionsCount += 1 + invokedPurchaseWithOptionsParameters = (product, options) + invokedPurchaseWithOptionsParametersList.append((product, options)) + } + + var invokedAsyncPurchaseWithOptions = false + var invokedAsyncPurchaseWithOptionsCount = 0 + var invokedAsyncPurchaseWithOptionsParameters: (product: StoreProduct, options: Any)? + var invokedAsyncPurchaseWithOptionsParametersList = [(product: StoreProduct, options: Any)]() + var stubbedAsyncPurchaseWithOptions: StoreTransaction! + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func purchase( + product: StoreProduct, + options: Set + ) async throws -> StoreTransaction { + invokedAsyncPurchaseWithOptions = true + invokedAsyncPurchaseWithOptionsCount += 1 + invokedAsyncPurchaseWithOptionsParameters = (product, options) + invokedAsyncPurchaseWithOptionsParametersList.append((product, options)) + return stubbedAsyncPurchaseWithOptions + } } diff --git a/Tests/FlareTests/Mocks/PurchaseProviderMock.swift b/Tests/FlareTests/Mocks/PurchaseProviderMock.swift index 8feb555f8..8e7696dfb 100644 --- a/Tests/FlareTests/Mocks/PurchaseProviderMock.swift +++ b/Tests/FlareTests/Mocks/PurchaseProviderMock.swift @@ -4,6 +4,7 @@ // @testable import Flare +import StoreKit final class PurchaseProviderMock: IPurchaseProvider { var invokedFinish = false @@ -54,4 +55,27 @@ final class PurchaseProviderMock: IPurchaseProvider { completion(result.0) } } + + var invokedPurchaseWithOptions = false + var invokedPurchaseWithOptionsCount = 0 + var invokedPurchaseWithOptionsParameters: (product: StoreProduct, Any)? + var invokedPurchaseWithOptionsParametersList = [(product: StoreProduct, Any)]() + var stubbedinvokedPurchaseWithOptionsCompletionResult: (Result, Void)? + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + @MainActor + func purchase( + product: StoreProduct, + options: Set, + completion: @escaping PurchaseCompletionHandler + ) { + invokedPurchaseWithOptions = true + invokedPurchaseWithOptionsCount += 1 + invokedPurchaseWithOptionsParameters = (product, options) + invokedPurchaseWithOptionsParametersList.append((product, options)) + + if let result = stubbedinvokedPurchaseWithOptionsCompletionResult { + completion(result.0) + } + } } diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index c2a003eee..191f39355 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -60,11 +60,11 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedCanMakePayments = true // when - flare.purchase(productID: .productID, completion: { _ in }) + flare.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) // then XCTAssertTrue(iapProviderMock.invokedPurchase) - XCTAssertEqual(iapProviderMock.invokedPurchaseParameters?.productID, .productID) + XCTAssertEqual(iapProviderMock.invokedPurchaseParameters?.product.productIdentifier, .productID) } func test_thatFlareDoesNotPurchaseAProduct_whenUserCannotMakePayments() { @@ -72,7 +72,7 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedCanMakePayments = false // when - flare.purchase(productID: .productID, completion: { _ in }) + flare.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) // then XCTAssertFalse(iapProviderMock.invokedPurchase) @@ -85,7 +85,7 @@ class FlareTests: XCTestCase { // when var transaction: IStoreTransaction? - flare.purchase(productID: .productID, completion: { result in + flare.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { result in if case let .success(result) = result { transaction = result } @@ -104,7 +104,7 @@ class FlareTests: XCTestCase { // when var error: IAPError? - flare.purchase(productID: .productID, completion: { result in + flare.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { result in if case let .failure(result) = result { error = result } @@ -124,7 +124,7 @@ class FlareTests: XCTestCase { // when var iapError: IAPError? do { - _ = try await flare.purchase(productID: .productID) + _ = try await flare.purchase(product: .fake(skProduct: .fake(id: .productID))) } catch { iapError = error as? IAPError } @@ -145,7 +145,7 @@ class FlareTests: XCTestCase { var transaction: IStoreTransaction? var iapError: IAPError? do { - transaction = try await flare.purchase(productID: .productID) + transaction = try await flare.purchase(product: .fake(skProduct: .fake(id: .productID))) } catch { iapError = error as? IAPError } diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index 526de30a5..80dd5c89c 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -72,10 +72,10 @@ class IAPProviderTests: XCTestCase { func test_thatIAPProviderPurchasesProduct() throws { // when - iapProvider.purchase(productID: .productID, completion: { _ in }) + iapProvider.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) // then - XCTAssertTrue(productProviderMock.invokedFetch) + XCTAssertTrue(purchaseProvider.invokedPurchase) } func test_thatIAPProviderRefreshesReceipt() { @@ -165,22 +165,6 @@ class IAPProviderTests: XCTestCase { XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) } - func test_thatIAPProviderThrowsStoreProductNotAvailableError_whenProductProviderDoesNotHaveProducts() { - // given - productProviderMock.stubbedFetchResult = .success([]) - - // when - var error: Error? - iapProvider.purchase(productID: .productID) { result in - if case let .failure(result) = result { - error = result - } - } - - // then - XCTAssertEqual(error as? NSError, IAPError.storeProductNotAvailable as NSError) - } - func test_thatIAPProviderReturnsError_whenAddingPaymentFailed() { // given productProviderMock.stubbedFetchResult = .success([SK1StoreProduct(ProductMock())]) @@ -188,7 +172,7 @@ class IAPProviderTests: XCTestCase { // when var errorResult: Error? - iapProvider.purchase(productID: .productID) { result in + iapProvider.purchase(product: .fake(skProduct: .fake(id: .productID))) { result in if case let .failure(error) = result { errorResult = error } @@ -200,34 +184,18 @@ class IAPProviderTests: XCTestCase { func test_thatIAPProviderReturnsError_whenFetchRequestFailed() { // given - productProviderMock.stubbedFetchResult = .failure(.storeProductNotAvailable) + purchaseProvider.stubbedPurchaseCompletionResult = (.failure(IAPError.unknown), ()) // when var errorResult: Error? - iapProvider.purchase(productID: .productID) { result in + iapProvider.purchase(product: .fake(skProduct: .fake(id: .productID))) { result in if case let .failure(error) = result { errorResult = error } } // then - XCTAssertEqual(errorResult as? NSError, IAPError.storeProductNotAvailable as NSError) - } - - func test_thatIAPProviderThrowsStoreProductNotAvailableError_whenProductsDoNotExist() async throws { - // given - productProviderMock.stubbedFetchResult = .success([]) - - // when - var errorResult: Error? - do { - _ = try await iapProvider.purchase(productID: .productID) - } catch { - errorResult = error - } - - // then - XCTAssertEqual(errorResult as? NSError, IAPError.storeProductNotAvailable as NSError) + XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) } func test_thatIAPProviderRefreshesReceipt_when() { From 04839f29be0476fa561152e279cb316d2e5af141 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Thu, 28 Dec 2023 13:40:37 +0100 Subject: [PATCH 10/27] Integrate a host app for unit-tests A host app provides a means to test in-app purchases using the StoreKit2 API. --- .gitignore | 1 + Package@swift-5.7.swift | 3 + Sources/Flare/Flare.swift | 3 +- Tests/FlareTests/Flare.storekit | 106 ------------------ .../UnitTestHostApp/AppDelegate.swift | 26 +++++ .../AccentColor.colorset/Contents.json | 11 ++ .../AppIcon.appiconset/Contents.json | 98 ++++++++++++++++ .../Assets.xcassets/Contents.json | 6 + Tests/FlareTests/UnitTestHostApp/Info.plist | 45 ++++++++ .../Extensions/StoreKitSessionTestCase.swift | 18 +++ Tests/FlareTests/UnitTests/Flare.storekit | 48 ++++++++ Tests/FlareTests/UnitTests/FlareTests.swift | 49 ++++++++ .../Providers/IAPProviderTests.swift | 26 ++--- .../Providers/PurchaseProviderTests.swift | 71 ++++++------ .../RefundRequestProviderTests.swift | 36 +++--- .../TestHelpers}/Fakes/SKProduct+Fake.swift | 0 .../Fakes/StoreProduct+Fake.swift | 0 .../Fakes/StoreTransactionFake.swift | 0 .../Helpers/AvailabilityChecker.swift | 0 .../Helpers/ProductProviderHelper.swift | 0 .../Helpers/PurchaseManagerTestHelper.swift | 0 .../Helpers/WindowSceneFactory.swift | 0 .../Mocks/AppStoreReceiptProviderMock.swift | 0 .../TestHelpers}/Mocks/FileManagerMock.swift | 0 .../TestHelpers}/Mocks/IAPProviderMock.swift | 7 +- .../Mocks/PaymentProviderMock.swift | 0 .../TestHelpers}/Mocks/PaymentQueueMock.swift | 0 .../Mocks/PaymentTransactionMock.swift | 0 .../TestHelpers}/Mocks/ProductMock.swift | 0 .../Mocks/ProductProviderMock.swift | 0 .../Mocks/ProductResponseMock.swift | 0 .../Mocks/ProductsRequestMock.swift | 0 .../Mocks/PurchaseProviderMock.swift | 0 .../Mocks/ReceiptRefreshProviderMock.swift | 0 .../Mocks/ReceiptRefreshRequestFactory.swift | 0 .../Mocks/ReceiptRefreshRequestMock.swift | 0 .../Mocks/RefundProviderMock.swift | 0 .../Mocks/RefundRequestProviderMock.swift | 0 .../TestHelpers}/Mocks/ScenesHolderMock.swift | 0 .../Mocks/StoreTransactionMock.swift | 0 .../Mocks/SystemInfoProviderMock.swift | 0 .../Stubs/StoreTransactionStub.swift | 0 project.yml | 59 ++++++++++ 43 files changed, 438 insertions(+), 175 deletions(-) delete mode 100644 Tests/FlareTests/Flare.storekit create mode 100644 Tests/FlareTests/UnitTestHostApp/AppDelegate.swift create mode 100644 Tests/FlareTests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Tests/FlareTests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Tests/FlareTests/UnitTestHostApp/Assets.xcassets/Contents.json create mode 100644 Tests/FlareTests/UnitTestHostApp/Info.plist create mode 100644 Tests/FlareTests/UnitTests/Extensions/StoreKitSessionTestCase.swift create mode 100644 Tests/FlareTests/UnitTests/Flare.storekit rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Fakes/SKProduct+Fake.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Fakes/StoreProduct+Fake.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Fakes/StoreTransactionFake.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Helpers/AvailabilityChecker.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Helpers/ProductProviderHelper.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Helpers/PurchaseManagerTestHelper.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Helpers/WindowSceneFactory.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/AppStoreReceiptProviderMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/FileManagerMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/IAPProviderMock.swift (96%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/PaymentProviderMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/PaymentQueueMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/PaymentTransactionMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/ProductMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/ProductProviderMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/ProductResponseMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/ProductsRequestMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/PurchaseProviderMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/ReceiptRefreshProviderMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/ReceiptRefreshRequestFactory.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/ReceiptRefreshRequestMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/RefundProviderMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/RefundRequestProviderMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/ScenesHolderMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/StoreTransactionMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Mocks/SystemInfoProviderMock.swift (100%) rename Tests/FlareTests/{ => UnitTests/TestHelpers}/Stubs/StoreTransactionStub.swift (100%) create mode 100644 project.yml diff --git a/.gitignore b/.gitignore index 330d1674f..5ca27c72b 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ +*.xcodeproj \ No newline at end of file diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift index f933cf240..5a2b1d50b 100644 --- a/Package@swift-5.7.swift +++ b/Package@swift-5.7.swift @@ -32,6 +32,9 @@ let package = Package( "Flare", .product(name: "ObjectsFactory", package: "objects-factory"), .product(name: "TestConcurrency", package: "concurrency"), + ], + resources: [ + .process("Flare.storekit"), ] ), ] diff --git a/Sources/Flare/Flare.swift b/Sources/Flare/Flare.swift index 84d57e759..d2278acb3 100644 --- a/Sources/Flare/Flare.swift +++ b/Sources/Flare/Flare.swift @@ -83,7 +83,8 @@ extension Flare: IFlare { product: StoreProduct, options: Set ) async throws -> StoreTransaction { - try await iapProvider.purchase(product: product, options: options) + guard iapProvider.canMakePayments else { throw IAPError.paymentNotAllowed } + return try await iapProvider.purchase(product: product, options: options) } public func receipt(completion: @escaping Closure>) { diff --git a/Tests/FlareTests/Flare.storekit b/Tests/FlareTests/Flare.storekit deleted file mode 100644 index c067ffaf8..000000000 --- a/Tests/FlareTests/Flare.storekit +++ /dev/null @@ -1,106 +0,0 @@ -{ - "identifier" : "8783124E", - "nonRenewingSubscriptions" : [ - - ], - "products" : [ - { - "displayPrice" : "0.99", - "familyShareable" : false, - "internalID" : "630EB7DE", - "localizations" : [ - { - "description" : "Temp Subscription", - "displayName" : "Temp Subscription", - "locale" : "en_US" - } - ], - "productID" : "com.flare.test_purchase_1", - "referenceName" : "TestPurchase1", - "type" : "Consumable" - }, - { - "displayPrice" : "0.99", - "familyShareable" : false, - "internalID" : "17CD4A2E", - "localizations" : [ - { - "description" : "Temp Subscription", - "displayName" : "Temp Subscription", - "locale" : "en_US" - } - ], - "productID" : "com.flare.test_purchase_2", - "referenceName" : "TestPurchase2", - "type" : "Consumable" - } - ], - "settings" : { - - }, - "subscriptionGroups" : [ - { - "id" : "791CA910", - "localizations" : [ - - ], - "name" : "flare_subscription", - "subscriptions" : [ - { - "adHocOffers" : [ - - ], - "codeOffers" : [ - - ], - "displayPrice" : "0.99", - "familyShareable" : false, - "groupNumber" : 1, - "internalID" : "E6933AB5", - "introductoryOffer" : null, - "localizations" : [ - { - "description" : "", - "displayName" : "TestSubscription1", - "locale" : "en_US" - } - ], - "productID" : "com.flare.test_subscription_1", - "recurringSubscriptionPeriod" : "P1M", - "referenceName" : "TestSubscription1", - "subscriptionGroupID" : "791CA910", - "type" : "RecurringSubscription" - }, - { - "adHocOffers" : [ - - ], - "codeOffers" : [ - - ], - "displayPrice" : "1.29", - "familyShareable" : true, - "groupNumber" : 1, - "internalID" : "1F6680BD", - "introductoryOffer" : null, - "localizations" : [ - { - "description" : "", - "displayName" : "TestSubscription2", - "locale" : "en_US" - } - ], - "productID" : "com.flare.test_subscription_2", - "recurringSubscriptionPeriod" : "P3M", - "referenceName" : "TestSubscription2", - "subscriptionGroupID" : "791CA910", - "type" : "RecurringSubscription" - } - ] - } - ], - "version" : { - "major" : 2, - "minor" : 0 - } -} diff --git a/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift b/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift new file mode 100644 index 000000000..603d7aff0 --- /dev/null +++ b/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift @@ -0,0 +1,26 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import SwiftUI + +#if os(watchOS) || os(tvOS) + + @main + struct TestApp: App { + var body: some Scene { + WindowGroup { + Text("Hello World") + } + } + } + +#else + + // Scene isn't available until iOS 14.0, so this is for backwards compatibility. + + @main + class AppDelegate: UIResponder, UIApplicationDelegate {} + +#endif diff --git a/Tests/FlareTests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json b/Tests/FlareTests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Tests/FlareTests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/FlareTests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Tests/FlareTests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..9221b9bb1 --- /dev/null +++ b/Tests/FlareTests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/FlareTests/UnitTestHostApp/Assets.xcassets/Contents.json b/Tests/FlareTests/UnitTestHostApp/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Tests/FlareTests/UnitTestHostApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/FlareTests/UnitTestHostApp/Info.plist b/Tests/FlareTests/UnitTestHostApp/Info.plist new file mode 100644 index 000000000..8a7dc9a70 --- /dev/null +++ b/Tests/FlareTests/UnitTestHostApp/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + StoreKitUnitTestsHostApp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 4.32.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Tests/FlareTests/UnitTests/Extensions/StoreKitSessionTestCase.swift b/Tests/FlareTests/UnitTests/Extensions/StoreKitSessionTestCase.swift new file mode 100644 index 000000000..298b4cf3f --- /dev/null +++ b/Tests/FlareTests/UnitTests/Extensions/StoreKitSessionTestCase.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import StoreKit +import StoreKitTest +import XCTest + +class StoreKitSessionTestCase: XCTestCase { + // MARK: Properties + + private var session: SKTestSession! + + // MARK: XCTestCase + +// override func +} diff --git a/Tests/FlareTests/UnitTests/Flare.storekit b/Tests/FlareTests/UnitTests/Flare.storekit new file mode 100644 index 000000000..386d0cc2f --- /dev/null +++ b/Tests/FlareTests/UnitTests/Flare.storekit @@ -0,0 +1,48 @@ +{ + "identifier" : "15BDB648", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "55B686B4", + "localizations" : [ + { + "description" : "com.flare.test_purchase_1", + "displayName" : "com.flare.test_purchase_1", + "locale" : "en_US" + } + ], + "productID" : "com.flare.test_purchase_1", + "referenceName" : "com.flare.test_purchase_1", + "type" : "NonConsumable" + }, + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "63681F89", + "localizations" : [ + { + "description" : "com.flare.test_purchase_2", + "displayName" : "com.flare.test_purchase_2", + "locale" : "en_US" + } + ], + "productID" : "com.flare.test_purchase_2", + "referenceName" : "com.flare.test_purchase_2", + "type" : "Consumable" + } + ], + "settings" : { + + }, + "subscriptionGroups" : [ + + ], + "version" : { + "major" : 2, + "minor" : 0 + } +} diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 191f39355..c060e06b6 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -267,6 +267,55 @@ class FlareTests: XCTestCase { else { XCTFail("state must be `failed`") } } #endif + + func test_thatFlarePurchasesWithOptions_whenPurchaseCompleteSuccessfully() async throws { + // given + let product = try await ProductProviderHelper.all.randomElement() + let storeTransactionStub = StoreTransactionStub() + storeTransactionStub.stubbedProductIdentifier = product?.id + + iapProviderMock.stubbedCanMakePayments = true + iapProviderMock.stubbedAsyncPurchaseWithOptions = StoreTransaction( + storeTransaction: storeTransactionStub + ) + + // when + let transaction = try await flare.purchase( + product: StoreProduct(product: product!), + options: [.simulatesAskToBuyInSandbox(false)] + ) + + // then + XCTAssertEqual(transaction.productIdentifier, product?.id) + } + + func test_thatFlarePurchasesWithOptionsAndCompletionHandler_whenPurchaseCompleteSuccessfully() async throws { + // given + let expectation = XCTestExpectation(description: "Purchase a product") + + let product = try await ProductProviderHelper.all.randomElement() + let storeTransactionStub = StoreTransactionStub() + storeTransactionStub.stubbedProductIdentifier = product?.id + + iapProviderMock.stubbedCanMakePayments = true + iapProviderMock.stubbedPurchaseWithOptionsResult = .success(StoreTransaction(storeTransaction: storeTransactionStub)) + + // when + flare.purchase( + product: StoreProduct(product: product!), + options: [.simulatesAskToBuyInSandbox(false)] + ) { result in + if case let .success(transaction) = result { + XCTAssertEqual(transaction.productIdentifier, product?.id) + expectation.fulfill() + } else { + XCTFail("Purchase should complete successfully") + } + } + + // then + wait(for: [expectation], timeout: 2.0) + } } // MARK: - Constants diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index 80dd5c89c..6f0f75a48 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -128,24 +128,18 @@ class IAPProviderTests: XCTestCase { XCTAssertEqual(productsMock.count, products.count) } - #if os(iOS) - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func test_thatIAPProviderFetchesSK2Products_whenProductsAreAvailable() async throws { - guard #available(iOS 17.0, *) else { - throw XCTSkip("This test is currently working only on iOS 17") - } - - let productsMock = try await ProductProviderHelper.all.map(SK2StoreProduct.init) - productProviderMock.stubbedAsyncFetchResult = .success(productsMock) + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_thatIAPProviderFetchesSK2Products_whenProductsAreAvailable() async throws { + let productsMock = try await ProductProviderHelper.all.map(SK2StoreProduct.init) + productProviderMock.stubbedAsyncFetchResult = .success(productsMock) - // when - let products = try await iapProvider.fetch(productIDs: .productIDs) + // when + let products = try await iapProvider.fetch(productIDs: .productIDs) - // then - XCTAssertFalse(products.isEmpty) - XCTAssertEqual(productsMock.count, products.count) - } - #endif + // then + XCTAssertFalse(products.isEmpty) + XCTAssertEqual(productsMock.count, products.count) + } func test_thatIAPProviderThrowsNoProductsError_whenProductsProductProviderReturnsError() async throws { try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift index 982beb6f3..6a5a24768 100644 --- a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift @@ -52,38 +52,43 @@ final class PurchaseProviderTests: XCTestCase { } } -// #if os(iOS) -// @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) -// func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK2ProductExist() async throws { -// guard #available(iOS 17.0, *) else { -// throw XCTSkip("This test is currently working only on iOS 17") -// } -// -// // given - //// let url = Bundle.module.url(forResource: "Flare", withExtension: "storekit")! -// let session = try SKTestSession(configurationFileNamed: "Flare") -// session.disableDialogs = true -// session.clearTransactions() -// - //// try await session.buyProduct(productIdentifier: ProductProviderHelper.all[0].id) -// -// let expectation = XCTestExpectation(description: "Purchase a product") -// let productMock = StoreProduct(product: try await ProductProviderHelper.all[0]) -// -// // when -// sut.purchase(product: productMock) { result in -// switch result { -// case let .success(transaction): -// XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) -// expectation.fulfill() -// case let .failure(error): -// print(error) -// } -// } -// -// wait(for: [expectation], timeout: 2.0) -// } -// #endif + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK2ProductExist() async throws { + let expectation = XCTestExpectation(description: "Purchase a product") + let productMock = try StoreProduct(product: await ProductProviderHelper.purchases[0]) + + // when + sut.purchase(product: productMock) { result in + switch result { + case let .success(transaction): + XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) + expectation.fulfill() + case let .failure(error): + XCTFail(error.localizedDescription) + } + } + + wait(for: [expectation], timeout: 2.0) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_thatPurchaseProviderReturnsPaymentTransaction_whenPurchasesAProductWithOptions() async throws { + let expectation = XCTestExpectation(description: "Purchase a product") + let productMock = try StoreProduct(product: await ProductProviderHelper.purchases[0]) + + // when + sut.purchase(product: productMock, options: [.simulatesAskToBuyInSandbox(false)]) { result in + switch result { + case let .success(transaction): + XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) + expectation.fulfill() + case let .failure(error): + XCTFail(error.localizedDescription) + } + } + + wait(for: [expectation], timeout: 2.0) + } func test_thatPurchaseProviderFinishesTransaction() { // given @@ -96,7 +101,7 @@ final class PurchaseProviderTests: XCTestCase { XCTAssertTrue(paymentProviderMock.invokedFinishTransaction) } - func test_thatPurchaseProviderAddsTransactionObserver_when() { + func test_thatPurchaseProviderAddsTransactionObserver_whenPaymentDidSuccess() { // given let paymentTransactionMock = SKPaymentTransaction() paymentProviderMock.stubbedFallbackHandlerResult = (paymentQueueMock, .success(paymentTransactionMock)) diff --git a/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift index 6488c5ea6..949ce9214 100644 --- a/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift @@ -28,24 +28,24 @@ import XCTest // MARK: Tests - @MainActor - func test_thatRefundRequestProviderThrowsAnUnknownError_whenRequestDidFailed() async throws { - // given - let windowScene = WindowSceneFactory.makeWindowScene() - - // when - let status = try await sut.beginRefundRequest( - transactionID: .transactionID, - windowScene: windowScene - ) - - // then - if case let .failure(error) = status { - XCTAssertEqual(error as NSError, IAPError.refund(error: .failed) as NSError) - } else { - XCTFail("state must be `failure`") - } - } +// @MainActor +// func test_thatRefundRequestProviderThrowsAnUnknownError_whenRequestDidFailed() async throws { +// // given +// let windowScene = WindowSceneFactory.makeWindowScene() +// +// // when +// let status = try await sut.beginRefundRequest( +// transactionID: .transactionID, +// windowScene: windowScene +// ) +// +// // then +// if case let .failure(error) = status { +// XCTAssertEqual(error as NSError, IAPError.refund(error: .failed) as NSError) +// } else { +// XCTFail("state must be `failure`") +// } +// } } // MARK: - Constants diff --git a/Tests/FlareTests/Fakes/SKProduct+Fake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/SKProduct+Fake.swift similarity index 100% rename from Tests/FlareTests/Fakes/SKProduct+Fake.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Fakes/SKProduct+Fake.swift diff --git a/Tests/FlareTests/Fakes/StoreProduct+Fake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreProduct+Fake.swift similarity index 100% rename from Tests/FlareTests/Fakes/StoreProduct+Fake.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreProduct+Fake.swift diff --git a/Tests/FlareTests/Fakes/StoreTransactionFake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreTransactionFake.swift similarity index 100% rename from Tests/FlareTests/Fakes/StoreTransactionFake.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreTransactionFake.swift diff --git a/Tests/FlareTests/Helpers/AvailabilityChecker.swift b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/AvailabilityChecker.swift similarity index 100% rename from Tests/FlareTests/Helpers/AvailabilityChecker.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Helpers/AvailabilityChecker.swift diff --git a/Tests/FlareTests/Helpers/ProductProviderHelper.swift b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/ProductProviderHelper.swift similarity index 100% rename from Tests/FlareTests/Helpers/ProductProviderHelper.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Helpers/ProductProviderHelper.swift diff --git a/Tests/FlareTests/Helpers/PurchaseManagerTestHelper.swift b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/PurchaseManagerTestHelper.swift similarity index 100% rename from Tests/FlareTests/Helpers/PurchaseManagerTestHelper.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Helpers/PurchaseManagerTestHelper.swift diff --git a/Tests/FlareTests/Helpers/WindowSceneFactory.swift b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift similarity index 100% rename from Tests/FlareTests/Helpers/WindowSceneFactory.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift diff --git a/Tests/FlareTests/Mocks/AppStoreReceiptProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/AppStoreReceiptProviderMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/AppStoreReceiptProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/AppStoreReceiptProviderMock.swift diff --git a/Tests/FlareTests/Mocks/FileManagerMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/FileManagerMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/FileManagerMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/FileManagerMock.swift diff --git a/Tests/FlareTests/Mocks/IAPProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift similarity index 96% rename from Tests/FlareTests/Mocks/IAPProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift index 59e21f5f9..ecbfe2f46 100644 --- a/Tests/FlareTests/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift @@ -155,17 +155,22 @@ final class IAPProviderMock: IIAPProvider { var invokedPurchaseWithOptionsCount = 0 var invokedPurchaseWithOptionsParameters: (product: StoreProduct, options: Any)? var invokedPurchaseWithOptionsParametersList = [(product: StoreProduct, options: Any)]() + var stubbedPurchaseWithOptionsResult: Result? @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func purchase( product: StoreProduct, options: Set, - completion _: @escaping SendableClosure> + completion: @escaping SendableClosure> ) { invokedPurchaseWithOptions = true invokedPurchaseWithOptionsCount += 1 invokedPurchaseWithOptionsParameters = (product, options) invokedPurchaseWithOptionsParametersList.append((product, options)) + + if let result = stubbedPurchaseWithOptionsResult { + completion(result) + } } var invokedAsyncPurchaseWithOptions = false diff --git a/Tests/FlareTests/Mocks/PaymentProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentProviderMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/PaymentProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentProviderMock.swift diff --git a/Tests/FlareTests/Mocks/PaymentQueueMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentQueueMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/PaymentQueueMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentQueueMock.swift diff --git a/Tests/FlareTests/Mocks/PaymentTransactionMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentTransactionMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/PaymentTransactionMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentTransactionMock.swift diff --git a/Tests/FlareTests/Mocks/ProductMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/ProductMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductMock.swift diff --git a/Tests/FlareTests/Mocks/ProductProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/ProductProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift diff --git a/Tests/FlareTests/Mocks/ProductResponseMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductResponseMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/ProductResponseMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductResponseMock.swift diff --git a/Tests/FlareTests/Mocks/ProductsRequestMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductsRequestMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/ProductsRequestMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductsRequestMock.swift diff --git a/Tests/FlareTests/Mocks/PurchaseProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/PurchaseProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift diff --git a/Tests/FlareTests/Mocks/ReceiptRefreshProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ReceiptRefreshProviderMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/ReceiptRefreshProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ReceiptRefreshProviderMock.swift diff --git a/Tests/FlareTests/Mocks/ReceiptRefreshRequestFactory.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ReceiptRefreshRequestFactory.swift similarity index 100% rename from Tests/FlareTests/Mocks/ReceiptRefreshRequestFactory.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ReceiptRefreshRequestFactory.swift diff --git a/Tests/FlareTests/Mocks/ReceiptRefreshRequestMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ReceiptRefreshRequestMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/ReceiptRefreshRequestMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ReceiptRefreshRequestMock.swift diff --git a/Tests/FlareTests/Mocks/RefundProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RefundProviderMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/RefundProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/RefundProviderMock.swift diff --git a/Tests/FlareTests/Mocks/RefundRequestProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/RefundRequestProviderMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/RefundRequestProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/RefundRequestProviderMock.swift diff --git a/Tests/FlareTests/Mocks/ScenesHolderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ScenesHolderMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/ScenesHolderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/ScenesHolderMock.swift diff --git a/Tests/FlareTests/Mocks/StoreTransactionMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/StoreTransactionMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/StoreTransactionMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/StoreTransactionMock.swift diff --git a/Tests/FlareTests/Mocks/SystemInfoProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/SystemInfoProviderMock.swift similarity index 100% rename from Tests/FlareTests/Mocks/SystemInfoProviderMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/SystemInfoProviderMock.swift diff --git a/Tests/FlareTests/Stubs/StoreTransactionStub.swift b/Tests/FlareTests/UnitTests/TestHelpers/Stubs/StoreTransactionStub.swift similarity index 100% rename from Tests/FlareTests/Stubs/StoreTransactionStub.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Stubs/StoreTransactionStub.swift diff --git a/project.yml b/project.yml new file mode 100644 index 000000000..2b6101e38 --- /dev/null +++ b/project.yml @@ -0,0 +1,59 @@ +name: Flare +packages: + # External + + Concurrency: + url: https://github.com/space-code/concurrency.git + from: 0.0.1 + ObjectsFactory: + url: https://github.com/space-code/objects-factory.git + from: 1.0.0 + + # Flare: + # path: . + +targets: + UnitTestHostApp: + type: application + supportedDestinations: [iOS, tvOS, macOS, watchOS] + sources: Tests/FlareTests/UnitTestHostApp + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare + scheme: + testTargets: + - FlareTests + # dependencies: + # - package: Flare + Flare: + type: framework + supportedDestinations: [iOS, tvOS, macOS, watchOS] + dependencies: + - package: Concurrency + product: Concurrency + settings: + GENERATE_INFOPLIST_FILE: YES + sources: + - path: Sources + scheme: + testTargets: + - FlareTests + gatherCoverageData: true + coverageTargets: + - Flare + FlareTests: + type: bundle.unit-test + supportedDestinations: [iOS, tvOS, macOS, watchOS] + # targets: ["1"] + dependencies: + - package: Concurrency + product: TestConcurrency + - package: ObjectsFactory + - target: Flare + settings: + base: + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: $(BUILT_PRODUCTS_DIR)/UnitTestHostApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/UnitTestHostApp + PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare + sources: + - Tests/FlareTests/UnitTests \ No newline at end of file From 5242ebfc3eb3689f71d31a8526224b3a4890f9d4 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 29 Dec 2023 10:16:26 +0100 Subject: [PATCH 11/27] Refactor unit tests - Write unit tests for the new methods - Improve the readability of the existing test cases --- .../Classes/Models/StoreTransaction.swift | 8 + .../Providers/IAPProvider/IAPProvider.swift | 2 +- .../Extensions/StoreKitSessionTestCase.swift | 18 +- Tests/FlareTests/UnitTests/Flare.storekit | 23 ++- Tests/FlareTests/UnitTests/FlareTests.swift | 177 +++++++++-------- .../Providers/IAPProviderTests.swift | 188 +++++++++--------- .../Providers/ProductProviderTests.swift | 59 +++--- .../Providers/PurchaseProviderTests.swift | 14 +- .../ReceiptRefreshProviderTests.swift | 43 ++-- .../Providers/RefundProviderTests.swift | 11 +- .../RefundRequestProviderTests.swift | 4 +- .../Providers/SystemInfoProviderTests.swift | 14 +- .../TestHelpers/Extensions/Result+.swift | 26 +++ .../TestHelpers/Extensions/XCTestCase+.swift | 35 ++++ .../Helpers/ProductProviderHelper.swift | 36 ++-- .../Mocks/ProductProviderMock.swift | 2 +- .../TestHelpers/StoreSessionTestCase.swift | 36 ++++ 17 files changed, 410 insertions(+), 286 deletions(-) create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Extensions/Result+.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/Extensions/XCTestCase+.swift create mode 100644 Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift diff --git a/Sources/Flare/Classes/Models/StoreTransaction.swift b/Sources/Flare/Classes/Models/StoreTransaction.swift index f9e79c024..fa16b2478 100644 --- a/Sources/Flare/Classes/Models/StoreTransaction.swift +++ b/Sources/Flare/Classes/Models/StoreTransaction.swift @@ -81,3 +81,11 @@ extension StoreTransaction: IStoreTransaction { storeTransaction.environment } } + +// MARK: Equatable + +extension StoreTransaction: Equatable { + public static func == (lhs: StoreTransaction, rhs: StoreTransaction) -> Bool { + lhs.transactionIdentifier == rhs.transactionIdentifier + } +} diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index 118e27606..646f1527d 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -93,7 +93,7 @@ final class IAPProvider: IIAPProvider { func purchase(product: StoreProduct) async throws -> StoreTransaction { try await withCheckedThrowingContinuation { continuation in - purchase(product: product) { result in + self.purchase(product: product) { result in continuation.resume(with: result) } } diff --git a/Tests/FlareTests/UnitTests/Extensions/StoreKitSessionTestCase.swift b/Tests/FlareTests/UnitTests/Extensions/StoreKitSessionTestCase.swift index 298b4cf3f..8be4b716f 100644 --- a/Tests/FlareTests/UnitTests/Extensions/StoreKitSessionTestCase.swift +++ b/Tests/FlareTests/UnitTests/Extensions/StoreKitSessionTestCase.swift @@ -7,12 +7,12 @@ import StoreKit import StoreKitTest import XCTest -class StoreKitSessionTestCase: XCTestCase { - // MARK: Properties - - private var session: SKTestSession! - - // MARK: XCTestCase - -// override func -} +// class StoreKitSessionTestCase: XCTestCase { +// // MARK: Properties +// +// private var session: SKTestSession! +// +// // MARK: XCTestCase +// +//// override func +// } diff --git a/Tests/FlareTests/UnitTests/Flare.storekit b/Tests/FlareTests/UnitTests/Flare.storekit index 386d0cc2f..102d022b8 100644 --- a/Tests/FlareTests/UnitTests/Flare.storekit +++ b/Tests/FlareTests/UnitTests/Flare.storekit @@ -1,5 +1,5 @@ { - "identifier" : "15BDB648", + "identifier" : "95D98A48", "nonRenewingSubscriptions" : [ ], @@ -7,7 +7,7 @@ { "displayPrice" : "0.99", "familyShareable" : false, - "internalID" : "55B686B4", + "internalID" : "169432A7", "localizations" : [ { "description" : "com.flare.test_purchase_1", @@ -17,12 +17,12 @@ ], "productID" : "com.flare.test_purchase_1", "referenceName" : "com.flare.test_purchase_1", - "type" : "NonConsumable" + "type" : "Consumable" }, { "displayPrice" : "0.99", "familyShareable" : false, - "internalID" : "63681F89", + "internalID" : "33E61322", "localizations" : [ { "description" : "com.flare.test_purchase_2", @@ -33,6 +33,21 @@ "productID" : "com.flare.test_purchase_2", "referenceName" : "com.flare.test_purchase_2", "type" : "Consumable" + }, + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "1CBF43E6", + "localizations" : [ + { + "description" : "com.flare.test_non_consumable_purchase_1", + "displayName" : "com.flare.test_non_consumable_", + "locale" : "en_US" + } + ], + "productID" : "com.flare.test_non_consumable_purchase_1", + "referenceName" : null, + "type" : "NonConsumable" } ], "settings" : { diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index c060e06b6..6f97ebb93 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -9,23 +9,24 @@ import XCTest // MARK: - FlareTests -class FlareTests: XCTestCase { +class FlareTests: StoreSessionTestCase { // MARK: - Properties private var iapProviderMock: IAPProviderMock! - private var flare: Flare! + + private var sut: Flare! // MARK: - XCTestCase override func setUp() { super.setUp() iapProviderMock = IAPProviderMock() - flare = Flare(iapProvider: iapProviderMock) + sut = Flare(iapProvider: iapProviderMock) } override func tearDown() { iapProviderMock = nil - flare = nil + sut = nil super.tearDown() } @@ -33,7 +34,7 @@ class FlareTests: XCTestCase { func test_thatFlareFetchesProductsWithGivenProductIDs() { // when - flare.fetch(productIDs: .ids, completion: { _ in }) + sut.fetch(productIDs: .ids, completion: { _ in }) // then XCTAssertTrue(iapProviderMock.invokedFetch) @@ -49,7 +50,7 @@ class FlareTests: XCTestCase { iapProviderMock.fetchAsyncResult = productMocks // when - let products = try await flare.fetch(productIDs: .ids) + let products = try await sut.fetch(productIDs: .ids) // then XCTAssertEqual(products, productMocks) @@ -60,35 +61,33 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedCanMakePayments = true // when - flare.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) + sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) // then XCTAssertTrue(iapProviderMock.invokedPurchase) XCTAssertEqual(iapProviderMock.invokedPurchaseParameters?.product.productIdentifier, .productID) } - func test_thatFlareDoesNotPurchaseAProduct_whenUserCannotMakePayments() { + func test_thatFlareThrowsAnError_whenUserCannotMakePayments() { // given iapProviderMock.stubbedCanMakePayments = false // when - flare.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) + sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) // then XCTAssertFalse(iapProviderMock.invokedPurchase) } - func test_thatFlarePurchasesAProduct_whenRequestCompletedSuccessfully() { + func test_thatFlarePurchasesAProduct_whenRequestCompleted() { // given let paymentTransaction = StoreTransaction(storeTransaction: StoreTransactionStub()) iapProviderMock.stubbedCanMakePayments = true // when var transaction: IStoreTransaction? - flare.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { result in - if case let .success(result) = result { - transaction = result - } + sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { result in + transaction = result.success }) iapProviderMock.invokedPurchaseParameters?.completion(.success(paymentTransaction)) @@ -97,17 +96,15 @@ class FlareTests: XCTestCase { XCTAssertEqual(transaction?.productIdentifier, paymentTransaction.productIdentifier) } - func test_thatFlareDoesNotPurchaseAProduct_whenUnknownErrorOccurred() { + func test_thatFlareDoesNotPurchaseAProduct_whenPurchaseReturnsUnkownError() { // given let errorMock = IAPError.paymentNotAllowed iapProviderMock.stubbedCanMakePayments = true // when var error: IAPError? - flare.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { result in - if case let .failure(result) = result { - error = result - } + sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { result in + error = result.error }) iapProviderMock.invokedPurchaseParameters?.completion(.failure(errorMock)) @@ -122,12 +119,7 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedAsyncPurchase = StoreTransaction(storeTransaction: StoreTransactionStub()) // when - var iapError: IAPError? - do { - _ = try await flare.purchase(product: .fake(skProduct: .fake(id: .productID))) - } catch { - iapError = error as? IAPError - } + let iapError: IAPError? = await error(for: { try await sut.purchase(product: .fake(skProduct: .fake(id: .productID))) }) // then XCTAssertFalse(iapProviderMock.invokedAsyncPurchase) @@ -142,28 +134,18 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedAsyncPurchase = transactionMock // when - var transaction: IStoreTransaction? - var iapError: IAPError? - do { - transaction = try await flare.purchase(product: .fake(skProduct: .fake(id: .productID))) - } catch { - iapError = error as? IAPError - } + let transaction = await value(for: { try await sut.purchase(product: .fake(skProduct: .fake(id: .productID))) }) // then XCTAssertTrue(iapProviderMock.invokedAsyncPurchase) - XCTAssertNil(iapError) XCTAssertEqual(transaction?.productIdentifier, transactionMock.productIdentifier) } - func test_thatFlareFetchesReceipt_whenRequestCompletedSuccessfully() { + func test_thatFlareFetchesReceipt_whenRequestCompleted() { // when var receipt: String? - flare.receipt(completion: { result in - if case let .success(result) = result { - receipt = result - } - }) + sut.receipt { receipt = $0.success } + iapProviderMock.invokedRefreshReceiptParameters?.completion(.success(.receipt)) // then @@ -174,11 +156,8 @@ class FlareTests: XCTestCase { func test_thatFlareDoesNotFetchReceipt_whenRequestFailed() { // when var error: IAPError? - flare.receipt(completion: { result in - if case let .failure(result) = result { - error = result - } - }) + sut.receipt { error = $0.error } + iapProviderMock.invokedRefreshReceiptParameters?.completion(.failure(.paymentNotAllowed)) // then @@ -188,18 +167,18 @@ class FlareTests: XCTestCase { func test_thatFlareRemovesTransactionObserver() { // when - flare.removeTransactionObserver() + sut.removeTransactionObserver() // then XCTAssertTrue(iapProviderMock.invokedRemoveTransactionObserver) } - func test_thatFlareFetchesReceipt_whenRequestCompletedSuccessfully() async throws { + func test_thatFlareFetchesReceipt_whenRequestCompleted() async throws { // given iapProviderMock.stubbedRefreshReceiptAsyncResult = .success(.receipt) // when - let receipt = try await flare.receipt() + let receipt = try await sut.receipt() // then XCTAssertEqual(receipt, .receipt) @@ -210,15 +189,10 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedRefreshReceiptAsyncResult = .failure(.paymentNotAllowed) // when - var iapError: IAPError? - do { - _ = try await flare.receipt() - } catch { - iapError = error as? IAPError - } + let error: IAPError? = await self.error(for: { try await sut.receipt() }) // then - XCTAssertEqual(iapError, .paymentNotAllowed) + XCTAssertEqual(error, .paymentNotAllowed) } func test_thatFlareFinishesTransaction() { @@ -226,7 +200,7 @@ class FlareTests: XCTestCase { let transaction = PaymentTransaction(PaymentTransactionMock()) // when - flare.finish(transaction: transaction) + sut.finish(transaction: transaction) // then XCTAssertTrue(iapProviderMock.invokedFinishTransaction) @@ -234,7 +208,7 @@ class FlareTests: XCTestCase { func test_thatFlareAddsTransactionObserver() { // when - flare.addTransactionObserver(fallbackHandler: { _ in }) + sut.addTransactionObserver(fallbackHandler: { _ in }) // then XCTAssertTrue(iapProviderMock.invokedAddTransactionObserver) @@ -247,7 +221,7 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedBeginRefundRequest = .success // when - let state = try await flare.beginRefundRequest(productID: .productID) + let state = try await sut.beginRefundRequest(productID: .productID) // then if case .success = state {} @@ -255,12 +229,12 @@ class FlareTests: XCTestCase { } @available(iOS 15.0, *) - func test_thatFlareThrowsAnError_whenBeginRefundRequestFailed() async throws { + func test_thatFlareRefundRequestThrowsAnError_whenBeginRefundRequestFailed() async throws { // given iapProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) // when - let state = try await flare.beginRefundRequest(productID: .productID) + let state = try await sut.beginRefundRequest(productID: .productID) // then if case let .failed(error) = state { XCTAssertEqual(error as NSError, IAPError.unknown as NSError) } @@ -268,53 +242,92 @@ class FlareTests: XCTestCase { } #endif - func test_thatFlarePurchasesWithOptions_whenPurchaseCompleteSuccessfully() async throws { + func test_thatFlarePurchasesAProductWithOptions_whenPurchaseCompleted() async throws { + let transaction = StoreTransactionStub() + try await test_purchaseWithOptionsAndCompletion( + transaction: transaction, + canMakePayments: true, + expectedResult: .success(StoreTransaction(storeTransaction: transaction)) + ) + } + + func test_thatFlarePurchaseThrowsAnError_whenPaymentNotAllowed() async throws { + try await test_purchaseWithOptionsAndCompletion( + canMakePayments: false, + expectedResult: .failure(IAPError.paymentNotAllowed) + ) + } + + func test_thatFlarePurchasesAsyncAProductWithOptionsAndCompletionHandler_whenPurchaseCompleted() async throws { + let transaction = StoreTransactionStub() + try await test_purchaseWithOptions( + canMakePayments: true, + expectedResult: .success(StoreTransaction(storeTransaction: transaction)) + ) + } + + func test_thatFlarePurchaseAsyncThrowsAnError_whenPaymentNotAllowed() async throws { + try await test_purchaseWithOptions( + canMakePayments: false, + expectedResult: .failure(IAPError.paymentNotAllowed) + ) + } + + // MARK: Private + + private func test_purchaseWithOptionsAndCompletion( + transaction: StoreTransactionStub? = nil, + canMakePayments: Bool, + expectedResult: Result + ) async throws { // given - let product = try await ProductProviderHelper.all.randomElement() - let storeTransactionStub = StoreTransactionStub() + let product = try await ProductProviderHelper.purchases.randomElement() + let storeTransactionStub = transaction ?? StoreTransactionStub() storeTransactionStub.stubbedProductIdentifier = product?.id - iapProviderMock.stubbedCanMakePayments = true + iapProviderMock.stubbedCanMakePayments = canMakePayments iapProviderMock.stubbedAsyncPurchaseWithOptions = StoreTransaction( storeTransaction: storeTransactionStub ) // when - let transaction = try await flare.purchase( - product: StoreProduct(product: product!), - options: [.simulatesAskToBuyInSandbox(false)] - ) + let result: Result = await result(for: { + try await sut.purchase( + product: StoreProduct(product: product!), + options: [.simulatesAskToBuyInSandbox(false)] + ) + }) // then - XCTAssertEqual(transaction.productIdentifier, product?.id) + XCTAssertEqual(result, expectedResult) } - func test_thatFlarePurchasesWithOptionsAndCompletionHandler_whenPurchaseCompleteSuccessfully() async throws { + private func test_purchaseWithOptions( + transaction: StoreTransactionStub? = nil, + canMakePayments: Bool, + expectedResult: Result + ) async throws { // given let expectation = XCTestExpectation(description: "Purchase a product") - let product = try await ProductProviderHelper.all.randomElement() - let storeTransactionStub = StoreTransactionStub() + let product = try await ProductProviderHelper.purchases.randomElement() + let storeTransactionStub = transaction ?? StoreTransactionStub() storeTransactionStub.stubbedProductIdentifier = product?.id - iapProviderMock.stubbedCanMakePayments = true + iapProviderMock.stubbedCanMakePayments = canMakePayments iapProviderMock.stubbedPurchaseWithOptionsResult = .success(StoreTransaction(storeTransaction: storeTransactionStub)) // when - flare.purchase( + sut.purchase( product: StoreProduct(product: product!), options: [.simulatesAskToBuyInSandbox(false)] ) { result in - if case let .success(transaction) = result { - XCTAssertEqual(transaction.productIdentifier, product?.id) - expectation.fulfill() - } else { - XCTFail("Purchase should complete successfully") - } + XCTAssertEqual(result, expectedResult) + expectation.fulfill() } // then - wait(for: [expectation], timeout: 2.0) + wait(for: [expectation], timeout: .second) } } @@ -328,3 +341,7 @@ private extension String { static let productID = "product_ID" static let receipt = "receipt" } + +private extension TimeInterval { + static let second: CGFloat = 1.0 +} diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index 6f0f75a48..63c967817 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -18,7 +18,7 @@ class IAPProviderTests: XCTestCase { private var receiptRefreshProviderMock: ReceiptRefreshProviderMock! private var refundProviderMock: RefundProviderMock! - private var iapProvider: IIAPProvider! + private var sut: IIAPProvider! // MARK: - XCTestCase @@ -29,7 +29,7 @@ class IAPProviderTests: XCTestCase { purchaseProvider = PurchaseProviderMock() receiptRefreshProviderMock = ReceiptRefreshProviderMock() refundProviderMock = RefundProviderMock() - iapProvider = IAPProvider( + sut = IAPProvider( paymentQueue: paymentQueueMock, productProvider: productProviderMock, purchaseProvider: purchaseProvider, @@ -44,7 +44,7 @@ class IAPProviderTests: XCTestCase { purchaseProvider = nil receiptRefreshProviderMock = nil refundProviderMock = nil - iapProvider = nil + sut = nil super.tearDown() } @@ -55,14 +55,14 @@ class IAPProviderTests: XCTestCase { paymentQueueMock.stubbedCanMakePayments = true // then - XCTAssertTrue(iapProvider.canMakePayments) + XCTAssertTrue(sut.canMakePayments) } func test_thatIAPProviderFetchesProducts() throws { try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() // when - iapProvider.fetch(productIDs: .productIDs, completion: { _ in }) + sut.fetch(productIDs: .productIDs, completion: { _ in }) // then let parameters = try XCTUnwrap(productProviderMock.invokedFetchParameters) @@ -72,7 +72,7 @@ class IAPProviderTests: XCTestCase { func test_thatIAPProviderPurchasesProduct() throws { // when - iapProvider.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) + sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) // then XCTAssertTrue(purchaseProvider.invokedPurchase) @@ -80,7 +80,7 @@ class IAPProviderTests: XCTestCase { func test_thatIAPProviderRefreshesReceipt() { // when - iapProvider.refreshReceipt(completion: { _ in }) + sut.refreshReceipt(completion: { _ in }) // then XCTAssertTrue(receiptRefreshProviderMock.invokedRefresh) @@ -91,7 +91,7 @@ class IAPProviderTests: XCTestCase { let transaction = PurchaseManagerTestHelper.makePaymentTransaction(state: .purchased) // when - iapProvider.finish(transaction: PaymentTransaction(transaction)) + sut.finish(transaction: PaymentTransaction(transaction)) // then XCTAssertTrue(purchaseProvider.invokedFinish) @@ -99,7 +99,7 @@ class IAPProviderTests: XCTestCase { func test_thatIAPProviderAddsTransactionObserver() { // when - iapProvider.addTransactionObserver(fallbackHandler: { _ in }) + sut.addTransactionObserver(fallbackHandler: { _ in }) // then XCTAssertTrue(purchaseProvider.invokedAddTransactionObserver) @@ -107,7 +107,7 @@ class IAPProviderTests: XCTestCase { func test_thatIAPProviderRemovesTransactionObserver() { // when - iapProvider.removeTransactionObserver() + sut.removeTransactionObserver() // then XCTAssertTrue(purchaseProvider.invokedRemoveTransactionObserver) @@ -122,7 +122,7 @@ class IAPProviderTests: XCTestCase { productProviderMock.stubbedFetchResult = .success(productsMock) // when - let products = try await iapProvider.fetch(productIDs: .productIDs) + let products = try await sut.fetch(productIDs: .productIDs) // then XCTAssertEqual(productsMock.count, products.count) @@ -130,17 +130,39 @@ class IAPProviderTests: XCTestCase { @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func test_thatIAPProviderFetchesSK2Products_whenProductsAreAvailable() async throws { - let productsMock = try await ProductProviderHelper.all.map(SK2StoreProduct.init) + let productsMock = try await ProductProviderHelper.purchases.map(SK2StoreProduct.init) productProviderMock.stubbedAsyncFetchResult = .success(productsMock) // when - let products = try await iapProvider.fetch(productIDs: .productIDs) + let products = try await sut.fetch(productIDs: .productIDs) // then XCTAssertFalse(products.isEmpty) XCTAssertEqual(productsMock.count, products.count) } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_thatIAPProviderThrowsAnIAPError_whenFetchingProductsFailed() async { + productProviderMock.stubbedAsyncFetchResult = .failure(IAPError.unknown) + + // when + let error: IAPError? = await error(for: { try await sut.fetch(productIDs: .productIDs) }) + + // then + XCTAssertEqual(error, .unknown) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_thatIAPProviderThrowsAPlainError_whenFetchingProductsFailed() async { + productProviderMock.stubbedAsyncFetchResult = .failure(URLError(.unknown)) + + // when + let error: IAPError? = await error(for: { try await sut.fetch(productIDs: .productIDs) }) + + // then + XCTAssertEqual(error, .with(error: URLError(.unknown))) + } + func test_thatIAPProviderThrowsNoProductsError_whenProductsProductProviderReturnsError() async throws { try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() @@ -148,12 +170,7 @@ class IAPProviderTests: XCTestCase { productProviderMock.stubbedFetchResult = .failure(IAPError.unknown) // when - var errorResult: Error? - do { - _ = try await iapProvider.fetch(productIDs: .productIDs) - } catch { - errorResult = error - } + let errorResult: Error? = await error(for: { try await sut.fetch(productIDs: .productIDs) }) // then XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) @@ -165,15 +182,11 @@ class IAPProviderTests: XCTestCase { purchaseProvider.stubbedPurchaseCompletionResult = (.failure(.unknown), ()) // when - var errorResult: Error? - iapProvider.purchase(product: .fake(skProduct: .fake(id: .productID))) { result in - if case let .failure(error) = result { - errorResult = error - } - } + var error: Error? + sut.purchase(product: .fake(skProduct: .fake(id: .productID))) { error = $0.error } // then - XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) + XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) } func test_thatIAPProviderReturnsError_whenFetchRequestFailed() { @@ -181,32 +194,24 @@ class IAPProviderTests: XCTestCase { purchaseProvider.stubbedPurchaseCompletionResult = (.failure(IAPError.unknown), ()) // when - var errorResult: Error? - iapProvider.purchase(product: .fake(skProduct: .fake(id: .productID))) { result in - if case let .failure(error) = result { - errorResult = error - } - } + var error: Error? + sut.purchase(product: .fake(skProduct: .fake(id: .productID))) { error = $0.error } // then - XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) + XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) } - func test_thatIAPProviderRefreshesReceipt_when() { + func test_thatIAPProviderRefreshesReceipt_whenReceiptExist() { // given receiptRefreshProviderMock.stubbedReceipt = .receipt receiptRefreshProviderMock.stubbedRefreshResult = .success(()) // when - var receiptResult: String? - iapProvider.refreshReceipt { result in - if case let .success(receipt) = result { - receiptResult = receipt - } - } + var receipt: String? + sut.refreshReceipt { receipt = $0.success } // then - XCTAssertEqual(receiptResult, .receipt) + XCTAssertEqual(receipt, .receipt) } func test_thatIAPProviderDoesNotRefreshReceipt_whenRequestFailed() { @@ -215,15 +220,11 @@ class IAPProviderTests: XCTestCase { receiptRefreshProviderMock.stubbedRefreshResult = .failure(.receiptNotFound) // when - var errorResult: Error? - iapProvider.refreshReceipt { result in - if case let .failure(error) = result { - errorResult = error - } - } + var error: Error? + sut.refreshReceipt { error = $0.error } // then - XCTAssertEqual(errorResult as? NSError, IAPError.receiptNotFound as NSError) + XCTAssertEqual(error as? NSError, IAPError.receiptNotFound as NSError) } func test_thatIAPProviderReturnsReceiptNotFoundError_whenReceiptIsNil() { @@ -232,15 +233,11 @@ class IAPProviderTests: XCTestCase { receiptRefreshProviderMock.stubbedRefreshResult = .success(()) // when - var errorResult: Error? - iapProvider.refreshReceipt { result in - if case let .failure(error) = result { - errorResult = error - } - } + var error: Error? + sut.refreshReceipt { error = $0.error } // then - XCTAssertEqual(errorResult as? NSError, IAPError.receiptNotFound as NSError) + XCTAssertEqual(error as? NSError, IAPError.receiptNotFound as NSError) } func test_thatIAPProviderRefreshesReceipt_whenReceiptIsNotNil() async throws { @@ -249,7 +246,7 @@ class IAPProviderTests: XCTestCase { receiptRefreshProviderMock.stubbedRefreshResult = .success(()) // when - let receipt = try await iapProvider.refreshReceipt() + let receipt = try await sut.refreshReceipt() // then XCTAssertEqual(receipt, .receipt) @@ -261,50 +258,12 @@ class IAPProviderTests: XCTestCase { receiptRefreshProviderMock.stubbedRefreshResult = .success(()) // when - var errorResult: Error? - do { - _ = try await iapProvider.refreshReceipt() - } catch { - errorResult = error - } + let errorResult: Error? = await error(for: { try await sut.refreshReceipt() }) // then XCTAssertEqual(errorResult as? NSError, IAPError.receiptNotFound as NSError) } -// func test_thatIAPProviderReturnsTransaction() { -// // given -// let transactionMock = SKPaymentTransaction() -// purchaseProvider.stubbedFallbackHandlerResult = (paymentQueueMock, .success(transactionMock)) -// -// // when -// var transactionResult: PaymentTransaction? -// iapProvider.addTransactionObserver { result in -// if case let .success(transaction) = result { -// transactionResult = transaction -// } -// } -// -// // then -// XCTAssertEqual(transactionResult?.skTransaction, transactionMock) -// } - -// func test_thatIAPProviderReturnsError() { -// // given -// purchaseProvider.stubbedFallbackHandlerResult = (paymentQueueMock, .failure(.unknown)) -// -// // when -// var errorResult: Error? -// iapProvider.addTransactionObserver { result in -// if case let .failure(error) = result { -// errorResult = error -// } -// } -// -// // then -// XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) -// } - #if os(iOS) || VISION_OS @available(iOS 15.0, *) func test_thatIAPProviderRefundsPurchase() async throws { @@ -312,7 +271,7 @@ class IAPProviderTests: XCTestCase { refundProviderMock.stubbedBeginRefundRequest = .success // when - let state = try await iapProvider.beginRefundRequest(productID: .productID) + let state = try await sut.beginRefundRequest(productID: .productID) // then if case .success = state {} @@ -325,13 +284,47 @@ class IAPProviderTests: XCTestCase { refundProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) // when - let state = try await iapProvider.beginRefundRequest(productID: .productID) + let state = try await sut.beginRefundRequest(productID: .productID) // then if case let .failed(error) = state { XCTAssertEqual(error as NSError, IAPError.unknown as NSError) } else { XCTFail("state must be `failed`") } } #endif + + func test_thatIAPProviderPurchasesAProduct() async throws { + // given + let transactionMock = StoreTransactionMock() + transactionMock.stubbedTransactionIdentifier = .transactionID + + let storeTransaction = StoreTransaction(storeTransaction: transactionMock) + purchaseProvider.stubbedPurchaseCompletionResult = (.success(storeTransaction), ()) + + let product = try await ProductProviderHelper.purchases[0] + + // when + let transaction = try await sut.purchase(product: StoreProduct(product: product)) + + // then + XCTAssertEqual(transaction.transactionIdentifier, .transactionID) + } + + func test_thatIAPProviderPurchasesAProductWithOptions() async throws { + // given + let transactionMock = StoreTransactionMock() + transactionMock.stubbedTransactionIdentifier = .transactionID + + let storeTransaction = StoreTransaction(storeTransaction: transactionMock) + purchaseProvider.stubbedinvokedPurchaseWithOptionsCompletionResult = (.success(storeTransaction), ()) + + let product = try await ProductProviderHelper.purchases[0] + + // when + let transaction = try await sut.purchase(product: StoreProduct(product: product), options: []) + + // then + XCTAssertEqual(transaction.transactionIdentifier, .transactionID) + } } // MARK: - Constants @@ -339,6 +332,7 @@ class IAPProviderTests: XCTestCase { private extension String { static let receipt = "receipt" static let productID = "product_identifier" + static let transactionID = "transaction_identifier" } private extension Set where Element == String { diff --git a/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift index c3655b4d1..22dd068a7 100644 --- a/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift @@ -11,12 +11,13 @@ import XCTest // MARK: - ProductProviderTests -class ProductProviderTests: XCTestCase { +final class ProductProviderTests: XCTestCase { // MARK: - Properties private var testDispatchQueue: TestDispatchQueue! private var dispatchQueueFactory: IDispatchQueueFactory! - private var productProvider: ProductProvider! + + private var sut: ProductProvider! // MARK: - XCTestCase @@ -24,19 +25,19 @@ class ProductProviderTests: XCTestCase { super.setUp() testDispatchQueue = TestDispatchQueue() dispatchQueueFactory = TestDispatchQueueFactory(testQueue: testDispatchQueue) - productProvider = ProductProvider(dispatchQueueFactory: dispatchQueueFactory) + sut = ProductProvider(dispatchQueueFactory: dispatchQueueFactory) } override func tearDown() { testDispatchQueue = nil dispatchQueueFactory = nil - productProvider = nil + sut = nil super.tearDown() } // MARK: - Tests - func test_thatProductProviderReturnsInvalidProductIDs_whenRequestProductsWithInvalidIDs() { + func test_thatProductProviderReturnsInvalidProductIDs_whenRequestProductsAreFetchedWithInvalidIDs() { // given var fetchResult: Result<[SK1StoreProduct], IAPError>? let completionHandler: IProductProvider.ProductsHandler = { result in fetchResult = result } @@ -46,8 +47,8 @@ class ProductProviderTests: XCTestCase { response.stubbedInvokedInvalidProductsIdentifiers = [.productID] // when - productProvider.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) - productProvider.productsRequest(request, didReceive: response) + sut.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) + sut.productsRequest(request, didReceive: response) // then if case let .failure(error) = fetchResult, case let .invalid(products) = error { @@ -57,49 +58,51 @@ class ProductProviderTests: XCTestCase { } } - func test_thatProductProviderReturnsProducts_whenRequestProductsWithValidProductIDs() { + func test_thatProductProviderReturnsProducts_whenRequestProductsAreFetchedWithValidProductIDs() { // given - var fetchResult: Result<[SK1StoreProduct], IAPError>? - let completionHandler: IProductProvider.ProductsHandler = { result in fetchResult = result } + var products: [SK1StoreProduct]? = [] + let completionHandler: IProductProvider.ProductsHandler = { products = $0.success } let request = PurchaseManagerTestHelper.makeRequest(with: .requestID) let response = ProductResponseMock() // when - productProvider.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) - productProvider.productsRequest(request, didReceive: response) + sut.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) + sut.productsRequest(request, didReceive: response) // then - if case let .success(products) = fetchResult { - XCTAssertEqual(products.map(\.product), response.products) - } else { - XCTFail() - } + XCTAssertEqual(products?.map(\.product), response.products) } func test_thatProductProviderHandlesError_whenRequestDidFailWithError() { // given - var fetchResult: Result<[SK1StoreProduct], IAPError>? - let completionHandler: IProductProvider.ProductsHandler = { result in fetchResult = result } + var error: IAPError? + let completionHandler: IProductProvider.ProductsHandler = { error = $0.error } let request = PurchaseManagerTestHelper.makeRequest(with: .requestID) - let error = IAPError.emptyProducts + let errorStub = IAPError.emptyProducts // when - productProvider.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) - productProvider.request(request, didFailWithError: error) + sut.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) + sut.request(request, didFailWithError: errorStub) // then - if case let .failure(resultError) = fetchResult { - XCTAssertEqual(resultError.plainError as NSError, error.plainError as NSError) - } else { - XCTFail() - } + XCTAssertEqual(error?.plainError as? NSError, errorStub.plainError as NSError) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_thatProductProvider() async throws { + // when + let products = try await sut.fetch(productIDs: [.productID]) + + // then + XCTAssertEqual(products.count, 1) + XCTAssertEqual(products.first?.productIdentifier, .productID) } } // MARK: - Constants private extension String { - static let productID = "product_ID" + static let productID = "com.flare.test_purchase_1" static let requestID = "request_identifier" } diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift index 6a5a24768..11638516f 100644 --- a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift @@ -8,7 +8,9 @@ import StoreKit import StoreKitTest import XCTest -final class PurchaseProviderTests: XCTestCase { +// MARK: - PurchaseProviderTests + +final class PurchaseProviderTests: StoreSessionTestCase { // MARK: Properties private var paymentQueueMock: PaymentQueueMock! @@ -68,7 +70,7 @@ final class PurchaseProviderTests: XCTestCase { } } - wait(for: [expectation], timeout: 2.0) + wait(for: [expectation], timeout: .second) } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) @@ -87,7 +89,7 @@ final class PurchaseProviderTests: XCTestCase { } } - wait(for: [expectation], timeout: 2.0) + wait(for: [expectation], timeout: .second) } func test_thatPurchaseProviderFinishesTransaction() { @@ -138,3 +140,9 @@ final class PurchaseProviderTests: XCTestCase { XCTAssertTrue(paymentProviderMock.invokedRemoveTransactionObserver) } } + +// MARK: - Constants + +private extension TimeInterval { + static let second: TimeInterval = 1.0 +} diff --git a/Tests/FlareTests/UnitTests/Providers/ReceiptRefreshProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/ReceiptRefreshProviderTests.swift index cd3b4c67e..61b25272c 100644 --- a/Tests/FlareTests/UnitTests/Providers/ReceiptRefreshProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/ReceiptRefreshProviderTests.swift @@ -15,11 +15,12 @@ class ReceiptRefreshProviderTests: XCTestCase { private var testDispatchQueue: TestDispatchQueue! private var dispatchQueueFactory: TestDispatchQueueFactory! - private var receiptRefreshProvider: ReceiptRefreshProvider! private var appStoreReceiptProviderMock: AppStoreReceiptProviderMock! private var fileManagerMock: FileManagerMock! private var receiptRefreshRequestFactoryMock: ReceiptRefreshRequestFactoryMock! + private var sut: ReceiptRefreshProvider! + // MARK: - XCTestCase override func setUp() { @@ -29,7 +30,7 @@ class ReceiptRefreshProviderTests: XCTestCase { dispatchQueueFactory = TestDispatchQueueFactory(testQueue: testDispatchQueue) fileManagerMock = FileManagerMock() receiptRefreshRequestFactoryMock = ReceiptRefreshRequestFactoryMock() - receiptRefreshProvider = ReceiptRefreshProvider( + sut = ReceiptRefreshProvider( dispatchQueueFactory: dispatchQueueFactory, fileManager: fileManagerMock, appStoreReceiptProvider: appStoreReceiptProviderMock, @@ -40,7 +41,7 @@ class ReceiptRefreshProviderTests: XCTestCase { override func tearDown() { testDispatchQueue = nil dispatchQueueFactory = nil - receiptRefreshProvider = nil + sut = nil appStoreReceiptProviderMock = nil fileManagerMock = nil receiptRefreshRequestFactoryMock = nil @@ -59,8 +60,8 @@ class ReceiptRefreshProviderTests: XCTestCase { let error = IAPError.paymentCancelled // when - receiptRefreshProvider.refresh(requestID: .requestID, handler: handler) - receiptRefreshProvider.request(request, didFailWithError: error) + sut.refresh(requestID: .requestID, handler: handler) + sut.request(request, didFailWithError: error) // then if case let .failure(resultError) = result { @@ -77,13 +78,11 @@ class ReceiptRefreshProviderTests: XCTestCase { let handler: ReceiptRefreshHandler = { result = $0 } // when - receiptRefreshProvider.refresh(requestID: .requestID, handler: handler) - receiptRefreshProvider.requestDidFinish(request) + sut.refresh(requestID: .requestID, handler: handler) + sut.requestDidFinish(request) // then - if case .failure = result { - XCTFail() - } + if case .failure = result { XCTFail("The result must be `success`") } } func test_thatReceiptRefreshProviderLoadsAppStoreReceipt_whenReceiptExists() { @@ -92,7 +91,7 @@ class ReceiptRefreshProviderTests: XCTestCase { fileManagerMock.stubbedFileExistsResult = true // when - let receipt = receiptRefreshProvider.receipt + let receipt = sut.receipt // then XCTAssertNotNil(receipt) @@ -104,7 +103,7 @@ class ReceiptRefreshProviderTests: XCTestCase { fileManagerMock.stubbedFileExistsResult = false // when - let receipt = receiptRefreshProvider.receipt + let receipt = sut.receipt // then XCTAssertNil(receipt) @@ -116,20 +115,14 @@ class ReceiptRefreshProviderTests: XCTestCase { receiptRefreshRequestFactoryMock.stubbedMakeResult = request request.stubbedStartAction = { - self.receiptRefreshProvider.request( + self.sut.request( self.makeSKRequest(id: request.id), didFailWithError: IAPError.paymentNotAllowed ) } // when - var iapError: IAPError? - - do { - try await receiptRefreshProvider.refresh(requestID: .requestID) - } catch { - iapError = error as? IAPError - } + let iapError: IAPError? = await error(for: { try await sut.refresh(requestID: .requestID) }) // then XCTAssertEqual(iapError, IAPError(error: IAPError.paymentNotAllowed)) @@ -143,17 +136,11 @@ class ReceiptRefreshProviderTests: XCTestCase { receiptRefreshRequestFactoryMock.stubbedMakeResult = request request.stubbedStartAction = { - self.receiptRefreshProvider.requestDidFinish(self.makeSKRequest(id: .requestID)) + self.sut.requestDidFinish(self.makeSKRequest(id: .requestID)) } // when - var iapError: IAPError? - - do { - try await receiptRefreshProvider.refresh(requestID: .requestID) - } catch { - iapError = error as? IAPError - } + let iapError: IAPError? = await error(for: { try await sut.refresh(requestID: .requestID) }) // then XCTAssertNil(iapError) diff --git a/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift index 90908c7ea..39624aef1 100644 --- a/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift @@ -34,21 +34,16 @@ // MARK: - Tests - func testThatRefundProviderThrowsAnErrorWhenVerificationDidFail() async throws { + func testThatRefundProviderThrowsAnError_whenVerificationDidFail() async throws { // given refundRequestProviderMock.stubbedVerifyTransaction = nil systemInfoProviderMock.stubbedCurrentScene = .failure(IAPError.unknown) // when - var resultError: Error? - do { - _ = try await sut.beginRefundRequest(productID: .productID) - } catch { - resultError = error - } + let error: Error? = await error(for: { try await sut.beginRefundRequest(productID: .productID) }) // then - XCTAssertEqual(resultError as? NSError, IAPError.unknown as NSError) + XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) } func testThatRefundProviderThrowsAnErrorWhenRefundRequestDidFail() async throws { diff --git a/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift index 949ce9214..b35747c15 100644 --- a/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift @@ -4,6 +4,8 @@ // @testable import Flare +import StoreKit +import StoreKitTest import XCTest #if os(iOS) || VISION_OS @@ -55,7 +57,7 @@ import XCTest } private extension String { - static let productID: String = "product_id" + static let productID: String = "com.flare.test_purchase_1" } #endif diff --git a/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift index 2dee81c07..c4cc292ab 100644 --- a/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift @@ -10,9 +10,10 @@ final class SystemInfoProviderTests: XCTestCase { // MARK: Properties - private var sut: SystemInfoProvider! private var scenesHolderMock: ScenesHolderMock! + private var sut: SystemInfoProvider! + // MARK: Initialization override func setUp() { @@ -43,17 +44,12 @@ } @MainActor - func test_thatScenesHolderThrowsAnErrorWhenThereIsNoActiveWindowScene() { + func test_thatScenesHolderThrowsAnErrorWhenThereIsNoActiveWindowScene() async throws { // when - var receivedError: Error? - do { - _ = try sut.currentScene - } catch { - receivedError = error - } + let error: Error? = await self.error(for: { try sut.currentScene }) // then - XCTAssertEqual(receivedError as? NSError, IAPError.unknown as NSError) + XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) } } #endif diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Extensions/Result+.swift b/Tests/FlareTests/UnitTests/TestHelpers/Extensions/Result+.swift new file mode 100644 index 000000000..9c779804a --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Extensions/Result+.swift @@ -0,0 +1,26 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension Result { + var error: Failure? { + switch self { + case let .failure(error): + return error + default: + return nil + } + } + + var success: Success? { + switch self { + case let .success(value): + return value + default: + return nil + } + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Extensions/XCTestCase+.swift b/Tests/FlareTests/UnitTests/TestHelpers/Extensions/XCTestCase+.swift new file mode 100644 index 000000000..8d55a0f50 --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/Extensions/XCTestCase+.swift @@ -0,0 +1,35 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import XCTest + +extension XCTestCase { + func value(for closure: () async throws -> U) async -> U? { + do { + let value = try await closure() + return value + } catch { + return nil + } + } + + func error(for closure: () async throws -> U) async -> T? { + do { + _ = try await closure() + return nil + } catch { + return error as? T + } + } + + func result(for closure: () async throws -> U) async -> Result { + do { + let value = try await closure() + return .success(value) + } catch { + return .failure(error as! T) + } + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Helpers/ProductProviderHelper.swift b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/ProductProviderHelper.swift index 2289ed7b2..2deb3d05b 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Helpers/ProductProviderHelper.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/ProductProviderHelper.swift @@ -11,24 +11,24 @@ import StoreKit enum ProductProviderHelper { static var purchases: [StoreKit.Product] { get async throws { - try await StoreKit.Product.products(for: [.testPurchase1ID, .testPurchase2ID]) + try await StoreKit.Product.products(for: [.testNonConsumableID]) } } - static var subscriptions: [StoreKit.Product] { - get async throws { - try await StoreKit.Product.products(for: [.testSubscription1ID, .testSubscription2ID]) - } - } - - static var all: [StoreKit.Product] { - get async throws { - let purchases = try await self.purchases - let subscriptions = try await self.subscriptions - - return purchases + subscriptions - } - } +// static var subscriptions: [StoreKit.Product] { +// get async throws { +// try await StoreKit.Product.products(for: [.testSubscription1ID, .testSubscription2ID]) +// } +// } +// +// static var all: [StoreKit.Product] { +// get async throws { +// let purchases = try await self.purchases +// let subscriptions = try await self.subscriptions +// +// return purchases + subscriptions +// } +// } } // MARK: - Constants @@ -37,6 +37,8 @@ private extension String { static let testPurchase1ID = "com.flare.test_purchase_1" static let testPurchase2ID = "com.flare.test_purchase_2" - static let testSubscription1ID = "com.flare.test_subscription_1" - static let testSubscription2ID = "com.flare.test_subscription_2" + static let testNonConsumableID = "com.flare.test_non_consumable_purchase_1" + +// static let testSubscription1ID = "com.flare.test_subscription_1" +// static let testSubscription2ID = "com.flare.test_subscription_2" } diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift index 8b83b0254..38a26470a 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift @@ -28,7 +28,7 @@ final class ProductProviderMock: IProductProvider { var invokedAsyncFetchCount = 0 var invokedAsyncFetchParameters: (productIDs: Set, Void)? var invokedAsyncFetchParamtersList = [(productIDs: Set, Void)]() - var stubbedAsyncFetchResult: Result<[ISKProduct], IAPError>? + var stubbedAsyncFetchResult: Result<[ISKProduct], Error>? @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func fetch(productIDs: Set) async throws -> [SK2StoreProduct] { diff --git a/Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift b/Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift new file mode 100644 index 000000000..4a542cb2f --- /dev/null +++ b/Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift @@ -0,0 +1,36 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import StoreKitTest +import XCTest + +class StoreSessionTestCase: XCTestCase { + // MARK: Properties + + var session: SKTestSession? + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + do { + session = try SKTestSession(configurationFileNamed: "Flare") + session?.resetToDefaultState() + session?.askToBuyEnabled = false + session?.disableDialogs = true + } catch { + debugPrint("[StoreSessionTestCase] An error occurred while initializing a session: \(error.localizedDescription)") + } + } + } + + override func tearDown() { + session?.clearTransactions() + session = nil + super.tearDown() + } +} From 7bb4a646d09ed7011bd345a2130c7ee21badb0d6 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 29 Dec 2023 11:41:17 +0100 Subject: [PATCH 12/27] Refactor the test target for supported platforms --- .github/workflows/ci.yml | 8 ++++---- Makefile | 5 ++++- .../FlareTests/UnitTestHostApp/AppDelegate.swift | 3 +-- Tests/FlareTests/UnitTestHostApp/Info.plist | 2 +- Tests/FlareTests/UnitTests/FlareTests.swift | 6 ++++++ .../UnitTests/Providers/IAPProviderTests.swift | 2 ++ project.yml | 16 ++++++++-------- 7 files changed, 26 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff5df7f4e..c32b2e917 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,10 +42,10 @@ jobs: name: "tvOS" scheme: "Flare" sdk: appletvsimulator - - destination: "OS=9.1,name=Apple Watch Series 8 (45mm)" - name: "watchOS" - scheme: "Flare" - sdk: watchsimulator + # - destination: "OS=9.1,name=Apple Watch Series 8 (45mm)" + # name: "watchOS" + # scheme: "Flare" + # sdk: watchsimulator - destination: "platform=macOS" name: "macOS" scheme: "Flare" diff --git a/Makefile b/Makefile index 856d64b45..55d969d56 100644 --- a/Makefile +++ b/Makefile @@ -16,4 +16,7 @@ lint: fmt: mint run swiftformat Sources Tests -.PHONY: all bootstrap hook mint lint fmt \ No newline at end of file +generate: + xcodegen generate + +.PHONY: all bootstrap hook mint lint fmt generate \ No newline at end of file diff --git a/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift b/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift index 603d7aff0..eb5f98aae 100644 --- a/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift +++ b/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift @@ -5,7 +5,7 @@ import SwiftUI -#if os(watchOS) || os(tvOS) +#if os(watchOS) || os(tvOS) || os(macOS) @main struct TestApp: App { @@ -17,7 +17,6 @@ import SwiftUI } #else - // Scene isn't available until iOS 14.0, so this is for backwards compatibility. @main diff --git a/Tests/FlareTests/UnitTestHostApp/Info.plist b/Tests/FlareTests/UnitTestHostApp/Info.plist index 8a7dc9a70..ceae02525 100644 --- a/Tests/FlareTests/UnitTestHostApp/Info.plist +++ b/Tests/FlareTests/UnitTestHostApp/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 4.32.0 + 1 CFBundleVersion 1 LSRequiresIPhoneOS diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 6f97ebb93..d2c186fcb 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -242,6 +242,7 @@ class FlareTests: StoreSessionTestCase { } #endif + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func test_thatFlarePurchasesAProductWithOptions_whenPurchaseCompleted() async throws { let transaction = StoreTransactionStub() try await test_purchaseWithOptionsAndCompletion( @@ -251,6 +252,7 @@ class FlareTests: StoreSessionTestCase { ) } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func test_thatFlarePurchaseThrowsAnError_whenPaymentNotAllowed() async throws { try await test_purchaseWithOptionsAndCompletion( canMakePayments: false, @@ -258,6 +260,7 @@ class FlareTests: StoreSessionTestCase { ) } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func test_thatFlarePurchasesAsyncAProductWithOptionsAndCompletionHandler_whenPurchaseCompleted() async throws { let transaction = StoreTransactionStub() try await test_purchaseWithOptions( @@ -266,6 +269,7 @@ class FlareTests: StoreSessionTestCase { ) } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func test_thatFlarePurchaseAsyncThrowsAnError_whenPaymentNotAllowed() async throws { try await test_purchaseWithOptions( canMakePayments: false, @@ -275,6 +279,7 @@ class FlareTests: StoreSessionTestCase { // MARK: Private + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) private func test_purchaseWithOptionsAndCompletion( transaction: StoreTransactionStub? = nil, canMakePayments: Bool, @@ -302,6 +307,7 @@ class FlareTests: StoreSessionTestCase { XCTAssertEqual(result, expectedResult) } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) private func test_purchaseWithOptions( transaction: StoreTransactionStub? = nil, canMakePayments: Bool, diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index 63c967817..5689faee8 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -292,6 +292,7 @@ class IAPProviderTests: XCTestCase { } #endif + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func test_thatIAPProviderPurchasesAProduct() async throws { // given let transactionMock = StoreTransactionMock() @@ -309,6 +310,7 @@ class IAPProviderTests: XCTestCase { XCTAssertEqual(transaction.transactionIdentifier, .transactionID) } + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func test_thatIAPProviderPurchasesAProductWithOptions() async throws { // given let transactionMock = StoreTransactionMock() diff --git a/project.yml b/project.yml index 2b6101e38..25419d99a 100644 --- a/project.yml +++ b/project.yml @@ -1,4 +1,10 @@ name: Flare +options: + deploymentTarget: + iOS: 13.0 + macOS: 10.15 + tvOS: 13.0 + watchOS: 7.0 packages: # External @@ -8,23 +14,18 @@ packages: ObjectsFactory: url: https://github.com/space-code/objects-factory.git from: 1.0.0 - - # Flare: - # path: . - targets: UnitTestHostApp: type: application - supportedDestinations: [iOS, tvOS, macOS, watchOS] + supportedDestinations: [iOS, tvOS, macOS] sources: Tests/FlareTests/UnitTestHostApp settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare + DEVELOPMENT_TEAM: A8WE5LL2GU scheme: testTargets: - FlareTests - # dependencies: - # - package: Flare Flare: type: framework supportedDestinations: [iOS, tvOS, macOS, watchOS] @@ -44,7 +45,6 @@ targets: FlareTests: type: bundle.unit-test supportedDestinations: [iOS, tvOS, macOS, watchOS] - # targets: ["1"] dependencies: - package: Concurrency product: TestConcurrency From bc561e4182ed7b92e1cf360705c19c6746cd7ba8 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 29 Dec 2023 12:36:57 +0100 Subject: [PATCH 13/27] Update `ci.yml` - Implement testing on different OS versions - Temporarily drop testing on watchOS --- .github/workflows/ci.yml | 125 +++++++++++--- Makefile | 5 +- .../UnitTestHostApp/AppDelegate.swift | 14 +- .../UnitTests/FlareStoreKit2Tests.swift | 156 ++++++++++++++++++ Tests/FlareTests/UnitTests/FlareTests.swift | 128 +------------- .../PurchaseProviderStoreKit2Tests.swift | 77 +++++++++ .../Providers/PurchaseProviderTests.swift | 46 +----- .../TestHelpers/StoreSessionTestCase.swift | 15 +- project.yml | 5 +- scripts/setup_build_tools.sh | 8 + 10 files changed, 358 insertions(+), 221 deletions(-) create mode 100644 Tests/FlareTests/UnitTests/FlareStoreKit2Tests.swift create mode 100644 Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift create mode 100644 scripts/setup_build_tools.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c32b2e917..26d69d98a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,54 +24,129 @@ jobs: args: --strict env: DIFF_BASE: ${{ github.base_ref }} - Latest: - name: Test Latest (iOS, macOS, tvOS, watchOS) - runs-on: macOS-12 + macOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} env: - DEVELOPER_DIR: "/Applications/Xcode_14.1.app/Contents/Developer" + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" timeout-minutes: 10 strategy: fail-fast: false matrix: include: - - destination: "OS=16.1,name=iPhone 14 Pro" - name: "iOS" - scheme: "Flare" - sdk: iphonesimulator - - destination: "OS=16.1,name=Apple TV" - name: "tvOS" - scheme: "Flare" - sdk: appletvsimulator - # - destination: "OS=9.1,name=Apple Watch Series 8 (45mm)" - # name: "watchOS" - # scheme: "Flare" - # sdk: watchsimulator - - destination: "platform=macOS" - name: "macOS" - scheme: "Flare" - sdk: macosx + - xcode: "Xcode_15.0" + runsOn: macos-13 + name: "macOS 13, Xcode 15.0, Swift 5.9.0" + - xcode: "Xcode_14.3.1" + runsOn: macos-13 + name: "macOS 13, Xcode 14.3.1, Swift 5.8.0" + - xcode: "Xcode_14.2" + runsOn: macOS-12 + name: "macOS 12, Xcode 14.2, Swift 5.7.2" + - xcode: "Xcode_14.1" + runsOn: macOS-12 + name: "macOS 12, Xcode 14.1, Swift 5.7.1" + steps: + - uses: actions/checkout@v3 + - name: Install Dependencies + run: make setup_build_tools + - name: Generate project + run: make generate + - name: ${{ matrix.name }} + run: xcodebuild test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -scheme "Flare" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "./macos.xcresult" || exit 1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: "./macos.xcresult" + + iOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - destination: "OS=17.0.1,name=iPhone 14 Pro" + name: "iOS 17.0.1" + xcode: "Xcode_15.0" + runsOn: macos-13 + - destination: "OS=16.4,name=iPhone 14 Pro" + name: "iOS 16.4" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + - destination: "OS=15.5,name=iPhone 13 Pro" + name: "iOS 15.5" + xcode: "Xcode_13.4.1" + runsOn: macOS-12 steps: - uses: actions/checkout@v3 + - name: Install Dependencies + run: make setup_build_tools + - name: Generate project + run: make generate - name: ${{ matrix.name }} - run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "./${{ matrix.sdk }}.xcresult" || exit 1 + run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "./iphonesimulator.xcresult" || exit 1 - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3.1.0 with: token: ${{ secrets.CODECOV_TOKEN }} xcode: true - xcode_archive_path: "./${{ matrix.sdk }}.xcresult" + xcode_archive_path: "./iphonesimulator.xcresult" + + tvOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - destination: "OS=17.0,name=Apple TV" + name: "tvOS 17.0" + xcode: "Xcode_15.0" + runsOn: macos-13 + - destination: "OS=16.4,name=Apple TV" + name: "tvOS 16.4" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + - destination: "OS=15.4,name=Apple TV" + name: "tvOS 15.4" + xcode: "Xcode_13.4.1" + runsOn: macos-12 + steps: + - uses: actions/checkout@v3 + - name: Install Dependencies + run: make setup_build_tools + - name: Generate project + run: make generate + - name: ${{ matrix.name }} + run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "./appletvsimulator.xcresult" || exit 1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: "./appletvsimulator.xcresult" + Beta: - name: "Test Betas" + name: ${{ matrix.name }} runs-on: macos-13 env: - DEVELOPER_DIR: "/Applications/Xcode_15.0.app/Contents/Developer" + DEVELOPER_DIR: "/Applications/Xcode_15.1.app/Contents/Developer" timeout-minutes: 10 strategy: fail-fast: false matrix: include: - destination: "OS=1.0,name=Apple Vision Pro" - name: "visionOS" + name: "visionOS 1.0" scheme: "Flare" steps: - uses: actions/checkout@v3 diff --git a/Makefile b/Makefile index 55d969d56..f11937816 100644 --- a/Makefile +++ b/Makefile @@ -19,4 +19,7 @@ fmt: generate: xcodegen generate -.PHONY: all bootstrap hook mint lint fmt generate \ No newline at end of file +setup_build_tools: + sh scripts/setup_build_tools.sh + +.PHONY: all bootstrap hook mint lint fmt generate setup_build_tools \ No newline at end of file diff --git a/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift b/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift index eb5f98aae..aea816e94 100644 --- a/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift +++ b/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift @@ -5,20 +5,14 @@ import SwiftUI -#if os(watchOS) || os(tvOS) || os(macOS) +#if os(macOS) + + import Cocoa @main - struct TestApp: App { - var body: some Scene { - WindowGroup { - Text("Hello World") - } - } - } + class AppDelegate: NSObject, NSApplicationDelegate {} #else - // Scene isn't available until iOS 14.0, so this is for backwards compatibility. - @main class AppDelegate: UIResponder, UIApplicationDelegate {} diff --git a/Tests/FlareTests/UnitTests/FlareStoreKit2Tests.swift b/Tests/FlareTests/UnitTests/FlareStoreKit2Tests.swift new file mode 100644 index 000000000..24922b25b --- /dev/null +++ b/Tests/FlareTests/UnitTests/FlareStoreKit2Tests.swift @@ -0,0 +1,156 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +// MARK: - FlareStoreKit2Tests + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +final class FlareStoreKit2Tests: StoreSessionTestCase { + // MARK: - Properties + + private var iapProviderMock: IAPProviderMock! + + private var sut: Flare! + + // MARK: - XCTestCase + + override func setUp() { + super.setUp() + iapProviderMock = IAPProviderMock() + sut = Flare(iapProvider: iapProviderMock) + } + + override func tearDown() { + iapProviderMock = nil + sut = nil + super.tearDown() + } + + #if os(iOS) || VISION_OS + func test_thatFlareRefundsPurchase() async throws { + // given + iapProviderMock.stubbedBeginRefundRequest = .success + + // when + let state = try await sut.beginRefundRequest(productID: .productID) + + // then + if case .success = state {} + else { XCTFail("state must be `success`") } + } + + func test_thatFlareRefundRequestThrowsAnError_whenBeginRefundRequestFailed() async throws { + // given + iapProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) + + // when + let state = try await sut.beginRefundRequest(productID: .productID) + + // then + if case let .failed(error) = state { XCTAssertEqual(error as NSError, IAPError.unknown as NSError) } + else { XCTFail("state must be `failed`") } + } + #endif + + func test_thatFlarePurchasesAProductWithOptions_whenPurchaseCompleted() async throws { + let transaction = StoreTransactionStub() + try await test_purchaseWithOptionsAndCompletion( + transaction: transaction, + canMakePayments: true, + expectedResult: .success(StoreTransaction(storeTransaction: transaction)) + ) + } + + func test_thatFlarePurchaseThrowsAnError_whenPaymentNotAllowed() async throws { + try await test_purchaseWithOptionsAndCompletion( + canMakePayments: false, + expectedResult: .failure(IAPError.paymentNotAllowed) + ) + } + + func test_thatFlarePurchasesAsyncAProductWithOptionsAndCompletionHandler_whenPurchaseCompleted() async throws { + let transaction = StoreTransactionStub() + try await test_purchaseWithOptions( + canMakePayments: true, + expectedResult: .success(StoreTransaction(storeTransaction: transaction)) + ) + } + + func test_thatFlarePurchaseAsyncThrowsAnError_whenPaymentNotAllowed() async throws { + try await test_purchaseWithOptions( + canMakePayments: false, + expectedResult: .failure(IAPError.paymentNotAllowed) + ) + } + + // MARK: Private + + private func test_purchaseWithOptionsAndCompletion( + transaction: StoreTransactionStub? = nil, + canMakePayments: Bool, + expectedResult: Result + ) async throws { + // given + let product = try await ProductProviderHelper.purchases.randomElement() + let storeTransactionStub = transaction ?? StoreTransactionStub() + storeTransactionStub.stubbedProductIdentifier = product?.id + + iapProviderMock.stubbedCanMakePayments = canMakePayments + iapProviderMock.stubbedAsyncPurchaseWithOptions = StoreTransaction( + storeTransaction: storeTransactionStub + ) + + // when + let result: Result = await result(for: { + try await sut.purchase( + product: StoreProduct(product: product!), + options: [.simulatesAskToBuyInSandbox(false)] + ) + }) + + // then + XCTAssertEqual(result, expectedResult) + } + + private func test_purchaseWithOptions( + transaction: StoreTransactionStub? = nil, + canMakePayments: Bool, + expectedResult: Result + ) async throws { + // given + let expectation = XCTestExpectation(description: "Purchase a product") + + let product = try await ProductProviderHelper.purchases.randomElement() + let storeTransactionStub = transaction ?? StoreTransactionStub() + storeTransactionStub.stubbedProductIdentifier = product?.id + + iapProviderMock.stubbedCanMakePayments = canMakePayments + iapProviderMock.stubbedPurchaseWithOptionsResult = .success(StoreTransaction(storeTransaction: storeTransactionStub)) + + // when + sut.purchase( + product: StoreProduct(product: product!), + options: [.simulatesAskToBuyInSandbox(false)] + ) { result in + XCTAssertEqual(result, expectedResult) + expectation.fulfill() + } + + // then + wait(for: [expectation], timeout: .second) + } +} + +// MARK: - Constants + +private extension TimeInterval { + static let second: CGFloat = 1.0 +} + +private extension String { + static let productID = "com.flare.test_purchase_2" +} diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index d2c186fcb..0192a970d 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -9,7 +9,7 @@ import XCTest // MARK: - FlareTests -class FlareTests: StoreSessionTestCase { +class FlareTests: XCTestCase { // MARK: - Properties private var iapProviderMock: IAPProviderMock! @@ -213,128 +213,6 @@ class FlareTests: StoreSessionTestCase { // then XCTAssertTrue(iapProviderMock.invokedAddTransactionObserver) } - - #if os(iOS) || VISION_OS - @available(iOS 15.0, *) - func test_thatFlareRefundsPurchase() async throws { - // given - iapProviderMock.stubbedBeginRefundRequest = .success - - // when - let state = try await sut.beginRefundRequest(productID: .productID) - - // then - if case .success = state {} - else { XCTFail("state must be `success`") } - } - - @available(iOS 15.0, *) - func test_thatFlareRefundRequestThrowsAnError_whenBeginRefundRequestFailed() async throws { - // given - iapProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) - - // when - let state = try await sut.beginRefundRequest(productID: .productID) - - // then - if case let .failed(error) = state { XCTAssertEqual(error as NSError, IAPError.unknown as NSError) } - else { XCTFail("state must be `failed`") } - } - #endif - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - func test_thatFlarePurchasesAProductWithOptions_whenPurchaseCompleted() async throws { - let transaction = StoreTransactionStub() - try await test_purchaseWithOptionsAndCompletion( - transaction: transaction, - canMakePayments: true, - expectedResult: .success(StoreTransaction(storeTransaction: transaction)) - ) - } - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - func test_thatFlarePurchaseThrowsAnError_whenPaymentNotAllowed() async throws { - try await test_purchaseWithOptionsAndCompletion( - canMakePayments: false, - expectedResult: .failure(IAPError.paymentNotAllowed) - ) - } - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - func test_thatFlarePurchasesAsyncAProductWithOptionsAndCompletionHandler_whenPurchaseCompleted() async throws { - let transaction = StoreTransactionStub() - try await test_purchaseWithOptions( - canMakePayments: true, - expectedResult: .success(StoreTransaction(storeTransaction: transaction)) - ) - } - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - func test_thatFlarePurchaseAsyncThrowsAnError_whenPaymentNotAllowed() async throws { - try await test_purchaseWithOptions( - canMakePayments: false, - expectedResult: .failure(IAPError.paymentNotAllowed) - ) - } - - // MARK: Private - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - private func test_purchaseWithOptionsAndCompletion( - transaction: StoreTransactionStub? = nil, - canMakePayments: Bool, - expectedResult: Result - ) async throws { - // given - let product = try await ProductProviderHelper.purchases.randomElement() - let storeTransactionStub = transaction ?? StoreTransactionStub() - storeTransactionStub.stubbedProductIdentifier = product?.id - - iapProviderMock.stubbedCanMakePayments = canMakePayments - iapProviderMock.stubbedAsyncPurchaseWithOptions = StoreTransaction( - storeTransaction: storeTransactionStub - ) - - // when - let result: Result = await result(for: { - try await sut.purchase( - product: StoreProduct(product: product!), - options: [.simulatesAskToBuyInSandbox(false)] - ) - }) - - // then - XCTAssertEqual(result, expectedResult) - } - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - private func test_purchaseWithOptions( - transaction: StoreTransactionStub? = nil, - canMakePayments: Bool, - expectedResult: Result - ) async throws { - // given - let expectation = XCTestExpectation(description: "Purchase a product") - - let product = try await ProductProviderHelper.purchases.randomElement() - let storeTransactionStub = transaction ?? StoreTransactionStub() - storeTransactionStub.stubbedProductIdentifier = product?.id - - iapProviderMock.stubbedCanMakePayments = canMakePayments - iapProviderMock.stubbedPurchaseWithOptionsResult = .success(StoreTransaction(storeTransaction: storeTransactionStub)) - - // when - sut.purchase( - product: StoreProduct(product: product!), - options: [.simulatesAskToBuyInSandbox(false)] - ) { result in - XCTAssertEqual(result, expectedResult) - expectation.fulfill() - } - - // then - wait(for: [expectation], timeout: .second) - } } // MARK: - Constants @@ -347,7 +225,3 @@ private extension String { static let productID = "product_ID" static let receipt = "receipt" } - -private extension TimeInterval { - static let second: CGFloat = 1.0 -} diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift new file mode 100644 index 000000000..312c2f32e --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift @@ -0,0 +1,77 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +// MARK: - PurchaseProviderStoreKit2Tests + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +final class PurchaseProviderStoreKit2Tests: StoreSessionTestCase { + // MARK: Properties + + private var paymentProviderMock: PaymentProviderMock! + + private var sut: PurchaseProvider! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + paymentProviderMock = PaymentProviderMock() + sut = PurchaseProvider( + paymentProvider: paymentProviderMock + ) + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatPurchaseProviderReturnsPaymentTransaction_whenPurchasesAProductWithOptions() async throws { + let expectation = XCTestExpectation(description: "Purchase a product") + let productMock = try StoreProduct(product: await ProductProviderHelper.purchases.randomElement()!) + + // when + sut.purchase(product: productMock, options: [.simulatesAskToBuyInSandbox(false)]) { result in + switch result { + case let .success(transaction): + XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) + expectation.fulfill() + case let .failure(error): + XCTFail(error.localizedDescription) + } + } + + wait(for: [expectation], timeout: .second) + } + + func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK2ProductExist() async throws { + let expectation = XCTestExpectation(description: "Purchase a product") + let productMock = try StoreProduct(product: await ProductProviderHelper.purchases.randomElement()!) + + // when + sut.purchase(product: productMock) { result in + switch result { + case let .success(transaction): + XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) + expectation.fulfill() + case let .failure(error): + XCTFail(error.localizedDescription) + } + } + + wait(for: [expectation], timeout: .second) + } +} + +// MARK: - Constants + +private extension TimeInterval { + static let second: TimeInterval = 1.0 +} diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift index 11638516f..2e377f714 100644 --- a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift @@ -10,7 +10,7 @@ import XCTest // MARK: - PurchaseProviderTests -final class PurchaseProviderTests: StoreSessionTestCase { +final class PurchaseProviderTests: XCTestCase { // MARK: Properties private var paymentQueueMock: PaymentQueueMock! @@ -54,44 +54,6 @@ final class PurchaseProviderTests: StoreSessionTestCase { } } - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK2ProductExist() async throws { - let expectation = XCTestExpectation(description: "Purchase a product") - let productMock = try StoreProduct(product: await ProductProviderHelper.purchases[0]) - - // when - sut.purchase(product: productMock) { result in - switch result { - case let .success(transaction): - XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) - expectation.fulfill() - case let .failure(error): - XCTFail(error.localizedDescription) - } - } - - wait(for: [expectation], timeout: .second) - } - - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func test_thatPurchaseProviderReturnsPaymentTransaction_whenPurchasesAProductWithOptions() async throws { - let expectation = XCTestExpectation(description: "Purchase a product") - let productMock = try StoreProduct(product: await ProductProviderHelper.purchases[0]) - - // when - sut.purchase(product: productMock, options: [.simulatesAskToBuyInSandbox(false)]) { result in - switch result { - case let .success(transaction): - XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) - expectation.fulfill() - case let .failure(error): - XCTFail(error.localizedDescription) - } - } - - wait(for: [expectation], timeout: .second) - } - func test_thatPurchaseProviderFinishesTransaction() { // given let transaction = PurchaseManagerTestHelper.makePaymentTransaction(state: .purchased) @@ -140,9 +102,3 @@ final class PurchaseProviderTests: StoreSessionTestCase { XCTAssertTrue(paymentProviderMock.invokedRemoveTransactionObserver) } } - -// MARK: - Constants - -private extension TimeInterval { - static let second: TimeInterval = 1.0 -} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift b/Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift index 4a542cb2f..4e33a5234 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift @@ -6,6 +6,7 @@ import StoreKitTest import XCTest +@available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) class StoreSessionTestCase: XCTestCase { // MARK: Properties @@ -16,16 +17,10 @@ class StoreSessionTestCase: XCTestCase { override func setUp() { super.setUp() - if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { - do { - session = try SKTestSession(configurationFileNamed: "Flare") - session?.resetToDefaultState() - session?.askToBuyEnabled = false - session?.disableDialogs = true - } catch { - debugPrint("[StoreSessionTestCase] An error occurred while initializing a session: \(error.localizedDescription)") - } - } + session = try? SKTestSession(configurationFileNamed: "Flare") + session?.resetToDefaultState() + session?.askToBuyEnabled = false + session?.disableDialogs = true } override func tearDown() { diff --git a/project.yml b/project.yml index 25419d99a..558a79566 100644 --- a/project.yml +++ b/project.yml @@ -22,13 +22,12 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare - DEVELOPMENT_TEAM: A8WE5LL2GU scheme: testTargets: - FlareTests Flare: type: framework - supportedDestinations: [iOS, tvOS, macOS, watchOS] + supportedDestinations: [iOS, tvOS, macOS] dependencies: - package: Concurrency product: Concurrency @@ -44,7 +43,7 @@ targets: - Flare FlareTests: type: bundle.unit-test - supportedDestinations: [iOS, tvOS, macOS, watchOS] + supportedDestinations: [iOS, tvOS, macOS] dependencies: - package: Concurrency product: TestConcurrency diff --git a/scripts/setup_build_tools.sh b/scripts/setup_build_tools.sh new file mode 100644 index 000000000..a0b9e5235 --- /dev/null +++ b/scripts/setup_build_tools.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +which -s xcodegen +if [[ $? != 0 ]] ; then + # Install xcodegen + echo "Installing xcodegen." + brew install xcodegen +fi \ No newline at end of file From 8f5dcb449f7b053fc094e883d0170878d1c72f0b Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 29 Dec 2023 14:22:13 +0100 Subject: [PATCH 14/27] Remove `ObjectFactory` dependency --- Package.swift | 5 ----- Package@swift-5.7.swift | 5 ----- .../TestHelpers/Helpers/WindowSceneFactory.swift | 9 +-------- project.yml | 4 ---- 4 files changed, 1 insertion(+), 22 deletions(-) diff --git a/Package.swift b/Package.swift index dd4acbb0b..fea55f4e2 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,6 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/space-code/concurrency.git", .upToNextMajor(from: "0.0.1")), - .package(url: "https://github.com/space-code/objects-factory.git", .upToNextMajor(from: "1.0.0")), ], targets: [ .target( @@ -34,11 +33,7 @@ let package = Package( name: "FlareTests", dependencies: [ "Flare", - .product(name: "ObjectsFactory", package: "objects-factory"), .product(name: "TestConcurrency", package: "concurrency"), - ], - resources: [ - .process("Flare.storekit"), ] ), ] diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift index 5a2b1d50b..814b9fc31 100644 --- a/Package@swift-5.7.swift +++ b/Package@swift-5.7.swift @@ -17,7 +17,6 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/space-code/concurrency.git", .upToNextMajor(from: "0.0.1")), - .package(url: "https://github.com/space-code/objects-factory.git", .upToNextMajor(from: "1.0.0")), ], targets: [ .target( @@ -30,12 +29,8 @@ let package = Package( name: "FlareTests", dependencies: [ "Flare", - .product(name: "ObjectsFactory", package: "objects-factory"), .product(name: "TestConcurrency", package: "concurrency"), ], - resources: [ - .process("Flare.storekit"), - ] ), ] ) diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift index 337484a51..4c21b2c38 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/WindowSceneFactory.swift @@ -4,18 +4,11 @@ // #if os(iOS) || VISION_OS - import ObjectsFactory import UIKit final class WindowSceneFactory { static func makeWindowScene() -> UIWindowScene { - do { - let session = try ObjectsFactory.create(UISceneSession.self) - let scene = try ObjectsFactory.create(UIWindowScene.self, properties: ["session": session]) - return scene - } catch { - fatalError(error.localizedDescription) - } + UIApplication.shared.connectedScenes.first as! UIWindowScene } } #endif diff --git a/project.yml b/project.yml index 558a79566..45748d5a3 100644 --- a/project.yml +++ b/project.yml @@ -11,9 +11,6 @@ packages: Concurrency: url: https://github.com/space-code/concurrency.git from: 0.0.1 - ObjectsFactory: - url: https://github.com/space-code/objects-factory.git - from: 1.0.0 targets: UnitTestHostApp: type: application @@ -47,7 +44,6 @@ targets: dependencies: - package: Concurrency product: TestConcurrency - - package: ObjectsFactory - target: Flare settings: base: From 978641c6d35ec18b6792acd103765ff162a5a8c6 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 29 Dec 2023 14:42:07 +0100 Subject: [PATCH 15/27] Update `project.yml` --- .github/workflows/ci.yml | 2 +- project.yml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26d69d98a..64ebef369 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,7 +139,7 @@ jobs: name: ${{ matrix.name }} runs-on: macos-13 env: - DEVELOPER_DIR: "/Applications/Xcode_15.1.app/Contents/Developer" + DEVELOPER_DIR: "/Applications/Xcode_15.0.app/Contents/Developer" timeout-minutes: 10 strategy: fail-fast: false diff --git a/project.yml b/project.yml index 45748d5a3..703146a47 100644 --- a/project.yml +++ b/project.yml @@ -45,8 +45,10 @@ targets: - package: Concurrency product: TestConcurrency - target: Flare + - target: UnitTestHostApp settings: base: + BUNDLE_LOADER: $(TEST_HOST) GENERATE_INFOPLIST_FILE: YES TEST_HOST: $(BUILT_PRODUCTS_DIR)/UnitTestHostApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/UnitTestHostApp PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare From 69923de15e632444325fa5465dc8c9be5a9599ec Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 29 Dec 2023 15:01:27 +0100 Subject: [PATCH 16/27] Refactor test cases --- .../Providers/IAPProviderStoreKit2Tests.swift | 146 ++++++++++++++++++ .../Providers/IAPProviderTests.swift | 99 ------------ .../ProductProviderStoreKit2Tests.swift | 54 +++++++ .../Providers/ProductProviderTests.swift | 10 -- .../Providers/RefundProviderTests.swift | 97 ++++++------ .../Providers/SystemInfoProviderTests.swift | 42 ++--- 6 files changed, 270 insertions(+), 178 deletions(-) create mode 100644 Tests/FlareTests/UnitTests/Providers/IAPProviderStoreKit2Tests.swift create mode 100644 Tests/FlareTests/UnitTests/Providers/ProductProviderStoreKit2Tests.swift diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderStoreKit2Tests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderStoreKit2Tests.swift new file mode 100644 index 000000000..0d129112e --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderStoreKit2Tests.swift @@ -0,0 +1,146 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import XCTest + +// MARK: - IAPProviderStoreKit2Tests + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +final class IAPProviderStoreKit2Tests: StoreSessionTestCase { + // MARK: - Properties + + private var productProviderMock: ProductProviderMock! + private var purchaseProvider: PurchaseProviderMock! + private var refundProviderMock: RefundProviderMock! + + private var sut: IIAPProvider! + + // MARK: - XCTestCase + + override func setUp() { + super.setUp() + productProviderMock = ProductProviderMock() + purchaseProvider = PurchaseProviderMock() + refundProviderMock = RefundProviderMock() + sut = IAPProvider( + paymentQueue: PaymentQueueMock(), + productProvider: productProviderMock, + purchaseProvider: purchaseProvider, + receiptRefreshProvider: ReceiptRefreshProviderMock(), + refundProvider: refundProviderMock + ) + } + + override func tearDown() { + productProviderMock = nil + purchaseProvider = nil + refundProviderMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatIAPProviderFetchesSK2Products_whenProductsAreAvailable() async throws { + let productsMock = try await ProductProviderHelper.purchases.map(SK2StoreProduct.init) + productProviderMock.stubbedAsyncFetchResult = .success(productsMock) + + // when + let products = try await sut.fetch(productIDs: [.productID]) + + // then + XCTAssertFalse(products.isEmpty) + XCTAssertEqual(productsMock.count, products.count) + } + + func test_thatIAPProviderThrowsAnIAPError_whenFetchingProductsFailed() async { + productProviderMock.stubbedAsyncFetchResult = .failure(IAPError.unknown) + + // when + let error: IAPError? = await error(for: { try await sut.fetch(productIDs: [.productID]) }) + + // then + XCTAssertEqual(error, .unknown) + } + + func test_thatIAPProviderThrowsAPlainError_whenFetchingProductsFailed() async { + productProviderMock.stubbedAsyncFetchResult = .failure(URLError(.unknown)) + + // when + let error: IAPError? = await error(for: { try await sut.fetch(productIDs: [.productID]) }) + + // then + XCTAssertEqual(error, .with(error: URLError(.unknown))) + } + + #if os(iOS) || VISION_OS + func test_thatIAPProviderRefundsPurchase() async throws { + // given + refundProviderMock.stubbedBeginRefundRequest = .success + + // when + let state = try await sut.beginRefundRequest(productID: .productID) + + // then + if case .success = state {} + else { XCTFail("state must be `success`") } + } + + func test_thatFlareThrowsAnError_whenBeginRefundRequestFailed() async throws { + // given + refundProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) + + // when + let state = try await sut.beginRefundRequest(productID: .productID) + + // then + if case let .failed(error) = state { XCTAssertEqual(error as NSError, IAPError.unknown as NSError) } + else { XCTFail("state must be `failed`") } + } + #endif + + func test_thatIAPProviderPurchasesAProduct() async throws { + // given + let transactionMock = StoreTransactionMock() + transactionMock.stubbedTransactionIdentifier = .transactionID + + let storeTransaction = StoreTransaction(storeTransaction: transactionMock) + purchaseProvider.stubbedPurchaseCompletionResult = (.success(storeTransaction), ()) + + let product = try await ProductProviderHelper.purchases[0] + + // when + let transaction = try await sut.purchase(product: StoreProduct(product: product)) + + // then + XCTAssertEqual(transaction.transactionIdentifier, .transactionID) + } + + func test_thatIAPProviderPurchasesAProductWithOptions() async throws { + // given + let transactionMock = StoreTransactionMock() + transactionMock.stubbedTransactionIdentifier = .transactionID + + let storeTransaction = StoreTransaction(storeTransaction: transactionMock) + purchaseProvider.stubbedinvokedPurchaseWithOptionsCompletionResult = (.success(storeTransaction), ()) + + let product = try await ProductProviderHelper.purchases[0] + + // when + let transaction = try await sut.purchase(product: StoreProduct(product: product), options: []) + + // then + XCTAssertEqual(transaction.transactionIdentifier, .transactionID) + } +} + +// MARK: - Constants + +private extension String { +// static let receipt = "receipt" + static let productID = "product_identifier" + static let transactionID = "transaction_identifier" +} diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index 5689faee8..d859c0d8c 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -128,41 +128,6 @@ class IAPProviderTests: XCTestCase { XCTAssertEqual(productsMock.count, products.count) } - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func test_thatIAPProviderFetchesSK2Products_whenProductsAreAvailable() async throws { - let productsMock = try await ProductProviderHelper.purchases.map(SK2StoreProduct.init) - productProviderMock.stubbedAsyncFetchResult = .success(productsMock) - - // when - let products = try await sut.fetch(productIDs: .productIDs) - - // then - XCTAssertFalse(products.isEmpty) - XCTAssertEqual(productsMock.count, products.count) - } - - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func test_thatIAPProviderThrowsAnIAPError_whenFetchingProductsFailed() async { - productProviderMock.stubbedAsyncFetchResult = .failure(IAPError.unknown) - - // when - let error: IAPError? = await error(for: { try await sut.fetch(productIDs: .productIDs) }) - - // then - XCTAssertEqual(error, .unknown) - } - - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func test_thatIAPProviderThrowsAPlainError_whenFetchingProductsFailed() async { - productProviderMock.stubbedAsyncFetchResult = .failure(URLError(.unknown)) - - // when - let error: IAPError? = await error(for: { try await sut.fetch(productIDs: .productIDs) }) - - // then - XCTAssertEqual(error, .with(error: URLError(.unknown))) - } - func test_thatIAPProviderThrowsNoProductsError_whenProductsProductProviderReturnsError() async throws { try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() @@ -263,70 +228,6 @@ class IAPProviderTests: XCTestCase { // then XCTAssertEqual(errorResult as? NSError, IAPError.receiptNotFound as NSError) } - - #if os(iOS) || VISION_OS - @available(iOS 15.0, *) - func test_thatIAPProviderRefundsPurchase() async throws { - // given - refundProviderMock.stubbedBeginRefundRequest = .success - - // when - let state = try await sut.beginRefundRequest(productID: .productID) - - // then - if case .success = state {} - else { XCTFail("state must be `success`") } - } - - @available(iOS 15.0, *) - func test_thatFlareThrowsAnError_whenBeginRefundRequestFailed() async throws { - // given - refundProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) - - // when - let state = try await sut.beginRefundRequest(productID: .productID) - - // then - if case let .failed(error) = state { XCTAssertEqual(error as NSError, IAPError.unknown as NSError) } - else { XCTFail("state must be `failed`") } - } - #endif - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - func test_thatIAPProviderPurchasesAProduct() async throws { - // given - let transactionMock = StoreTransactionMock() - transactionMock.stubbedTransactionIdentifier = .transactionID - - let storeTransaction = StoreTransaction(storeTransaction: transactionMock) - purchaseProvider.stubbedPurchaseCompletionResult = (.success(storeTransaction), ()) - - let product = try await ProductProviderHelper.purchases[0] - - // when - let transaction = try await sut.purchase(product: StoreProduct(product: product)) - - // then - XCTAssertEqual(transaction.transactionIdentifier, .transactionID) - } - - @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) - func test_thatIAPProviderPurchasesAProductWithOptions() async throws { - // given - let transactionMock = StoreTransactionMock() - transactionMock.stubbedTransactionIdentifier = .transactionID - - let storeTransaction = StoreTransaction(storeTransaction: transactionMock) - purchaseProvider.stubbedinvokedPurchaseWithOptionsCompletionResult = (.success(storeTransaction), ()) - - let product = try await ProductProviderHelper.purchases[0] - - // when - let transaction = try await sut.purchase(product: StoreProduct(product: product), options: []) - - // then - XCTAssertEqual(transaction.transactionIdentifier, .transactionID) - } } // MARK: - Constants diff --git a/Tests/FlareTests/UnitTests/Providers/ProductProviderStoreKit2Tests.swift b/Tests/FlareTests/UnitTests/Providers/ProductProviderStoreKit2Tests.swift new file mode 100644 index 000000000..87eba9e5a --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/ProductProviderStoreKit2Tests.swift @@ -0,0 +1,54 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Concurrency +@testable import Flare +import TestConcurrency +import XCTest + +// MARK: - ProductProviderStoreKit2Tests + +@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +final class ProductProviderStoreKit2Tests: StoreSessionTestCase { + // MARK: - Properties + + private var testDispatchQueue: TestDispatchQueue! + private var dispatchQueueFactory: IDispatchQueueFactory! + + private var sut: ProductProvider! + + // MARK: - XCTestCase + + override func setUp() { + super.setUp() + testDispatchQueue = TestDispatchQueue() + dispatchQueueFactory = TestDispatchQueueFactory(testQueue: testDispatchQueue) + sut = ProductProvider(dispatchQueueFactory: dispatchQueueFactory) + } + + override func tearDown() { + testDispatchQueue = nil + dispatchQueueFactory = nil + sut = nil + super.tearDown() + } + + // MARK: - Tests + + func test_thatProductProviderFetchesProductsWithIDs() async throws { + // when + let products = try await sut.fetch(productIDs: [.productID]) + + // then + XCTAssertEqual(products.count, 1) + XCTAssertEqual(products.first?.productIdentifier, .productID) + } +} + +// MARK: - Constants + +private extension String { + static let productID = "com.flare.test_purchase_1" +} diff --git a/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift index 22dd068a7..fc0a2edf1 100644 --- a/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift @@ -87,16 +87,6 @@ final class ProductProviderTests: XCTestCase { // then XCTAssertEqual(error?.plainError as? NSError, errorStub.plainError as NSError) } - - @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func test_thatProductProvider() async throws { - // when - let products = try await sut.fetch(productIDs: [.productID]) - - // then - XCTAssertEqual(products.count, 1) - XCTAssertEqual(products.first?.productIdentifier, .productID) - } } // MARK: - Constants diff --git a/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift index 39624aef1..4dbe95d6e 100644 --- a/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift @@ -46,58 +46,59 @@ XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) } - func testThatRefundProviderThrowsAnErrorWhenRefundRequestDidFail() async throws { - // given - refundRequestProviderMock.stubbedVerifyTransaction = .transactionID - refundRequestProviderMock.stubbedBeginRefundRequest = .failure(IAPError.unknown) - systemInfoProviderMock.stubbedCurrentScene = .success(WindowSceneFactory.makeWindowScene()) - - // when - let status: RefundRequestStatus? = try await sut.beginRefundRequest(productID: .productID) - - // then - if case .failed = status {} - else { XCTFail("The status must be `failed`") } - XCTAssertEqual(refundRequestProviderMock.invokedBeginRefundRequestCount, 1) - } - - func testThatRefundProviderReturnsSuccessStatusWhenRefundRequestCompleted() async throws { - // given - refundRequestProviderMock.stubbedVerifyTransaction = .transactionID - refundRequestProviderMock.stubbedBeginRefundRequest = .success(.success) - systemInfoProviderMock.stubbedCurrentScene = .success(WindowSceneFactory.makeWindowScene()) - - // when - let status: RefundRequestStatus? = try await sut.beginRefundRequest(productID: .productID) - - // then - if case .success = status {} - else { XCTFail("The status must be `success`") } - XCTAssertEqual(refundRequestProviderMock.invokedBeginRefundRequestCount, 1) - } - - func testThatRefundProviderReturnsUserCancelledStatusWhenUserCancelledRequest() async throws { - // given - refundRequestProviderMock.stubbedVerifyTransaction = .transactionID - refundRequestProviderMock.stubbedBeginRefundRequest = .success(.userCancelled) - systemInfoProviderMock.stubbedCurrentScene = .success(WindowSceneFactory.makeWindowScene()) - - // when - let status: RefundRequestStatus? = try await sut.beginRefundRequest(productID: .productID) - - // then - if case .userCancelled = status {} - else { XCTFail("The status must be `userCancelled`") } - XCTAssertEqual(refundRequestProviderMock.invokedBeginRefundRequestCount, 1) - } +// func testThatRefundProviderThrowsAnErrorWhenRefundRequestDidFail() async throws { +// // given +// refundRequestProviderMock.stubbedVerifyTransaction = .transactionID +// refundRequestProviderMock.stubbedBeginRefundRequest = .failure(IAPError.unknown) +// systemInfoProviderMock.stubbedCurrentScene = .success(WindowSceneFactory.makeWindowScene()) +// +// // when +// let status: RefundRequestStatus? = try await sut.beginRefundRequest(productID: .productID) +// +// // then +// if case .failed = status {} +// else { XCTFail("The status must be `failed`") } +// XCTAssertEqual(refundRequestProviderMock.invokedBeginRefundRequestCount, 1) +// } +// +// func testThatRefundProviderReturnsSuccessStatusWhenRefundRequestCompleted() async throws { +// // given +// refundRequestProviderMock.stubbedVerifyTransaction = .transactionID +// refundRequestProviderMock.stubbedBeginRefundRequest = .success(.success) +// systemInfoProviderMock.stubbedCurrentScene = .success(WindowSceneFactory.makeWindowScene()) +// +// // when +// let status: RefundRequestStatus? = try await sut.beginRefundRequest(productID: .productID) +// +// // then +// if case .success = status {} +// else { XCTFail("The status must be `success`") } +// XCTAssertEqual(refundRequestProviderMock.invokedBeginRefundRequestCount, 1) +// } +// +// func testThatRefundProviderReturnsUserCancelledStatusWhenUserCancelledRequest() async throws { +// // given +// refundRequestProviderMock.stubbedVerifyTransaction = .transactionID +// refundRequestProviderMock.stubbedBeginRefundRequest = .success(.userCancelled) +// systemInfoProviderMock.stubbedCurrentScene = .success(WindowSceneFactory.makeWindowScene()) +// +// // when +// let status: RefundRequestStatus? = try await sut.beginRefundRequest(productID: .productID) +// +// // then +// if case .userCancelled = status {} +// else { XCTFail("The status must be `userCancelled`") } +// XCTAssertEqual(refundRequestProviderMock.invokedBeginRefundRequestCount, 1) +// } } // MARK: - Constants - private extension UInt64 { - static let transactionID: UInt64 = 5 - } - +// +// private extension UInt64 { +// static let transactionID: UInt64 = 5 +// } +// private extension String { static let productID: String = "product_id" } diff --git a/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift index c4cc292ab..5d92412bb 100644 --- a/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift @@ -30,26 +30,26 @@ // MARK: Tests - @MainActor - func test_thatScenesHolderReturnsCurrentScene() throws { - // given - let windowScene = WindowSceneFactory.makeWindowScene() - scenesHolderMock.stubbedConnectedScenes = Set(arrayLiteral: windowScene) - - // when - let scene = try sut.currentScene - - // then - XCTAssertEqual(windowScene, scene) - } - - @MainActor - func test_thatScenesHolderThrowsAnErrorWhenThereIsNoActiveWindowScene() async throws { - // when - let error: Error? = await self.error(for: { try sut.currentScene }) - - // then - XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) - } +// @MainActor +// func test_thatScenesHolderReturnsCurrentScene() throws { +// // given +// let windowScene = WindowSceneFactory.makeWindowScene() +// scenesHolderMock.stubbedConnectedScenes = Set(arrayLiteral: windowScene) +// +// // when +// let scene = try sut.currentScene +// +// // then +// XCTAssertEqual(windowScene, scene) +// } +// +// @MainActor +// func test_thatScenesHolderThrowsAnErrorWhenThereIsNoActiveWindowScene() async throws { +// // when +// let error: Error? = await self.error(for: { try sut.currentScene }) +// +// // then +// XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) +// } } #endif From 27dbbba249451f702c3d76d71a3e7e64691e38d8 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 29 Dec 2023 15:17:31 +0100 Subject: [PATCH 17/27] Update `ci.yml` --- .github/workflows/ci.yml | 56 +++++++++---------- .../PurchaseProviderStoreKit2Tests.swift | 12 +++- 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64ebef369..6942e7db3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: runs-on: ${{ matrix.runsOn }} env: DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" - timeout-minutes: 10 + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -66,7 +66,7 @@ jobs: runs-on: ${{ matrix.runsOn }} env: DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" - timeout-minutes: 10 + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -79,10 +79,10 @@ jobs: name: "iOS 16.4" xcode: "Xcode_14.3.1" runsOn: macos-13 - - destination: "OS=15.5,name=iPhone 13 Pro" - name: "iOS 15.5" - xcode: "Xcode_13.4.1" - runsOn: macOS-12 + # - destination: "OS=15.5,name=iPhone 13 Pro" + # name: "iOS 15.5" + # xcode: "Xcode_13.4.1" + # runsOn: macOS-12 steps: - uses: actions/checkout@v3 - name: Install Dependencies @@ -103,7 +103,7 @@ jobs: runs-on: ${{ matrix.runsOn }} env: DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" - timeout-minutes: 10 + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -116,10 +116,10 @@ jobs: name: "tvOS 16.4" xcode: "Xcode_14.3.1" runsOn: macos-13 - - destination: "OS=15.4,name=Apple TV" - name: "tvOS 15.4" - xcode: "Xcode_13.4.1" - runsOn: macos-12 + # - destination: "OS=15.4,name=Apple TV" + # name: "tvOS 15.4" + # xcode: "Xcode_13.4.1" + # runsOn: macos-12 steps: - uses: actions/checkout@v3 - name: Install Dependencies @@ -135,20 +135,20 @@ jobs: xcode: true xcode_archive_path: "./appletvsimulator.xcresult" - Beta: - name: ${{ matrix.name }} - runs-on: macos-13 - env: - DEVELOPER_DIR: "/Applications/Xcode_15.0.app/Contents/Developer" - timeout-minutes: 10 - strategy: - fail-fast: false - matrix: - include: - - destination: "OS=1.0,name=Apple Vision Pro" - name: "visionOS 1.0" - scheme: "Flare" - steps: - - uses: actions/checkout@v3 - - name: ${{ matrix.name }} - run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean || exit 1 \ No newline at end of file + # Beta: + # name: ${{ matrix.name }} + # runs-on: macos-13 + # env: + # DEVELOPER_DIR: "/Applications/Xcode_15.0.app/Contents/Developer" + # timeout-minutes: 10 + # strategy: + # fail-fast: false + # matrix: + # include: + # - destination: "OS=1.0,name=Apple Vision Pro" + # name: "visionOS 1.0" + # scheme: "Flare" + # steps: + # - uses: actions/checkout@v3 + # - name: ${{ matrix.name }} + # run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean || exit 1 \ No newline at end of file diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift index 312c2f32e..eb1ceee9c 100644 --- a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift @@ -48,7 +48,11 @@ final class PurchaseProviderStoreKit2Tests: StoreSessionTestCase { } } - wait(for: [expectation], timeout: .second) + #if swift(>=5.9) + await fulfillment(of: [expectation]) + #else + wait(for: [expectation], timeout: .second) + #endif } func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK2ProductExist() async throws { @@ -66,7 +70,11 @@ final class PurchaseProviderStoreKit2Tests: StoreSessionTestCase { } } - wait(for: [expectation], timeout: .second) + #if swift(>=5.9) + await fulfillment(of: [expectation]) + #else + wait(for: [expectation], timeout: .second) + #endif } } From dc5ed78c5bd87acae7bc2d02fa12d38400889707 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Sat, 30 Dec 2023 19:13:12 +0100 Subject: [PATCH 18/27] Update `CHANGELOG.md` --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7ef5e23..cec795e73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ## Added +- Integrate the `StoreKit2` purchase method + - Added in Pull Request [#10](https://github.com/space-code/flare/pull/10). + - Add badges for `Swift Version Compatibility` and `Platform Compatibility` - Added in Pull Request [#7](https://github.com/space-code/flare/pull/8). From 8d294abccf215afe2f27a6123b60ff6a6bc73076 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Sat, 30 Dec 2023 19:18:02 +0100 Subject: [PATCH 19/27] Fix `Package.swift` & `Package@swift-5.7.swift` --- .github/workflows/ci.yml | 25 +++++++++++++------------ Package.resolved | 9 --------- Package@swift-5.7.swift | 2 +- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6942e7db3..7bbe73e60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,18 +48,19 @@ jobs: name: "macOS 12, Xcode 14.1, Swift 5.7.1" steps: - uses: actions/checkout@v3 - - name: Install Dependencies - run: make setup_build_tools - - name: Generate project - run: make generate + # - name: Install Dependencies + # run: make setup_build_tools + # - name: Generate project + # run: make generate - name: ${{ matrix.name }} - run: xcodebuild test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -scheme "Flare" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "./macos.xcresult" || exit 1 - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3.1.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} - xcode: true - xcode_archive_path: "./macos.xcresult" + run: swift build -v + # run: xcodebuild test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -scheme "Flare" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "./macos.xcresult" || exit 1 + # - name: Upload coverage reports to Codecov + # uses: codecov/codecov-action@v3.1.0 + # with: + # token: ${{ secrets.CODECOV_TOKEN }} + # xcode: true + # xcode_archive_path: "./macos.xcresult" iOS: name: ${{ matrix.name }} @@ -137,7 +138,7 @@ jobs: # Beta: # name: ${{ matrix.name }} - # runs-on: macos-13 + # runs-on: firebreak # env: # DEVELOPER_DIR: "/Applications/Xcode_15.0.app/Contents/Developer" # timeout-minutes: 10 diff --git a/Package.resolved b/Package.resolved index ff7b43099..a03aac1cb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -8,15 +8,6 @@ "revision" : "f9611694f77f64e43d9467a16b2f5212cd04099b", "version" : "0.0.1" } - }, - { - "identity" : "objects-factory", - "kind" : "remoteSourceControl", - "location" : "https://github.com/space-code/objects-factory.git", - "state" : { - "revision" : "be016801934d18d91e33845e5e5b9a12617698b0", - "version" : "1.0.0" - } } ], "version" : 2 diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift index 814b9fc31..e645e1d10 100644 --- a/Package@swift-5.7.swift +++ b/Package@swift-5.7.swift @@ -30,7 +30,7 @@ let package = Package( dependencies: [ "Flare", .product(name: "TestConcurrency", package: "concurrency"), - ], + ] ), ] ) From 239e38a7e0069094bbc63ee0c61394b14c021753 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Sat, 30 Dec 2023 20:35:36 +0100 Subject: [PATCH 20/27] Add `Package@swift-5.8.swift` --- .swiftlint.yml | 1 + Package@swift-5.8.swift | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 Package@swift-5.8.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index ca37591b9..d7d7e5fad 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -2,6 +2,7 @@ excluded: - Tests - Package.swift - Package@swift-5.7.swift + - Package@swift-5.8.swift - .build # Rules diff --git a/Package@swift-5.8.swift b/Package@swift-5.8.swift new file mode 100644 index 000000000..256bf4f11 --- /dev/null +++ b/Package@swift-5.8.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. +// swiftlint:disable all + +import PackageDescription + +let package = Package( + name: "Flare", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v7), + .tvOS(.v13), + ], + products: [ + .library(name: "Flare", targets: ["Flare"]), + ], + dependencies: [ + .package(url: "https://github.com/space-code/concurrency.git", .upToNextMajor(from: "0.0.1")), + ], + targets: [ + .target( + name: "Flare", + dependencies: [ + .product(name: "Concurrency", package: "concurrency"), + ] + ), + .testTarget( + name: "FlareTests", + dependencies: [ + "Flare", + .product(name: "TestConcurrency", package: "concurrency"), + ] + ), + ] +) From e828dc424748ec220beee62824409094bc9687eb Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Sun, 31 Dec 2023 15:57:25 +0100 Subject: [PATCH 21/27] Implement integration tests - Create a new target that contains the integration tests - Implement three test plans: `AllTests`, `UnitTests`, `IntegrationTests` --- Sources/Flare/Classes/Models/IAPError.swift | 24 +++ .../RefundProvider/IRefundProvider.swift | 1 + .../RefundProvider/RefundProvider.swift | 1 + .../Extensions/StoreKitSessionTestCase.swift | 18 -- .../UnitTests/FlareStoreKit2Tests.swift | 156 ------------------ .../Providers/IAPProviderStoreKit2Tests.swift | 146 ---------------- .../ProductProviderStoreKit2Tests.swift | 54 ------ .../PurchaseProviderStoreKit2Tests.swift | 85 ---------- .../Flare.storekit | 0 .../Helpers/Extensions/Result+.swift | 26 +++ .../Helpers/Extensions/XCTestCase+.swift | 35 ++++ .../StoreSessionTestCase.swift | 0 Tests/IntegrationTests/Tests/FlareTests.swift | 154 +++++++++++++++++ .../Tests/IAPProviderTests.swift | 151 +++++++++++++++++ .../Tests}/ProductProviderHelper.swift | 0 .../Tests/ProductProviderTests.swift | 59 +++++++ .../Tests/PurchaseProviderTests.swift | 90 ++++++++++ Tests/TestPlans/AllTests.xctestplan | 44 +++++ Tests/TestPlans/IntegrationTests.xctestplan | 37 +++++ Tests/TestPlans/UnitTests.xctestplan | 37 +++++ .../UnitTestHostApp/AppDelegate.swift | 11 ++ .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/Contents.json | 0 .../Assets.xcassets/Contents.json | 0 .../UnitTestHostApp/Info.plist | 2 +- project.yml | 38 ++++- 26 files changed, 702 insertions(+), 467 deletions(-) delete mode 100644 Tests/FlareTests/UnitTests/Extensions/StoreKitSessionTestCase.swift delete mode 100644 Tests/FlareTests/UnitTests/FlareStoreKit2Tests.swift delete mode 100644 Tests/FlareTests/UnitTests/Providers/IAPProviderStoreKit2Tests.swift delete mode 100644 Tests/FlareTests/UnitTests/Providers/ProductProviderStoreKit2Tests.swift delete mode 100644 Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift rename Tests/{FlareTests/UnitTests => IntegrationTests}/Flare.storekit (100%) create mode 100644 Tests/IntegrationTests/Helpers/Extensions/Result+.swift create mode 100644 Tests/IntegrationTests/Helpers/Extensions/XCTestCase+.swift rename Tests/{FlareTests/UnitTests/TestHelpers => IntegrationTests/Helpers/StoreSessionTestCase}/StoreSessionTestCase.swift (100%) create mode 100644 Tests/IntegrationTests/Tests/FlareTests.swift create mode 100644 Tests/IntegrationTests/Tests/IAPProviderTests.swift rename Tests/{FlareTests/UnitTests/TestHelpers/Helpers => IntegrationTests/Tests}/ProductProviderHelper.swift (100%) create mode 100644 Tests/IntegrationTests/Tests/ProductProviderTests.swift create mode 100644 Tests/IntegrationTests/Tests/PurchaseProviderTests.swift create mode 100644 Tests/TestPlans/AllTests.xctestplan create mode 100644 Tests/TestPlans/IntegrationTests.xctestplan create mode 100644 Tests/TestPlans/UnitTests.xctestplan rename Tests/{FlareTests => }/UnitTestHostApp/AppDelegate.swift (60%) rename Tests/{FlareTests => }/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename Tests/{FlareTests => }/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename Tests/{FlareTests => }/UnitTestHostApp/Assets.xcassets/Contents.json (100%) rename Tests/{FlareTests => }/UnitTestHostApp/Info.plist (97%) diff --git a/Sources/Flare/Classes/Models/IAPError.swift b/Sources/Flare/Classes/Models/IAPError.swift index 96c64fc58..83537685e 100644 --- a/Sources/Flare/Classes/Models/IAPError.swift +++ b/Sources/Flare/Classes/Models/IAPError.swift @@ -44,6 +44,30 @@ public enum IAPError: Swift.Error { extension IAPError { init(error: Swift.Error?) { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + if let storeKitError = error as? StoreKitError { + self.init(storeKitError: storeKitError) + } else { + self.init(error) + } + } else { + self.init(error) + } + } + + @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) + private init(storeKitError: StoreKit.StoreKitError) { + switch storeKitError { + case .unknown: + self = .unknown + case .userCancelled: + self = .paymentCancelled + default: + self = .with(error: storeKitError) + } + } + + private init(_ error: Swift.Error?) { switch (error as? SKError)?.code { case .paymentNotAllowed: self = .paymentNotAllowed diff --git a/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift b/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift index 97ef2dd1c..350c1a3f7 100644 --- a/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift +++ b/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift @@ -15,6 +15,7 @@ protocol IRefundProvider { @available(macOS, unavailable) @available(watchOS, unavailable) @available(tvOS, unavailable) + @MainActor func beginRefundRequest(productID: String) async throws -> RefundRequestStatus #endif } diff --git a/Sources/Flare/Classes/Providers/RefundProvider/RefundProvider.swift b/Sources/Flare/Classes/Providers/RefundProvider/RefundProvider.swift index f4a8adc8e..d77bf4f0a 100644 --- a/Sources/Flare/Classes/Providers/RefundProvider/RefundProvider.swift +++ b/Sources/Flare/Classes/Providers/RefundProvider/RefundProvider.swift @@ -75,6 +75,7 @@ extension RefundProvider: IRefundProvider { @available(macOS, unavailable) @available(watchOS, unavailable) @available(tvOS, unavailable) + @MainActor func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { let windowScene = try systemInfoProvider.currentScene let transactionID = try await refundRequestProvider.verifyTransaction(productID: productID) diff --git a/Tests/FlareTests/UnitTests/Extensions/StoreKitSessionTestCase.swift b/Tests/FlareTests/UnitTests/Extensions/StoreKitSessionTestCase.swift deleted file mode 100644 index 8be4b716f..000000000 --- a/Tests/FlareTests/UnitTests/Extensions/StoreKitSessionTestCase.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -import StoreKit -import StoreKitTest -import XCTest - -// class StoreKitSessionTestCase: XCTestCase { -// // MARK: Properties -// -// private var session: SKTestSession! -// -// // MARK: XCTestCase -// -//// override func -// } diff --git a/Tests/FlareTests/UnitTests/FlareStoreKit2Tests.swift b/Tests/FlareTests/UnitTests/FlareStoreKit2Tests.swift deleted file mode 100644 index 24922b25b..000000000 --- a/Tests/FlareTests/UnitTests/FlareStoreKit2Tests.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -@testable import Flare -import XCTest - -// MARK: - FlareStoreKit2Tests - -@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) -final class FlareStoreKit2Tests: StoreSessionTestCase { - // MARK: - Properties - - private var iapProviderMock: IAPProviderMock! - - private var sut: Flare! - - // MARK: - XCTestCase - - override func setUp() { - super.setUp() - iapProviderMock = IAPProviderMock() - sut = Flare(iapProvider: iapProviderMock) - } - - override func tearDown() { - iapProviderMock = nil - sut = nil - super.tearDown() - } - - #if os(iOS) || VISION_OS - func test_thatFlareRefundsPurchase() async throws { - // given - iapProviderMock.stubbedBeginRefundRequest = .success - - // when - let state = try await sut.beginRefundRequest(productID: .productID) - - // then - if case .success = state {} - else { XCTFail("state must be `success`") } - } - - func test_thatFlareRefundRequestThrowsAnError_whenBeginRefundRequestFailed() async throws { - // given - iapProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) - - // when - let state = try await sut.beginRefundRequest(productID: .productID) - - // then - if case let .failed(error) = state { XCTAssertEqual(error as NSError, IAPError.unknown as NSError) } - else { XCTFail("state must be `failed`") } - } - #endif - - func test_thatFlarePurchasesAProductWithOptions_whenPurchaseCompleted() async throws { - let transaction = StoreTransactionStub() - try await test_purchaseWithOptionsAndCompletion( - transaction: transaction, - canMakePayments: true, - expectedResult: .success(StoreTransaction(storeTransaction: transaction)) - ) - } - - func test_thatFlarePurchaseThrowsAnError_whenPaymentNotAllowed() async throws { - try await test_purchaseWithOptionsAndCompletion( - canMakePayments: false, - expectedResult: .failure(IAPError.paymentNotAllowed) - ) - } - - func test_thatFlarePurchasesAsyncAProductWithOptionsAndCompletionHandler_whenPurchaseCompleted() async throws { - let transaction = StoreTransactionStub() - try await test_purchaseWithOptions( - canMakePayments: true, - expectedResult: .success(StoreTransaction(storeTransaction: transaction)) - ) - } - - func test_thatFlarePurchaseAsyncThrowsAnError_whenPaymentNotAllowed() async throws { - try await test_purchaseWithOptions( - canMakePayments: false, - expectedResult: .failure(IAPError.paymentNotAllowed) - ) - } - - // MARK: Private - - private func test_purchaseWithOptionsAndCompletion( - transaction: StoreTransactionStub? = nil, - canMakePayments: Bool, - expectedResult: Result - ) async throws { - // given - let product = try await ProductProviderHelper.purchases.randomElement() - let storeTransactionStub = transaction ?? StoreTransactionStub() - storeTransactionStub.stubbedProductIdentifier = product?.id - - iapProviderMock.stubbedCanMakePayments = canMakePayments - iapProviderMock.stubbedAsyncPurchaseWithOptions = StoreTransaction( - storeTransaction: storeTransactionStub - ) - - // when - let result: Result = await result(for: { - try await sut.purchase( - product: StoreProduct(product: product!), - options: [.simulatesAskToBuyInSandbox(false)] - ) - }) - - // then - XCTAssertEqual(result, expectedResult) - } - - private func test_purchaseWithOptions( - transaction: StoreTransactionStub? = nil, - canMakePayments: Bool, - expectedResult: Result - ) async throws { - // given - let expectation = XCTestExpectation(description: "Purchase a product") - - let product = try await ProductProviderHelper.purchases.randomElement() - let storeTransactionStub = transaction ?? StoreTransactionStub() - storeTransactionStub.stubbedProductIdentifier = product?.id - - iapProviderMock.stubbedCanMakePayments = canMakePayments - iapProviderMock.stubbedPurchaseWithOptionsResult = .success(StoreTransaction(storeTransaction: storeTransactionStub)) - - // when - sut.purchase( - product: StoreProduct(product: product!), - options: [.simulatesAskToBuyInSandbox(false)] - ) { result in - XCTAssertEqual(result, expectedResult) - expectation.fulfill() - } - - // then - wait(for: [expectation], timeout: .second) - } -} - -// MARK: - Constants - -private extension TimeInterval { - static let second: CGFloat = 1.0 -} - -private extension String { - static let productID = "com.flare.test_purchase_2" -} diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderStoreKit2Tests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderStoreKit2Tests.swift deleted file mode 100644 index 0d129112e..000000000 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderStoreKit2Tests.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -@testable import Flare -import XCTest - -// MARK: - IAPProviderStoreKit2Tests - -@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) -final class IAPProviderStoreKit2Tests: StoreSessionTestCase { - // MARK: - Properties - - private var productProviderMock: ProductProviderMock! - private var purchaseProvider: PurchaseProviderMock! - private var refundProviderMock: RefundProviderMock! - - private var sut: IIAPProvider! - - // MARK: - XCTestCase - - override func setUp() { - super.setUp() - productProviderMock = ProductProviderMock() - purchaseProvider = PurchaseProviderMock() - refundProviderMock = RefundProviderMock() - sut = IAPProvider( - paymentQueue: PaymentQueueMock(), - productProvider: productProviderMock, - purchaseProvider: purchaseProvider, - receiptRefreshProvider: ReceiptRefreshProviderMock(), - refundProvider: refundProviderMock - ) - } - - override func tearDown() { - productProviderMock = nil - purchaseProvider = nil - refundProviderMock = nil - sut = nil - super.tearDown() - } - - // MARK: Tests - - func test_thatIAPProviderFetchesSK2Products_whenProductsAreAvailable() async throws { - let productsMock = try await ProductProviderHelper.purchases.map(SK2StoreProduct.init) - productProviderMock.stubbedAsyncFetchResult = .success(productsMock) - - // when - let products = try await sut.fetch(productIDs: [.productID]) - - // then - XCTAssertFalse(products.isEmpty) - XCTAssertEqual(productsMock.count, products.count) - } - - func test_thatIAPProviderThrowsAnIAPError_whenFetchingProductsFailed() async { - productProviderMock.stubbedAsyncFetchResult = .failure(IAPError.unknown) - - // when - let error: IAPError? = await error(for: { try await sut.fetch(productIDs: [.productID]) }) - - // then - XCTAssertEqual(error, .unknown) - } - - func test_thatIAPProviderThrowsAPlainError_whenFetchingProductsFailed() async { - productProviderMock.stubbedAsyncFetchResult = .failure(URLError(.unknown)) - - // when - let error: IAPError? = await error(for: { try await sut.fetch(productIDs: [.productID]) }) - - // then - XCTAssertEqual(error, .with(error: URLError(.unknown))) - } - - #if os(iOS) || VISION_OS - func test_thatIAPProviderRefundsPurchase() async throws { - // given - refundProviderMock.stubbedBeginRefundRequest = .success - - // when - let state = try await sut.beginRefundRequest(productID: .productID) - - // then - if case .success = state {} - else { XCTFail("state must be `success`") } - } - - func test_thatFlareThrowsAnError_whenBeginRefundRequestFailed() async throws { - // given - refundProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) - - // when - let state = try await sut.beginRefundRequest(productID: .productID) - - // then - if case let .failed(error) = state { XCTAssertEqual(error as NSError, IAPError.unknown as NSError) } - else { XCTFail("state must be `failed`") } - } - #endif - - func test_thatIAPProviderPurchasesAProduct() async throws { - // given - let transactionMock = StoreTransactionMock() - transactionMock.stubbedTransactionIdentifier = .transactionID - - let storeTransaction = StoreTransaction(storeTransaction: transactionMock) - purchaseProvider.stubbedPurchaseCompletionResult = (.success(storeTransaction), ()) - - let product = try await ProductProviderHelper.purchases[0] - - // when - let transaction = try await sut.purchase(product: StoreProduct(product: product)) - - // then - XCTAssertEqual(transaction.transactionIdentifier, .transactionID) - } - - func test_thatIAPProviderPurchasesAProductWithOptions() async throws { - // given - let transactionMock = StoreTransactionMock() - transactionMock.stubbedTransactionIdentifier = .transactionID - - let storeTransaction = StoreTransaction(storeTransaction: transactionMock) - purchaseProvider.stubbedinvokedPurchaseWithOptionsCompletionResult = (.success(storeTransaction), ()) - - let product = try await ProductProviderHelper.purchases[0] - - // when - let transaction = try await sut.purchase(product: StoreProduct(product: product), options: []) - - // then - XCTAssertEqual(transaction.transactionIdentifier, .transactionID) - } -} - -// MARK: - Constants - -private extension String { -// static let receipt = "receipt" - static let productID = "product_identifier" - static let transactionID = "transaction_identifier" -} diff --git a/Tests/FlareTests/UnitTests/Providers/ProductProviderStoreKit2Tests.swift b/Tests/FlareTests/UnitTests/Providers/ProductProviderStoreKit2Tests.swift deleted file mode 100644 index 87eba9e5a..000000000 --- a/Tests/FlareTests/UnitTests/Providers/ProductProviderStoreKit2Tests.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -import Concurrency -@testable import Flare -import TestConcurrency -import XCTest - -// MARK: - ProductProviderStoreKit2Tests - -@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) -final class ProductProviderStoreKit2Tests: StoreSessionTestCase { - // MARK: - Properties - - private var testDispatchQueue: TestDispatchQueue! - private var dispatchQueueFactory: IDispatchQueueFactory! - - private var sut: ProductProvider! - - // MARK: - XCTestCase - - override func setUp() { - super.setUp() - testDispatchQueue = TestDispatchQueue() - dispatchQueueFactory = TestDispatchQueueFactory(testQueue: testDispatchQueue) - sut = ProductProvider(dispatchQueueFactory: dispatchQueueFactory) - } - - override func tearDown() { - testDispatchQueue = nil - dispatchQueueFactory = nil - sut = nil - super.tearDown() - } - - // MARK: - Tests - - func test_thatProductProviderFetchesProductsWithIDs() async throws { - // when - let products = try await sut.fetch(productIDs: [.productID]) - - // then - XCTAssertEqual(products.count, 1) - XCTAssertEqual(products.first?.productIdentifier, .productID) - } -} - -// MARK: - Constants - -private extension String { - static let productID = "com.flare.test_purchase_1" -} diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift deleted file mode 100644 index eb1ceee9c..000000000 --- a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderStoreKit2Tests.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -@testable import Flare -import XCTest - -// MARK: - PurchaseProviderStoreKit2Tests - -@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) -final class PurchaseProviderStoreKit2Tests: StoreSessionTestCase { - // MARK: Properties - - private var paymentProviderMock: PaymentProviderMock! - - private var sut: PurchaseProvider! - - // MARK: XCTestCase - - override func setUp() { - super.setUp() - paymentProviderMock = PaymentProviderMock() - sut = PurchaseProvider( - paymentProvider: paymentProviderMock - ) - } - - override func tearDown() { - sut = nil - super.tearDown() - } - - // MARK: Tests - - func test_thatPurchaseProviderReturnsPaymentTransaction_whenPurchasesAProductWithOptions() async throws { - let expectation = XCTestExpectation(description: "Purchase a product") - let productMock = try StoreProduct(product: await ProductProviderHelper.purchases.randomElement()!) - - // when - sut.purchase(product: productMock, options: [.simulatesAskToBuyInSandbox(false)]) { result in - switch result { - case let .success(transaction): - XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) - expectation.fulfill() - case let .failure(error): - XCTFail(error.localizedDescription) - } - } - - #if swift(>=5.9) - await fulfillment(of: [expectation]) - #else - wait(for: [expectation], timeout: .second) - #endif - } - - func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK2ProductExist() async throws { - let expectation = XCTestExpectation(description: "Purchase a product") - let productMock = try StoreProduct(product: await ProductProviderHelper.purchases.randomElement()!) - - // when - sut.purchase(product: productMock) { result in - switch result { - case let .success(transaction): - XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) - expectation.fulfill() - case let .failure(error): - XCTFail(error.localizedDescription) - } - } - - #if swift(>=5.9) - await fulfillment(of: [expectation]) - #else - wait(for: [expectation], timeout: .second) - #endif - } -} - -// MARK: - Constants - -private extension TimeInterval { - static let second: TimeInterval = 1.0 -} diff --git a/Tests/FlareTests/UnitTests/Flare.storekit b/Tests/IntegrationTests/Flare.storekit similarity index 100% rename from Tests/FlareTests/UnitTests/Flare.storekit rename to Tests/IntegrationTests/Flare.storekit diff --git a/Tests/IntegrationTests/Helpers/Extensions/Result+.swift b/Tests/IntegrationTests/Helpers/Extensions/Result+.swift new file mode 100644 index 000000000..9c779804a --- /dev/null +++ b/Tests/IntegrationTests/Helpers/Extensions/Result+.swift @@ -0,0 +1,26 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import Foundation + +extension Result { + var error: Failure? { + switch self { + case let .failure(error): + return error + default: + return nil + } + } + + var success: Success? { + switch self { + case let .success(value): + return value + default: + return nil + } + } +} diff --git a/Tests/IntegrationTests/Helpers/Extensions/XCTestCase+.swift b/Tests/IntegrationTests/Helpers/Extensions/XCTestCase+.swift new file mode 100644 index 000000000..8d55a0f50 --- /dev/null +++ b/Tests/IntegrationTests/Helpers/Extensions/XCTestCase+.swift @@ -0,0 +1,35 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +import XCTest + +extension XCTestCase { + func value(for closure: () async throws -> U) async -> U? { + do { + let value = try await closure() + return value + } catch { + return nil + } + } + + func error(for closure: () async throws -> U) async -> T? { + do { + _ = try await closure() + return nil + } catch { + return error as? T + } + } + + func result(for closure: () async throws -> U) async -> Result { + do { + let value = try await closure() + return .success(value) + } catch { + return .failure(error as! T) + } + } +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift b/Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift similarity index 100% rename from Tests/FlareTests/UnitTests/TestHelpers/StoreSessionTestCase.swift rename to Tests/IntegrationTests/Helpers/StoreSessionTestCase/StoreSessionTestCase.swift diff --git a/Tests/IntegrationTests/Tests/FlareTests.swift b/Tests/IntegrationTests/Tests/FlareTests.swift new file mode 100644 index 000000000..c03449e46 --- /dev/null +++ b/Tests/IntegrationTests/Tests/FlareTests.swift @@ -0,0 +1,154 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit +import XCTest + +// MARK: - FlareTests + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +final class FlareTests: StoreSessionTestCase { + // MARK: - Properties + + private var sut: Flare! + + // MARK: - XCTestCase + + override func setUp() { + super.setUp() + sut = Flare() + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatFlarePurchasesAProductWithCompletion_whenPurchaseCompleted() async throws { + try await test_purchaseWithOptions( + options: [], + expectedResult: .success(()) + ) + } + + func test_thatFlarePurchasesAProductWithCompletion_whenUnkownErrorOccurred() async throws { + // given + session?.failTransactionsEnabled = true + session?.failureError = .unknown + + // when + try await test_purchaseWithOptions( + options: [], + expectedResult: .failure(.unknown) + ) + } + + func test_thatFlarePurchasesAProductWithOptions_whenPurchaseCompleted() async throws { + try await test_purchaseWithOptionsAndCompletion( + expectedResult: .success(()) + ) + } + + func test_thatFlarePurchaseThrowsAnError_whenUnkownErrorOccurred() async throws { + // given + session?.failTransactionsEnabled = true + session?.failureError = .unknown + + // when + try await test_purchaseWithOptionsAndCompletion( + expectedResult: .failure(IAPError.unknown) + ) + } + + func test_thatFlarePurchasesAsyncAProductWithOptionsAndCompletionHandler_whenPurchaseCompleted() async throws { + try await test_purchaseWithOptions( + expectedResult: .success(()) + ) + } + + func test_thatFlarePurchaseAsyncThrowsAnError_whenUnkownErrorOccurred() async throws { + // given + session?.failTransactionsEnabled = true + session?.failureError = .unknown + + // when + try await test_purchaseWithOptions( + expectedResult: .failure(IAPError.unknown) + ) + } + + // MARK: Private + + private func test_purchaseWithOptionsAndCompletion( + expectedResult: Result + ) async throws { + // given + let product = try await ProductProviderHelper.purchases.randomElement() + + // when + let result: Result = await result(for: { + try await sut.purchase( + product: StoreProduct(product: product!), + options: [.simulatesAskToBuyInSandbox(false)] + ) + }) + + // then + switch expectedResult { + case .success: + XCTAssertEqual(result.success?.productIdentifier, product?.id) + case let .failure(error): + XCTAssertEqual(error, result.error) + } + } + + private func test_purchaseWithOptions( + options: Set = [.simulatesAskToBuyInSandbox(true)], + expectedResult: Result + ) async throws { + // given + let expectation = XCTestExpectation(description: "Purchase a product") + + let product = try await ProductProviderHelper.purchases.randomElement() + + // when + var handler: Closure> = { result in + switch expectedResult { + case .success: + XCTAssertEqual(result.success?.productIdentifier, product?.id) + case let .failure(error): + XCTAssertEqual(error, result.error) + } + expectation.fulfill() + } + + if options.isEmpty { + sut.purchase(product: StoreProduct(product: product!)) { handler($0) } + } else { + sut.purchase( + product: StoreProduct(product: product!), + options: options + ) { [handler] result in + Task { handler(result) } + } + } + + // then + wait(for: [expectation], timeout: .second) + } +} + +// MARK: - Constants + +private extension TimeInterval { + static let second: CGFloat = 1.0 +} + +private extension String { + static let productID = "com.flare.test_purchase_2" +} diff --git a/Tests/IntegrationTests/Tests/IAPProviderTests.swift b/Tests/IntegrationTests/Tests/IAPProviderTests.swift new file mode 100644 index 000000000..d1c0d99d4 --- /dev/null +++ b/Tests/IntegrationTests/Tests/IAPProviderTests.swift @@ -0,0 +1,151 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +////// +////// Flare +////// Copyright © 2023 Space Code. All rights reserved. +////// +// +// @testable import Flare +// import XCTest +// +//// MARK: - IAPProviderStoreKit2Tests +// +// @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +// final class IAPProviderStoreKit2Tests: StoreSessionTestCase { +// // MARK: - Properties +// +// private var productProviderMock: ProductProviderMock! +// private var purchaseProvider: PurchaseProviderMock! +// private var refundProviderMock: RefundProviderMock! +// +// private var sut: IIAPProvider! +// +// // MARK: - XCTestCase +// +// override func setUp() { +// super.setUp() +// productProviderMock = ProductProviderMock() +// purchaseProvider = PurchaseProviderMock() +// refundProviderMock = RefundProviderMock() +// sut = IAPProvider( +// paymentQueue: PaymentQueueMock(), +// productProvider: productProviderMock, +// purchaseProvider: purchaseProvider, +// receiptRefreshProvider: ReceiptRefreshProviderMock(), +// refundProvider: refundProviderMock +// ) +// } +// +// override func tearDown() { +// productProviderMock = nil +// purchaseProvider = nil +// refundProviderMock = nil +// sut = nil +// super.tearDown() +// } +// +// // MARK: Tests +// +// func test_thatIAPProviderFetchesSK2Products_whenProductsAreAvailable() async throws { +// let productsMock = try await ProductProviderHelper.purchases.map(SK2StoreProduct.init) +// productProviderMock.stubbedAsyncFetchResult = .success(productsMock) +// +// // when +// let products = try await sut.fetch(productIDs: [.productID]) +// +// // then +// XCTAssertFalse(products.isEmpty) +// XCTAssertEqual(productsMock.count, products.count) +// } +// +// func test_thatIAPProviderThrowsAnIAPError_whenFetchingProductsFailed() async { +// productProviderMock.stubbedAsyncFetchResult = .failure(IAPError.unknown) +// +// // when +// let error: IAPError? = await error(for: { try await sut.fetch(productIDs: [.productID]) }) +// +// // then +// XCTAssertEqual(error, .unknown) +// } +// +// func test_thatIAPProviderThrowsAPlainError_whenFetchingProductsFailed() async { +// productProviderMock.stubbedAsyncFetchResult = .failure(URLError(.unknown)) +// +// // when +// let error: IAPError? = await error(for: { try await sut.fetch(productIDs: [.productID]) }) +// +// // then +// XCTAssertEqual(error, .with(error: URLError(.unknown))) +// } +// +// #if os(iOS) || VISION_OS +// func test_thatIAPProviderRefundsPurchase() async throws { +// // given +// refundProviderMock.stubbedBeginRefundRequest = .success +// +// // when +// let state = try await sut.beginRefundRequest(productID: .productID) +// +// // then +// if case .success = state {} +// else { XCTFail("state must be `success`") } +// } +// +// func test_thatFlareThrowsAnError_whenBeginRefundRequestFailed() async throws { +// // given +// refundProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown) +// +// // when +// let state = try await sut.beginRefundRequest(productID: .productID) +// +// // then +// if case let .failed(error) = state { XCTAssertEqual(error as NSError, IAPError.unknown as NSError) } +// else { XCTFail("state must be `failed`") } +// } +// #endif +// +// func test_thatIAPProviderPurchasesAProduct() async throws { +// // given +// let transactionMock = StoreTransactionMock() +// transactionMock.stubbedTransactionIdentifier = .transactionID +// +// let storeTransaction = StoreTransaction(storeTransaction: transactionMock) +// purchaseProvider.stubbedPurchaseCompletionResult = (.success(storeTransaction), ()) +// +// let product = try await ProductProviderHelper.purchases[0] +// +// // when +// let transaction = try await sut.purchase(product: StoreProduct(product: product)) +// +// // then +// XCTAssertEqual(transaction.transactionIdentifier, .transactionID) +// } +// +// func test_thatIAPProviderPurchasesAProductWithOptions() async throws { +// // given +// let transactionMock = StoreTransactionMock() +// transactionMock.stubbedTransactionIdentifier = .transactionID +// +// let storeTransaction = StoreTransaction(storeTransaction: transactionMock) +// purchaseProvider.stubbedinvokedPurchaseWithOptionsCompletionResult = (.success(storeTransaction), ()) +// +// let product = try await ProductProviderHelper.purchases[0] +// +// // when +// let transaction = try await sut.purchase(product: StoreProduct(product: product), options: []) +// +// // then +// XCTAssertEqual(transaction.transactionIdentifier, .transactionID) +// } +// } +// +//// MARK: - Constants +// +// private extension String { +//// static let receipt = "receipt" +// static let productID = "product_identifier" +// static let transactionID = "transaction_identifier" +// } diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Helpers/ProductProviderHelper.swift b/Tests/IntegrationTests/Tests/ProductProviderHelper.swift similarity index 100% rename from Tests/FlareTests/UnitTests/TestHelpers/Helpers/ProductProviderHelper.swift rename to Tests/IntegrationTests/Tests/ProductProviderHelper.swift diff --git a/Tests/IntegrationTests/Tests/ProductProviderTests.swift b/Tests/IntegrationTests/Tests/ProductProviderTests.swift new file mode 100644 index 000000000..3edf243fb --- /dev/null +++ b/Tests/IntegrationTests/Tests/ProductProviderTests.swift @@ -0,0 +1,59 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +//// +//// Flare +//// Copyright © 2023 Space Code. All rights reserved. +//// +// +// import Concurrency +// @testable import Flare +// import TestConcurrency +// import XCTest +// +//// MARK: - ProductProviderStoreKit2Tests +// +// @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) +// final class ProductProviderStoreKit2Tests: StoreSessionTestCase { +// // MARK: - Properties +// +// private var testDispatchQueue: TestDispatchQueue! +// private var dispatchQueueFactory: IDispatchQueueFactory! +// +// private var sut: ProductProvider! +// +// // MARK: - XCTestCase +// +// override func setUp() { +// super.setUp() +// testDispatchQueue = TestDispatchQueue() +// dispatchQueueFactory = TestDispatchQueueFactory(testQueue: testDispatchQueue) +// sut = ProductProvider(dispatchQueueFactory: dispatchQueueFactory) +// } +// +// override func tearDown() { +// testDispatchQueue = nil +// dispatchQueueFactory = nil +// sut = nil +// super.tearDown() +// } +// +// // MARK: - Tests +// +// func test_thatProductProviderFetchesProductsWithIDs() async throws { +// // when +// let products = try await sut.fetch(productIDs: [.productID]) +// +// // then +// XCTAssertEqual(products.count, 1) +// XCTAssertEqual(products.first?.productIdentifier, .productID) +// } +// } +// +//// MARK: - Constants +// +// private extension String { +// static let productID = "com.flare.test_purchase_1" +// } diff --git a/Tests/IntegrationTests/Tests/PurchaseProviderTests.swift b/Tests/IntegrationTests/Tests/PurchaseProviderTests.swift new file mode 100644 index 000000000..31170ffb3 --- /dev/null +++ b/Tests/IntegrationTests/Tests/PurchaseProviderTests.swift @@ -0,0 +1,90 @@ +// +// Flare +// Copyright © 2023 Space Code. All rights reserved. +// + +//// +//// Flare +//// Copyright © 2023 Space Code. All rights reserved. +//// +// +// @testable import Flare +// import XCTest +// +//// MARK: - PurchaseProviderStoreKit2Tests +// +// @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +// final class PurchaseProviderStoreKit2Tests: StoreSessionTestCase { +// // MARK: Properties +// +// private var paymentProviderMock: PaymentProviderMock! +// +// private var sut: PurchaseProvider! +// +// // MARK: XCTestCase +// +// override func setUp() { +// super.setUp() +// paymentProviderMock = PaymentProviderMock() +// sut = PurchaseProvider( +// paymentProvider: paymentProviderMock +// ) +// } +// +// override func tearDown() { +// sut = nil +// super.tearDown() +// } +// +// // MARK: Tests +// +// func test_thatPurchaseProviderReturnsPaymentTransaction_whenPurchasesAProductWithOptions() async throws { +// let expectation = XCTestExpectation(description: "Purchase a product") +// let productMock = try StoreProduct(product: await ProductProviderHelper.purchases.randomElement()!) +// +// // when +// sut.purchase(product: productMock, options: [.simulatesAskToBuyInSandbox(false)]) { result in +// switch result { +// case let .success(transaction): +// XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) +// expectation.fulfill() +// case let .failure(error): +// XCTFail(error.localizedDescription) +// } +// } +// +// #if swift(>=5.9) +// await fulfillment(of: [expectation]) +// #else +// wait(for: [expectation], timeout: .second) +// #endif +// } +// +// func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK2ProductExist() async throws { +// let expectation = XCTestExpectation(description: "Purchase a product") +// let productMock = try StoreProduct(product: await ProductProviderHelper.purchases.randomElement()!) +// +// // when +// sut.purchase(product: productMock) { result in +// switch result { +// case let .success(transaction): +// XCTAssertEqual(transaction.productIdentifier, productMock.productIdentifier) +// expectation.fulfill() +// case let .failure(error): +// XCTFail(error.localizedDescription) +// } +// } +// +// #if swift(>=5.9) +// await fulfillment(of: [expectation]) +// #else +// wait(for: [expectation], timeout: .second) +// #endif +// } +// } +// +//// MARK: - Constants +// +// private extension TimeInterval { +// static let second: TimeInterval = 1.0 +// } diff --git a/Tests/TestPlans/AllTests.xctestplan b/Tests/TestPlans/AllTests.xctestplan new file mode 100644 index 000000000..259da0494 --- /dev/null +++ b/Tests/TestPlans/AllTests.xctestplan @@ -0,0 +1,44 @@ +{ + "configurations" : [ + { + "id" : "1CA61D79-6551-4DA7-8DEF-CC28563C2658", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "AAEFF2D6694AA197C07481DA", + "name" : "Flare" + } + ] + }, + "targetForVariableExpansion" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "AAEFF2D6694AA197C07481DA", + "name" : "Flare" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "2053CB2B5F4780EC86D0DE04", + "name" : "FlareTests" + } + }, + { + "target" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "5A649E8F4319C5B59E9588FD", + "name" : "IntegrationTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/TestPlans/IntegrationTests.xctestplan b/Tests/TestPlans/IntegrationTests.xctestplan new file mode 100644 index 000000000..57f3054e2 --- /dev/null +++ b/Tests/TestPlans/IntegrationTests.xctestplan @@ -0,0 +1,37 @@ +{ + "configurations" : [ + { + "id" : "1CA61D79-6551-4DA7-8DEF-CC28563C2658", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "AAEFF2D6694AA197C07481DA", + "name" : "Flare" + } + ] + }, + "targetForVariableExpansion" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "AAEFF2D6694AA197C07481DA", + "name" : "Flare" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "5A649E8F4319C5B59E9588FD", + "name" : "IntegrationTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/TestPlans/UnitTests.xctestplan b/Tests/TestPlans/UnitTests.xctestplan new file mode 100644 index 000000000..98e230890 --- /dev/null +++ b/Tests/TestPlans/UnitTests.xctestplan @@ -0,0 +1,37 @@ +{ + "configurations" : [ + { + "id" : "1CA61D79-6551-4DA7-8DEF-CC28563C2658", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "AAEFF2D6694AA197C07481DA", + "name" : "Flare" + } + ] + }, + "targetForVariableExpansion" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "AAEFF2D6694AA197C07481DA", + "name" : "Flare" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "2053CB2B5F4780EC86D0DE04", + "name" : "FlareTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift b/Tests/UnitTestHostApp/AppDelegate.swift similarity index 60% rename from Tests/FlareTests/UnitTestHostApp/AppDelegate.swift rename to Tests/UnitTestHostApp/AppDelegate.swift index aea816e94..4f3e9b07c 100644 --- a/Tests/FlareTests/UnitTestHostApp/AppDelegate.swift +++ b/Tests/UnitTestHostApp/AppDelegate.swift @@ -12,6 +12,17 @@ import SwiftUI @main class AppDelegate: NSObject, NSApplicationDelegate {} +#elseif os(watchOS) + + @main + struct TestApp: App { + var body: some Scene { + WindowGroup { + Text("Hello World") + } + } + } + #else @main class AppDelegate: UIResponder, UIApplicationDelegate {} diff --git a/Tests/FlareTests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json b/Tests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Tests/FlareTests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json rename to Tests/UnitTestHostApp/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Tests/FlareTests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Tests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Tests/FlareTests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Tests/UnitTestHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Tests/FlareTests/UnitTestHostApp/Assets.xcassets/Contents.json b/Tests/UnitTestHostApp/Assets.xcassets/Contents.json similarity index 100% rename from Tests/FlareTests/UnitTestHostApp/Assets.xcassets/Contents.json rename to Tests/UnitTestHostApp/Assets.xcassets/Contents.json diff --git a/Tests/FlareTests/UnitTestHostApp/Info.plist b/Tests/UnitTestHostApp/Info.plist similarity index 97% rename from Tests/FlareTests/UnitTestHostApp/Info.plist rename to Tests/UnitTestHostApp/Info.plist index ceae02525..c468f022f 100644 --- a/Tests/FlareTests/UnitTestHostApp/Info.plist +++ b/Tests/UnitTestHostApp/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - StoreKitUnitTestsHostApp + UnitTestsHostApp CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/project.yml b/project.yml index 703146a47..df58e74b5 100644 --- a/project.yml +++ b/project.yml @@ -15,13 +15,16 @@ targets: UnitTestHostApp: type: application supportedDestinations: [iOS, tvOS, macOS] - sources: Tests/FlareTests/UnitTestHostApp + sources: Tests/UnitTestHostApp settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare + TARGETED_DEVICE_FAMILY: "1,2,3,4" + SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" scheme: + storeKitConfiguration: "Tests/FlareTests/IntegrationTests/Flare.storekit" testTargets: - - FlareTests + - IntegrationTests Flare: type: framework supportedDestinations: [iOS, tvOS, macOS] @@ -29,16 +32,37 @@ targets: - package: Concurrency product: Concurrency settings: - GENERATE_INFOPLIST_FILE: YES + base: + GENERATE_INFOPLIST_FILE: YES + TARGETED_DEVICE_FAMILY: "1,2,3,4" + SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" sources: - path: Sources scheme: - testTargets: - - FlareTests + testPlans: + - path: Tests/TestPlans/AllTests.xctestplan + defaultPlan: true + - path: Tests/TestPlans/UnitTests.xctestplan + - path: Tests/TestPlans/IntegrationTests.xctestplan gatherCoverageData: true coverageTargets: - Flare FlareTests: + type: bundle.unit-test + supportedDestinations: [iOS, tvOS, macOS] + dependencies: + - package: Concurrency + product: TestConcurrency + - target: Flare + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare-unit-tests + SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" + TARGETED_DEVICE_FAMILY: "1,2,3,4" + sources: + - Tests/FlareTests/UnitTests + IntegrationTests: type: bundle.unit-test supportedDestinations: [iOS, tvOS, macOS] dependencies: @@ -51,6 +75,6 @@ targets: BUNDLE_LOADER: $(TEST_HOST) GENERATE_INFOPLIST_FILE: YES TEST_HOST: $(BUILT_PRODUCTS_DIR)/UnitTestHostApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/UnitTestHostApp - PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare + PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare.storekit-unit-tests sources: - - Tests/FlareTests/UnitTests \ No newline at end of file + - Tests/IntegrationTests \ No newline at end of file From 79ee91dc7de296c2e61005e1bae4978cd46deb2e Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Sun, 31 Dec 2023 16:14:43 +0100 Subject: [PATCH 22/27] Update configuration - Update the GitHub Actions CI script to enable testing on various platforms --- .github/workflows/ci.yml | 89 ++++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bbe73e60..3e4d7f1e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,19 +48,14 @@ jobs: name: "macOS 12, Xcode 14.1, Swift 5.7.1" steps: - uses: actions/checkout@v3 - # - name: Install Dependencies - # run: make setup_build_tools - # - name: Generate project - # run: make generate - name: ${{ matrix.name }} - run: swift build -v - # run: xcodebuild test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -scheme "Flare" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "./macos.xcresult" || exit 1 - # - name: Upload coverage reports to Codecov - # uses: codecov/codecov-action@v3.1.0 - # with: - # token: ${{ secrets.CODECOV_TOKEN }} - # xcode: true - # xcode_archive_path: "./macos.xcresult" + run: xcodebuild test -scheme "Flare" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "./macos.xcresult" || exit 1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: "./macos.xcresult" iOS: name: ${{ matrix.name }} @@ -80,10 +75,6 @@ jobs: name: "iOS 16.4" xcode: "Xcode_14.3.1" runsOn: macos-13 - # - destination: "OS=15.5,name=iPhone 13 Pro" - # name: "iOS 15.5" - # xcode: "Xcode_13.4.1" - # runsOn: macOS-12 steps: - uses: actions/checkout@v3 - name: Install Dependencies @@ -91,7 +82,7 @@ jobs: - name: Generate project run: make generate - name: ${{ matrix.name }} - run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "./iphonesimulator.xcresult" || exit 1 + run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan AllTests clean -enableCodeCoverage YES -resultBundlePath "./iphonesimulator.xcresult" || exit 1 - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3.1.0 with: @@ -117,10 +108,6 @@ jobs: name: "tvOS 16.4" xcode: "Xcode_14.3.1" runsOn: macos-13 - # - destination: "OS=15.4,name=Apple TV" - # name: "tvOS 15.4" - # xcode: "Xcode_13.4.1" - # runsOn: macos-12 steps: - uses: actions/checkout@v3 - name: Install Dependencies @@ -128,7 +115,7 @@ jobs: - name: Generate project run: make generate - name: ${{ matrix.name }} - run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "./appletvsimulator.xcresult" || exit 1 + run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan AllTests clean -enableCodeCoverage YES -resultBundlePath "./appletvsimulator.xcresult" || exit 1 - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3.1.0 with: @@ -136,6 +123,64 @@ jobs: xcode: true xcode_archive_path: "./appletvsimulator.xcresult" + watchOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - destination: "OS=10.0,name=Apple Watch Series 9 (45mm)" + name: "watchOS 10.0" + xcode: "Xcode_15.0" + runsOn: macos-13 + - destination: "OS=9.4,name=Apple Watch Series 8 (45mm)" + name: "watchOS 9.4" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + - destination: "OS=8.5,name=Apple Watch Series 7 (45mm)" + name: "watchOS 8.5" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + steps: + - uses: actions/checkout@v3 + - name: Install Dependencies + run: make setup_build_tools + - name: Generate project + run: make generate + - name: ${{ matrix.name }} + run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan UnitTests clean -enableCodeCoverage YES -resultBundlePath "./watchsimulator.xcresult" || exit 1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: "./watchsimulator.xcresult" + + spm: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: "Xcode 15" + xcode: "Xcode_15.0" + runsOn: macos-13 + - name: "Xcode 14" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + steps: + - uses: actions/checkout@v3 + - name: ${{ matrix.name }} + run: swift build -c release --target Flare + # Beta: # name: ${{ matrix.name }} # runs-on: firebreak From 015440691540b7d3314e6dc06975bc66627f17c4 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Sun, 31 Dec 2023 16:40:56 +0100 Subject: [PATCH 23/27] Fix typos --- Tests/IntegrationTests/Tests/FlareTests.swift | 2 +- project.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/IntegrationTests/Tests/FlareTests.swift b/Tests/IntegrationTests/Tests/FlareTests.swift index c03449e46..6c7657ab1 100644 --- a/Tests/IntegrationTests/Tests/FlareTests.swift +++ b/Tests/IntegrationTests/Tests/FlareTests.swift @@ -117,7 +117,7 @@ final class FlareTests: StoreSessionTestCase { let product = try await ProductProviderHelper.purchases.randomElement() // when - var handler: Closure> = { result in + let handler: Closure> = { result in switch expectedResult { case .success: XCTAssertEqual(result.success?.productIdentifier, product?.id) diff --git a/project.yml b/project.yml index df58e74b5..59e13d92d 100644 --- a/project.yml +++ b/project.yml @@ -22,7 +22,7 @@ targets: TARGETED_DEVICE_FAMILY: "1,2,3,4" SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" scheme: - storeKitConfiguration: "Tests/FlareTests/IntegrationTests/Flare.storekit" + storeKitConfiguration: "Tests/IntegrationTests/Flare.storekit" testTargets: - IntegrationTests Flare: From 2f3d1bced8d369aa1b34eacedcd717a8b8268dd7 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Sun, 31 Dec 2023 17:00:37 +0100 Subject: [PATCH 24/27] Write a script for merging test results from various platforms --- .github/workflows/ci.yml | 61 +++++++++++-------- Tests/IntegrationTests/Tests/FlareTests.swift | 6 +- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e4d7f1e6..10b947d86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,13 +49,11 @@ jobs: steps: - uses: actions/checkout@v3 - name: ${{ matrix.name }} - run: xcodebuild test -scheme "Flare" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "./macos.xcresult" || exit 1 - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3.1.0 + run: xcodebuild test -scheme "Flare" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 + - uses: actions/upload-artifact@v4 with: - token: ${{ secrets.CODECOV_TOKEN }} - xcode: true - xcode_archive_path: "./macos.xcresult" + name: ${{ matrix.name }} + path: test_output iOS: name: ${{ matrix.name }} @@ -82,13 +80,11 @@ jobs: - name: Generate project run: make generate - name: ${{ matrix.name }} - run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan AllTests clean -enableCodeCoverage YES -resultBundlePath "./iphonesimulator.xcresult" || exit 1 - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3.1.0 + run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan AllTests clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 + - uses: actions/upload-artifact@v4 with: - token: ${{ secrets.CODECOV_TOKEN }} - xcode: true - xcode_archive_path: "./iphonesimulator.xcresult" + name: ${{ matrix.name }} + path: test_output tvOS: name: ${{ matrix.name }} @@ -115,13 +111,11 @@ jobs: - name: Generate project run: make generate - name: ${{ matrix.name }} - run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan AllTests clean -enableCodeCoverage YES -resultBundlePath "./appletvsimulator.xcresult" || exit 1 - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3.1.0 + run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan AllTests clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 + - uses: actions/upload-artifact@v4 with: - token: ${{ secrets.CODECOV_TOKEN }} - xcode: true - xcode_archive_path: "./appletvsimulator.xcresult" + name: ${{ matrix.name }} + path: test_output watchOS: name: ${{ matrix.name }} @@ -152,13 +146,11 @@ jobs: - name: Generate project run: make generate - name: ${{ matrix.name }} - run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan UnitTests clean -enableCodeCoverage YES -resultBundlePath "./watchsimulator.xcresult" || exit 1 - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3.1.0 + run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan UnitTests clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 + - uses: actions/upload-artifact@v4 with: - token: ${{ secrets.CODECOV_TOKEN }} - xcode: true - xcode_archive_path: "./watchsimulator.xcresult" + name: ${{ matrix.name }} + path: test_output spm: name: ${{ matrix.name }} @@ -181,6 +173,27 @@ jobs: - name: ${{ matrix.name }} run: swift build -c release --target Flare + upload-to-codecov: + needs: [iOS, macOS, watchOS, tvOS] + runs-on: macos-13 + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: test_output + - run: xcrun xcresulttool merge test_output/**/*.xcresult --output-path test_output/final.xcresult + - name: Convert Test Report + run: | + brew tap a7ex/homebrew-formulae && + brew install xcresultparser && + xcresultparser -o cobertura test_output/final.xcresult > test_output/report.xml + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: test_output/report.xml + # Beta: # name: ${{ matrix.name }} # runs-on: firebreak diff --git a/Tests/IntegrationTests/Tests/FlareTests.swift b/Tests/IntegrationTests/Tests/FlareTests.swift index 6c7657ab1..c3ff08793 100644 --- a/Tests/IntegrationTests/Tests/FlareTests.swift +++ b/Tests/IntegrationTests/Tests/FlareTests.swift @@ -139,7 +139,11 @@ final class FlareTests: StoreSessionTestCase { } // then - wait(for: [expectation], timeout: .second) + #if swift(>=5.9) + await fulfillment(of: [expectation]) + #else + wait(for: [expectation], timeout: .second) + #endif } } From b03b78cdd28263e45a0c5daf716d872e30ed5d23 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 3 Jan 2024 11:24:44 +0100 Subject: [PATCH 25/27] Implement the `finish(_:)` transaction method --- .swiftlint.yml | 1 + .../Models/Internal/SK1StoreTransaction.swift | 2 +- .../Models/Internal/SK2StoreTransaction.swift | 2 +- .../Providers/IAPProvider/IAPProvider.swift | 4 ++-- .../Providers/IAPProvider/IIAPProvider.swift | 6 ++++-- .../PurchaseProvider/IPurchaseProvider.swift | 17 +++++++++++++++-- .../PurchaseProvider/PurchaseProvider.swift | 18 ++++++++++++++++-- Sources/Flare/Flare.swift | 4 ++-- Sources/Flare/IFlare.swift | 6 ++++-- Tests/FlareTests/UnitTests/FlareTests.swift | 2 +- .../UnitTests/Providers/IAPProviderTests.swift | 2 +- .../Providers/PurchaseProviderTests.swift | 2 +- .../TestHelpers/Mocks/IAPProviderMock.swift | 6 +++--- .../Mocks/PurchaseProviderMock.swift | 6 +++--- 14 files changed, 55 insertions(+), 23 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index d7d7e5fad..66d17c089 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -11,6 +11,7 @@ disabled_rules: - trailing_comma - todo - opening_brace + - unneeded_synthesized_initializer opt_in_rules: # some rules are only opt-in - anyobject_protocol diff --git a/Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift index a325e61ff..e7f02aa29 100644 --- a/Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreTransaction.swift @@ -12,7 +12,7 @@ struct SK1StoreTransaction { // MARK: Properties /// The StoreKit transaction. - private let transaction: PaymentTransaction + let transaction: PaymentTransaction // MARK: Initialization diff --git a/Sources/Flare/Classes/Models/Internal/SK2StoreTransaction.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreTransaction.swift index d5a19933e..5ccde0c40 100644 --- a/Sources/Flare/Classes/Models/Internal/SK2StoreTransaction.swift +++ b/Sources/Flare/Classes/Models/Internal/SK2StoreTransaction.swift @@ -14,7 +14,7 @@ struct SK2StoreTransaction { // MARK: Properties /// The StoreKit transaction. - private let transaction: StoreKit.Transaction + let transaction: StoreKit.Transaction /// The raw JWS repesentation of the transaction. private let _jwsRepresentation: String? diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index 646f1527d..f2ed8c0a0 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -140,8 +140,8 @@ final class IAPProvider: IIAPProvider { } } - func finish(transaction: PaymentTransaction) { - purchaseProvider.finish(transaction: transaction) + func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) { + purchaseProvider.finish(transaction: transaction, completion: completion) } func addTransactionObserver(fallbackHandler: Closure>?) { diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift index a7197ef93..ac7cf0bac 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift @@ -102,8 +102,10 @@ public protocol IIAPProvider { /// Removes a finished (i.e. failed or completed) transaction from the queue. /// Attempting to finish a purchasing transaction will throw an exception. /// - /// - Parameter transaction: An object in the payment queue. - func finish(transaction: PaymentTransaction) + /// - Parameters: + /// - transaction: An object in the payment queue. + /// - completion: If a completion closure is provided, call it after finishing the transaction. + func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) /// Adds transaction observer to the payment queue. /// The transactions array will only be synchronized with the server while the queue has observers. diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift index a58abbb6c..a02d3fad4 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift @@ -14,8 +14,10 @@ protocol IPurchaseProvider { /// Removes a finished (i.e. failed or completed) transaction from the queue. /// Attempting to finish a purchasing transaction will throw an exception. /// - /// - Parameter transaction: An object in the payment queue. - func finish(transaction: PaymentTransaction) + /// - Parameters: + /// - transaction: An object in the payment queue. + /// - completion: If a completion closure is provided, call it after finishing the transaction. + func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) /// Adds transaction observer to the payment queue. /// The transactions array will only be synchronized with the server while the queue has observers. @@ -29,8 +31,19 @@ protocol IPurchaseProvider { /// - Note: This may require that the user authenticate. func removeTransactionObserver() + /// Purchases a product. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - completion: The closure to be executed once the purchase is complete. func purchase(product: StoreProduct, completion: @escaping PurchaseCompletionHandler) + /// Purchases a product. + /// + /// - Parameters: + /// - product: The product to be purchased. + /// - options: The optional settings for a product purchase. + /// - completion: The closure to be executed once the purchase is complete. @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func purchase( product: StoreProduct, diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift index 95112a083..50cf3a690 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift @@ -110,8 +110,22 @@ extension PurchaseProvider: IPurchaseProvider { } } - func finish(transaction: PaymentTransaction) { - paymentProvider.finish(transaction: transaction) + func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), + let sk2Transaction = transaction.storeTransaction as? SK2StoreTransaction + { + AsyncHandler.call( + completion: { _ in + completion?() + }, + asyncMethod: { + await sk2Transaction.transaction.finish() + } + ) + } else if let sk1Transaction = transaction.storeTransaction as? SK1StoreTransaction { + paymentProvider.finish(transaction: sk1Transaction.transaction) + completion?() + } } func addTransactionObserver(fallbackHandler: Closure>?) { diff --git a/Sources/Flare/Flare.swift b/Sources/Flare/Flare.swift index d2278acb3..159ac18fc 100644 --- a/Sources/Flare/Flare.swift +++ b/Sources/Flare/Flare.swift @@ -102,8 +102,8 @@ extension Flare: IFlare { try await iapProvider.refreshReceipt() } - public func finish(transaction: PaymentTransaction) { - iapProvider.finish(transaction: transaction) + public func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) { + iapProvider.finish(transaction: transaction, completion: completion) } public func addTransactionObserver(fallbackHandler: Closure>?) { diff --git a/Sources/Flare/IFlare.swift b/Sources/Flare/IFlare.swift index 6ee5794c1..69cc89aa3 100644 --- a/Sources/Flare/IFlare.swift +++ b/Sources/Flare/IFlare.swift @@ -100,8 +100,10 @@ public protocol IFlare { /// Removes a finished (i.e. failed or completed) transaction from the queue. /// Attempting to finish a purchasing transaction will throw an exception. /// - /// - Parameter transaction: An object in the payment queue. - func finish(transaction: PaymentTransaction) + /// - Parameters: + /// - transaction: An object in the payment queue. + /// - completion: If a completion closure is provided, call it after finishing the transaction. + func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) /// The transactions array will only be synchronized with the server while the queue has observers. /// diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 0192a970d..33cc4debc 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -200,7 +200,7 @@ class FlareTests: XCTestCase { let transaction = PaymentTransaction(PaymentTransactionMock()) // when - sut.finish(transaction: transaction) + sut.finish(transaction: StoreTransaction(paymentTransaction: transaction), completion: nil) // then XCTAssertTrue(iapProviderMock.invokedFinishTransaction) diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index d859c0d8c..319d97f15 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -91,7 +91,7 @@ class IAPProviderTests: XCTestCase { let transaction = PurchaseManagerTestHelper.makePaymentTransaction(state: .purchased) // when - sut.finish(transaction: PaymentTransaction(transaction)) + sut.finish(transaction: StoreTransaction(paymentTransaction: PaymentTransaction(transaction)), completion: nil) // then XCTAssertTrue(purchaseProvider.invokedFinish) diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift index 2e377f714..af4b7a49a 100644 --- a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift @@ -59,7 +59,7 @@ final class PurchaseProviderTests: XCTestCase { let transaction = PurchaseManagerTestHelper.makePaymentTransaction(state: .purchased) // when - sut.finish(transaction: PaymentTransaction(transaction)) + sut.finish(transaction: StoreTransaction(paymentTransaction: PaymentTransaction(transaction)), completion: nil) // then XCTAssertTrue(paymentProviderMock.invokedFinishTransaction) diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift index ecbfe2f46..e6a194485 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift @@ -60,10 +60,10 @@ final class IAPProviderMock: IIAPProvider { var invokedFinishTransaction = false var invokedFinishTransactionCount = 0 - var invokedFinishTransactionParameters: (PaymentTransaction, Void)? - var invokedFinishTransactionParanetersList = [(PaymentTransaction, Void)]() + var invokedFinishTransactionParameters: (StoreTransaction, Void)? + var invokedFinishTransactionParanetersList = [(StoreTransaction, Void)]() - func finish(transaction: PaymentTransaction) { + func finish(transaction: StoreTransaction, completion _: (@Sendable () -> Void)?) { invokedFinishTransaction = true invokedFinishTransactionCount += 1 invokedFinishTransactionParameters = (transaction, ()) diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift index 8e7696dfb..b24200266 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift @@ -9,10 +9,10 @@ import StoreKit final class PurchaseProviderMock: IPurchaseProvider { var invokedFinish = false var invokedFinishCount = 0 - var invokedFinishParameters: (transaction: PaymentTransaction, Void)? - var invokedFinishParametersList = [(transaction: PaymentTransaction, Void)]() + var invokedFinishParameters: (transaction: StoreTransaction, Void)? + var invokedFinishParametersList = [(transaction: StoreTransaction, Void)]() - func finish(transaction: PaymentTransaction) { + func finish(transaction: StoreTransaction, completion _: (@Sendable () -> Void)?) { invokedFinish = true invokedFinishCount += 1 invokedFinishParameters = (transaction, ()) From b39b3d41bde7045cd15fd1f0f4b469a098176b66 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 3 Jan 2024 17:40:08 +0100 Subject: [PATCH 26/27] Upload test coverate reports --- .github/workflows/ci.yml | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10b947d86..6dda051e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,12 @@ jobs: - uses: actions/checkout@v3 - name: ${{ matrix.name }} run: xcodebuild test -scheme "Flare" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: test_output/${{ matrix.name }}.xcresult - uses: actions/upload-artifact@v4 with: name: ${{ matrix.name }} @@ -112,6 +118,12 @@ jobs: run: make generate - name: ${{ matrix.name }} run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan AllTests clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: test_output/${{ matrix.name }}.xcresult - uses: actions/upload-artifact@v4 with: name: ${{ matrix.name }} @@ -147,6 +159,12 @@ jobs: run: make generate - name: ${{ matrix.name }} run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan UnitTests clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + xcode: true + xcode_archive_path: test_output/${{ matrix.name }}.xcresult - uses: actions/upload-artifact@v4 with: name: ${{ matrix.name }} @@ -181,18 +199,12 @@ jobs: uses: actions/download-artifact@v4 with: path: test_output - - run: xcrun xcresulttool merge test_output/**/*.xcresult --output-path test_output/final.xcresult - - name: Convert Test Report - run: | - brew tap a7ex/homebrew-formulae && - brew install xcresultparser && - xcresultparser -o cobertura test_output/final.xcresult > test_output/report.xml - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3.1.0 + - run: xcrun xcresulttool merge test_output/**/*.xcresult --output-path test_output/final/final.xcresult + - name: Upload Merged Artifact + uses: actions/upload-artifact@v4 with: - token: ${{ secrets.CODECOV_TOKEN }} - xcode: true - xcode_archive_path: test_output/report.xml + name: MergedResult + path: test_output/final # Beta: # name: ${{ matrix.name }} From 6124451b2b029a274ea172aee6763011fad31bfe Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 3 Jan 2024 18:06:34 +0100 Subject: [PATCH 27/27] Rename step --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dda051e2..ca56e598a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,7 +191,7 @@ jobs: - name: ${{ matrix.name }} run: swift build -c release --target Flare - upload-to-codecov: + merge-test-reports: needs: [iOS, macOS, watchOS, tvOS] runs-on: macos-13 steps: