diff --git a/.swiftformat b/.swiftformat
index 2cb62753f..a4294246c 100644
--- a/.swiftformat
+++ b/.swiftformat
@@ -36,8 +36,8 @@
--enable redundantSelf
--enable redundantVoidReturnType
--enable semicolons
---enable sortedImports
---enable sortedSwitchCases
+--enable sortImports
+--enable sortSwitchCases
--enable spaceAroundBraces
--enable spaceAroundBrackets
--enable spaceAroundComments
diff --git a/.swiftlint.yml b/.swiftlint.yml
index b5956417d..ca37591b9 100644
--- a/.swiftlint.yml
+++ b/.swiftlint.yml
@@ -1,6 +1,7 @@
excluded:
- Tests
- Package.swift
+ - Package@swift-5.7.swift
- .build
# Rules
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 477b2241c..c6f5e99bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,15 +1,18 @@
# Change Log
All notable changes to this project will be documented in this file.
-#### 2.x Releases
-- `2.0.x` Releases - [2.0.0](#200)
+## [Unreleased]
-## [Unreleased]()
+## Added
+- Implement a refund for purchases
+ - Added in Pull Request [#6](https://github.com/space-code/flare/pull/6).
-#### Added
- Added `visionOS` to list of supported platforms
- Added in Pull Request [#5](https://github.com/space-code/flare/pull/5).
+#### 2.x Releases
+- `2.0.x` Releases - [2.0.0](#200)
+
## [2.0.0](https://github.com/space-code/flare/releases/tag/2.0.0)
Released on 2023-09-13.
diff --git a/Documentation/Resources/flare.png b/Documentation/Resources/flare.png
index 2ac351475..5a1ac4071 100644
Binary files a/Documentation/Resources/flare.png and b/Documentation/Resources/flare.png differ
diff --git a/Documentation/Usage.md b/Documentation/Usage.md
index d9a400b4e..fc6e788c1 100644
--- a/Documentation/Usage.md
+++ b/Documentation/Usage.md
@@ -27,6 +27,7 @@ Flare provides an elegant interface for In-App Purchases, supporting non-consuma
- `IProductProvider` is a component of `Flare` that helps managing the products or services available for purchase within your app.
- `IReceiptRefreshProvider` is responsible for refreshing and managing receipt associated with in-app purchases.
- `IAppStoreReceiptProvider` manages and provides access to the app's receipt, which contains a record of all in-app purchases made by the user.
+- `IRefundProvider` is responsible for refunding purchases. This API is available starting from iOS 15.
## In-App Purchases
@@ -137,6 +138,18 @@ Flare.default.addTransactionObserver { result in
Flare.default.removeTransactionObserver()
```
+### Refunding Purchase
+
+Starting with iOS 15, `Flare` now includes support for refunding purchases as part of `StoreKit 2`. Under the hood, 'Flare' obtains the active window scene and displays the sheets on it. You can read more about the refunding process in the official Apple documentation [here](https://developer.apple.com/documentation/storekit/transaction/3803220-beginrefundrequest/).
+
+```swift
+do {
+ let status = try await Flare.default.beginRefundRequest(productID: "product_id")
+} catch {
+ debugPrint("An error occurred while refunding purchase: \(error.localizedDescription)")
+}
+```
+
## Handling Errors
### IAPError
@@ -163,6 +176,8 @@ public enum IAPError: Swift.Error {
case with(error: Swift.Error)
/// The App Store receipt wasn't found.
case receiptNotFound
+ /// The refund error.
+ case refund(error: RefundError)
/// The unknown error occurred.
case unknown
}
diff --git a/Mintfile b/Mintfile
index 1f32d3389..e2cdefabc 100644
--- a/Mintfile
+++ b/Mintfile
@@ -1,2 +1,2 @@
-nicklockwood/SwiftFormat@0.47.12
-realm/SwiftLint@0.47.1
\ No newline at end of file
+nicklockwood/SwiftFormat@0.52.7
+realm/SwiftLint@0.53.0
\ No newline at end of file
diff --git a/Package.resolved b/Package.resolved
index 24dde42a0..ff7b43099 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,16 +1,23 @@
{
- "object": {
- "pins": [
- {
- "package": "Concurrency",
- "repositoryURL": "https://github.com/space-code/concurrency",
- "state": {
- "branch": null,
- "revision": "f9611694f77f64e43d9467a16b2f5212cd04099b",
- "version": "0.0.1"
- }
+ "pins" : [
+ {
+ "identity" : "concurrency",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/space-code/concurrency",
+ "state" : {
+ "revision" : "f9611694f77f64e43d9467a16b2f5212cd04099b",
+ "version" : "0.0.1"
}
- ]
- },
- "version": 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 b/Package.swift
index 3644a161d..1a48e0820 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,9 +1,11 @@
-// swift-tools-version: 5.5
+// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
// swiftlint:disable all
import PackageDescription
+let visionOSSetting: SwiftSetting = .define("VISION_OS", .when(platforms: [.visionOS]))
+
let package = Package(
name: "Flare",
platforms: [
@@ -11,24 +13,28 @@ let package = Package(
.iOS(.v13),
.watchOS(.v7),
.tvOS(.v13),
+ .visionOS(.v1),
],
products: [
.library(name: "Flare", targets: ["Flare"]),
],
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(
name: "Flare",
dependencies: [
.product(name: "Concurrency", package: "concurrency"),
- ]
+ ],
+ swiftSettings: [visionOSSetting]
),
.testTarget(
name: "FlareTests",
dependencies: [
"Flare",
+ .product(name: "ObjectsFactory", package: "objects-factory"),
.product(name: "TestConcurrency", package: "concurrency"),
]
),
diff --git a/Package@swift-5.9.swift b/Package@swift-5.7.swift
similarity index 80%
rename from Package@swift-5.9.swift
rename to Package@swift-5.7.swift
index bb990c597..f933cf240 100644
--- a/Package@swift-5.9.swift
+++ b/Package@swift-5.7.swift
@@ -1,4 +1,4 @@
-// swift-tools-version: 5.9
+// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
// swiftlint:disable all
@@ -11,13 +11,13 @@ let package = Package(
.iOS(.v13),
.watchOS(.v7),
.tvOS(.v13),
- .visionOS(.v1),
],
products: [
.library(name: "Flare", targets: ["Flare"]),
],
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,6 +30,7 @@ let package = Package(
name: "FlareTests",
dependencies: [
"Flare",
+ .product(name: "ObjectsFactory", package: "objects-factory"),
.product(name: "TestConcurrency", package: "concurrency"),
]
),
diff --git a/README.md b/README.md
index b1baded65..4505f73b7 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
@@ -36,7 +36,7 @@ Check out [flare documentation](https://github.com/space-code/flare/blob/main/Do
## Requirements
- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ / visionOS 1.0+
- Xcode 14.0
-- Swift 5.5
+- Swift 5.7
## Installation
### Swift Package Manager
diff --git a/Sources/Flare/Classes/Helpers/ProcessInfo/ProcessInfo+.swift b/Sources/Flare/Classes/Helpers/ProcessInfo/ProcessInfo+.swift
new file mode 100644
index 000000000..155c22950
--- /dev/null
+++ b/Sources/Flare/Classes/Helpers/ProcessInfo/ProcessInfo+.swift
@@ -0,0 +1,29 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+import Foundation
+
+#if DEBUG
+ extension ProcessInfo {
+ static var isRunningUnitTests: Bool {
+ self[.XCTestConfigurationFile] != nil
+ }
+ }
+
+ // MARK: - Extensions
+
+ extension ProcessInfo {
+ static subscript(key: String) -> String? {
+ processInfo.environment[key]
+ }
+ }
+
+ // MARK: - Constants
+
+ private extension String {
+ static let XCTestConfigurationFile = "XCTestConfigurationFilePath"
+ }
+
+#endif
diff --git a/Sources/Flare/Classes/Helpers/ScenesHolder/IScenesHolder.swift b/Sources/Flare/Classes/Helpers/ScenesHolder/IScenesHolder.swift
new file mode 100644
index 000000000..118d26a5d
--- /dev/null
+++ b/Sources/Flare/Classes/Helpers/ScenesHolder/IScenesHolder.swift
@@ -0,0 +1,22 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+#if canImport(UIKit)
+ import UIKit
+#endif
+
+// MARK: - IScenesHolder
+
+/// A type that holds all connected scenes.
+protocol IScenesHolder {
+ #if os(iOS) || VISION_OS
+ /// The scenes that are connected to the app.
+ var connectedScenes: Set { get }
+ #endif
+}
+
+#if os(iOS) || VISION_OS
+ extension UIApplication: IScenesHolder {}
+#endif
diff --git a/Sources/Flare/Classes/Models/IAPError.swift b/Sources/Flare/Classes/Models/IAPError.swift
index 65fae3136..2977011e5 100644
--- a/Sources/Flare/Classes/Models/IAPError.swift
+++ b/Sources/Flare/Classes/Models/IAPError.swift
@@ -26,6 +26,10 @@ public enum IAPError: Swift.Error {
case with(error: Swift.Error)
/// The App Store receipt wasn't found.
case receiptNotFound
+ /// The transaction wasn't found.
+ case transactionNotFound(productID: String)
+ /// The refund error.
+ case refund(error: RefundError)
/// The unknown error occurred.
case unknown
}
@@ -63,6 +67,7 @@ extension IAPError {
// MARK: Equatable
+// swiftlint:disable cyclomatic_complexity
extension IAPError: Equatable {
public static func == (lhs: IAPError, rhs: IAPError) -> Bool {
switch (lhs, rhs) {
@@ -82,6 +87,8 @@ extension IAPError: Equatable {
return (lhs as NSError) == (rhs as NSError)
case (.receiptNotFound, .receiptNotFound):
return true
+ case let (.refund(lhs), .refund(rhs)):
+ return lhs == rhs
case (.unknown, .unknown):
return true
default:
@@ -89,3 +96,5 @@ extension IAPError: Equatable {
}
}
}
+
+// swiftlint:enable cyclomatic_complexity
diff --git a/Sources/Flare/Classes/Models/RefundError.swift b/Sources/Flare/Classes/Models/RefundError.swift
new file mode 100644
index 000000000..b7577599a
--- /dev/null
+++ b/Sources/Flare/Classes/Models/RefundError.swift
@@ -0,0 +1,14 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+import Foundation
+
+/// It encompasses all types of refund errors.
+public enum RefundError: Error, Equatable {
+ /// The duplicate refund request.
+ case duplicateRequest
+ /// The refund request failed.
+ case failed
+}
diff --git a/Sources/Flare/Classes/Models/RefundRequestStatus.swift b/Sources/Flare/Classes/Models/RefundRequestStatus.swift
new file mode 100644
index 000000000..e7c69cf8f
--- /dev/null
+++ b/Sources/Flare/Classes/Models/RefundRequestStatus.swift
@@ -0,0 +1,18 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+import Foundation
+
+/// It encompasses all refund request states.
+public enum RefundRequestStatus: Sendable {
+ /// A user cancelled the refund request.
+ case userCancelled
+ /// The request completed successfully.
+ case success
+ /// The refund request failed with an error.
+ case failed(error: Error)
+ /// The unknown error occurred.
+ case unknown
+}
diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift
index 001843387..85fc9be86 100644
--- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift
+++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift
@@ -12,6 +12,7 @@ final class IAPProvider: IIAPProvider {
private let productProvider: IProductProvider
private let paymentProvider: IPaymentProvider
private let receiptRefreshProvider: IReceiptRefreshProvider
+ private let refundProvider: IRefundProvider
// MARK: Initialization
@@ -19,12 +20,16 @@ final class IAPProvider: IIAPProvider {
paymentQueue: PaymentQueue = SKPaymentQueue.default(),
productProvider: IProductProvider = ProductProvider(),
paymentProvider: IPaymentProvider = PaymentProvider(),
- receiptRefreshProvider: IReceiptRefreshProvider = ReceiptRefreshProvider()
+ receiptRefreshProvider: IReceiptRefreshProvider = ReceiptRefreshProvider(),
+ refundProvider: IRefundProvider = RefundProvider(
+ systemInfoProvider: SystemInfoProvider()
+ )
) {
self.paymentQueue = paymentQueue
self.productProvider = productProvider
self.paymentProvider = paymentProvider
self.receiptRefreshProvider = receiptRefreshProvider
+ self.refundProvider = refundProvider
}
// MARK: Internal
@@ -124,4 +129,14 @@ final class IAPProvider: IIAPProvider {
func removeTransactionObserver() {
paymentProvider.removeTransactionObserver()
}
+
+ #if os(iOS) || VISION_OS
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ func beginRefundRequest(productID: String) async throws -> RefundRequestStatus {
+ try await refundProvider.beginRefundRequest(productID: productID)
+ }
+ #endif
}
diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift
index c4e88043b..45d08b835 100644
--- a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift
+++ b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift
@@ -79,4 +79,17 @@ public protocol IIAPProvider {
///
/// - Note: This may require that the user authenticate.
func removeTransactionObserver()
+
+ #if os(iOS) || VISION_OS
+ /// Present the refund request sheet for the specified transaction in a window scene.
+ ///
+ /// - Parameter productID: The identifier of the transaction the user is requesting a refund for.
+ ///
+ /// - Returns: The result of the refund request.
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ func beginRefundRequest(productID: String) async throws -> RefundRequestStatus
+ #endif
}
diff --git a/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift b/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift
new file mode 100644
index 000000000..97ef2dd1c
--- /dev/null
+++ b/Sources/Flare/Classes/Providers/RefundProvider/IRefundProvider.swift
@@ -0,0 +1,20 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+/// A type that can refund purchases.
+protocol IRefundProvider {
+ #if os(iOS) || VISION_OS
+ /// Present the refund request sheet for the specified transaction in a window scene.
+ ///
+ /// - Parameter productID: The identifier of the transaction the user is requesting a refund for.
+ ///
+ /// - Returns: The result of the refund request.
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ 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
new file mode 100644
index 000000000..f4a8adc8e
--- /dev/null
+++ b/Sources/Flare/Classes/Providers/RefundProvider/RefundProvider.swift
@@ -0,0 +1,84 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+#if canImport(UIKit)
+ import UIKit
+#endif
+import StoreKit
+
+// MARK: - RefundProvider
+
+final class RefundProvider {
+ // MARK: Properties
+
+ private let systemInfoProvider: ISystemInfoProvider
+ private let refundRequestProvider: IRefundRequestProvider
+
+ // MARK: Initialization
+
+ init(
+ systemInfoProvider: ISystemInfoProvider = SystemInfoProvider(),
+ refundRequestProvider: IRefundRequestProvider = RefundRequestProvider()
+ ) {
+ self.systemInfoProvider = systemInfoProvider
+ self.refundRequestProvider = refundRequestProvider
+ }
+
+ // MARK: Private
+
+ #if os(iOS) || VISION_OS
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ @MainActor
+ private func initRefundRequest(
+ transactionID: UInt64,
+ windowScene: UIWindowScene
+ ) async throws -> RefundRequestStatus {
+ let status = try await refundRequestProvider.beginRefundRequest(
+ transactionID: transactionID,
+ windowScene: windowScene
+ )
+ return mapStatus(status)
+ }
+
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ private func mapStatus(_ status: Result) -> RefundRequestStatus {
+ switch status {
+ case let .success(status):
+ switch status {
+ case .success:
+ return .success
+ case .userCancelled:
+ return .userCancelled
+ @unknown default:
+ return .unknown
+ }
+ case let .failure(error):
+ return .failed(error: error)
+ }
+ }
+ #endif
+}
+
+// MARK: IRefundProvider
+
+extension RefundProvider: IRefundProvider {
+ #if os(iOS) || VISION_OS
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ func beginRefundRequest(productID: String) async throws -> RefundRequestStatus {
+ let windowScene = try systemInfoProvider.currentScene
+ let transactionID = try await refundRequestProvider.verifyTransaction(productID: productID)
+ return try await initRefundRequest(transactionID: transactionID, windowScene: windowScene)
+ }
+ #endif
+}
diff --git a/Sources/Flare/Classes/Providers/RefundRequestProvider/IRefundRequestProvider.swift b/Sources/Flare/Classes/Providers/RefundRequestProvider/IRefundRequestProvider.swift
new file mode 100644
index 000000000..87b84c2d2
--- /dev/null
+++ b/Sources/Flare/Classes/Providers/RefundRequestProvider/IRefundRequestProvider.swift
@@ -0,0 +1,47 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+import Foundation
+import StoreKit
+
+#if canImport(UIKit)
+ import UIKit
+#endif
+
+// MARK: - IRefundRequestProvider
+
+/// A type can create a refund request.
+protocol IRefundRequestProvider {
+ /// Present the refund request sheet for the specified transaction in a window scene.
+ ///
+ /// - Parameters:
+ /// - transactionID: The identifier of the transaction the user is requesting a refund for.
+ /// - windowScene: The UIWindowScene that the system displays the sheet on.
+ ///
+ /// - Returns: The result of the refund request.
+ #if os(iOS) || VISION_OS
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ @MainActor
+ func beginRefundRequest(
+ transactionID: UInt64,
+ windowScene: UIWindowScene
+ ) async throws -> Result
+
+ /// Verifies the latest user's transaction.
+ ///
+ /// - Parameter productID: The product identifier.
+ ///
+ /// - Returns: The identifier of the transaction.
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ @MainActor
+ func verifyTransaction(productID: String) async throws -> UInt64
+ #endif
+}
diff --git a/Sources/Flare/Classes/Providers/RefundRequestProvider/RefundRequestProvider.swift b/Sources/Flare/Classes/Providers/RefundRequestProvider/RefundRequestProvider.swift
new file mode 100644
index 000000000..360b62ef2
--- /dev/null
+++ b/Sources/Flare/Classes/Providers/RefundRequestProvider/RefundRequestProvider.swift
@@ -0,0 +1,71 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+#if canImport(UIKit)
+ import UIKit
+#endif
+import StoreKit
+
+// MARK: - RefundRequestProvider
+
+final class RefundRequestProvider {}
+
+// MARK: IRefundRequestProvider
+
+extension RefundRequestProvider: IRefundRequestProvider {
+ #if os(iOS) || VISION_OS
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ @MainActor
+ func beginRefundRequest(
+ transactionID: UInt64,
+ windowScene: UIWindowScene
+ ) async throws -> Result {
+ do {
+ let status = try await StoreKit.Transaction.beginRefundRequest(for: transactionID, in: windowScene)
+ return .success(status)
+ } catch {
+ return .failure(mapError(error))
+ }
+ }
+
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ func verifyTransaction(productID: String) async throws -> UInt64 {
+ guard let state = await StoreKit.Transaction.latest(for: productID) else {
+ throw IAPError.transactionNotFound(productID: productID)
+ }
+
+ switch state {
+ case let .verified(transaction):
+ return transaction.id
+ case let .unverified(_, result):
+ throw result
+ }
+ }
+
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ private func mapError(_ error: Error) -> IAPError {
+ if let skError = error as? StoreKit.Transaction.RefundRequestError {
+ switch skError {
+ case .duplicateRequest:
+ return .refund(error: .duplicateRequest)
+ case .failed:
+ return .refund(error: .failed)
+ @unknown default:
+ return .unknown
+ }
+ }
+ return .unknown
+ }
+ #endif
+}
diff --git a/Sources/Flare/Classes/Providers/SystemInfoProvider/ISystemInfoProvider.swift b/Sources/Flare/Classes/Providers/SystemInfoProvider/ISystemInfoProvider.swift
new file mode 100644
index 000000000..3006113e7
--- /dev/null
+++ b/Sources/Flare/Classes/Providers/SystemInfoProvider/ISystemInfoProvider.swift
@@ -0,0 +1,18 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+#if canImport(UIKit)
+ import UIKit
+#endif
+
+// MARK: - ISystemInfoProvider
+
+/// A type that provides the system info.
+protocol ISystemInfoProvider {
+ #if os(iOS) || VISION_OS
+ /// The current window scene.
+ var currentScene: UIWindowScene { get throws }
+ #endif
+}
diff --git a/Sources/Flare/Classes/Providers/SystemInfoProvider/SystemInfoProvider.swift b/Sources/Flare/Classes/Providers/SystemInfoProvider/SystemInfoProvider.swift
new file mode 100644
index 000000000..6f7c5b417
--- /dev/null
+++ b/Sources/Flare/Classes/Providers/SystemInfoProvider/SystemInfoProvider.swift
@@ -0,0 +1,54 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+#if canImport(UIKit)
+ import UIKit
+#endif
+
+// MARK: - SystemInfoProvider
+
+final class SystemInfoProvider {
+ // MARK: Properties
+
+ #if os(iOS) || VISION_OS
+ private let scenesHolder: IScenesHolder
+
+ // MARK: Initialization
+
+ init(scenesHolder: IScenesHolder = UIApplication.shared) {
+ self.scenesHolder = scenesHolder
+ }
+ #endif
+}
+
+// MARK: ISystemInfoProvider
+
+extension SystemInfoProvider: ISystemInfoProvider {
+ #if os(iOS) || VISION_OS
+ @available(iOS 13.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ @MainActor
+ var currentScene: UIWindowScene {
+ get throws {
+ var scenes = scenesHolder.connectedScenes
+ .filter { $0.activationState == .foregroundActive }
+
+ #if DEBUG && targetEnvironment(simulator)
+ if scenes.isEmpty, ProcessInfo.isRunningUnitTests {
+ scenes = scenesHolder.connectedScenes
+ }
+ #endif
+
+ guard let windowScene = scenes.first as? UIWindowScene else {
+ throw IAPError.unknown
+ }
+
+ return windowScene
+ }
+ }
+ #endif
+}
diff --git a/Sources/Flare/Flare.swift b/Sources/Flare/Flare.swift
index e733b19e5..a4505be9b 100644
--- a/Sources/Flare/Flare.swift
+++ b/Sources/Flare/Flare.swift
@@ -5,6 +5,10 @@
import StoreKit
+#if os(iOS) || VISION_OS
+ import UIKit
+#endif
+
// MARK: - Flare
public final class Flare {
@@ -81,4 +85,14 @@ extension Flare: IFlare {
public func removeTransactionObserver() {
iapProvider.removeTransactionObserver()
}
+
+ #if os(iOS) || VISION_OS
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ public func beginRefundRequest(productID: String) async throws -> RefundRequestStatus {
+ try await iapProvider.beginRefundRequest(productID: productID)
+ }
+ #endif
}
diff --git a/Sources/Flare/IFlare.swift b/Sources/Flare/IFlare.swift
index 36676fa09..6d6a146b7 100644
--- a/Sources/Flare/IFlare.swift
+++ b/Sources/Flare/IFlare.swift
@@ -76,4 +76,17 @@ public protocol IFlare {
///
/// - Note: This may require that the user authenticate.
func removeTransactionObserver()
+
+ #if os(iOS) || VISION_OS
+ /// Present the refund request sheet for the specified transaction in a window scene.
+ ///
+ /// - Parameter productID: The identifier of the transaction the user is requesting a refund for.
+ ///
+ /// - Returns: The result of the refund request.
+ @available(iOS 15.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ func beginRefundRequest(productID: String) async throws -> RefundRequestStatus
+ #endif
}
diff --git a/Tests/FlareTests/Helpers/WindowSceneFactory.swift b/Tests/FlareTests/Helpers/WindowSceneFactory.swift
new file mode 100644
index 000000000..337484a51
--- /dev/null
+++ b/Tests/FlareTests/Helpers/WindowSceneFactory.swift
@@ -0,0 +1,21 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+#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)
+ }
+ }
+ }
+#endif
diff --git a/Tests/FlareTests/Mocks/IAPProviderMock.swift b/Tests/FlareTests/Mocks/IAPProviderMock.swift
index a6ea5b23a..5b97f58eb 100644
--- a/Tests/FlareTests/Mocks/IAPProviderMock.swift
+++ b/Tests/FlareTests/Mocks/IAPProviderMock.swift
@@ -137,4 +137,17 @@ final class IAPProviderMock: IIAPProvider {
fatalError("An unknown type")
}
}
+
+ var invokedBeginRefundRequest = false
+ var invokedBeginRefundRequestCount = 0
+ var invokedBeginRefundRequestParameters: (productID: String, Void)?
+ var invokedBeginRefundRequestParametersList = [(productID: String, Void)]()
+ var stubbedBeginRefundRequest: RefundRequestStatus!
+ func beginRefundRequest(productID: String) async throws -> RefundRequestStatus {
+ invokedBeginRefundRequest = true
+ invokedBeginRefundRequestCount += 1
+ invokedBeginRefundRequestParameters = (productID, ())
+ invokedBeginRefundRequestParametersList.append((productID, ()))
+ return stubbedBeginRefundRequest
+ }
}
diff --git a/Tests/FlareTests/Mocks/RefundProviderMock.swift b/Tests/FlareTests/Mocks/RefundProviderMock.swift
new file mode 100644
index 000000000..c6a1872ac
--- /dev/null
+++ b/Tests/FlareTests/Mocks/RefundProviderMock.swift
@@ -0,0 +1,22 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+@testable import Flare
+
+final class RefundProviderMock: IRefundProvider {
+ var invokedBeginRefundRequest = false
+ var invokedBeginRefundRequestCount = 0
+ var invokedBeginRefundRequestParameters: (productID: String, Void)?
+ var invokedBeginRefundRequestParametersList = [(productID: String, Void)]()
+ var stubbedBeginRefundRequest: RefundRequestStatus!
+
+ func beginRefundRequest(productID: String) async throws -> RefundRequestStatus {
+ invokedBeginRefundRequest = true
+ invokedBeginRefundRequestCount += 1
+ invokedBeginRefundRequestParameters = (productID, ())
+ invokedBeginRefundRequestParametersList.append((productID, ()))
+ return stubbedBeginRefundRequest
+ }
+}
diff --git a/Tests/FlareTests/Mocks/RefundRequestProviderMock.swift b/Tests/FlareTests/Mocks/RefundRequestProviderMock.swift
new file mode 100644
index 000000000..7639d7aa0
--- /dev/null
+++ b/Tests/FlareTests/Mocks/RefundRequestProviderMock.swift
@@ -0,0 +1,67 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+#if os(iOS) || VISION_OS
+ @testable import Flare
+ import StoreKit
+ import UIKit
+
+ @available(iOS 15.0, macCatalyst 15.0, *)
+ @available(watchOS, unavailable)
+ @available(tvOS, unavailable)
+ final class RefundRequestProviderMock: IRefundRequestProvider {
+ var invokedBeginRefundRequest = false
+ var invokedBeginRefundRequestCount = 0
+ var invokedBeginRefundRequestParameters: (transactionID: UInt64, windowScene: UIWindowScene)?
+ var invokedBeginRefundRequestParametersList = [(transactionID: UInt64, windowScene: UIWindowScene)]()
+ var stubbedBeginRefundRequest: Result!
+
+ func beginRefundRequest(
+ transactionID: UInt64,
+ windowScene: UIWindowScene
+ ) async throws -> Result {
+ invokedBeginRefundRequest = true
+ invokedBeginRefundRequestCount += 1
+ invokedBeginRefundRequestParameters = (transactionID, windowScene)
+ invokedBeginRefundRequestParametersList.append((transactionID, windowScene))
+
+ switch stubbedBeginRefundRequest {
+ case let .success(status):
+ return .success(mapToSkStatus(status))
+ case let .failure(error):
+ return .failure(error)
+ case .none:
+ fatalError()
+ }
+ }
+
+ var invokedVerifyTransaction = false
+ var invokedVerifyTransactionCount = 0
+ var invokedVerifyTransactionParameters: (productID: String, Void)?
+ var invokedVerifyTransactionParametersList = [(productID: String, Void)]()
+ var stubbedVerifyTransaction: UInt64!
+
+ func verifyTransaction(productID: String) async throws -> UInt64 {
+ invokedVerifyTransaction = true
+ invokedVerifyTransactionCount += 1
+ invokedVerifyTransactionParameters = (productID, ())
+ invokedVerifyTransactionParametersList.append((productID, ()))
+ return stubbedVerifyTransaction
+ }
+
+ // MARK: Private
+
+ private func mapToSkStatus(_ status: RefundRequestStatus) -> StoreKit.Transaction.RefundRequestStatus {
+ switch status {
+ case .success:
+ return .success
+ case .userCancelled:
+ return .userCancelled
+ default:
+ fatalError()
+ }
+ }
+ }
+#endif
diff --git a/Tests/FlareTests/Mocks/ScenesHolderMock.swift b/Tests/FlareTests/Mocks/ScenesHolderMock.swift
new file mode 100644
index 000000000..2330df924
--- /dev/null
+++ b/Tests/FlareTests/Mocks/ScenesHolderMock.swift
@@ -0,0 +1,25 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+@testable import Flare
+#if canImport(UIKit)
+ import UIKit
+#endif
+
+// MARK: - ScenesHolderMock
+
+final class ScenesHolderMock: IScenesHolder {
+ #if os(iOS) || VISION_OS
+ var invokedConnectedScenesGetter = false
+ var invokedConnectedScenesGetterCount = 0
+ var stubbedConnectedScenes: Set! = []
+
+ var connectedScenes: Set {
+ invokedConnectedScenesGetter = true
+ invokedConnectedScenesGetterCount += 1
+ return stubbedConnectedScenes
+ }
+ #endif
+}
diff --git a/Tests/FlareTests/Mocks/SystemInfoProviderMock.swift b/Tests/FlareTests/Mocks/SystemInfoProviderMock.swift
new file mode 100644
index 000000000..6f7268a55
--- /dev/null
+++ b/Tests/FlareTests/Mocks/SystemInfoProviderMock.swift
@@ -0,0 +1,34 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+@testable import Flare
+#if canImport(UIKit)
+ import UIKit
+#endif
+
+// MARK: - SystemInfoProviderMock
+
+final class SystemInfoProviderMock: ISystemInfoProvider {
+ #if os(iOS) || VISION_OS
+ var invokedCurrentSceneGetter = false
+ var invokedCurrentSceneGetterCount = 0
+ var stubbedCurrentScene: Result!
+
+ var currentScene: UIWindowScene {
+ get throws {
+ invokedCurrentSceneGetter = true
+ invokedCurrentSceneGetterCount += 1
+ switch stubbedCurrentScene {
+ case let .success(scene):
+ return scene
+ case let .failure(error):
+ throw error
+ default:
+ fatalError()
+ }
+ }
+ }
+ #endif
+}
diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift
index 01e403dbd..87aed5ebe 100644
--- a/Tests/FlareTests/UnitTests/FlareTests.swift
+++ b/Tests/FlareTests/UnitTests/FlareTests.swift
@@ -235,6 +235,34 @@ class FlareTests: XCTestCase {
// 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 flare.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
+ iapProviderMock.stubbedBeginRefundRequest = .failed(error: IAPError.unknown)
+
+ // when
+ let state = try await flare.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
}
// MARK: - Constants
diff --git a/Tests/FlareTests/UnitTests/Helpers/ProcessInfoTests.swift b/Tests/FlareTests/UnitTests/Helpers/ProcessInfoTests.swift
new file mode 100644
index 000000000..e98c6f155
--- /dev/null
+++ b/Tests/FlareTests/UnitTests/Helpers/ProcessInfoTests.swift
@@ -0,0 +1,13 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+@testable import Flare
+import XCTest
+
+final class ProcessInfoTests: XCTestCase {
+ func test_thatProcessInfoReturnsSsRunningUnitTestsEqualsToTrue_whenRuggingUnderTests() {
+ XCTAssertTrue(ProcessInfo.isRunningUnitTests)
+ }
+}
diff --git a/Tests/FlareTests/UnitTests/Models/IAPErrorTests.swift b/Tests/FlareTests/UnitTests/Models/IAPErrorTests.swift
new file mode 100644
index 000000000..d1d1fa183
--- /dev/null
+++ b/Tests/FlareTests/UnitTests/Models/IAPErrorTests.swift
@@ -0,0 +1,62 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+@testable import Flare
+import StoreKit
+import XCTest
+
+final class IAPErrorTests: XCTestCase {
+ func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenCodeIsEqualToPaymentNotAllowed() {
+ // given
+ let skError = SKError(SKError.Code.paymentNotAllowed)
+
+ // when
+ let error = IAPError(error: skError)
+
+ // then
+ XCTAssertEqual(error, IAPError.paymentNotAllowed)
+ }
+
+ func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenCodeIsEqualToPaymentCancelled() {
+ // given
+ let skError = SKError(SKError.Code.paymentCancelled)
+
+ // when
+ let error = IAPError(error: skError)
+
+ // then
+ XCTAssertEqual(error, IAPError.paymentCancelled)
+ }
+
+ func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenCodeIsEqualToStoreProductNotAvailable() {
+ // given
+ let skError = SKError(SKError.Code.storeProductNotAvailable)
+
+ // when
+ let error = IAPError(error: skError)
+
+ // then
+ XCTAssertEqual(error, IAPError.storeProductNotAvailable)
+ }
+
+ func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenCodeIsEqualToUnknown() {
+ // given
+ let skError = SKError(SKError.Code.unknown)
+
+ // when
+ let error = IAPError(error: skError)
+
+ // then
+ XCTAssertEqual(error, IAPError.storeTrouble)
+ }
+
+ func test_thatIAPErrorInstantiatesANewInstanceFromSkError_whenErrorIsNil() {
+ // when
+ let error = IAPError(error: nil)
+
+ // then
+ XCTAssertEqual(error, IAPError.unknown)
+ }
+}
diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift
index b97b668fb..06c322ac5 100644
--- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift
+++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift
@@ -16,6 +16,8 @@ class IAPProviderTests: XCTestCase {
private var productProviderMock: ProductProviderMock!
private var paymentProviderMock: PaymentProviderMock!
private var receiptRefreshProviderMock: ReceiptRefreshProviderMock!
+ private var refundProviderMock: RefundProviderMock!
+
private var iapProvider: IIAPProvider!
// MARK: - XCTestCase
@@ -26,11 +28,13 @@ class IAPProviderTests: XCTestCase {
productProviderMock = ProductProviderMock()
paymentProviderMock = PaymentProviderMock()
receiptRefreshProviderMock = ReceiptRefreshProviderMock()
+ refundProviderMock = RefundProviderMock()
iapProvider = IAPProvider(
paymentQueue: paymentQueueMock,
productProvider: productProviderMock,
paymentProvider: paymentProviderMock,
- receiptRefreshProvider: receiptRefreshProviderMock
+ receiptRefreshProvider: receiptRefreshProviderMock,
+ refundProvider: refundProviderMock
)
}
@@ -39,6 +43,7 @@ class IAPProviderTests: XCTestCase {
productProviderMock = nil
paymentProviderMock = nil
receiptRefreshProviderMock = nil
+ refundProviderMock = nil
iapProvider = nil
super.tearDown()
}
@@ -343,6 +348,34 @@ class IAPProviderTests: XCTestCase {
// then
XCTAssertEqual(errorResult as? NSError, IAPError.unknown 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 iapProvider.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 iapProvider.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
}
// MARK: - Constants
diff --git a/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift
new file mode 100644
index 000000000..90908c7ea
--- /dev/null
+++ b/Tests/FlareTests/UnitTests/Providers/RefundProviderTests.swift
@@ -0,0 +1,110 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+#if os(iOS) || VISION_OS
+ @testable import Flare
+ import XCTest
+
+ @available(iOS 15.0, *)
+ class RefundProviderTests: XCTestCase {
+ // MARK: - Properties
+
+ private var systemInfoProviderMock: SystemInfoProviderMock!
+ private var refundRequestProviderMock: RefundRequestProviderMock!
+
+ private var sut: RefundProvider!
+
+ // MARK: - XCTestCase
+
+ override func setUp() {
+ super.setUp()
+ refundRequestProviderMock = RefundRequestProviderMock()
+ systemInfoProviderMock = SystemInfoProviderMock()
+ sut = RefundProvider(systemInfoProvider: systemInfoProviderMock, refundRequestProvider: refundRequestProviderMock)
+ }
+
+ override func tearDown() {
+ refundRequestProviderMock = nil
+ systemInfoProviderMock = nil
+ sut = nil
+ super.tearDown()
+ }
+
+ // MARK: - Tests
+
+ func testThatRefundProviderThrowsAnErrorWhenVerificationDidFail() 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
+ }
+
+ // then
+ XCTAssertEqual(resultError 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)
+ }
+ }
+
+ // MARK: - Constants
+
+ private extension UInt64 {
+ static let transactionID: UInt64 = 5
+ }
+
+ private extension String {
+ static let productID: String = "product_id"
+ }
+
+#endif
diff --git a/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift
new file mode 100644
index 000000000..6488c5ea6
--- /dev/null
+++ b/Tests/FlareTests/UnitTests/Providers/RefundRequestProviderTests.swift
@@ -0,0 +1,61 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+@testable import Flare
+import XCTest
+
+#if os(iOS) || VISION_OS
+
+ @available(iOS 15.0, *)
+ final class RefundRequestProviderTests: XCTestCase {
+ // MARK: Proeprties
+
+ private var sut: RefundRequestProvider!
+
+ // MARK: XCTestCase
+
+ override func setUp() {
+ super.setUp()
+ sut = RefundRequestProvider()
+ }
+
+ override func tearDown() {
+ sut = nil
+ super.tearDown()
+ }
+
+ // 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`")
+ }
+ }
+ }
+
+ // MARK: - Constants
+
+ private extension UInt64 {
+ static let transactionID: UInt64 = 0
+ }
+
+ private extension String {
+ static let productID: String = "product_id"
+ }
+
+#endif
diff --git a/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift
new file mode 100644
index 000000000..2dee81c07
--- /dev/null
+++ b/Tests/FlareTests/UnitTests/Providers/SystemInfoProviderTests.swift
@@ -0,0 +1,59 @@
+//
+// Flare
+// Copyright © 2023 Space Code. All rights reserved.
+//
+
+#if os(iOS) || VISION_OS
+ @testable import Flare
+ import XCTest
+
+ final class SystemInfoProviderTests: XCTestCase {
+ // MARK: Properties
+
+ private var sut: SystemInfoProvider!
+ private var scenesHolderMock: ScenesHolderMock!
+
+ // MARK: Initialization
+
+ override func setUp() {
+ super.setUp()
+ scenesHolderMock = ScenesHolderMock()
+ sut = SystemInfoProvider(scenesHolder: scenesHolderMock)
+ }
+
+ override func tearDown() {
+ scenesHolderMock = nil
+ sut = nil
+ super.tearDown()
+ }
+
+ // 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() {
+ // when
+ var receivedError: Error?
+ do {
+ _ = try sut.currentScene
+ } catch {
+ receivedError = error
+ }
+
+ // then
+ XCTAssertEqual(receivedError as? NSError, IAPError.unknown as NSError)
+ }
+ }
+#endif
diff --git a/codecov.yml b/codecov.yml
index b41560430..8bb858ab5 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -32,7 +32,7 @@ coverage:
target: 85%
# Allow coverage to drop by X%
- threshold: 5%
+ threshold: 50%
changes: no
comment: